diff --git a/.cargo/config.toml b/.cargo/config.toml index 698325483..62922132d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -17,3 +17,7 @@ CC_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-gcc-posix" CXX_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-g++-posix" AR_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-ar" CFLAGS_x86_64_pc_windows_gnu = "-O2" +# 8 MiB minimum stack for spawned threads (incl. tokio worker threads), up from +# Rust's smaller default. Deep call chains (grovedb proof verification, nested +# serde/protobuf decoding) can overflow the default on debug builds. +RUST_MIN_STACK = "8388608" diff --git a/.gitignore b/.gitignore index 72e4d3a34..547416ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ test_db* explorer.log .gitaipconfig .claude/worktrees +.codex/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..7f4ce6a81 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Read and follow all instructions in [CLAUDE.md](./CLAUDE.md) before starting any task. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..1cc9de5ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,220 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **Search tags in the "Send to" field**: type `type:core`, `type:platform`, + `type:shielded`, or `wallet:` to narrow the address suggestions + instead of scrolling through everything; plain words still search like + before. Each suggestion now shows a small label for its wallet and its + type, and the field shows a hint listing the tags you can use when it's + empty. + +- **Masternodes tab**: a new "Masternodes" entry in the left nav (visible when + Expert mode is on) for loading and managing masternode and evonode (HP + masternode) identities by ProTxHash. Loaded nodes appear as a card list + showing type, voter-key readiness, key status, and DPNS-voting status; + opening a card shows a detail view with inline DPNS contested-name voting, + Withdraw / Top up / Transfer actions, key management, and — for evonodes + only — a link to claim token rewards. The load form accepts an optional + password to encrypt the entered voting/owner/payout keys immediately + instead of only after a separate step; leaving it blank keeps today's + behavior, and protection can always be added later from the key screen. + This replaces loading a masternode or evonode from *Identities → Load + Existing Identity → Show Advanced Options*, which no longer offers those + identity types. + +- **Wallet/identity indicator on more screens (rollout in progress)**: the + wallet and identity picker previously shown only at the top of the Identity + Hub now also appears at the top of the Identities, DashPay, DPNS, and + Wallets screens. On the Identity Hub and the new Masternodes tab it's fully + interactive — you can change which wallet or identity you're acting as + right there. On the other four it's currently a read-only preview of your + active wallet/identity, with a tooltip on where to change it; making it + interactive there, and adding it to the remaining screens, is tracked as a + follow-up. + +### Changed + +- **Masternode and evonode identities no longer appear in the Identity Hub or + Identities picker**: they now live exclusively on the new Masternodes tab, + so you're never offered actions (like registering a username) that don't + apply to a node's collateral/voting identity. + +- **Wallet balance breakdown is single-sourced**: the per-account tabs and the + wallet header now derive every balance from one place. The Core header total + and the Core per-account breakdown are read from the same generation of synced + wallet data — even while the wallet is busy syncing, the header and the tabs + are never spliced from different moments — and the Platform tab shows the exact + same Platform total as the header. Any funds held on addresses outside your + main and Platform accounts now always appear in a visible tab, so the tabs + always add up to the header total. This removes an earlier internal cross-check + (and its occasional "balances didn't fully add up" warning), no longer needed + now that the figures come from one consistent source. + +- **Platform tab shows immediately**: a newly created or imported wallet now + shows its Platform tab (empty until funded) as soon as it loads, instead of + waiting for the first Platform-address sync to complete. Your Platform receive + address is reachable right away, even before or without a network sync. + +- **Sign-time wallet unlock**: the passphrase is now requested just-in-time, the + moment an operation actually needs your secret (sending funds, registering an + identity, signing). The prompt offers an optional "Keep this wallet unlocked + until I close the app" checkbox so a busy session asks only once. This replaces + the old upfront unlock gate that held the wallet open for the whole session — + the HD seed is no longer kept in memory between operations; it is decrypted on + demand and wiped as soon as the operation finishes. + +- **Wallet storage backend**: HD wallet seeds and single-key private keys are now + stored in an upstream `platform-wallet-storage` encrypted vault + (`secrets/det-secrets.pwsvault` in the app data directory) rather than in the legacy + `data.db` SQLite database. Wallet metadata (alias, main flag, + Core wallet name) moves to a new `det-app.sqlite` key-value sidecar. The legacy + `data.db` file is left intact for safety; it is no longer read at runtime. + +- **Cold-start migration**: on the first launch after upgrading, DET automatically + migrates wallet seeds, metadata, and imported single-key data from `data.db` into + the new storage layout. A progress banner is shown during migration (typically + under one second on local storage). The migration is idempotent — subsequent + launches skip it via a completion sentinel in `det-app.sqlite`. + +- **Identity funding now goes through one spend engine**: registering or topping up + an identity always funds from your wallet balance through the upstream + asset-lock engine, which selects coins and tracks the lock to confirmation. The + separate "fund directly from a specific transaction output" path was removed so + there is a single, double-spend-safe funding flow. + +- **DashPay profile no longer requires a display name**: you can save your profile + with the display name left blank; only the length limits on name, bio, and + avatar URL are still enforced. + +- **Shield, Send Privately, and Unshield are now one screen**: each of these + actions opens the same Send screen, already set up for what you clicked, instead + of a separate dedicated screen per action. The steps and options are unchanged, + including pasting a raw shielded address in hex form — there are just fewer + screens to navigate between. + +### Known Limitations + +- **Single-key wallets — send and balance refresh not available**: importing a + single-key wallet (WIF), viewing it, and signing with it all work in this + release. Sending funds and refreshing the balance or UTXO list are not yet + supported. Your key data is preserved and these actions will be available in a + future update. To send funds now, use an HD (recovery-phrase) wallet. + +- **DashPay contacts — non-mainnet / non-account-0 legacy addresses**: this + release drops back-compat for contact-request addresses derived outside mainnet + account 0 under the old DIP-14 scheme (non-mainnet networks, or secondary + account indices). If you used DashPay on testnet or devnet with a non-default + account, existing contact payment addresses for those contacts may not be + reproduced. Re-establishing the contact from both sides restores full + functionality. + +### Removed + +- Proof log screen (internal developer tool, not part of the public feature set). +- 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 + described under Changed). +- The unused "Memo (optional)" field on the wallet send dialog and the single-key + send screen — the note was never attached to the transaction, so it has been + removed to avoid implying a memo would be saved. +- The "Core Only" wallet refresh option. Core wallet balances and UTXOs now stay + current automatically, so a manual Core-only refresh had nothing to do; refresh + now covers Core plus Platform, or Platform only. +- The unused ZMQ Core-event listener subsystem and the non-functional "Disable ZMQ" + setting. The listener was already gated off and never delivered events, so the + toggle did nothing; both have been removed (the `zmq`, `zeromq`, and + `crossbeam-channel` dependencies are no longer needed). +- 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. + +### Fixed + +- **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 + nav entry could stay hidden (reading a stale value on whichever network context + the app was showing) until the app was restarted. Expert mode is now a single + app-wide flag shared across all networks. + +- **Clearer error when loading a masternode by an unknown ProTxHash**: entering a + valid-looking but unregistered ProTxHash in the masternode load form now says no + masternode or evonode was found for that ProTxHash, instead of the misleading + generic "Identity not found — check the ID or name" message. + +- **Withdrawal key selection**: the Withdraw screen now pre-selects only a key + whose private key you actually hold (a payout/Transfer key preferred, Owner as + fallback). Previously it could pick a key that exists on the identity but whose + private key isn't loaded locally — common on loaded masternode/evonode + identities where only the Owner key was supplied — which made the withdrawal + fail at signing with an unhelpful technical error. When no usable key is + loaded, the screen guides you to add one instead of failing mid-withdrawal. +- `WalletBackend` is now initialised eagerly at `AppState` start, eliminating a + retry-loop spam on the SDK connection during cold boot. +- Wallet store is rehydrated on cold start, resolving a regression where wallets + were not visible after the storage migration. +- The Disconnect button on the network settings screen now actually disconnects: + it stops the wallet backend and updates the connection indicator instead of + silently doing nothing. A fast double-click can no longer start two disconnects + at once. +- Shielded balances no longer overstate after upgrading or after using + "Resync Notes": the spent-note scan cursor is reset so previously spent notes + are detected again. Previously a migrated or resynced wallet could show notes as + available that had already been spent, causing later spends to fail. +- The unused-asset-lock picker on the identity registration and top-up screens now + shows a plain-language status and the funding address for each lock, instead of + an internal status name, so you can tell which lock is which. +- A failed wallet-funded identity registration now tells you that your funds are + safe as a funding lock and how to finish: start a new identity and fund it from + your existing asset lock. +- Platform and identity features stay reachable during initial sync. Previously, + on a fresh connection the app contacted Platform network nodes before its local + masternode list had finished syncing; every node it tried was wrongly marked as + failed and set aside, and once all of them were set aside Platform stopped + working until restart. The app now waits for the masternode list to be ready + before contacting those nodes. +- Silent crashes now leave a trace: the app captures stderr output and fatal + signals to its log file, so an unexpected exit can be diagnosed from the logs + instead of vanishing without a record. +- Withdrawing from a Platform address to a Core address could fail with an + internal error even though the address's balance was clearly visible in the + withdrawal picker. A Platform address discovered by background syncing was + not always recognized as one the wallet could sign for. The wallet now + reconciles this automatically, so a visible balance can always be withdrawn. + No funds were ever at risk — the withdrawal simply failed before anything + was sent. +- If your wallet's storage was ever unusually slow to finish preparing (e.g. + after a network switch or on a cold start), the app could wait forever with + no indication anything was wrong. It now tells you after 30 seconds and + suggests restarting, instead of leaving the wallet silently invisible. +- The wallet screen's overall balance could show more Dash than its Core or + Platform account tabs added up to, especially on wallets that have handed + out many addresses. Funds on addresses the wallet had not yet finished + indexing into an account tab were counted in the total but missing from the + per-account breakdown and address list. All known funds now appear in their + correct account tab. +- Important messages — a saved wallet that couldn't be reopened, a failed + send or identity operation, a lost connection — could disappear on their + own after a few seconds, before you had a chance to read them. These now + stay on screen until you dismiss them yourself. Routine notices (a + successful action, a validation hint, an in-progress status) still clear + automatically as before. +- Choosing a funding method on the identity registration or top-up screen and + then switching wallets no longer silently discards your choice and reverts + to the default funding method. +- The top-up screen's automatic wallet selection no longer pre-selects a + wallet whose spendable balance is too small or currently locked to actually + cover the top-up, which would then be immediately rejected. +- The "My Tokens" tab no longer gets stuck on a loading spinner when you have + no identities yet; it now shows the expected empty state. +- Two settings changes made in quick succession could occasionally cause one + of them to be silently lost. Saving settings is now a single atomic step, + so no change is dropped. diff --git a/CLAUDE.md b/CLAUDE.md index 9a9e7cce1..7fe926ba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,23 @@ scripts/safe-cargo.sh +nightly fmt --all * **Never parse error strings** to extract information. Always use the typed error chain (downcast, match on variants, access structured fields). If no typed variant exists for the information you need, define a new `TaskError` variant or extend the existing error type. String parsing is fragile, breaks on message changes, and bypasses the type system. * **Validation placement**: Pure input validation (format, length, character sets) lives in `model/` as stateless functions — single source of truth, unit-testable, no dependencies on `AppContext` or `Sdk`. Backend tasks are the authoritative enforcement layer: they call model validators for format checks AND perform stateful validation that requires network or database (existence checks, uniqueness, business rules). UI screens may call model validators for instant user feedback, but must never implement their own validation logic — always delegate to the model function. +### DET Module Placement Policy + +Code lives by responsibility, not convenience: + +- **`model/`** — stateless data types and pure validation (format/length/charset). The single source of truth for validation. No `AppContext`, `Sdk`, DB, or `BackendTask`. All fee estimation goes in `model/fee_estimation.rs` — never inlined elsewhere. +- **`backend_task/`** — async business logic, one submodule per domain; the authoritative enforcement layer. `TaskError` and its typed variants live in `backend_task/error.rs`. +- **`database/`** — SQLite persistence, one module per domain. +- **`context/`** — `AppContext` submodules (`*_db.rs`, lifecycle, settings, status). +- **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. All wallet secret bytes (HD seed, imported single key, identity private key) enter/leave the vault through ONE chokepoint, `wallet_backend/secret_seam.rs` (raw `SecretBytes`, no DET-side serialization). Per-secret at-rest encryption is implemented via `put_secret_protected`/`get_secret_protected` (Argon2id + XChaCha20-Poly1305, per-secret object-password envelope, AAD bound to `wallet_id ‖ label`); unprotected secrets use `put_secret`/`get_secret` (raw, keyless vault). Identity keys (imported/loaded, including masternode voting/owner/payout) enter unprotected (Tier-1 keyless) at load/creation time — the load flow has no password field — but can be sealed to Tier-2 per-identity afterward via `IdentityTask::ProtectIdentityKeys` (Key Info screen → "Add password protection…"; gated by vault-key scheme, not identity type). The keyless-vault residual is only no-password secrets and keys the user has not opted to protect. Design + migration: `docs/ai-design/2026-06-19-secret-storage-seam/`. +- **`ui//`** — screens (`ScreenLike`). UI may *call* `model/` validators for instant feedback but never implements its own validation. +- **`ui/components/`** — reusable **Component-pattern widgets ONLY**: a `show()` plus a `ComponentResponse`, a display-only render widget, or component infrastructure. If it does not render egui, it is not a component. +- **`ui/state/`** — non-widget UI state: per-screen view-models and async fetch-state caches (e.g. `TrackedAssetLockCache`). Owned by screens, may return `BackendTask`, render nothing. +- **`src/mcp/tools/`** — MCP tool logic, one file per domain (e.g. `wallet.rs`, `shielded.rs`, `identity.rs`) with multiple tool structs per file; never in `src/bin/det_cli/`. +- **`src/localization.rs`** — localization logic. **`src/ui/theme.rs`** — theme/alignment helpers. + +Discriminator for `ui/components/` vs `ui/state/`: *does it render egui (`show`/`ui`/a render fn)?* Yes → component. No → state. + ### Error messages User-facing error messages (shown in `MessageBanner` via `Display`) must follow these rules: @@ -93,16 +110,65 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **docs/ux-design-patterns.md** is the UI/UX reference card — explains **when and how** to use design tokens, buttons, dialogs, forms, accessibility rules, and progressive disclosure. For exact values (sizes, colors, padding), refer to source files (`src/ui/theme.rs`, `src/ui/components/`). Consult when building or reviewing UI. - end-user documentation is in a separate repo: https://github.com/dashpay/docs/tree/HEAD/docs/user/network/dash-evo-tool , published at https://docs.dash.org/en/stable/docs/user/network/dash-evo-tool/ -### Core Module Structure +### System Layers (top → bottom) + +- **UI (`ui/`)** — Screens (`ui//`), reusable components (`ui/components/`), and non-widget view state (`ui/state/`). No business logic. Returns `AppAction`s. +- **App (`app.rs`)** — `AppState`: owns all screens, polls task results each frame, dispatches to visible screen. Bridges UI and backend. +- **Backend Tasks (`backend_task/`)** — Async business logic and the authoritative validation/enforcement layer, one submodule per domain (identity, wallet, contract, etc.). Operates through `AppContext`, returns typed `Result` over a channel. +- **Wallet Backend (`wallet_backend/`)** — Wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint (`secret_seam.rs`), and the event bridge. A thin adapter over the upstream `platform-wallet` crate. +- **Context (`context/`)** — `AppContext`: shared state — network config, SDK client, database, wallets, settings cache, connection health (`ConnectionStatus` / `SpvManager`), split into submodules (`identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.). Glue between layers. +- **Model (`model/`)** — Pure data types and stateless validation (amounts, fees, settings, wallet/identity models). No side effects, no IO. All fee estimation lives in `model/fee_estimation.rs` — never inline fee math elsewhere. +- **Database (`database/`)** — SQLite persistence (rusqlite), one module per domain. Typed CRUD, no business decisions. +- **Platform Integration** — Chain sync, address derivation, asset-lock/identity handling, and the shielded coordinator come from the upstream **`platform-wallet`** crate (git dep, dashpay/platform); DET is a thin adapter over it via `wallet_backend/`. SPV health is surfaced through `SpvManager` → `ConnectionStatus`. (DET's bespoke `src/spv/` stack and the `core_zmq_listener` module were removed in the platform-wallet migration.) + +### Layer Rules + +**Model rules** (ideal target for new code): +- UI never calls SDK or database directly — always through `BackendTask` +- Backend tasks receive `AppContext`, do async work, return typed results +- Models are shared across all layers — pure data types, no IO +- Database modules are pure data access — no business logic or domain decisions +- Context is the glue: UI reads from it, backend tasks operate through it +- Data types shared between layers belong in `model/`, not in `ui/` or `database/` +- Wallet secret bytes enter/leave only through the `wallet_backend/secret_seam.rs` chokepoint + +**In practice**, the codebase has established patterns that differ from the model: +- UI may **read** from DB through `AppContext` wrapper methods (e.g., `app_context.load_local_qualified_identities()`) +- UI may **write** to DB in `display_task_result()` for caching backend results +- `Wallet` (`model/wallet/`) is a large module that mixes data, address derivation, and SDK/RPC concerns — this is intentional +- Some data types live in `ui/` and are imported by `backend_task/` +- Database methods occasionally contain domain logic (e.g., contest state derivation) + +These are accepted. Do not refactor existing code to match the model rules. + +### Standard Flows + +**User action → async work → result displayed:** +``` +Screen::ui() → AppAction::BackendTask(task) + → tokio::spawn → AppContext::run_backend_task() + → sender.send(TaskResult::Success(result)) + → AppState::update() polls → Screen::display_task_result() +``` + +**UI needs fresh data on construction/refresh:** +``` +Screen::new() or refresh() → app_context.read_wrapper_method() + → returns cached or DB-read data (read-only, no writes) +``` + +**Backend task fetches + persists data:** +``` +BackendTask variant → AppContext::run_*_task() + → SDK/RPC call → persist results to DB → return typed result + → Screen::display_task_result() updates in-memory state only +``` -- **app.rs** - `AppState`: owns all screens, polls task results each frame, dispatches to visible screen -- **ui/** - Screens and reusable components (`ui/components/`) -- **backend_task/** - Async business logic, one submodule per domain (identity, wallet, contract, etc.) -- **model/** - Data types (amounts, fees, settings, wallet/identity models). **All fee estimation logic must be centralized in `model/fee_estimation.rs`** — both platform state transition fees and shielded fee calculations. Never inline fee math in UI or backend task code. -- **database/** - SQLite persistence (rusqlite), one module per domain -- **context/** - `AppContext`: network config, SDK client, database, wallets, settings cache (split into submodules: `identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.) -- **spv/** - Simplified Payment Verification for light wallet support -- **components/core_zmq_listener** - Real-time Dash Core event listening via ZMQ +**Anti-patterns (do not add new instances):** +- `app_context.db.save_*()` / `db.delete_*()` from UI code +- `tokio::spawn` in UI bypassing the `BackendTask` system +- Business logic (signing, filtering, state derivation) in UI or database layers +- Accessing wallet secret bytes outside the `wallet_backend/secret_seam.rs` chokepoint ### MCP Server & CLI (`src/mcp/`, `src/bin/det_cli/`) @@ -118,10 +184,45 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **Error type**: `McpToolError` enum (InvalidParam, WalletNotFound, SpvSyncFailed, TaskFailed, Internal) converts to `rmcp::ErrorData` via `From`. - **Docs**: `docs/MCP.md` (server config, tool reference), `docs/CLI.md` (usage, examples), `docs/MCP_TOOL_DEVELOPMENT.md` (checklist for adding new MCP tools). +### Smoke-testing changes with det-cli + +`det-cli` in standalone (stdio, lazy-init) mode is a fast, no-funds, no-GUI smoke test for the **MCP-tool layer + context wiring**. Run these after changes that touch MCP tools (`src/mcp/`), `AppContext` construction, or the wallet-backend boot path — they catch compile/API drift and context-init regressions before any live-network testing. + +Build: + +```bash +cargo build --bin det-cli --features cli +``` + +Then, with `MCP_API_KEY` unset (or empty — the default `.env` ships it empty, which means standalone), run the read-only checks. Point `DASH_EVO_DATA_DIR` at a throwaway dir to avoid touching real user data or contending with a running GUI / `det-cli serve` instance: + +```bash +DET=$(mktemp -d) && cp .env.example "$DET/.env" +BIN=target/debug/det-cli # or "$CARGO_TARGET_DIR/debug/det-cli" if that env var is set +run() { env -u MCP_API_KEY DASH_EVO_DATA_DIR="$DET" RUST_LOG=off "$BIN" "$@"; } + +run network-info # active network as JSON — no SPV sync (network-exempt) +run tools # discovers all tools via tools/list +run tool-describe name=network_info # full schema for one tool (meta tool, network-exempt) +run core-wallets-list # exercises in-process MCP -> tool -> AppContext -> DB; returns {"wallets":[]} +``` + +What each verifies: + +- **`network-info`** — binary starts, lazy-inits `AppContext` (creates `.env`/DB/secret store), reports the active network. No SPV gate, so it's a pure context-wiring check. +- **`tools`** — the in-process MCP server is up and the dynamic `tools/list` discovery path works (catches a tool that fails to register in `tool_router()`). +- **`tool-describe name=...`** — the meta tool returns a tool's JSON schema; confirms tool metadata serializes cleanly. +- **`core-wallets-list`** — drives the full dispatch chain (MCP service → tool invoke → `AppContext` → SQLite) without funds; skips the SPV gate. + +`--help`, ` --help`, and `completion ` work from the on-disk tool cache without any context init. + +**Not smoke tests** (need a synced chain / live DAPI — they wait on the SPV gate, up to a 10-min timeout): all fund-moving and balance/withdrawal tools — `core-balances-get`, `core-funds-send`, `platform-addresses-list`, `platform-withdrawals-get`, every `identity-*` and `shielded-*` tool. Don't force these in a no-network smoke run. + ### Key Dependencies - `dash-sdk` - Dash blockchain SDK (git dep from dashpay/platform) -- `egui/eframe 0.33` - Immediate mode GUI framework +- `platform-wallet` / `platform-wallet-storage` - Upstream wallet backend (git dep from dashpay/platform): SPV chain sync, address derivation, asset-lock/identity handling, shielded coordinator +- `egui/eframe 0.35` - Immediate mode GUI framework - `tokio` - Async runtime (12 worker threads) - `rusqlite` - SQLite with bundled library - Rust edition 2024, minimum rust-version 1.92 @@ -137,20 +238,7 @@ See `.env.example` for network configuration options. ## App Task System (Critical Pattern) -The UI and async backend communicate through an action/channel pattern: - -1. **Screens return `AppAction`** from their `ui()` method (e.g., `AppAction::BackendTask(task)`) -2. **`AppState` spawns a tokio task** that calls `app_context.run_backend_task(task, sender)` -3. **`AppContext::run_backend_task()`** matches on the `BackendTask` enum and dispatches to domain-specific async methods -4. **Results come back** via tokio MPSC channel as `TaskResult` (Success/Error/Refresh) -5. **Main `update()` loop** polls `task_result_receiver.try_recv()` each frame and routes results to the visible screen's `display_task_result()` - -``` -Screen::ui() → AppAction::BackendTask(task) - → tokio::spawn → AppContext::run_backend_task() - → sender.send(TaskResult::Success(result)) - → AppState::update() polls receiver → Screen::display_task_result() -``` +The UI and async backend communicate through the action/channel pattern described in Standard Flows above. **Backend task enums**: `BackendTask` has variants like `IdentityTask(IdentityTask)`, `WalletTask(WalletTask)`, `TokenTask(Box)`, etc. Each sub-enum has its own variants and corresponding `run_*_task()` method. Results are `BackendTaskSuccessResult` with 50+ typed variants. @@ -159,7 +247,7 @@ Screen::ui() → AppAction::BackendTask(task) ## Screen Pattern All screens implement the `ScreenLike` trait: -- `ui(&mut self, ctx: &Context) -> AppAction` - Render UI, return actions +- `ui(&mut self, ui: &mut egui::Ui) -> AppAction` - Render UI, return actions - `display_task_result(&mut self, result: BackendTaskSuccessResult)` - Handle async results - `display_message(&mut self, msg: &str, type: MessageType)` - Show user feedback - `refresh(&mut self)` / `refresh_on_arrival(&mut self)` - Re-fetch data @@ -183,7 +271,7 @@ Screens hold `Arc` and manage their own UI state. ### ConnectionStatus (single source of truth for connection health) -`ConnectionStatus` (`src/context/connection_status.rs`) is the **single source of truth** for all high-level connection health state — RPC, ZMQ, SPV, and DAPI. For connection health (status, peer counts, errors, overall state), always read from `ConnectionStatus`, not directly from `SpvManager` or other subsystems. +`ConnectionStatus` (`src/context/connection_status.rs`) is the **single source of truth** for all high-level connection health state — SPV and DAPI. For connection health (status, peer counts, errors, overall state), always read from `ConnectionStatus`, not directly from `SpvManager` or other subsystems. SPV status is **push-based**: `SpvManager` event handlers write directly to `ConnectionStatus` atomics (status, peer count, errors) as events arrive. The UI frame loop calls `refresh_state()` to recompute `overall_state` from these atomics, but does not poll SPV for health. This means `ConnectionStatus` is up-to-date in both GUI and headless/test contexts. Detailed SPV sync progress (heights, phase summaries used by tooltips) may still be read directly from `SpvManager.status()` until that progress reporting is migrated into `ConnectionStatus`. @@ -239,4 +327,4 @@ Single SQLite connection wrapped in `Mutex`. Schema initialized in ` Linux (x86_64/aarch64), Windows (x86_64), macOS (x86_64/aarch64 with code signing) -Requires protoc v25.2+ for protocol buffer compilation. Different ZMQ libraries for Windows (`zeromq`) vs Unix (`zmq`). +Requires protoc v25.2+ for protocol buffer compilation. diff --git a/Cargo.lock b/Cargo.lock index 64d75b06a..613be354e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,57 +20,68 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.21.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ "enumn", "serde", + "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.14.2" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" dependencies = [ "accesskit", - "accesskit_consumer 0.31.0", + "accesskit_consumer 0.36.0", "atspi-common", + "phf 0.13.1", "serde", - "thiserror 1.0.69", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.30.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd06f5fea9819250fffd4debf926709f3593ac22f8c1541a2573e5ee0ca01cd" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" dependencies = [ "accesskit", - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_consumer" -version = "0.31.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" dependencies = [ "accesskit", - "hashbrown 0.15.5", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_macos" -version = "0.22.2" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b" dependencies = [ "accesskit", - "accesskit_consumer 0.31.0", - "hashbrown 0.15.5", + "accesskit_consumer 0.37.0", + "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -78,9 +89,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.17.2" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -96,23 +107,23 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.29.2" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" dependencies = [ "accesskit", - "accesskit_consumer 0.31.0", - "hashbrown 0.15.5", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", "static_assertions", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", ] [[package]] name = "accesskit_winit" -version = "0.29.2" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" dependencies = [ "accesskit", "accesskit_macos", @@ -199,9 +210,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.11.0", + "bitflags 2.13.0", "cc", - "jni 0.22.4", + "jni", "libc", "log", "ndk", @@ -226,6 +237,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -282,6 +299,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arboard" version = "3.6.1" @@ -304,9 +332,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -321,6 +349,7 @@ dependencies = [ "blake2", "cpufeatures 0.2.17", "password-hash", + "zeroize", ] [[package]] @@ -384,7 +413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -431,7 +460,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_repr", "url", @@ -584,9 +613,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -644,16 +673,12 @@ dependencies = [ ] [[package]] -name = "asynchronous-codec" -version = "0.7.0" +name = "atomic-polyfill" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" dependencies = [ - "bytes", - "futures-sink", - "futures-util", - "memchr", - "pin-project-lite", + "critical-section", ] [[package]] @@ -664,20 +689,19 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atspi" -version = "0.25.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ "atspi-common", - "atspi-connection", "atspi-proxies", ] [[package]] name = "atspi-common" -version = "0.9.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" dependencies = [ "enumflags2", "serde", @@ -689,23 +713,11 @@ dependencies = [ "zvariant", ] -[[package]] -name = "atspi-connection" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" -dependencies = [ - "atspi-common", - "atspi-proxies", - "futures-lite", - "zbus", -] - [[package]] name = "atspi-proxies" -version = "0.9.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", @@ -714,15 +726,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -730,9 +742,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -742,9 +754,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -806,11 +818,10 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base58ck" -version = "0.1.0" +version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816" dependencies = [ - "bitcoin-internals", "bitcoin_hashes", ] @@ -853,6 +864,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + [[package]] name = "bincode" version = "1.3.3" @@ -922,22 +939,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "serde", "unicode-normalization", "zeroize", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -948,10 +956,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.6.3" +name = "bit-set" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", +] [[package]] name = "bit-vec" @@ -960,22 +971,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "bitcoin-internals" -version = "0.3.0" +name = "bit-vec" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitcoin-io" -version = "0.1.4" +version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" dependencies = [ "bitcoin-io", "hex-conservative", @@ -989,9 +1000,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -1030,24 +1041,18 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -1119,7 +1124,7 @@ dependencies = [ "hkdf", "merlin", "pairing", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", @@ -1174,9 +1179,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -1225,7 +1230,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "log", "polling", "rustix 0.38.44", @@ -1239,7 +1244,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "polling", "rustix 1.1.4", "slab", @@ -1270,6 +1275,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -1281,13 +1292,13 @@ dependencies = [ [[package]] name = "cbindgen" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d" dependencies = [ "clap", "heck", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -1300,9 +1311,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1310,12 +1321,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -1325,16 +1330,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1375,7 +1370,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1402,7 +1397,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1422,7 +1417,7 @@ checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -1432,7 +1427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" dependencies = [ "parse-zoneinfo", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -1487,9 +1482,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1509,18 +1504,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -1545,24 +1540,42 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", "unicode-width", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1594,6 +1607,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + [[package]] name = "const-oid" version = "0.9.6" @@ -1649,7 +1668,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] @@ -1665,35 +1684,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "core2" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239fa3ae9b63c2dc74bd3fa852d4792b8b305ae64eeede946265b6af62f1fff3" -dependencies = [ - "memchr", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "core_maths" version = "0.1.1" @@ -1703,6 +1693,12 @@ dependencies = [ "libm", ] +[[package]] +name = "corez" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df6f98652d30167eaeea34d77b730e07c8caba6df17bd4551842b9b8da01deb" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1731,24 +1727,47 @@ dependencies = [ ] [[package]] -name = "critical-section" -version = "1.2.0" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] [[package]] -name = "crossbeam" -version = "0.8.4" +name = "criterion-plot" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", + "cast", + "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1777,15 +1796,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1866,8 +1876,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "dash-platform-macros", "futures-core", @@ -1968,18 +1978,20 @@ dependencies = [ [[package]] name = "dash-async" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ + "futures", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", ] [[package]] name = "dash-context-provider" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "dash-async", "dpp", @@ -1997,17 +2009,18 @@ dependencies = [ "arboard", "arc-swap", "argon2", + "async-trait", "axum", "base64 0.22.1", "bincode 2.0.1", "bip39", - "bitflags 2.11.0", + "bitflags 2.13.0", "cbc", "chrono", "chrono-humanize", "clap", "clap_complete", - "crossbeam-channel", + "criterion", "dark-light", "dash-sdk", "derive_more 2.1.1", @@ -2030,13 +2043,16 @@ dependencies = [ "native-dialog", "nix", "objc2 0.6.4", + "platform-wallet", + "platform-wallet-storage", "qrcode", - "rand 0.9.2", + "rand 0.9.4", "raw-cpuid", "rayon", + "refinery", "regex", "region", - "reqwest 0.13.2", + "reqwest 0.13.4", "resvg", "rfd", "rmcp", @@ -2060,16 +2076,14 @@ dependencies = [ "which 8.0.2", "winres", "zeroize", - "zeromq", "zip32", - "zmq", "zxcvbn", ] [[package]] name = "dash-network" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -2079,16 +2093,16 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dash-network", ] [[package]] name = "dash-platform-macros" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "heck", "quote", @@ -2097,8 +2111,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "arc-swap", "async-trait", @@ -2108,6 +2122,7 @@ dependencies = [ "dapi-grpc", "dash-async", "dash-context-provider", + "dash-network-seeds", "dash-platform-macros", "derive_more 1.0.0", "dotenvy", @@ -2134,8 +2149,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "chrono", @@ -2144,11 +2159,11 @@ dependencies = [ "dashcore", "dashcore_hashes", "futures", + "git-state", "hex", - "hickory-resolver", "key-wallet", "key-wallet-manager", - "rand 0.8.5", + "rand 0.8.6", "rayon", "serde", "serde_json", @@ -2163,8 +2178,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "anyhow", "base64-compat", @@ -2189,13 +2204,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" [[package]] name = "dashcore-rpc" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dashcore-rpc-json", "hex", @@ -2207,8 +2222,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2222,8 +2237,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2231,23 +2246,10 @@ dependencies = [ "serde", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "dashpay-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -2257,8 +2259,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2273,18 +2275,52 @@ dependencies = [ "withdrawals-contract", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + [[package]] name = "der" version = "0.7.10" @@ -2317,7 +2353,18 @@ dependencies = [ ] [[package]] -name = "derive_builder" +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" @@ -2401,17 +2448,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dircpy" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88521b0517f5f9d51d11925d8ab4523497dcf947073fa3231a311b63941131c" -dependencies = [ - "jwalk", - "log", - "walkdir", -] - [[package]] name = "directories" version = "6.0.0" @@ -2454,7 +2490,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -2462,9 +2498,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -2509,8 +2545,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -2520,8 +2556,8 @@ dependencies = [ [[package]] name = "dpp" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "anyhow", "async-trait", @@ -2543,7 +2579,7 @@ dependencies = [ "getrandom 0.2.17", "grovedb-commitment-tree", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "itertools 0.13.0", "key-wallet", @@ -2557,7 +2593,7 @@ dependencies = [ "platform-value", "platform-version", "platform-versioning", - "rand 0.8.5", + "rand 0.8.6", "regex", "serde", "serde_json", @@ -2570,8 +2606,8 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "proc-macro2", "quote", @@ -2580,20 +2616,20 @@ dependencies = [ [[package]] name = "drive" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb 5.0.0", + "grovedb-costs 5.0.0", "grovedb-epoch-based-storage-flags", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-path 5.0.0", + "grovedb-version 5.0.0", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "nohash-hasher", "platform-version", @@ -2605,8 +2641,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2615,7 +2651,7 @@ dependencies = [ "dpp", "drive", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "platform-serialization", "platform-serialization-derive", "serde", @@ -2638,9 +2674,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecolor" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" dependencies = [ "bytemuck", "emath", @@ -2697,9 +2733,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +checksum = "8dc8234e2f681b2afd2b2e8c332fa614de2fddbdcb28d0acf5c420449682ea90" dependencies = [ "ahash", "bytemuck", @@ -2708,16 +2744,15 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "home", "image", "js-sys", "log", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "pollster", @@ -2737,15 +2772,16 @@ dependencies = [ [[package]] name = "egui" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" dependencies = [ "accesskit", "ahash", - "bitflags 2.11.0", + "bitflags 2.13.0", "emath", "epaint", + "itertools 0.14.0", "log", "nohash-hasher", "profiling", @@ -2757,9 +2793,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" dependencies = [ "ahash", "bytemuck", @@ -2777,18 +2813,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" dependencies = [ "accesskit_winit", "arboard", "bytemuck", "egui", "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", "serde", @@ -2800,9 +2836,9 @@ dependencies = [ [[package]] name = "egui_commonmark" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5246a4e9b83c345ec8230933bd0dca16d1c3c11db0edd4fd9c1a90683240b49" +checksum = "c833127228cc61cef806166adadc23572431fe2ea4cf753e60f654f82a2b3270" dependencies = [ "egui", "egui_commonmark_backend", @@ -2812,9 +2848,9 @@ dependencies = [ [[package]] name = "egui_commonmark_backend" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3cff846279556f57af8ea606f2e4ceaf83e60b81db014c126dfb926fa06c75b" +checksum = "5758a1110eb8259743c9b220241c30ff014b2dec53c1a2b861d464e41fa3483e" dependencies = [ "egui", "egui_extras", @@ -2823,14 +2859,15 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" +checksum = "e2bd33be7338367bf21f54d62e069d58a5fb3ae033fa8bc6df7a77b9ef4cf957" dependencies = [ "ahash", "egui", "enum-map", "image", + "itertools 0.14.0", "log", "mime_guess2", "profiling", @@ -2838,9 +2875,9 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +checksum = "52274b9bfb8d8e252cd0f00c9f6214f360d75a0962baa77a2d62ddb002fe99d4" dependencies = [ "bytemuck", "egui", @@ -2848,27 +2885,28 @@ dependencies = [ "log", "memoffset", "profiling", - "wasm-bindgen", - "web-sys", "winit", ] [[package]] name = "egui_kittest" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43afb5f968dfa9e6c8f5e609ab9039e11a2c4af79a326f4cb1b99cf6875cb6a0" +checksum = "002c31e1f41461ce206333ffbd2e07563b79e87eb71c27e026151d12ee7b78e0" dependencies = [ "eframe", "egui", "kittest", + "log", + "serde", + "toml 1.1.2+spec-1.1.0", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -2898,7 +2936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" dependencies = [ "elliptic-curve", - "heapless", + "heapless 0.8.0", "hex", "multiexp", "serde", @@ -2907,14 +2945,26 @@ dependencies = [ [[package]] name = "emath" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" dependencies = [ "bytemuck", "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2930,18 +2980,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "enum-iterator" version = "2.3.0" @@ -3054,28 +3092,35 @@ dependencies = [ [[package]] name = "epaint" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" dependencies = [ - "ab_glyph", "ahash", "bytemuck", "ecolor", "emath", "epaint_default_fonts", + "font-types", + "harfrust", "log", "nohash-hasher", "parking_lot", "profiling", + "self_cell", "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.33.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" [[package]] name = "equivalent" @@ -3149,39 +3194,36 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fancy-regex" -version = "0.13.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ - "bit-set 0.5.3", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] -name = "fax_derive" -version = "0.2.0" +name = "fd-lock" +version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", ] [[package]] @@ -3193,6 +3235,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "ff" version = "0.13.1" @@ -3257,6 +3305,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -3351,6 +3409,41 @@ dependencies = [ "num-traits", ] +[[package]] +name = "frost-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ef2787af391c7e8bedc037a3b9ea03dde803fbd93e778e6bb369547800e5cd" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools 0.14.0", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror 2.0.18", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4c5cedd2426728adef2c0b1720f57676354c473836d1ccc50d0f0d1c91942b" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3477,9 +3570,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.3.5" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649" dependencies = [ "rustversion", "serde_core", @@ -3493,7 +3586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3532,7 +3625,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -3561,14 +3654,19 @@ dependencies = [ [[package]] name = "gif" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", "weezl", ] +[[package]] +name = "git-state" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" + [[package]] name = "gl_generator" version = "0.14.0" @@ -3580,6 +3678,22 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + [[package]] name = "glob" version = "0.3.3" @@ -3600,9 +3714,9 @@ dependencies = [ [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -3616,7 +3730,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg_aliases", "cgl", "dispatch2", @@ -3676,35 +3790,18 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.11.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "gpu-allocator" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ + "ash", + "hashbrown 0.16.1", "log", "presser", - "thiserror 1.0.69", - "windows 0.58.0", + "thiserror 2.0.18", + "windows", ] [[package]] @@ -3713,7 +3810,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -3724,7 +3821,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] @@ -3735,7 +3832,7 @@ checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "memuse", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "rand_xorshift", "subtle", @@ -3749,14 +3846,14 @@ dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-costs 4.0.0", + "grovedb-element 4.0.0", + "grovedb-merk 4.0.0", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", "hex", "hex-literal", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "reqwest 0.12.28", "sha2", @@ -3765,38 +3862,35 @@ dependencies = [ [[package]] name = "grovedb" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-element 5.0.0", + "grovedb-merk 5.0.0", "grovedb-merkle-mountain-range", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-path 5.0.0", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-version 5.0.0", "hex", - "hex-literal", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", - "reqwest 0.13.2", - "sha2", "thiserror 2.0.18", ] [[package]] name = "grovedb-bulk-append-tree" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", "grovedb-dense-fixed-sized-merkle-tree", "grovedb-merkle-mountain-range", "grovedb-query", @@ -3806,12 +3900,12 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", "incrementalmerkletree", "orchard", "rusqlite", @@ -3831,8 +3925,8 @@ dependencies = [ [[package]] name = "grovedb-costs" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "integer-encoding", "intmap", @@ -3841,12 +3935,12 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", "grovedb-query", "thiserror 2.0.18", ] @@ -3858,8 +3952,8 @@ source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424 dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3867,13 +3961,13 @@ dependencies = [ [[package]] name = "grovedb-element" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-path 5.0.0", + "grovedb-version 5.0.0", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3881,10 +3975,10 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", "hex", "integer-encoding", "intmap", @@ -3901,47 +3995,47 @@ dependencies = [ "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb-costs 4.0.0", + "grovedb-element 4.0.0", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "grovedb-visualize 4.0.0", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "thiserror 2.0.18", ] [[package]] name = "grovedb-merk" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-element 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-path 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", + "grovedb-element 5.0.0", + "grovedb-path 5.0.0", "grovedb-query", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", - "grovedb-visualize 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-version 5.0.0", + "grovedb-visualize 5.0.0", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "thiserror 2.0.18", ] [[package]] name = "grovedb-merkle-mountain-range" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-costs 5.0.0", ] [[package]] @@ -3954,22 +4048,22 @@ dependencies = [ [[package]] name = "grovedb-path" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "hex", ] [[package]] name = "grovedb-query" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "bincode 2.0.1", "byteorder", "ed", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "integer-encoding", "thiserror 2.0.18", ] @@ -3985,8 +4079,8 @@ dependencies = [ [[package]] name = "grovedb-version" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -4003,8 +4097,8 @@ dependencies = [ [[package]] name = "grovedb-visualize" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b#8f25b20d04bfc0e8bdfb3870676d647a0d74918b" +version = "5.0.0" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" dependencies = [ "hex", "itertools 0.14.0", @@ -4024,16 +4118,16 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "env_logger", - "grovedb 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-costs 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", - "grovedb-merk 4.0.0 (git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897)", + "grovedb 4.0.0", + "grovedb-costs 4.0.0", + "grovedb-merk 4.0.0", "hex", "log", "num-bigint", "num-traits", "num_cpus", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "rayon", "serde", "serde_json", @@ -4047,11 +4141,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -4059,7 +4162,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -4080,9 +4183,9 @@ dependencies = [ [[package]] name = "halo2_gadgets" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45824ce0dd12e91ec0c68ebae2a7ed8ae19b70946624c849add59f1d1a62a143" +checksum = "fb2a697cad929f706b7987fe804ad57d43622cd37463ba7e4d662a926fdcfea3" dependencies = [ "arrayvec", "bitvec", @@ -4092,7 +4195,7 @@ dependencies = [ "halo2_proofs", "lazy_static", "pasta_curves", - "rand 0.8.5", + "rand 0.8.6", "sinsemilla", "subtle", "uint", @@ -4133,6 +4236,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -4148,12 +4272,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -4174,6 +4292,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.11.0" @@ -4183,13 +4310,27 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -4241,52 +4382,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -4316,9 +4411,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -4377,9 +4472,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -4392,7 +4487,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -4400,15 +4494,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -4481,7 +4574,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core", ] [[package]] @@ -4495,12 +4588,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -4508,9 +4602,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -4521,9 +4615,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4535,15 +4629,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -4555,15 +4649,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -4599,9 +4693,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -4615,6 +4709,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png 0.18.1", @@ -4661,12 +4757,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -4696,19 +4792,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result 0.4.1", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -4716,13 +4799,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "iri-string" -version = "0.7.11" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "memchr", - "serde", + "hermit-abi", + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -4766,9 +4850,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" dependencies = [ "jiff-static", "log", @@ -4779,31 +4863,15 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -4818,7 +4886,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4874,10 +4942,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4907,16 +4977,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "jwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" -dependencies = [ - "crossbeam", - "rayon", -] - [[package]] name = "keccak" version = "0.1.6" @@ -4928,13 +4988,13 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "base58ck", "bip39", - "bitflags 2.11.0", + "bitflags 2.13.0", "dashcore", "dashcore-private", "dashcore_hashes", @@ -4945,26 +5005,38 @@ dependencies = [ "serde_json", "sha2", "tracing", + "unicode-normalization", "zeroize", ] [[package]] name = "key-wallet-manager" -version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18b4f2a44997114c953fc36b0918866980724da1#18b4f2a44997114c953fc36b0918866980724da1" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "dashcore", "key-wallet", "rayon", + "serde", "tokio", + "tracing", "zeroize", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "keyword-search-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -4991,23 +5063,23 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fd6dd2cce251a360101038acb9334e3a50cd38cd02fefddbf28aa975f043c8" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" dependencies = [ "accesskit", - "accesskit_consumer 0.30.1", - "parking_lot", + "accesskit_consumer 0.35.0", ] [[package]] name = "kurbo" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -5049,9 +5121,19 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] [[package]] name = "libloading" @@ -5060,7 +5142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5071,14 +5153,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.8.0", ] [[package]] @@ -5092,6 +5174,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5106,9 +5194,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -5127,18 +5215,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" dependencies = [ "value-bag", ] [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -5158,19 +5246,10 @@ dependencies = [ "libc", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "masternode-reward-shares-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -5205,9 +5284,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -5245,21 +5324,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "metal" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" -dependencies = [ - "bitflags 2.11.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", - "log", - "objc", - "paste", -] - [[package]] name = "mime" version = "0.3.17" @@ -5273,8 +5337,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" dependencies = [ "mime", - "phf", - "phf_shared", + "phf 0.11.3", + "phf_shared 0.11.3", "unicase", ] @@ -5296,32 +5360,15 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -5360,20 +5407,20 @@ checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" [[package]] name = "naga" -version = "27.0.3" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" dependencies = [ "arrayvec", - "bit-set 0.8.0", - "bitflags 2.11.0", + "bit-set 0.9.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.13.0", + "indexmap 2.14.0", "libm", "log", "num-traits", @@ -5431,7 +5478,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -5457,11 +5504,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -5529,7 +5576,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "serde", ] @@ -5540,15 +5587,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", "serde", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -5656,15 +5703,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -5696,14 +5734,14 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -5712,7 +5750,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -5726,7 +5764,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -5750,7 +5788,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5762,7 +5800,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "dispatch2", "objc2 0.6.4", ] @@ -5773,7 +5811,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -5789,7 +5827,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -5816,7 +5854,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "dispatch", "libc", @@ -5829,7 +5867,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -5840,7 +5879,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", ] @@ -5863,23 +5902,48 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -5898,7 +5962,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -5907,12 +5971,24 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -5930,7 +6006,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -5942,10 +6018,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -5953,6 +6025,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -5961,15 +6039,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -5991,14 +6068,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -6011,9 +6098,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", @@ -6021,13 +6108,13 @@ dependencies = [ [[package]] name = "orchard" -version = "0.12.0" -source = "git+https://github.com/dashpay/orchard.git?rev=41c8f7169f2683c99cf0e0c63e8d25ec12c47a79#41c8f7169f2683c99cf0e0c63e8d25ec12c47a79" +version = "0.14.0" +source = "git+https://github.com/dashpay/orchard.git?tag=dashified-0.14.0#f05557390a5843bc4eb04c66d8140bc9ef0fe9b7" dependencies = [ "aes", "bitvec", "blake2b_simd", - "core2 0.3.3", + "corez", "ff", "fpe", "getset", @@ -6041,7 +6128,7 @@ dependencies = [ "memuse", "nonempty", "pasta_curves", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "reddsa", "serde", @@ -6056,9 +6143,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -6117,7 +6204,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6150,7 +6237,7 @@ dependencies = [ "ff", "group", "lazy_static", - "rand 0.8.5", + "rand 0.8.6", "static_assertions", "subtle", ] @@ -6163,9 +6250,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "peeking_take_while" @@ -6173,6 +6260,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6187,7 +6287,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -6196,8 +6296,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -6206,8 +6317,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -6216,8 +6327,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", - "rand 0.8.5", + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", ] [[package]] @@ -6226,14 +6347,27 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.117", "unicase", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -6244,6 +6378,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -6252,18 +6395,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -6305,9 +6448,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -6317,19 +6460,21 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" -version = "2.1.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "aes", "cbc", - "dashcore", + "hmac", + "secp256k1", + "sha2", "thiserror 1.0.69", ] [[package]] name = "platform-serialization" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6337,8 +6482,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "proc-macro2", "quote", @@ -6348,18 +6493,18 @@ dependencies = [ [[package]] name = "platform-value" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "base64 0.22.1", "bincode 2.0.1", "bs58", "ciborium", "hex", - "indexmap 2.13.0", + "indexmap 2.14.0", "platform-serialization", "platform-version", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "thiserror 2.0.18", @@ -6368,19 +6513,19 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "bincode 2.0.1", - "grovedb-version 4.0.0 (git+https://github.com/dashpay/grovedb?rev=8f25b20d04bfc0e8bdfb3870676d647a0d74918b)", + "grovedb-version 5.0.0", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "proc-macro2", "quote", @@ -6388,25 +6533,124 @@ dependencies = [ ] [[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +name = "platform-wallet" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" + "arc-swap", + "async-trait", + "bimap", + "bs58", + "dash-async", + "dash-sdk", + "dash-spv", + "dashcore", + "dpp", + "futures", + "grovedb-commitment-tree", + "hex", + "image", + "key-wallet", + "key-wallet-manager", + "platform-encryption", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "zeroize", + "zip32", +] + +[[package]] +name = "platform-wallet-storage" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" +dependencies = [ + "apple-native-keyring-store", + "argon2", + "bincode 2.0.1", + "chacha20poly1305", + "chrono", + "clap", + "dash-sdk", + "dashcore", + "dbus-secret-service-keyring-store", + "dpp", + "fd-lock", + "getrandom 0.2.17", + "hex", + "humantime", + "key-wallet", + "keyring-core", + "libc", + "platform-wallet", + "refinery", + "region", + "rusqlite", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "windows-native-keyring-store", + "zeroize", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags 2.11.0", + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -6444,6 +6688,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6464,18 +6717,31 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -6527,7 +6793,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -6563,9 +6829,9 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "prost" @@ -6622,11 +6888,11 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -6642,9 +6908,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "qrcode" @@ -6663,23 +6929,14 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", "serde", ] -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -6691,7 +6948,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -6710,9 +6967,9 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -6765,9 +7022,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6776,9 +7033,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -6786,13 +7043,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -6835,9 +7092,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" @@ -6860,7 +7117,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] @@ -6869,11 +7126,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -6889,21 +7158,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "reddsa" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" +checksum = "4784b85c8bfd17b36b86e664e6e504ecdb586001086ee23749e4a633bbb84832" dependencies = [ "blake2b_simd", "byteorder", + "frost-rerandomized", "group", "hex", "jubjub", "pasta_curves", "rand_core 0.6.4", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "zeroize", ] @@ -6922,16 +7202,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] @@ -6965,6 +7245,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "refinery" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "siphasher", + "thiserror 2.0.18", + "time", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -7060,14 +7381,13 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -7098,16 +7418,10 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "resvg" version = "0.46.0" @@ -7177,9 +7491,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.2.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", "bytes", @@ -7190,8 +7504,8 @@ dependencies = [ "http-body-util", "pastey", "pin-project-lite", - "rand 0.10.0", - "reqwest 0.13.2", + "rand 0.10.1", + "reqwest 0.13.4", "rmcp-macros", "schemars", "serde", @@ -7208,9 +7522,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.2.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7221,14 +7535,15 @@ dependencies = [ [[package]] name = "ron" -version = "0.11.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "base64 0.22.1", - "bitflags 2.11.0", + "bitflags 2.13.0", + "once_cell", "serde", "serde_derive", + "typeid", "unicode-ident", ] @@ -7249,8 +7564,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "backon", "chrono", @@ -7262,7 +7577,7 @@ dependencies = [ "http", "http-serde", "lru", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -7275,8 +7590,8 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "arc-swap", "dash-async", @@ -7305,9 +7620,9 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", "thiserror 2.0.18", @@ -7319,7 +7634,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -7370,9 +7685,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -7389,7 +7704,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7402,7 +7717,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7411,9 +7726,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -7439,9 +7754,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -7449,13 +7764,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni", "log", "once_cell", "rustls", @@ -7476,9 +7791,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -7498,7 +7813,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "bytemuck", "core_maths", "log", @@ -7606,7 +7921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", + "rand 0.8.6", "secp256k1-sys", "serde", ] @@ -7626,7 +7941,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7643,11 +7958,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -7711,11 +8032,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -7745,15 +8066,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -7809,7 +8121,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -7839,9 +8151,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest", "keccak", @@ -7862,7 +8174,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "either", "incrementalmerkletree", "tracing", @@ -7895,9 +8207,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" @@ -7937,9 +8249,19 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] [[package]] name = "slab" @@ -7961,6 +8283,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -7968,7 +8293,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -7993,7 +8318,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -8049,6 +8374,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spin" @@ -8058,11 +8386,11 @@ checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] @@ -8077,9 +8405,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -8098,9 +8426,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", @@ -8194,6 +8522,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -8242,7 +8576,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8257,37 +8591,12 @@ dependencies = [ "libc", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "tempfile" version = "3.27.0" @@ -8303,8 +8612,8 @@ dependencies = [ [[package]] name = "tenderdash-abci" -version = "1.5.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" +version = "1.5.1" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.1#fae47294618125fa9d69150e8a7a5b607af6867c" dependencies = [ "bytes", "hex", @@ -8318,8 +8627,8 @@ dependencies = [ [[package]] name = "tenderdash-proto" -version = "1.5.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" +version = "1.5.1" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.1#fae47294618125fa9d69150e8a7a5b607af6867c" dependencies = [ "bytes", "chrono", @@ -8336,8 +8645,8 @@ dependencies = [ [[package]] name = "tenderdash-proto-compiler" -version = "1.5.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" +version = "1.5.1" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.1#fae47294618125fa9d69150e8a7a5b607af6867c" dependencies = [ "fs_extra", "prost-build", @@ -8488,14 +8797,24 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -8513,8 +8832,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -8524,9 +8843,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -8541,9 +8860,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -8612,7 +8931,6 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -8629,29 +8947,30 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.13.0", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "toml_writer", - "winnow 0.7.15", + "winnow 1.0.3", ] [[package]] @@ -8659,9 +8978,6 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] [[package]] name = "toml_datetime" @@ -8674,9 +8990,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -8687,43 +9003,30 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime 0.6.11", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.8+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.3", ] [[package]] @@ -8734,9 +9037,9 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "base64 0.22.1", @@ -8765,9 +9068,9 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" dependencies = [ "prettyplease", "proc-macro2", @@ -8777,9 +9080,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -8788,9 +9091,9 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ "prettyplease", "proc-macro2", @@ -8804,9 +9107,9 @@ dependencies = [ [[package]] name = "tonic-web-wasm-client" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898cd44be5e23e59d2956056538f1d6b3c5336629d384ffd2d92e76f87fb98ff" +checksum = "3c0469c353de5f665c95f898074b5b004b500c6722214c3249f1dc79c0a2a3f6" dependencies = [ "base64 0.22.1", "byteorder", @@ -8823,7 +9126,7 @@ dependencies = [ "tower-service", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", + "wasm-streams", "web-sys", ] @@ -8835,7 +9138,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -8848,20 +9151,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -8890,11 +9193,12 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", + "symlink", "thiserror 2.0.18", "time", "tracing-subscriber", @@ -8977,7 +9281,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.1", + "rustc-hash 2.1.2", ] [[package]] @@ -8986,11 +9290,17 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "tz-rs" @@ -9023,12 +9333,9 @@ dependencies = [ [[package]] name = "uint-zigzag" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abbf77aed65cb885a8ba07138c365879be3d9a93dce82bf6cc50feca9138ec15" -dependencies = [ - "core2 0.4.0", -] +checksum = "61faa33dc26b2851a37da5390a1a4cac015887b1e97ecd77ce7b4f987431de9f" [[package]] name = "unicase" @@ -9054,6 +9361,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -9083,9 +9396,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-vo" @@ -9228,9 +9541,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9257,10 +9570,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "version-compare" -version = "0.2.1" +name = "vello_common" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] [[package]] name = "version_check" @@ -9320,7 +9655,7 @@ dependencies = [ "crypto-bigint", "elliptic-curve", "elliptic-curve-tools", - "generic-array 1.3.5", + "generic-array 1.4.1", "hex", "num", "rand_core 0.6.4", @@ -9342,8 +9677,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "platform-value", "platform-version", @@ -9368,11 +9703,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -9381,14 +9716,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -9399,23 +9734,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9423,9 +9754,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -9436,9 +9767,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -9460,24 +9791,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -9497,17 +9815,17 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] [[package]] name = "wayland-backend" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -9519,11 +9837,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -9535,16 +9853,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ "rustix 1.1.4", "wayland-client", @@ -9553,11 +9871,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -9569,7 +9887,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9578,11 +9896,11 @@ dependencies = [ [[package]] name = "wayland-protocols-misc" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9591,11 +9909,11 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9604,11 +9922,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9617,20 +9935,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -9640,9 +9958,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -9660,12 +9978,12 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ "core-foundation 0.10.1", - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2 0.6.4", @@ -9676,18 +9994,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -9710,12 +10028,13 @@ dependencies = [ [[package]] name = "wgpu" -version = "27.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" dependencies = [ "arrayvec", - "bitflags 2.11.0", + "bitflags 2.13.0", + "bytemuck", "cfg-if", "cfg_aliases", "document-features", @@ -9739,19 +10058,19 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "27.0.3" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" dependencies = [ "arrayvec", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.0", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.0", "bytemuck", "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -9764,57 +10083,66 @@ dependencies = [ "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] [[package]] name = "wgpu-core-deps-apple" -version = "27.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "27.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2f2fb042f36920771deb0b966543c5751b18f3d327760ffc90f74e20b2dcd4" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "27.0.4" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.8.0", - "bitflags 2.11.0", - "block", + "bit-set 0.9.1", + "bitflags 2.13.0", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", - "gpu-alloc", "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.1", @@ -9823,10 +10151,13 @@ dependencies = [ "libc", "libloading", "log", - "metal", "naga", "ndk-sys", - "objc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -9835,27 +10166,41 @@ dependencies = [ "profiling", "range-alloc", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", "smallvec", "thiserror 2.0.18", "wasm-bindgen", + "wayland-sys", "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +dependencies = [ + "naga", "wgpu-types", - "windows 0.58.0", - "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "27.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "bytemuck", "js-sys", "log", - "thiserror 2.0.18", + "raw-window-handle", "web-sys", ] @@ -9892,12 +10237,6 @@ dependencies = [ "libc", ] -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -9931,84 +10270,49 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.58.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", - "windows-link 0.1.3", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] name = "windows-core" -version = "0.58.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core", + "windows-link", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -10020,17 +10324,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -10042,12 +10335,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -10055,42 +10342,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-native-keyring-store" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", ] [[package]] -name = "windows-result" -version = "0.2.0" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] -name = "windows-result" -version = "0.3.4" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -10099,26 +10381,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -10127,16 +10390,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", + "windows-link", ] [[package]] @@ -10181,22 +10435,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link", ] [[package]] @@ -10236,7 +10475,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -10249,19 +10488,13 @@ dependencies = [ [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -10280,12 +10513,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -10304,12 +10531,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -10340,12 +10561,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -10364,12 +10579,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -10388,12 +10597,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -10412,12 +10615,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -10445,7 +10642,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.11.0", + "bitflags 2.13.0", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -10462,7 +10659,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", "orbclient", "percent-encoding", "pin-project", @@ -10502,15 +10699,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -10652,6 +10846,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -10671,7 +10871,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -10701,8 +10901,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", + "bitflags 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -10721,7 +10921,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -10733,8 +10933,8 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=54048b9352c5c8361f4cbfaa6a188dd3244e00c4#54048b9352c5c8361f4cbfaa6a188dd3244e00c4" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?branch=dash-evo-tool#44c20e35f572800d62e95ac9fcaa50697c25d1bd" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -10747,9 +10947,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -10804,7 +11004,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "dlib", "log", "once_cell", @@ -10831,9 +11031,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10842,9 +11042,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -10854,9 +11054,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -10881,7 +11081,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -10913,9 +11113,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -10928,22 +11128,22 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant", ] [[package]] name = "zbus_xml" -version = "5.1.0" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" dependencies = [ - "quick-xml 0.38.4", + "quick-xml", "serde", "zbus_names", "zvariant", @@ -10972,18 +11172,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", @@ -10992,18 +11192,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -11032,48 +11232,11 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zeromq" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a4528179201f6eecf211961a7d3276faa61554c82651ecc66387f68fc3004bd" -dependencies = [ - "async-trait", - "asynchronous-codec", - "bytes", - "crossbeam-queue", - "dashmap", - "futures-channel", - "futures-io", - "futures-task", - "futures-util", - "log", - "num-traits", - "once_cell", - "parking_lot", - "rand 0.8.5", - "regex", - "thiserror 1.0.69", - "tokio", - "tokio-util", - "uuid", -] - -[[package]] -name = "zeromq-src" -version = "0.2.6+4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc120b771270365d5ed0dfb4baf1005f2243ae1ae83703265cb3504070f4160b" -dependencies = [ - "cc", - "dircpy", -] - [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -11082,9 +11245,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -11093,9 +11256,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -11104,13 +11267,13 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", "typed-path", "zopfli", @@ -11141,28 +11304,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zmq" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd3091dd571fb84a9b3e5e5c6a807d186c411c812c8618786c3c30e5349234e7" -dependencies = [ - "bitflags 1.3.2", - "libc", - "zmq-sys", -] - -[[package]] -name = "zmq-sys" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8351dc72494b4d7f5652a681c33634063bbad58046c1689e75270908fdc864" -dependencies = [ - "libc", - "system-deps", - "zeromq-src", -] - [[package]] name = "zopfli" version = "0.8.3" @@ -11183,33 +11324,33 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] [[package]] name = "zvariant" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -11220,27 +11361,27 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.3", ] [[package]] name = "zxcvbn" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +checksum = "f9eaee90f4a795d1eb4ba6c51e1c1721d4784d550e8efa7b2600f29c867365e0" dependencies = [ "chrono", "derive_builder", "fancy-regex", - "itertools 0.13.0", + "itertools 0.14.0", "lazy_static", "regex", "time", diff --git a/Cargo.toml b/Cargo.toml index 9292dff18..bd7d3f9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,17 +8,17 @@ rust-version = "1.92" [dependencies] tokio-util = { version = "0.7.15" } -bip39 = { version = "2.2.0", features = ["all-languages", "rand"] } +bip39 = { version = "2.2.0", features = ["all-languages", "rand", "zeroize"] } derive_more = "2.1.1" -egui = "0.33.3" -egui_extras = "0.33.3" -egui_commonmark = "0.22.0" +egui = "0.35.0" +egui_extras = "0.35.0" +egui_commonmark = "0.24.0" rfd = "0.17.2" qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } -eframe = { version = "0.33.3", features = ["persistence", "wgpu"] } +eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "54048b9352c5c8361f4cbfaa6a188dd3244e00c4", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", branch = "dash-evo-tool", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,7 +28,14 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "54048b9352c5c83 "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "54048b9352c5c8361f4cbfaa6a188dd3244e00c4" } +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", branch = "dash-evo-tool" } +platform-wallet = { git = "https://github.com/dashpay/platform", branch = "dash-evo-tool", features = [ + "serde", + "shielded", +] } +platform-wallet-storage = { git = "https://github.com/dashpay/platform", branch = "dash-evo-tool", features = [ + "shielded", +] } zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" @@ -39,6 +46,7 @@ serde_yaml_ng = { version = "0.10.0" } tokio = { version = "1.46.1", features = ["full"] } bincode = { version = "=2.0.1", features = ["serde"] } hex = { version = "0.4.3" } +async-trait = "0.1.89" itertools = "0.14.0" enum-iterator = "2.1.0" futures = "0.3.31" @@ -55,7 +63,12 @@ arboard = { version = "3.6.0", default-features = false, features = [ "windows-sys", ] } directories = "6.0.0" -rusqlite = { version = "0.38.0", features = ["bundled", "functions", "fallible_uint"] } +rusqlite = { version = "0.38.0", features = [ + "bundled", + "functions", + "fallible_uint", + "backup", +] } dark-light = "2.0.0" image = { version = "0.25.6", default-features = false, features = [ "png", @@ -70,7 +83,6 @@ zxcvbn = "3.1.0" argon2 = "0.5.3" # For Argon2 key derivation aes-gcm = "0.10.3" # For AES-256-GCM encryption cbc = "0.1.2" # For CBC mode encryption -crossbeam-channel = "0.5.15" regex = "1.11.1" humantime = "2.2.0" which = { version = "8.0.0" } @@ -85,12 +97,6 @@ subtle = { version = "2.6", optional = true } clap = { version = "4", features = ["derive"], optional = true } clap_complete = { version = "4", optional = true } -[target.'cfg(not(target_os = "windows"))'.dependencies] -zmq = "0.10.0" - -[target.'cfg(target_os = "windows")'.dependencies] -zeromq = "0.4.1" - [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" @@ -99,20 +105,43 @@ native-dialog = "0.9.0" raw-cpuid = "11.5.0" [features] +default = [] testing = [] +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"] headless = ["cli", "mcp"] [dev-dependencies] -egui_kittest = { version = "0.33.3", features = ["eframe"] } +egui_kittest = { version = "0.35.0", features = ["eframe"] } tokio-shared-rt = "=0.1.0" +criterion = "0.5.1" +# Used only by error-mapping unit tests to construct a genuine divergent-version +# `refinery::Error` (the upstream `WalletStorageError::Migration` source). Same +# version already in the graph via `platform-wallet-storage`; the `rusqlite` +# feature is already enabled transitively, so this adds no new build. +refinery = { version = "0.9.1", default-features = false, features = ["rusqlite"] } [[bin]] name = "det-cli" path = "src/bin/det_cli/main.rs" required-features = ["cli"] +[[test]] +name = "backend-e2e" +path = "tests/backend-e2e/main.rs" +required-features = ["testing"] + +[[test]] +name = "mcp_http_auth" +path = "tests/mcp_http_auth.rs" +required-features = ["mcp"] + +[[bench]] +name = "wallet_hydration" +harness = false +required-features = ["bench"] + [build-dependencies] winres = "0.1" @@ -124,7 +153,7 @@ debug = "line-tables-only" [lints.rust.unexpected_cfgs] level = "warn" -check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"mcp\", \"cli\", \"headless\"))"] +check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"bench\", \"mcp\", \"cli\", \"headless\"))"] [lints.clippy] diff --git a/benches/wallet_hydration.rs b/benches/wallet_hydration.rs new file mode 100644 index 000000000..286b955f5 --- /dev/null +++ b/benches/wallet_hydration.rs @@ -0,0 +1,233 @@ +//! Cold-start hydration benchmarks (T-PERF-01). +//! +//! Measures the per-network rehydration paths that replaced the legacy +//! `db.get_wallets` / `db.get_single_key_wallets` reads after the +//! `platform-wallet` migration: +//! +//! - `hydrate_hd_wallets` exercises +//! [`hydrate_hd_wallets_from_views`](dash_evo_tool::wallet_backend::hydration::hydrate_hd_wallets_from_views), +//! the free-function version of `WalletBackend::hydrate_wallets_for_network` +//! (T-W-01). Setup writes N HD wallets through the +//! [`WalletSeedView`] + [`WalletMetaView`] sidecars; the measured +//! region opens a fresh `SqlitePersister` + `SecretStore` and walks +//! them cold. +//! +//! - `hydrate_single_key_wallets` exercises +//! [`SingleKeyView::hydrate_wallets`] + +//! [`SingleKeyView::rehydrate_index`] (T-W-01b). Setup imports N +//! single-key WIFs through `import_wif`; the measured region drops +//! the in-memory index and replays both reads. +//! +//! Each iteration runs in a fresh `tempfile::tempdir()` so iterations +//! are hermetic — no real network, no shared paths. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; + +use dash_sdk::dpp::dashcore::{Network, PrivateKey}; +use platform_wallet_storage::secrets::SecretStore; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +use dash_evo_tool::model::wallet::Wallet; +use dash_evo_tool::model::wallet::meta::WalletMeta; +use dash_evo_tool::model::wallet::seed_envelope::StoredSeedEnvelope; +use dash_evo_tool::wallet_backend::DetKv; +use dash_evo_tool::wallet_backend::hydration::hydrate_hd_wallets_from_views; +use dash_evo_tool::wallet_backend::single_key::{SingleKeyView, open_secret_store}; +use dash_evo_tool::wallet_backend::wallet_meta::WalletMetaView; +use dash_evo_tool::wallet_backend::wallet_seed_store::WalletSeedView; + +/// Wall-clock budget per measured sample. The 100-wallet case writes +/// the most sidecar rows at setup and benefits from the longer window; +/// the smaller cases finish well inside the default budget anyway. +const MEASUREMENT_TIME: Duration = Duration::from_secs(5); + +/// Wallet counts the bench groups iterate over. The 100-case is the +/// upper end of what a single power-user install plausibly carries. +const WALLET_COUNTS: [usize; 3] = [1, 10, 100]; + +/// Bench network. Hydration is per-network so the choice is arbitrary +/// — Testnet matches the dev-loop everyone runs locally. +const BENCH_NETWORK: Network = Network::Testnet; + +/// Build a unique 64-byte seed for the i-th wallet. `i` lands in bytes +/// 0..8 so every seed differs even when the high tail is constant. +fn seed_for(i: usize) -> [u8; 64] { + let mut seed = [0u8; 64]; + seed[..8].copy_from_slice(&(i as u64).to_be_bytes()); + // Sprinkle the rest so successive entropy bytes are not all zero + // (defensive — `Wallet::new_from_seed` derives an xpub regardless). + for (idx, b) in seed.iter_mut().enumerate().skip(8) { + *b = (idx as u8).wrapping_mul(31).wrapping_add(0x5A); + } + seed +} + +/// Generate the i-th deterministic 32-byte secp256k1 secret. Bytes +/// 0..8 carry the index so each WIF derives a distinct P2PKH address. +/// The constant tail keeps the bytes inside the secp256k1 group order +/// — `PrivateKey::from_byte_array` rejects zero / out-of-range keys, +/// so we sprinkle non-zero data. +fn priv_key_bytes_for(i: usize) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&((i as u64) + 1).to_be_bytes()); + for (idx, b) in bytes.iter_mut().enumerate().skip(8) { + *b = (idx as u8).wrapping_mul(17).wrapping_add(0x33); + } + bytes +} + +/// Open a fresh `DetKv` over a tempdir-backed `SqlitePersister`. +fn open_kv(dir: &std::path::Path) -> Arc { + let cfg = SqlitePersisterConfig::new(dir.join("det-app.sqlite")); + let persister = Arc::new(SqlitePersister::open(cfg).expect("open persister")); + Arc::new(DetKv::new(persister)) +} + +/// Open a fresh upstream secret-store vault under `dir/secrets.pwsvault`. +fn open_store(dir: &std::path::Path) -> Arc { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open secret store")) +} + +/// Seed N HD wallets into the on-disk sidecars. Mirrors the writes the +/// import path performs: an envelope blob behind the vault per wallet +/// plus a `:wallet_meta:` row in the app k/v. +fn seed_hd_wallets( + seed_view: &WalletSeedView<'_>, + meta_view: &WalletMetaView<'_>, + network: Network, + count: usize, +) { + for i in 0..count { + let seed = seed_for(i); + let wallet = Wallet::new_from_seed(seed, network, Some(format!("bench-{i}")), None) + .expect("derive bench wallet"); + let xpub = wallet + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(); + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: format!("bench-{i}"), + is_main: i == 0, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + let seed_hash = wallet.seed_hash(); + seed_view.set(&seed_hash, &envelope).expect("set envelope"); + meta_view.set(network, &seed_hash, &meta).expect("set meta"); + } +} + +/// Seed N single-key entries through `SingleKeyView::import_wif`. The +/// in-memory index is populated as a side-effect; callers that want to +/// measure cold hydration clear it explicitly. +fn seed_single_key_wallets(view: &SingleKeyView<'_>, count: usize) { + for i in 0..count { + let bytes = priv_key_bytes_for(i); + let priv_key = + PrivateKey::from_byte_array(&bytes, BENCH_NETWORK).expect("valid secp256k1 secret"); + let wif = priv_key.to_wif(); + view.import_wif(&wif, Some(format!("bench-sk-{i}"))) + .expect("import wif"); + } +} + +fn bench_hydrate_hd_wallets(c: &mut Criterion) { + let mut group = c.benchmark_group("hydrate_hd_wallets"); + group.measurement_time(MEASUREMENT_TIME); + for &n in &WALLET_COUNTS { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |bencher, &n| { + bencher.iter_custom(|iters| { + let mut total = Duration::ZERO; + for _ in 0..iters { + // Setup is intentionally outside the timer. + let dir = tempfile::tempdir().expect("tempdir"); + let kv = open_kv(dir.path()); + let store = open_store(dir.path()); + { + let seed_view = WalletSeedView::new(&store); + let meta_view = WalletMetaView::new(&kv); + seed_hd_wallets(&seed_view, &meta_view, BENCH_NETWORK, n); + } + // Reopen everything so the measured region sees cold + // SQLite caches and a fresh upstream vault handle — + // the closest a bench can get to a real cold boot. + drop(kv); + drop(store); + let kv = open_kv(dir.path()); + let store = open_store(dir.path()); + let seed_view = WalletSeedView::new(&store); + let meta_view = WalletMetaView::new(&kv); + + let start = Instant::now(); + let out = hydrate_hd_wallets_from_views(&seed_view, &meta_view, BENCH_NETWORK) + .expect("hydrate hd wallets"); + total += start.elapsed(); + assert_eq!(out.len(), n, "hydration must yield every seeded wallet"); + } + total + }); + }); + } + group.finish(); +} + +fn bench_hydrate_single_key_wallets(c: &mut Criterion) { + let mut group = c.benchmark_group("hydrate_single_key_wallets"); + group.measurement_time(MEASUREMENT_TIME); + for &n in &WALLET_COUNTS { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |bencher, &n| { + bencher.iter_custom(|iters| { + let mut total = Duration::ZERO; + for _ in 0..iters { + let dir = tempfile::tempdir().expect("tempdir"); + let kv = open_kv(dir.path()); + let store = open_store(dir.path()); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + { + let view = + SingleKeyView::from_views(&store, &index, BENCH_NETWORK, Some(&kv)); + seed_single_key_wallets(&view, n); + } + drop(kv); + drop(store); + + // Cold reopen of vault + k/v, with an empty index. + let kv = open_kv(dir.path()); + let store = open_store(dir.path()); + let cold_index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + let view = + SingleKeyView::from_views(&store, &cold_index, BENCH_NETWORK, Some(&kv)); + + let start = Instant::now(); + let wallets = view.hydrate_wallets(); + view.rehydrate_index().expect("rehydrate index"); + total += start.elapsed(); + assert_eq!(wallets.len(), n, "every imported key must rehydrate"); + } + total + }); + }); + } + group.finish(); +} + +criterion_group!( + benches, + bench_hydrate_hd_wallets, + bench_hydrate_single_key_wallets, +); +criterion_main!(benches); diff --git a/docs/CLI.md b/docs/CLI.md index 0caad5be4..60d97acb7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -126,6 +126,10 @@ det-cli platform-withdrawals-get # Query recently completed withdrawals det-cli platform-withdrawals-get status=completed +# Paginate: first page of 10, then continue from the returned next_cursor +det-cli platform-withdrawals-get status=completed limit=10 +det-cli platform-withdrawals-get status=completed limit=10 start_after= + # Get full schema and description for a tool det-cli tool-describe name=core_funds_send @@ -135,3 +139,83 @@ det-cli core-funds-send wallet-id=savings address=yXyz... amount-duffs=1000000 n # Run as stdio MCP server for Claude Desktop or Claude Code det-cli serve ``` + +## Masternode / evonode credit withdrawal (headless) + +Withdraw a masternode/evonode identity's Platform credits without the GUI: +first load the identity by ProTxHash + keys, then withdraw in either key mode. +The keys are accepted as inline `key=value` arguments (WIF or 64-char hex) — see +the private-key handling note in `MCP.md` and keep the HTTP endpoint loopback-only +for key-bearing calls. Inline `key=value` arguments are visible to other local +users (`ps`, `/proc//cmdline`) and are saved to shell history. On a shared or +untrusted host, prefer the deferred env-var/stdin entry path once available, or +clear your shell history afterward. In transfer mode, `to-address` must be a Core +address — Platform (bech32m `dash1…`/`tdash1…`) addresses are rejected. + +```bash +# 1. Load an evonode identity (testnet). Provide at least one of the owner or +# payout key; voting key and alias are optional. +det-cli masternode-identity-load \ + pro-tx-hash=<64-hex protx> \ + node-type=evonode \ + owner-private-key= \ + payout-private-key= \ + network=testnet +# -> { "identity_id": "...", "available_withdrawal_keys": ["owner","transfer"], +# "payout_address": "y...", ... } + +# 2a. Owner key — destination is forced to the registered payout address. +# Supplying to-address is rejected. +det-cli masternode-credits-withdraw \ + identity-id= \ + key-mode=owner \ + amount-credits=100000 \ + network=testnet + +# 2b. Payout/transfer key — withdraw to any Core address. +det-cli masternode-credits-withdraw \ + identity-id= \ + key-mode=transfer \ + to-address=y... \ + amount-credits=100000 \ + network=testnet +``` + +## Shielded self-verification loop (testnet) + +The shielded read/control tools let an agent drive and verify a full shielded +lifecycle headlessly — no GUI. Onboard a pre-funded testnet seed, prepare the +wallet, then move funds and confirm each balance change with `shielded-sync`. + +```bash +# 1. Import the funded testnet seed (returns its seed_hash; idempotent) +det-cli core-wallet-import mnemonic="word1 word2 ... word12" network=testnet alias=shielded-test + +# 2. Bind shielded keys + warm the proving key (~30s; idempotent) +det-cli shielded-init wallet-id=shielded-test + +# 3. Shield some Core DASH into the pool (SPV-gated — can take minutes) +det-cli shielded-shield-from-core wallet-id=shielded-test amount-duffs=2000000 network=testnet + +# 4. Sync and read the new shielded balance (expect it to increase) +det-cli shielded-sync wallet-id=shielded-test + +# 5. Read the wallet's own shielded address, then transfer to it privately +det-cli shielded-address-get wallet-id=shielded-test +det-cli shielded-transfer wallet-id=shielded-test to-address=tdash1z... amount-credits=50000 network=testnet + +# 6. Unshield part back to a Platform address, then withdraw part to Core +det-cli shielded-unshield wallet-id=shielded-test to-address=tdash1... amount-credits=300000 network=testnet +det-cli shielded-withdraw wallet-id=shielded-test to-address=yXyz... amount-credits=300000 network=testnet + +# 7. Final sync to confirm the closing balance +det-cli shielded-sync wallet-id=shielded-test + +# Fast read at any time (no sync, returns the last synced snapshot) +det-cli shielded-balance-get wallet-id=shielded-test +``` + +The `mnemonic` for the framework wallet is read from `E2E_WALLET_MNEMONIC` +(shell env or the project-root `.env`) in the backend-e2e harness; for the +standalone `det-cli` loop above, pass it directly to `core-wallet-import`. + diff --git a/docs/MCP.md b/docs/MCP.md index d46b96619..f977a80e7 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -71,21 +71,28 @@ Set these in the app's `.env` file (see `.env.example`) or as environment variab | `network_reinit_sdk` | `network` | `det-cli network-reinit-sdk` | Rebuild Core RPC client and Platform SDK with current config (use after changing credentials) | | `network_switch` | `network` | `det-cli network-switch` | Switch the active network (creates context if needed, may take a few seconds) | | `core_wallets_list` | `network`? | `det-cli core-wallets-list` | List wallets loaded in the app (alias + seed hash) | +| `core_wallet_import` | `mnemonic`, `network`, `alias`? | `det-cli core-wallet-import` | Import a wallet from a BIP-39 recovery phrase (unprotected); returns its seed hash. Idempotent | | `core_address_create` | `wallet_id`, `network`? | `det-cli core-address-create` | Generate a new receive address for a wallet | | `core_balances_get` | `wallet_id`, `network`? | `det-cli core-balances-get` | Show wallet balances (total, confirmed, unconfirmed) in duffs | | `platform_addresses_list` | `wallet_id`, `network`? | `det-cli platform-addresses-list` | Fetch platform address balances (credits and nonces) | | `core_funds_send` | `wallet_id`, `address`, `amount_duffs`, `network` | `det-cli core-funds-send` | Send DASH from a wallet to an address (amount in duffs) | -| `platform_withdrawals_get` | `status`?, `network`? | `det-cli platform-withdrawals-get` | Query Platform withdrawal documents (`"queued"` or `"completed"`) | +| `platform_withdrawals_get` | `status`?, `limit`?, `start_after`?, `network`? | `det-cli platform-withdrawals-get` | Query Platform withdrawal documents (`"queued"` or `"completed"`); returns structured entries with a `next_cursor` for pagination | | `identity_credits_topup` | `wallet_id`, `identity_id`, `amount_duffs`, `network` | `det-cli identity-credits-topup` | Top up an identity with DASH from wallet (via asset lock) | | `identity_credits_topup_from_platform` | `wallet_id`, `identity_id`, `amount_credits`, `network` | `det-cli identity-credits-topup-from-platform` | Top up an identity from Platform address balances | | `identity_credits_transfer` | `wallet_id`, `from_identity_id`, `to_identity_id`, `amount_credits`, `network` | `det-cli identity-credits-transfer` | Transfer credits between identities | | `identity_credits_withdraw` | `wallet_id`, `identity_id`, `to_address`, `amount_credits`, `network` | `det-cli identity-credits-withdraw` | Withdraw identity credits to a Core address | | `identity_credits_to_address` | `wallet_id`, `identity_id`, `to_address`, `amount_credits`, `network` | `det-cli identity-credits-to-address` | Transfer identity credits to a Platform address | +| `masternode_identity_load` | `pro_tx_hash`, `node_type`, `owner_private_key`?, `voting_private_key`?, `payout_private_key`?, `alias`?, `network` | `det-cli masternode-identity-load` | Load a masternode/evonode identity by ProTxHash and bind its owner/voting/payout keys. Returns which keys loaded, the available withdrawal modes, and the payout address. Requires at least one of the owner or payout key | +| `masternode_credits_withdraw` | `identity_id`, `key_mode`, `to_address`?, `amount_credits`, `network` | `det-cli masternode-credits-withdraw` | Withdraw a masternode/evonode identity's credits. `key_mode=owner` forces the registered payout address (no `to_address`); `key_mode=transfer` withdraws to any Core address. Platform addresses (bech32m) are rejected for both modes | | `shielded_shield_from_core` | `wallet_id`, `amount_duffs`, `network` | `det-cli shielded-shield-from-core` | Shield DASH from Core wallet into shielded pool (via asset lock, ~30s) | | `shielded_shield_from_platform` | `wallet_id`, `amount_credits`, `network` | `det-cli shielded-shield-from-platform` | Shield credits from Platform address into shielded pool | | `shielded_transfer` | `wallet_id`, `to_address`, `amount_credits`, `network` | `det-cli shielded-transfer` | Private shielded-to-shielded transfer | | `shielded_unshield` | `wallet_id`, `to_address`, `amount_credits`, `network` | `det-cli shielded-unshield` | Unshield credits to a Platform address | | `shielded_withdraw` | `wallet_id`, `to_address`, `amount_credits`, `network` | `det-cli shielded-withdraw` | Withdraw from shielded pool to a Core address | +| `shielded_init` | `wallet_id`, `network`? | `det-cli shielded-init` | Bind a wallet's shielded keys and warm the proving key (~30s); idempotent. Run once before shielded ops | +| `shielded_sync` | `wallet_id`, `network`? | `det-cli shielded-sync` | Force a shielded sync and return the post-sync balance (credits + duffs); use to verify a balance change | +| `shielded_balance_get` | `wallet_id`, `network`? | `det-cli shielded-balance-get` | Read the shielded balance from the last synced snapshot (credits + duffs); no sync | +| `shielded_address_get` | `wallet_id`, `network`? | `det-cli shielded-address-get` | Return the wallet's default shielded (Orchard) receive address (bech32m) | | `tool_describe` | `name` | `det-cli tool-describe` | Return the full MCP tool definition for a given tool name | Parameters marked `?` are optional. The `det-cli` column shows the equivalent CLI command (underscores become hyphens). @@ -94,7 +101,15 @@ Parameters marked `?` are optional. The `det-cli` column shows the equivalent CL All wallet-facing tools wait for SPV to fully sync before executing. This includes both core-chain tools (`core_address_create`, `core_balances_get`, `core_funds_send`) and platform tools (`platform_addresses_list`, `identity_credits_topup`, `shielded_shield_from_core`). Even DAPI-only operations need SPV because the SDK verifies DAPI proofs against quorum and masternode list data from the synced chain. When another DET instance is already running, SPV falls back to a temporary directory and must sync from scratch. -Only metadata tools that make no network calls (`core_wallets_list`, `network_info`, `tool_describe`) skip the SPV gate. +Tools that make no network calls skip the SPV gate: the metadata tools (`core_wallets_list`, `network_info`, `tool_describe`), the local wallet import (`core_wallet_import`), and the shielded snapshot read `shielded_balance_get` (a pure in-memory read of the last synced balance). `shielded_address_get` also skips the SPV gate, but it reads through the wallet backend, so the backend must already be wired — run `shielded_init` (or any SPV-gated tool) first in standalone mode. `shielded_init` and `shielded_sync` still wait for SPV — they wire the wallet backend and drive a coordinator sync. + +`masternode_credits_withdraw` waits for SPV before dispatching: a withdrawal does proof-verified Platform reads, so it gates like every other proof-verifying tool. (`identity_credits_withdraw` historically skipped this gate; the masternode tool deliberately adds it.) + +### Private-key handling + +`masternode_identity_load` accepts the masternode owner/voting/payout private keys as JSON string values (WIF or 64-char hex). They are typed as `Secret` in the parameter struct. At deserialization, `Secret::new()` copies the content into a zeroizing, best-effort page-locked buffer, then zeroes and frees the transient serde `String` before returning — so no plain copy of the key persists beyond the deserialization call. The keys are never echoed back: the tool output reports only which keys loaded (booleans), validation errors name the key by role and never its value, and the parameter struct's `Debug` renders each key as `Secret(***)` so it cannot leak into logs or the MCP error `data` payload. + +Over the MCP **HTTP** transport these keys traverse the request body. The HTTP endpoint is bearer-authenticated and binds to loopback by default; do not send live mainnet masternode keys over a non-loopback MCP HTTP endpoint. (HTTP transport-layer key handling is not separately enforced in code — keep the endpoint loopback-only for key-bearing calls.) ## CLI interface (det-cli) @@ -130,9 +145,9 @@ See [CLI.md](CLI.md) for full documentation. Tools accept a `network` parameter (e.g. `"mainnet"`, `"testnet"`, `"devnet"`, `"local"`). When provided, the request fails immediately if it does not match the server's active network. This prevents accidentally operating on the wrong network. -For **destructive tools** (those that spend funds or modify state — all identity and shielded tools), `network` is **required**. The tool will reject the request if `network` is omitted or does not match the active network. This is a safety measure to prevent accidentally spending funds on the wrong network. +For **destructive tools** (those that spend funds or modify state — the fund-moving identity tools `identity_credits_topup`, `identity_credits_topup_from_platform`, `identity_credits_transfer`, `identity_credits_withdraw`, `identity_credits_to_address`, `masternode_credits_withdraw`, plus `core_funds_send`, `core_wallet_import`, and the fund-moving shielded tools `shielded_shield_from_core`, `shielded_shield_from_platform`, `shielded_transfer`, `shielded_unshield`, `shielded_withdraw`), `network` is **required**. The tool will reject the request if `network` is omitted or does not match the active network. This is a safety measure to prevent accidentally spending funds on the wrong network. Note: `masternode_identity_load` is non-destructive (it does not move funds) but also requires `network`, because the private keys it binds are chain-scoped. -For **read-only tools** (e.g. `core_wallets_list`, `core_balances_get`), `network` is optional. When omitted, the tool operates on whatever network is currently active. +For **read-only tools** (e.g. `core_wallets_list`, `core_balances_get`) and the shielded control/read tools (`shielded_init`, `shielded_sync`, `shielded_balance_get`, `shielded_address_get`), `network` is optional. When omitted, the tool operates on whatever network is currently active. The `network_info` and `tool_describe` tools do not perform this check. diff --git a/docs/MCP_TOOL_DEVELOPMENT.md b/docs/MCP_TOOL_DEVELOPMENT.md index a53676b44..7bfe12e4e 100644 --- a/docs/MCP_TOOL_DEVELOPMENT.md +++ b/docs/MCP_TOOL_DEVELOPMENT.md @@ -97,7 +97,7 @@ impl AsyncTool for MyNewTool { - Skip `verify_network` only for `network_info` and `tool_describe`. - For destructive tools (`read_only: false`), the `network` parameter **must be required** (not optional with `#[serde(default)]`). Use `resolve::require_network()` instead of `resolve::verify_network()` to prevent accidental cross-network operations that could spend funds on the wrong network. - Skip wallet resolution if the tool doesn't operate on a wallet. -- **SPV gate rule**: Call `ensure_spv_synced` for **all wallet-facing tools** — both core-chain and platform/DAPI. The SDK verifies DAPI proofs against quorum and masternode list data from the synced SPV chain, so even platform-only queries fail without it. Skip only for metadata tools that make no network calls (`core_wallets_list`, `network_info`, `tool_describe`). +- **SPV gate rule**: Call `ensure_spv_synced` for **wallet-facing tools that make network calls** — both core-chain and platform/DAPI. The SDK verifies DAPI proofs against quorum and masternode list data from the synced SPV chain, so even platform-only queries fail without it. Skip it for tools that make no network calls: metadata tools (`core_wallets_list`, `network_info`, `tool_describe`), local wallet import (`core_wallet_import`), and pure snapshot reads that read only `AppContext` atomics (`shielded_balance_get`). A tool that touches `wallet_backend()` but makes no network call (e.g. `shielded_address_get`) may also skip the gate, but document the backend-wired prerequisite in its description (run a backend-wiring tool such as `shielded_init` first in standalone mode). ### 6. Register in `tool_router()` diff --git a/docs/ai-design/2026-04-22-identity-dashpay-redesign/README.md b/docs/ai-design/2026-04-22-identity-dashpay-redesign/README.md new file mode 100644 index 000000000..1c267674c --- /dev/null +++ b/docs/ai-design/2026-04-22-identity-dashpay-redesign/README.md @@ -0,0 +1,136 @@ +# Identity + DashPay Redesign Wireframe + +This directory contains the design specification and interactive wireframe for the unified +Identities section of Dash Evo Tool 2. The redesign collapses the current two left-nav +entries — Dashpay and Identities — into a single **Identities** section with four tabs: +Home, Contacts, Activity, and Settings. + +The critical distinction throughout this design: **Identity** is the primary on-chain Dash +Platform object (keys, DPNS usernames, credit balance). **Social profile** is optional +extended metadata — display name, bio, avatar — layered on top via a DashPay Profile +document. Many identities never have a social profile. The Contacts tab is gated on a social +profile existing. The nav label remains `Identities` (plural, unchanged from the codebase). + +## How to view + +Serve locally to avoid font-loading CORS restrictions: + +``` +cd docs/ai-design/2026-04-22-identity-dashpay-redesign +python3 -m http.server 8000 +``` + +Then open `http://localhost:8000/wireframe.html` in Chromium or Firefox. + +Google Fonts (Noto Sans) is loaded via ``. If your network blocks it, the fallback +stack (system-ui / Segoe UI / Helvetica) takes over — the layout is unaffected. + +## Controls + +| Control | Location | What it does | +|---|---|---| +| Persona toggle | Top-right of page header | Switches between Alex / Priya / Jordan. Arrow-key navigation supported. Flips `body[data-persona]` which CSS uses to show/hide `.adv` (Priya+Jordan) and `.dev` (Jordan only) elements. Default: Alex. | +| Theme toggle | Top-right of page header | Flips `` between `light` and `dark`. Updates `aria-pressed`. No persistence — resets on reload. | +| Advanced expanders | Inside frames | Native `
/` — click to expand/collapse. Frame 8 Advanced is open by default (Priya context). | + +## Frames + +| # | Caption | +|---|---| +| 1 | Onboarding empty state — breadcrumb shows `(no wallet yet)` and `(no identity yet)` placeholders | +| 2 | Identity picker — grid shown when ≥ 2 identities are loaded (4 cards + add-new card); breadcrumb shows wallet pill + `(choose an identity)` | +| 3 | Identity Home — Alex with social profile (canonical). Annotation callout documents no-profile state (see design-spec §B.3) | +| 4 | Contacts — populated: 2 received requests (amber), 5 active contacts, 2 sent requests (blue) | +| 5 | Activity tab | +| 6 | Send sheet — compose step | +| 7 | Settings — Priya, Advanced expanded (multi-wallet interactive breadcrumb) | +| 8 | App chrome reference — breadcrumb switcher variants A (Alex), B (Priya), C (empty state placeholders) | + +## Placeholder token legend + +No unresolved `{{PLACEHOLDER_TOKEN}}` strings remain in `wireframe.html`. Every dynamic +value from `design-spec.md` is rendered with representative sample data: + +| Token in design-spec.md | Wireframe sample value | Design-spec section | +|---|---|---| +| `{amount}` | `2.450 DASH`, `0.500 DASH`, etc. | §B.2, §B.7 | +| `{handle}` | `@alex.dash`, `@priya.dash` | §A.2, §B.2 | +| `{wallet_name}` | `Main Wallet`, `Masternode Ops` | §A.3 | +| `{fiat_code}` | `USD` | §B.2, §B.7 | +| `{fiat_amount}` | `214.30`, `43.70` | §B.2, §B.7 | +| `{fee_amount}` | `0.00002 DASH` | §B.7 | +| `{total_amount}` | `0.50002 DASH` | §B.7 | +| `{credit_amount}` | `50,000 credits` | §B.7 | +| `{counterparty_name}` | `@carol.dash`, `@dave.dash` | §B.6, §B.7 | +| `{network_name}` | `Mainnet`, `Testnet` | §D tooltip 15 | +| `{max}` | `200` | §B.8 | +| `{reason}` | `voting` | §B.3 | + +## Screenshot capture + +Capture all 8 frames in light and dark mode using Playwright (requires `npx playwright`): + +``` +npx playwright screenshot \ + --full-page \ + http://localhost:8000/wireframe.html \ + wireframe-full.png +``` + +For individual frames at 1280x800, use the Playwright Node API targeting each +`section[aria-labelledby]` element, iterating over personas `alex`, `priya`, `jordan` and +themes `light`, `dark`. This produces up to 48 PNGs (8 frames x 3 personas x 2 themes). + +## Design decisions recorded since initial commit + +- **Shadow alphas**: wireframe shadow CSS values (`0.08`–`0.30`) are intentionally brighter + than `theme.rs` egui alpha bytes (`8`–`30` / 255 ≈ `0.031`–`0.118`). The wireframe is the + visual target; `theme.rs` needs updating in the implementation PR. See design-spec.md §E. +- **Secondary Home actions**: Add funds / Send to wallet / Send to another identity are + visible to all personas, no `.adv` gate. Persona-specific funding paths are inside the + wizard, not on the Home row. See design-spec.md §G9. +- **Local nickname vs. DPNS aliases**: `QualifiedIdentity.alias` is renamed `Local nickname` + in Settings — not deprecated. See design-spec.md §G7. +- **Identity pill dropdown ordering**: Local nickname → DPNS username → shortened ID. + Search activates at 7+ identities. Drag-reorder deferred. See design-spec.md §G6. +- **Breadcrumb as switcher**: the wallet + identity switcher is now embedded directly in the + breadcrumb (`Identities › [wallet pill] › [identity pill]`). The old standalone switcher + row under the breadcrumb is removed. Placeholder text `(no wallet yet)`, `(no identity yet)`, + and `(choose an identity)` ensures all three segments are always present. Alex's wallet pill + is `.subdued` (non-interactive); Priya/Jordan's wallet pill is `.switcher-interactive`. + Pills use reduced vertical padding (2px) so the topbar stays single-line. See design-spec §A.3. +- **Identity picker page (F2) has no switcher segments filled**: the picker IS the selector. + Wallet pill is shown (subdued for Alex, interactive for Priya), but identity segment is the + `(choose an identity)` placeholder until a card is clicked. +- **Consolidated Identity Home (F3)**: a single frame covers both social-profile-set and + no-profile states. The no-profile state is documented via an annotation callout pointing to + design-spec §B.3. No separate "Priya, no social profile" frame. +- **Populated Contacts (F4)**: replaces the gated-state frame. Three sections: received + requests (amber, rendered first), active contacts (5 rows with search), sent requests + (muted blue). Gated state is now documented in §B.4.1 — shown inline in the active + contacts section when the identity has no social profile. +- **App chrome reference moved to F8**: opening with the component reference confused readers. + Moving it to the end means users encounter the actual screens first. Caption updated to + "App chrome reference" to signal this is a reference frame, not a starting point. +- **Identity picker card heading hierarchy**: display name preferred over DPNS handle, which is preferred over shortened Identity ID. This matches the priority order already established for the breadcrumb pill (see §A.3 / §G6) and avoids a separate rule set. +- **Picker avatar sizing**: 72×72 px chosen as a midpoint between the 40 px contact list avatar and the 96 px hero avatar, giving enough surface for a legible monogram glyph without dominating the card at ≥ 260 px width. +- **Picker card hover elevation**: shadow increases from `--shadow-small` to `--shadow-medium` on hover — same elevation step used by all other interactive cards in the design. No border-color change on standard cards (the add-new card switches from dashed to solid Dash Blue instead, since that border is its defining visual element). + +## Known limitations + +- Static visual reference only — not a clickable prototype. +- No real network calls; all data is hard-coded sample values. +- Persona toggle and theme toggle work; tab switching and dropdown interactions do not. +- Send sheet Retry / Review flow is shown statically; button states are for illustration. +- Google Fonts require a network connection; system fallback activates offline. + +## Links + +- [design-spec.md](./design-spec.md) — full UX specification (IA, screens, wording audit, + tooltip catalog, visual direction) +- [docs/personas/everyday-user.md](../../personas/everyday-user.md) — Alex Torres persona +- [docs/personas/power-user.md](../../personas/power-user.md) — Priya Nakamura persona +- [docs/personas/platform-developer.md](../../personas/platform-developer.md) — Jordan Kim +- [src/ui/theme.rs](../../../src/ui/theme.rs) — authoritative token source + (DashColors, Spacing, Shape, Shadow, Typography) +- [docs/ux-design-patterns.md](../../ux-design-patterns.md) — UI/UX reference card diff --git a/docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md b/docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md new file mode 100644 index 000000000..6353477bb --- /dev/null +++ b/docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md @@ -0,0 +1,879 @@ +# Identity + DashPay Redesign — UX Specification + +**Target**: Dash Evo Tool 2, `v1.0-dev` +**Date**: 2026-04-22 +**Author**: Trillian (Technical Writer) +**Status**: Approved — implementation reference + +--- + +## Orientation + +This spec collapses the current two left-nav entries — **Dashpay** and **Identities** — into +one unified section called **Identities**. + +**Critical distinction carried throughout this document:** + +- **Identity** is the primary on-chain Dash Platform object. It owns keys, DPNS usernames, + a credit balance, and optional documents. Every operation on Dash Platform requires an + identity. A user can own multiple identities across multiple wallets, or import identities + without a wallet. +- **Social profile** is *optional extended metadata* attached to an identity via a DashPay + Profile document — display name, bio, avatar. Many identities will never have a social + profile (masternodes, evonodes, DPNS-only users, developers). +- **Wallet** is the container for signing keys. A wallet can own zero, one, or many + identities. An identity may also have no wallet on this device (imported by ID and private + key). + +Every place a previous draft said "Profile" when referring to the on-chain object, this +document says "Identity." Every place the previous draft said "DashPay profile" or "profile" +as optional extended metadata, this document says "social profile." + +Nothing currently possible disappears. Feature parity is preserved. + +--- + +## A. Information Architecture + +### A.1 Left-nav entry + +**Label: `Identities`** — unchanged from the current codebase label to minimize churn for +existing users. Always plural. No pluralization logic needed. + +``` +Wallets +Identities ← collapses today's Identities + Dashpay +Contracts +Tokens +Tools +Settings +``` + +Nav icon: people-silhouette glyph (replaces today's card icon, subtly distinguishes from +Wallets). Network color stripe behavior is unchanged. + +**Info tooltip on the nav entry** (info, All): +> Your identities on Dash Platform. Manage usernames, balances, keys, and — if you set up a +> social profile — DashPay contacts and payments. + +### A.2 Tabs inside Identities + +| # | Tab | Purpose | Maps to today | +|---|-----|---------|---------------| +| 1 | **Home** | Identity hero (DPNS handle, type badge, balance, primary actions). If a social profile exists, its avatar + display name + bio render at the top of the hero. If not, an inline "Set up your social profile" card is shown. Onboarding checklist, recent activity preview. | Identities row summary + Dashpay Profile view | +| 2 | **Contacts** | Requests strip · search + filters · contact list · right-side detail drawer · Add by username · Scan QR · Show my QR. Disabled / gated when the current identity has no social profile. | Dashpay Contacts + Profile Search + Add Contact + Contact Details | +| 3 | **Activity** | Unified timeline merging DashPay payments, funding (Add funds / Send to wallet / Send to another identity), and platform ops (DPNS, key changes). Filter chips: Payments · Funding · Platform. Expandable detail per row. | Dashpay Payment History + identity credit movements | +| 4 | **Settings** | Identity essentials: DPNS username + aliases, keys table, raw Identity ID, identity type, refresh / diagnostics, danger zone. Social profile subsection — create / edit display name, bio, avatar, delete social profile. | Dashpay Profile edit + identity Keys / Add Key + Alias + DPNS registration | + +### A.3 Wallet + Identity switching — breadcrumb as switcher + +The breadcrumb IS the wallet and identity switcher. It is always visible in the topbar of +every tab. Three segments, left to right: + +``` +Identities › [💼 Main Wallet] › [👤 @alex.dash ▾] +``` + +``` + +``` + +**First segment — "Identities"**: plain text link. Navigates back to the Identity Picker +(§B.14) or to the section root. Not a pill. + +**Second segment — wallet pill** (`.breadcrumb-pill`): icon + wallet alias. Style and +interactive behavior vary by persona: +- Alex (single wallet): `.subdued` modifier — no chevron, non-interactive, transparent + background. Info tooltip `tt-3` unchanged. +- Priya / Jordan (multiple wallets): `.switcher-interactive` — hover border, chevron, + `aria-haspopup="listbox"`. Dropdown lists every loaded wallet on the current network plus + footer "Set up another wallet". + +**Third segment — identity pill** (`.breadcrumb-pill.switcher-interactive`): avatar (or +type-glyph monogram) + DPNS handle (or short Identity ID) + chevron. Always interactive +where an identity is active. `aria-haspopup="listbox"`. Dropdown is scoped to the selected +wallet. A grouped section "Identities without a wallet on this device" lists identities +imported by raw ID. Footer "Add another identity" opens a chooser (Create new · Load +existing · Dev Mode only: Create multiple test identities). + +**Placeholder rules** (when a segment has no value yet): + +| Situation | Second segment | Third segment | +|---|---|---| +| No wallet, no identity (onboarding) | `(no wallet yet)` — italic, `text-secondary`, `aria-disabled="true"`, `role="presentation"` | `(no identity yet)` — same treatment | +| Wallet selected, no identity chosen (picker page) | Wallet pill (subdued or interactive per persona) | `(choose an identity)` — italic placeholder | +| All tabs when identity is active | Wallet pill | Identity pill with handle/name | + +**Persona behavior**: + +- Alex (one wallet, one identity): wallet pill is `.subdued` (non-interactive) with info + tooltip. Identity pill is interactive. +- Priya (many wallets, many identities): both pills fully interactive with chevrons and + dropdowns. +- Jordan (Dev Mode): identity dropdown footer offers "New throwaway wallet + identity" that + chains wallet creation → funding → identity registration. + +**Network awareness**: switching network filters both dropdowns to wallets and identities +that exist on the new network. + +**Identity pill dropdown — ordering rule**: items are sorted `Local nickname → DPNS username +→ Identity ID (shortened)`. Inline search appears once the wallet contains 7 or more +identities. Drag-to-reorder is intentionally deferred to a later iteration (see §G). + +**Jordan dev-mode dropdown footer**: the identity pill dropdown contains a `+ New throwaway +wallet + identity` footer entry in Developer Mode (catalog §D entry #6). This Jordan-only +path chains wallet creation → funding → identity registration in one step. + +**CSS**: `.breadcrumb-pill` uses reduced vertical padding (`padding: 2px var(--sp-sm)`) so +the topbar stays single-line. The `.breadcrumb-ol` container is `display:flex; align-items: +center; gap:var(--sp-xs); flex-wrap:nowrap`. Focus ring and dropdown affordances are +unchanged from the previous standalone pill styling. + +### A.4 Default landing for the Identities nav + +When the user clicks **Identities** in the left nav the app decides what to show based on +how many identities are loaded for the active network: + +| Loaded identities | Landing | +|---|---| +| 0 | Onboarding empty state (F1) | +| 1 | Identity Home for that identity directly (F3 — covers both social-profile-set and no-profile states) | +| ≥ 2 | Identity Picker grid (F2) | + +**Navigation from the picker**: clicking a card selects that identity in the breadcrumb +switcher and navigates to Identity Home. The identity pill on Home becomes the route back +to the picker — clicking it opens the same identity list as a dropdown. Navigating to the +`Identities` breadcrumb link also returns to the picker. Both affordances use the same +behaviour so there is only one mental model. + +--- + +## B. Screen-by-screen Design + +All strings are complete sentences with named placeholders per the project i18n rule. +No concatenation. + +### B.1 Onboarding empty state (Frame 1) + +Shown when the user opens Identities on a network where they have no loaded identities. + +**Layout**: island central panel, centered content, max-width 640 px. Abstract avatar +silhouette against a soft Dash-blue radial gradient. Heading + body + two primary actions +stacked vertically. Muted footer band in Developer Mode. + +**Exact strings**: + +- Heading: `Welcome to Identities.` +- Body paragraph 1: `An identity is your account on Dash Platform. With one you can pick a + username, send and receive Dash by name, and — if you choose — connect with people through + DashPay.` +- Body paragraph 2: `You only need a small amount of Dash from your wallet to get started.` +- Primary button: `Create my first identity` +- Secondary button (ghost): `I already have an identity — load it` +- Developer Mode footer: `Developer tools:` `[Create multiple test identities]` `·` + `[Load identity by ID]` + +**Validation / failure banners**: +- Insufficient wallet balance: `Your wallet does not have enough Dash to create an identity + yet. Add at least {amount} to continue.` [Go to Wallets] (this app has no separate top-level + Receive screen, so the link goes to Wallets, where the user's receiving address lives) +- No wallet: `You need a wallet before you can create an identity.` [Set up a wallet] + +### B.2 Identity Home (Frame 3) + +The default tab landing once at least one identity exists. The canonical wireframe render +uses the Alex / social-profile-set state. See §B.3 for the no-social-profile variant, which +is annotated inside the same frame. + +**Layout zones** (vertical stack inside the island panel): + +1. Chrome strip — breadcrumb (with wallet + identity pills), tab bar. +2. Hero identity card (~240 px tall, full width, gradient surface). +3. Quick-actions row (Send / Receive / Add contact). +3a. Secondary actions row (Add funds / Send to wallet / Send to another identity) — all three + visible for all personas. See §PROJ-008 for entry-point rationale. +4. Onboarding checklist strip (conditional — until all three steps complete). The three + steps, in order: + 1. `Pick a username` + 2. `Set a display name` — hidden if the user has previously dismissed the social profile + card (treated as a deliberate skip; do not re-prompt). + 3. `Add your first contact` +5. Recent activity preview (latest 5 rows + See all link). + +**Hero identity card content** (social profile set): + +Left cluster: 96 px avatar circle (social profile image or initials fallback) + display name +(heading_large) + `@{handle}` below (body, text_secondary). + +Right cluster: `{amount} DASH` (heading_medium) + fiat equivalent (body, text_secondary) + +Identity-type badge pill + Network pill. + +If no DPNS name: `No username yet` (italic, text_secondary) with link `Pick a username`. + +**Quick-actions row**: + +| Button | Label | Tooltip | +|---|---|---| +| Primary | `Send` | `Send Dash to a contact, username, or address.` | +| Primary | `Receive` | `Show a QR code or your username so someone can pay you.` | +| Secondary | `Add contact` | `Find someone by username and add them to your contacts.` | + +**Recent activity preview** — up to 5 rows. Example strings: +- `Received {amount} DASH from {counterparty_name}` +- `Sent {amount} DASH to {counterparty_name}` +- `Added {amount} DASH to your identity` +- `Sent {amount} DASH to your wallet` +- `Registered the username {handle}` + +Footer link: `See all activity` → activates Activity tab. + +Empty state: `No activity yet. When you send or receive Dash, it will show up here.` + +**Progressive disclosure on Home** (Priya / Jordan only): + +Advanced expander below activity preview (collapsed for Alex, open for Priya / Jordan): +- Label: `Advanced details` +- Contents: raw Identity ID (copyable, monospace, RADIUS_SM), revision number, last + updated, keys summary. + +**Secondary actions row** (below quick-actions row, all personas): + +| Button | Label | Tooltip | +|---|---|---| +| Ghost | `Add funds` | `Move Dash from your wallet into this identity.` | +| Ghost | `Send to wallet` | `Convert your identity balance back to spendable Dash in your wallet.` | +| Ghost | `Send to another identity` | `Transfer Dash directly from this identity to another identity.` | + +These three buttons enter the Add funds wizard (§B.9), the Send to wallet flow, and the +Send sheet (§B.7) with pre-configured recipient mode respectively. All are visible for all +personas; no `.adv` gating. + +### B.3 Identity Home — no social profile state + +The same frame as §B.2 (Frame 3) covers this state via an annotation callout. No separate +frame exists for the no-profile state. + +**Hero identity card** (no social profile): type-glyph monogram in place of avatar (person / +masternode / evonode glyph in a Dash-Blue ring). DPNS handle + identity-type badge + +balance. No display name shown. + +**Inline social profile card** (rendered below the quick-actions row, above onboarding +checklist): + +- Heading: `Set up your social profile` +- Body: `Add a display name, bio, and avatar so people can find you on DashPay. This is + optional — you can still use every other feature without it.` +- Primary button: `Add a display name` +- Ghost link: `Skip — I use this identity only for {reason}` + +**Tooltip on the social profile card** (info, All): +> Add a display name, bio, and avatar so people can find you on DashPay. This is optional +> — you can still use every other feature without it. + +**Wireframe annotation** (inside Frame 3): +> When the selected identity has no social profile, the avatar shows the type-glyph monogram +> and the hero body renders an inline "Set up your social profile" card. See design-spec §B.3. + +### B.4 Contacts (Frame 4) + +Populated contacts page shown when the identity has a social profile. Three sections rendered +in priority order. + +**Layout zones**: +1. Tab header: `Contacts` title + right-aligned action buttons: `+ Add by username`, + `Scan QR`, `Show my QR` (catalog tooltips #26–28). +2. Section 1: Received requests — awaiting your approval (amber left-border, amber `2 new` + badge, rendered first so they surface immediately). +3. Section 2: Active contacts (the bulk of the page — heading `Active contacts · {n}` + + search input right-aligned). +4. Section 3: Sent requests — waiting for acceptance (muted, blue left-border, rendered at + bottom). + +**Received requests section** — horizontal row of request cards (`.request-card +.request-card--received`, amber `3px` left-border). Each card: +- 40 px avatar, display name, `@handle`, relative timestamp. +- Accept button (catalog tt-29) and Decline button (catalog tt-30). Both have `aria-describedby`. + +**Active contacts section** — list rows (`.list-row`). Each row: +- 40 px avatar, display name (body_large), `@{handle}` + last-payment hint (body_small, + text_secondary). +- Compact `Send` primary-small button (catalog tt-32) and `•••` overflow icon-button (catalog tt-33). +- Row is clickable and opens the contact detail drawer (right-hand slide-in, 480 px): + avatar, display name, `@{handle}`, four action buttons (Send Dash · Copy handle · Edit + private label · Remove contact). Collapsible sections: About · Private notes · Payment + history · Advanced. + +**Sent requests section** — request cards (`.request-card .request-card--sent`, blue left- +border, `opacity: 0.85`). Each card: +- Avatar, handle, display name. +- `Pending` pill (catalog tt-31) on the pill. +- `Cancel request` ghost button (catalog tt-29c): *Cancel the request. {counterparty_name} + will not be notified.* + +**Search input** (inside the active contacts section header, right-aligned): +- `type="search"`, `placeholder="Search your contacts"`, `aria-label="Search your contacts"`. + +**Empty states**: +- No received requests: the section collapses to a single muted line `No pending requests.` +- No active contacts: section shows `You have no contacts yet.` with the primary `Add by + username` CTA. +- No sent requests: section is hidden entirely (no empty state). + +**Add by username** (Contacts tab header button): the input field accepts `@username` or a +raw Base58 Identity ID — both resolve to the same lookup path. Tooltip copy: "Find someone +by their Dash username or identity ID and add them as a contact." + +### B.4.1 No-social-profile state + +When the current identity has no social profile, the Contacts tab does not show the three +sections above. Instead, the main content area renders a centered gate card: + +- Heading: `Set up a social profile first.` +- Body: `Contacts use your display name and avatar to let people find you. Your username + @{handle} already works for payments — a social profile only unlocks contacts. Without a + social profile, you cannot add contacts or receive contact requests.` +- Primary button: `Add a display name` +- Secondary button: `Why?` — expands an inline explanation panel. + +The setup card lives on Identity Home (§B.3). Once the social profile is set up, the +Contacts tab transitions to the populated state (§B.4). + +**Tooltip on the Contacts tab when gated** (info, All): +> Set up a social profile first. Contacts need a display name and avatar so people can find +> you. + +### B.6 Activity tab (Frame 5) + +Unified timeline. + +**Filter chips** (multi-select): All (default) · Payments · Funding · Platform (collapsed +under More for Alex; fully visible for Priya / Jordan). + +**Timeline row (collapsed)**: 48 px. Left: colored icon badge. Center: action sentence +(body_large) + counterparty + method (body_small). Right: timestamp + expand chevron. + +**Timeline row (expanded)**: detail panel slides down. Two-column for Priya, single-column +for Alex. Contents: Summary · Counterparty · Details (memo, fee, status: Confirmed / +Pending / Failed) · Advanced (Priya: raw TxID, state-transition hash, block height) · +Dev mode JSON dump. + +**Failed activity row**: red left-border accent. +- Row text: `Could not send {amount} DASH to {counterparty_name}` +- Right: `Retry` small button +- Expanded banner: `The network did not accept this payment. Your balance is unchanged. + Check your connection and try again, or try a smaller amount.` + +Empty state: `No activity yet. Your payments, additions, and identity changes will appear +here.` + +### B.7 Send sheet (Frame 6) + +Modal sheet, RADIUS_LG, elevated shadow, modal_overlay backdrop. Width 560 px desktop. +Escape = cancel. + +**Step 1 — Compose**: +- Heading: `Send Dash` +- Sub-heading (body_small, text_secondary): `Send from your identity {display_name}.` +- Recipient field label: `To` +- Placeholder: `Username, contact, Dash address, or identity ID` +- Validation strings: + - `Looking up @{handle}…` (info color) + - `{handle} not found.` (error color) + - `This address is not valid.` (error color) + - `This is your own identity. Pick someone else.` (warning color) +- Amount field label: `Amount` +- `Use {fiat_code}` toggle (link style): `Enter the amount in {fiat_code}. We convert it to + DASH for you.` +- Quick amount pills: `{amount_a}` · `{amount_b}` · Max +- Memo expander label: `Add a note` +- Memo placeholder: `Private to you and the recipient.` + +**Fee and total preview card** (calm grey, RADIUS_MD): +- `You send` `{amount} DASH` +- `Network fee` `{fee_amount} DASH` +- `Total from your identity` `{total_amount} DASH` +- Priya / Jordan only: `Credits used` `{credit_amount} credits` + +Buttons: `Cancel` (ghost) · `Review` (primary). Disabled tooltip: `Enter a valid recipient +and amount to continue.` + +**Step 2 — Review**: recipient card + summary rows + memo preview + `Send {amount} DASH` +primary + `Back` ghost. + +**Step 3 — Sent**: `Payment sent`. Body: `{counterparty_name} will see this in their +activity. Your identity balance is now {new_balance} DASH.` Post-success suggestion card if +the recipient is not yet a contact: `Would you like to add {counterparty_name} as a contact +so future payments are one click away?` [Add as contact] [Not now] + +**Step 3 — Failed**: Heading `Payment could not be sent`. Body: `Your balance is unchanged. +Check your connection and try again, or try a smaller amount.` Actions: Back · Try again. + +### B.8 Settings tab — Priya, Advanced expanded (Frame 7) + +**Layout**: two-column on >= 1024 px; single column otherwise. Left: social profile section. +Right: username and aliases section. Full-width Advanced expander below. + +**Left column — Social profile section** (renamed from "Public profile" in earlier draft): + +- Section heading: `Social profile` +- Helper: `This information is visible to everyone on Dash Platform.` +- Avatar editor: 128 px circle + `Change photo` ghost button. +- Display name input, label: `Display name`, placeholder: `How should people see your name?` +- Bio textarea (4 rows), label: `About`, placeholder: `A short description, up to {max} + characters.` +- Save button: `Save social profile` + - Disabled (no changes): `There are no changes to save.` + - Disabled (invalid): `Fix the highlighted fields before saving.` +- Danger link (danger color): `Delete social profile` + - Confirmation dialog: `Remove the display name, bio, and avatar from DashPay. Your + identity, usernames, and balance stay intact. Are you sure?` + +**Right column — Username and aliases**: +- Section heading: `Username` +- Current username row: monospace `@{handle}` + Copy button + `Primary` pill. +- No primary username: CTA card `Pick a username` with `Register a username` primary button. +- Section heading: `Aliases` +- Helper: `Extra usernames that also point to your identity.` +- List rows: `@{alias}` + `Make primary` + `Remove`. +- `Add an alias` ghost button. + +**Advanced expander** (collapsed by default for Alex; expanded for Priya / Jordan): +- Label: `Advanced` +- Sub-label: `Keys, raw identifiers, and identity type.` + +Contents: + +1. **Identity type and raw ID**: + - Identity-type badge (User identity / Masternode identity / Evonode identity). + - Raw Identity ID (monospace, RADIUS_SM surface, Copy icon-button). + - Masternode / Evonode only: `Masternode ID` (ProTxHash, monospace, Copy button). + +2. **Keys**: + - Sub-heading: `Keys` + - Helper: `Keys let this identity sign actions. Most people never need to manage these + directly.` + - Table: columns Purpose · Type · Status · Added · Actions. + - Per-row actions: View details · Disable (where applicable). + - Below table: `Add a new key` primary-small button. + +3. **Refresh and diagnostics**: + - `Refresh identity data` ghost button. + - Priya only: `Refresh mode` selector (Core only / Platform only / Both). + +4. **Danger zone** (red-bordered card at bottom of Advanced expander): + - Sub-heading: `Danger zone` + - Action: `Unload this identity from this device` + - Confirmation dialog: `This removes the identity from this device. It remains on Dash + Platform — you can load it again later.` + +5. **Voter identity keys** (Masternode / Evonode only — rendered inside Advanced, after the + main Keys table): + - Sub-heading: `Voting keys` (Alex-facing label for `PrivateKeyOnVoterIdentity`). + - Helper: `These keys belong to the separate voter identity tied to your masternode. Most + operators manage them via the CLI, not this screen.` + - Alex-facing label tooltip: "The keys your masternode uses to vote on username contests." + - Table: same columns as main Keys table (Purpose · Type · Status · Added · Actions). + - This section is only rendered for `Masternode identity` and `Evonode identity` types. + It is `.adv`-gated. + +6. **Local nickname** (under the Display name section, below the `Aliases` heading): + - Field label: `Local nickname` + - Placeholder: `A label only you see on this device.` + - Helper: `This nickname is never published to Dash Platform. It is useful if you manage + several identities and want a shorthand beyond your DPNS username.` + - Displayed in the identity pill dropdown: priority order is Local nickname → DPNS + username → shortened Identity ID (see §A.3). + - Wording audit entry: `alias (local QualifiedIdentity.alias)` → `Local nickname` (distinct + from DPNS Alias, which refers to on-chain secondary usernames). + +7. **Auto-accept contact requests** (under Social profile section): + - Toggle: `Auto-accept contact requests` + - Helper: `Generate a proof that automatically accepts inbound contact requests without + your approval. Useful for public-facing accounts.` + - Account-index selector (Priya / Jordan only): `Account index` + - Validity-period selector: `Valid for` (options: 1 week · 1 month · 3 months · 1 year) + - Catalog §D addition (All personas, info tooltip): + > Automatically accept contact requests for this identity using an HD-derived proof. + > The proof works for the selected validity period, then expires. + +### B.9 Add funds wizard + +Entry points: secondary actions row on Home (Add funds button), Advanced expander on Home. + +**Funding method chooser** (step 1) — four methods, persona-gated: + +| Method | Alex-facing label | Visibility | +|--------|-------------------|------------| +| `UseWalletBalance` | `From your wallet` (recommended) | Alex, Priya, Jordan | +| `AddressWithQRCode` | `Send to an address` | Alex, Priya, Jordan | +| `UsePlatformAddress` | `Use a Platform address` | Priya, Jordan | +| `UseUnusedAssetLock` | `Recover an unfinished funding` | Priya, Jordan | + +**Recover an unfinished funding** (`UseUnusedAssetLock`) is the orphan-recovery flow for +users whose identity creation failed mid-stream. Alex-facing label deliberately avoids "Asset +lock" jargon. Priya / Jordan see the technical name in a secondary gloss. + +**From your wallet** (`UseWalletBalance`) is the primary path and should be pre-selected. +After method selection, step 2 shows an amount input with the wallet balance shown inline, +fee preview, and a `Confirm` primary button. + +### B.10 Create identity wizard + +Triggered by `Create my first identity` on the onboarding screen, or `+ Add another identity +→ Create new` from the identity pill dropdown. + +1. **Fund the identity** — runs the Add funds wizard (§B.9) inline. Recommended method: + `UseWalletBalance`. Minimum required shown dynamically. +2. **Pick a username** — optional at creation time but encouraged. Contested name detection + runs here (see §B.13). User may skip; username can be registered later from Settings. +3. **Done** — brief success state; identity is now active on Home. + +### B.11 Load existing identity + +Triggered by `I already have an identity — load it` on the onboarding screen, or `+ Add +another identity → Load existing` from the identity pill dropdown. + +**Mode chooser** (three modes): + +| Mode | Alex-facing label | Visibility | +|------|-------------------|------------| +| By Identity ID + private key | `Enter the identity ID and private key` | Alex, Priya, Jordan | +| By DPNS username | `Enter my username` | Alex, Priya, Jordan | +| By wallet derivation | `Derive from my wallet` | Priya, Jordan (Advanced) | + +- **By Identity ID + private key**: two inputs — Identity ID (Base58) and private key. After + import, the identity is visible immediately on Home. +- **By DPNS username**: resolves the username to an Identity ID via the network, then + prompts for the private key for the resolved identity. +- **By wallet derivation**: scans the wallet's derivation path for registered identities. + Priya / Jordan only, hidden behind an Advanced expander for Alex. + +### B.12 — (reserved) + +### B.13 Pick a username + +Accessible from: Home hero `Pick a username` link (when no DPNS name), Settings right column +`Register a username` button, and step 2 of the Create identity wizard (§B.10). + +**Step 1 — Enter a username**: +- Input field with live availability check (debounced, 300 ms). +- Success state: `@{handle} is available.` (success color). +- Unavailable state: `@{handle} is taken.` with a `Browse alternatives` ghost link. +- Contested state: `@{handle} is contested. Registering it starts a masternode vote.` + - Contested fee preview card: shows the higher fee (e.g. `0.2 DASH contest fee`) alongside + the standard registration fee. + - Explanation banner: `Contested names are put to a vote by masternodes. If enough + masternodes vote against your registration, the name goes to the next applicant. The + voting period lasts approximately 2 weeks.` + - Alex sees the plain-language banner; Priya / Jordan also see the lock period in blocks. + +**Step 2 — Fee preview and confirm**: +- Standard: `You pay {fee_amount} DASH to register @{handle}.` +- Contested: `You pay {fee_amount} DASH (including the {contest_amount} DASH contest + deposit). If the vote succeeds, the deposit is burned.` +- Primary button: `Register @{handle}` + +**Step 3 — Registered / Failed**: +- Success: `@{handle} is yours. It is now your primary username.` +- Failed (contested, vote lost): `The vote on @{handle} did not go your way. Your contest + fee was returned. You can try a different username or wait and try again.` + +### B.14 Identity picker (Frame 2) + +Shown when the user clicks Identities in the left nav and two or more identities are loaded +on the current network (see §A.4 for the routing rules). + +**Layout**: island central panel. No switcher row — the picker itself is the selector. + +- Page heading: `Pick an identity` (heading_large / text-xxl) +- Sub-heading (body_small, text_secondary): `Each identity has its own balance, keys, and + optional social profile. Choose one to open it, or add a new identity.` +- CSS grid: `grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--sp-md);` + +**Identity card** (`.card .identity-card`, `RADIUS_LG`): + +- Whole card is a `role="button"` / `tabindex="0"` target. Focus ring on `:focus-visible`. + Hover elevates shadow from `--shadow-small` to `--shadow-medium`. +- 72×72 circular avatar at the top of the card. If a social profile exists: Dash-blue + fill with the first letter of the display name. If no social profile: monogram glyph + (User = person silhouette, Masternode = diamond, Evonode = diamond) in + `--bg-dark` / `--text-secondary`. +- Identity-type badge pill (`badge--user` / `badge--masternode` / `badge--evonode`) anchored + to the top-right corner of the card. +- Display name (heading_small / fw-600). When no display name: DPNS handle. When neither: + shortened Identity ID in monospace (e.g. `Fx1Kj…9Tt`). +- Sub-line (body_small, text_secondary): DPNS handle if not already the heading, otherwise + the identity-type label string (`User identity` / `Masternode identity` / `Evonode identity`). +- Balance (body_small, fw-600, tabular numerals): `{amount} DASH`. Fiat equivalent + (text_xs, text_secondary) on the line below when available. +- Static wireframe callout (not a real implementation artifact): `Opens Identity Home →` + rendered in a small info-colored chip at the card bottom. +- `aria-label="Open {display_name_or_handle}"`. +- Tooltip (tt-78x): `Open this identity. You can switch between identities anytime from + the pill under the breadcrumb.` + +**"Add a new identity" card** (`.identity-card.identity-card--add`): + +- Same grid cell, same dimensions. Visual treatment: dashed border (`2px dashed var(--border)`) + with hover switching to solid Dash Blue. +- 72×72 circle with `+` glyph (Dash Blue on transparent background). +- Heading: `Add a new identity`. Sub-line: `Create a new identity or load one you already own.` +- `role="button"`, `tabindex="0"`, `aria-label="Add a new identity"`. +- Tooltip (tt-78y): `Create a new identity or load one you already own.` + +**Card set rendered in the wireframe** (Priya / multi-identity context): + +| # | Card | Type | DPNS | Balance | +|---|---|---|---|---| +| 1 | Alex Torres (social profile) | User | @alex.dash | 0.75 DASH ≈ 45.25 USD | +| 2 | Priya Nakamura (social profile) | User | @priya.dash | 12.5 DASH | +| 3 | No social profile — monogram | Masternode | mn-east-01.dash | 0.10 DASH | +| 4 | Testing — no DPNS (`.dev` gated) | User | *(none)* — shows `Fx1Kj…9Tt` | 0.01 DASH | +| 5 | Add a new identity | — | — | — | + +Card 4 carries the `.dev` class and is hidden for Alex and Priya — Jordan only. + +**Card states**: + +- Default: `--shadow-small` border `1px solid var(--border)`. +- Hover: `--shadow-medium` (no border color change on standard cards). +- Focus-visible: 3 px Dash-blue outline with 2 px offset. +- Currently-selected identity (future real implementation): `border-color: var(--dash-blue)` + with a small `Selected` indicator — not shown in the static wireframe. + +**Empty state**: not applicable — the picker is only shown when ≥ 2 identities exist. When +0 or 1 identity exist the routing rule in §A.4 applies instead. + +--- + +## C. Wording Audit + +Authoritative replacement table. All strings are complete sentences with named placeholders. +No concatenation. Column 1 = current codebase / UI string. Column 2 = Alex-facing +replacement. Column 3 = Power / Dev tooltip gloss (shown on hover for Priya / Jordan). + +| Current | Alex-facing | Power/Dev tooltip | +|---|---|---| +| Top up | Add funds | Move Dash from your wallet into this identity as Platform credits. | +| Withdraw | Send to wallet | Convert Platform credits back to spendable Dash on the Core chain. Takes one or more blocks to settle. | +| Transfer | Send to another identity | Transfer Platform credits from this identity directly to another identity without leaving Platform. | +| Identity / Identities | Identity / Identities (kept) | A Dash Platform identity — the on-chain object that owns usernames, keys, and documents. | +| DashPay profile | Social profile | Optional display name, bio, and avatar linked to this identity for DashPay. | +| UserId / Identity ID | Identity ID | Base58-encoded identity ID on Dash Platform. | +| ProTxHash | Masternode ID | The ProTxHash that binds this identity to a masternode on Dash Core. | +| User / Masternode / Evonode (type) | User identity / Masternode identity / Evonode identity | Identity type: basic user, masternode-bound, or evonode-bound. | +| Credits | Balance (in identity context) | The raw Platform credit balance for this identity. | +| Credits spent | Network fee | Platform credits consumed by this action. | +| Asset lock | (hidden from Alex) | An unspent output on Core locked as funding for Platform operations. Created automatically when you add funds. | +| Dashpay (nav label) | (removed — subsumed into Identities) | — | +| DashPay (described in copy) | your social payments network on Dash Platform | Marketing label for the Platform-based social payments protocol. | +| Contact request | Add as contact / Connect | A DashPay contact request state transition that, once mutual, creates a shared payment channel. | +| Incoming contact requests | Requests from others | Pending inbound contact requests awaiting your response. | +| Outgoing contact requests | Requests you sent | Pending outbound contact requests. | +| Nickname (contact) | Private label | A local-only label for this contact. Never shared. | +| Note (contact) | Private note | Local-only note visible only on this device. | +| Hide contact | Hide from list | Exclude this contact from the default list view. They are not removed or notified. | +| Register DPNS name | Pick a username | Register a DPNS name against this identity. May be contested. | +| Update alias | Add or change usernames | Add a secondary DPNS alias or change which alias is primary. | +| Load identity | Load an existing identity | Import an existing identity by ID and owner private key. | +| Create identity | Create a new identity | Register a new identity on Dash Platform. | +| Create identities in bulk | Create multiple test identities | Bulk-register N identities for testing. Only available in Developer Mode. | +| Refresh identity | Refresh identity data | Re-query identity state from the network. | +| Add key | Add a new key | Add a key to the identity via an UpdateIdentity state transition. | +| Key purpose | Purpose | Authentication, Encryption, Decryption, Transfer, Voting, or System. | +| Key security level | Security level | High, Medium, Critical, or Master security level. | +| Scan QR | Scan QR | Scan a DashPay contact or payment QR code. | +| Generate QR | Show my QR | Generate a QR code for this identity. | +| Auto-accept contact request | Auto-accept contact requests | Generate an HD-derived proof that automatically accepts inbound contact requests without manual approval. | +| Platform credits | Identity balance | Platform credits owned by this identity. | +| Duffs | Satoshis (Dash) | 1/100,000,000 of a DASH; the Core-chain smallest unit. | +| State transition | Platform action | A signed payload submitted to Dash Platform to mutate state. | +| State transition result | Action result | The success/error envelope returned by Platform for the submitted state transition. | +| Broadcast | Send | Broadcast the transaction to the network. | +| Nonce | Sequence number | Monotonically increasing per-identity sequence number used to order state transitions. | +| Revision | Version | Identity revision counter, incremented on each change. | +| Proof | (hidden from Alex) | GroveSTARK inclusion proof for the queried Platform data. | +| Mnemonic | Recovery phrase | BIP39 mnemonic phrase; 12/24 words used to derive the HD wallet. | +| BIP44 account | Main account | BIP44 external chain for this wallet. | + +**DashPay brand note**: "DashPay" is retained as a descriptor — in onboarding copy, in +contact-related empty states, in Developer Mode tooltips. It is removed only as a top-level +navigation entry. + +--- + +## D. Tooltip Catalog + +All tooltips: complete sentences, named placeholders, no concatenation. Variant maps to +`ResponseExt` methods: `info` = `info_tooltip`, `clickable` = `clickable_tooltip`, +`disabled` = `disabled_tooltip`. + +| # | Element | Tooltip text | Variant | Persona | +|---|---|---|---|---| +| 1 | Left-nav entry `Identities` | Your identities on Dash Platform. Manage usernames, balances, keys, and — if you set up a social profile — DashPay contacts and payments. | info | All | +| 2 | Wallet pill (interactive, Priya / Jordan) | Switch between your wallets. Each wallet can own several identities. | clickable | Priya, Jordan | +| 3 | Wallet pill (single-wallet label, Alex) | This identity is funded by {wallet_name}. Set up another wallet on the Wallets screen to unlock switching. | info | All | +| 4 | Identity pill | Switch between identities in {wallet_name} or add a new one. | clickable | All | +| 5 | Identity pill dropdown group `Identities without a wallet on this device` | These identities were imported by ID and are not tied to any wallet on this device. | info | Priya, Jordan | +| 6 | `+ New throwaway wallet + identity` (Dev Mode) | Create a temporary wallet and a new identity in one step. Handy for testing. | clickable | Jordan | +| 7 | `Create my first identity` button | Start the short setup: pick a username, fund the identity from your wallet, and confirm. | clickable | All | +| 8 | `I already have an identity — load it` | Enter the identity ID and private key to import an existing identity into this device. | clickable | All | +| 9 | `Create multiple test identities` | Create a batch of identities for testing. Each one is funded and registered automatically. | clickable | Jordan | +| 10 | Hero balance (DASH amount) | This is the Dash held by your identity. Your wallet balance is shown on the Wallet screen. | info | All | +| 11 | Hero fiat equivalent | An estimated value in {fiat_code}. Rates can change. | info | Alex, Priya | +| 12 | Identity-type badge `User identity` | A regular identity used for payments, DPNS, and DashPay. | info | All | +| 13 | Identity-type badge `Masternode identity` | An identity tied to a Dash masternode. It can vote on name contests. | info | All | +| 14 | Identity-type badge `Evonode identity` | An identity tied to a Dash evonode. It can vote and validate Platform transactions. | info | All | +| 15 | Network pill | You are on {network_name}. Identities and balances are separate per network. | info | All | +| 16 | Quick action `Send` | Send Dash to a contact, username, or address. | clickable | All | +| 17 | Quick action `Receive` | Show a QR code or your username so someone can pay you. | clickable | All | +| 18 | Quick action `Add contact` | Find someone by username and add them to your contacts. | clickable | All | +| 19 | `Set up your social profile` card | Add a display name, bio, and avatar so people can find you on DashPay. This is optional — you can still use every other feature without it. | info | All | +| 20 | Onboarding checklist dismiss | Hide the setup checklist. You can find these actions on Settings and Contacts anytime. | clickable | All | +| 21 | Insight chip `@handle works like an address` | Share your username with anyone who wants to pay you. It works even if the sender is on a different Dash wallet. | info | Alex | +| 22 | Advanced expander on Home | Show technical details like raw IDs, keys, and revision numbers. | clickable | All | +| 23 | Raw Identity ID copy button | Copy the full identity ID to your clipboard. | clickable | Priya, Jordan | +| 24 | ProTxHash / Masternode ID copy button | Copy the masternode ID to your clipboard. | clickable | Priya, Jordan | +| 25 | Contacts tab (gated, no social profile) | Set up a social profile first. Contacts need a display name and avatar so people can find you. | info | All | +| 26 | Contacts tab header `Add by username` | Find someone by their Dash username or identity ID and add them as a contact. | clickable | All | +| 27 | Contacts tab header `Scan QR` | Use a camera or paste a QR image to add a contact. | clickable | All | +| 28 | Contacts tab header `Show my QR` | Show a QR code so someone nearby can add you or pay you. | clickable | All | +| 29 | Incoming request `Accept` | Accept this contact request. You will appear in each other's contact list. | clickable | All | +| 30 | Incoming request `Decline` | Decline this request. The other person will not be notified. | clickable | All | +| 31 | Outgoing request `Pending` pill | Waiting for {counterparty_name} to respond. | info | All | +| tt-29c | Sent request `Cancel request` button | Cancel the request. {counterparty_name} will not be notified. | clickable | All | +| 32 | Contact list row `Send` button | Send Dash to {counterparty_name}. | clickable | All | +| 33 | Contact list row `•••` overflow | More actions for this contact. | clickable | All | +| 34 | Contact overflow `Edit private label` | Change the local-only label for this contact. Only you see it. | clickable | All | +| 35 | Contact overflow `Hide from list` | Exclude this contact from your default list. They are not notified. | clickable | All | +| 36 | Contact overflow `Remove contact` (disabled) | Removing contacts is not yet available. It will arrive in a future update. | disabled | All | +| 37 | Contact detail `Copy handle` | Copy @{handle} to your clipboard. | clickable | All | +| 38 | Contact detail `Private notes` hint | Only you can see this. It is never shared with the contact. | info | All | +| 39 | Activity filter chip `Payments` | Shows money you sent or received. | info | All | +| 40 | Activity filter chip `Funding` | Shows when you added Dash to your identity, sent Dash back to your wallet, or moved Dash between identities. | info | All | +| 41 | Activity filter chip `Platform` | Shows identity changes like usernames, keys, and contracts. | info | Priya, Jordan | +| 42 | Activity row expand chevron | Show details for this activity. | clickable | All | +| 43 | Activity export button | Save your activity as a CSV file. | clickable | Priya, Jordan | +| 44 | Activity failed row `Retry` button | Try sending this payment again. Your balance has not been touched. | clickable | All | +| 45 | Activity `Request {amount} from {counterparty_name}` (disabled) | Payment requests are coming soon. | disabled | All | +| 46 | Settings: social profile `Change photo` | Upload a square image. Other apps will see this avatar. | clickable | All | +| 47 | Settings: `Save social profile` (disabled, no changes) | There are no changes to save. | disabled | All | +| 48 | Settings: `Save social profile` (disabled, invalid) | Fix the highlighted fields before saving. | disabled | All | +| 49 | Settings: `Delete social profile` | Remove the display name, bio, and avatar from DashPay. Your identity, usernames, and balance stay. | clickable | All | +| 50 | Username `Primary` pill | Your primary username is what people see by default. | info | All | +| 51 | `Make primary` alias action | Use this username as your main one. Your old primary will become an alias. | clickable | All | +| 52 | `Remove` alias action | Remove this alias. You will keep your other usernames. | clickable | All | +| 53 | `Add an alias` button | Register another DPNS name that points to this identity. | clickable | All | +| 54 | Keys table column `Purpose` | What this key is allowed to do (authenticate, transfer, decrypt, vote). | info | Priya, Jordan | +| 55 | Keys table column `Type` | The cryptographic algorithm for this key. | info | Priya, Jordan | +| 56 | Keys table column `Status` | Whether this key is active, disabled, or revoked. | info | Priya, Jordan | +| 57 | Keys `Add a new key` | Register a new key for this identity. You will choose its purpose and type. | clickable | Priya, Jordan | +| 58 | `Refresh identity data` button | Fetch the latest state of this identity from the network. | clickable | All | +| 59 | Danger zone `Unload this identity from this device` | Remove this identity from this device. It remains on Dash Platform — you can load it again later. | clickable | All | +| 60 | ProTxHash row info | The masternode identifier on the Dash Core chain. | info | Priya, Jordan | +| 61 | Send sheet `To` label | Paste a username, Dash address, or identity ID. You can also pick from your contacts. | info | All | +| 62 | Send sheet `Amount` label | How much Dash to send. We will show the network fee before you confirm. | info | All | +| 63 | Send sheet `Use {fiat_code}` toggle | Enter the amount in {fiat_code}. We convert it to DASH for you. | clickable | All | +| 64 | Send sheet `Max` quick-amount | Send your entire identity balance minus the network fee. | clickable | All | +| 65 | Send sheet memo `Add a note` expander | Attach a private note that only you and the recipient can see. | clickable | All | +| 66 | Send sheet fee row `Network fee` | Paid to the network to process this payment. Not paid to anyone you know. | info | All | +| 67 | Send sheet `Credits used` row (Priya / Jordan) | The Platform credits this action will spend. | info | Priya, Jordan | +| 68 | Send sheet `Review` (enabled) | Double-check before sending. | clickable | All | +| 69 | Send sheet `Review` (disabled) | Enter a valid recipient and amount to continue. | disabled | All | +| 70 | Send success `Send again` | Open the send sheet again to {counterparty_name}. | clickable | All | +| 71 | Send success `Add as contact` | Save {counterparty_name} to your contacts so future payments are one click away. | clickable | All | +| 72 | Receive `Copy username` | Copy @{handle} to your clipboard. | clickable | All | +| 73 | Receive `Share via…` | Share your username through another app. | clickable | All | +| 74 | Receive QR code | This QR code contains a Dash address for a one-time payment to this identity. | info | All | +| 75 | Receive `Copy address` | Copy the address to your clipboard. | clickable | All | +| 76 | Receive `Show full address list` | Open the address table for this wallet. Useful for advanced setups. | clickable | Priya | +| 77 | Connection indicator (connected) | You are connected and up to date. | info | All | +| 78 | Connection indicator (syncing) | You are catching up with the network. Balances may update shortly. | info | All | +| 79 | Connection indicator (offline) | You are offline. Payments cannot be sent until you reconnect. | info | All | +| 80 | Developer Mode chip (when on) | Developer Mode shows advanced fields and testnet tools. Turn it off in Settings. | info | Jordan | +| 81 | Settings: `Refresh mode` selector | Choose whether to refresh only Core chain data, only Platform data, or both at once. | info | Priya | +| 82 | Identity pill dropdown `Add another identity` footer | Create a new identity or load one you already own. | clickable | All | +| 83 | Home secondary action `Add funds` | Move Dash from your wallet into this identity. | clickable | All | +| 84 | Home secondary action `Send to wallet` | Convert your identity balance back to spendable Dash in your wallet. | clickable | All | +| 85 | Home secondary action `Send to another identity` | Transfer Dash directly from this identity to another identity. | clickable | All | +| 86 | Topbar `Refresh identity data` icon-button | Fetch the latest identity data from the network. | clickable | All | +| tt-78x | Identity picker card (each identity card) | Open this identity. You can switch between identities anytime from the breadcrumb. | clickable | All | +| tt-78y | Identity picker "Add a new identity" card | Create a new identity or load one you already own. | clickable | All | + +--- + +## E. Visual Direction + +Reuses all existing tokens from `src/ui/theme.rs`. No new color constants invented. + +**Shadow alpha intentional deviation**: the wireframe uses CSS shadow alphas +`0.08 / 0.12 / 0.15 / 0.18 / 0.30` (for `--shadow-small` through `--shadow-glow`). These +are the **visual target**. The current `theme.rs` `Shadow::*` constants store egui alpha +bytes `8 / 12 / 15 / 18 / 30` (out of 255), which decode to `0.031 / 0.047 / 0.059 / +0.071 / 0.118` — noticeably fainter than the wireframe intent. The implementation PR should +update `Shadow::*` alpha bytes in `theme.rs` to match the wireframe values +(8→20, 12→31, 15→38, 18→46, 30→76 in 255-scale). This is a deliberate mini-deviation +flagged here so reviewers know the wireframe is not wrong. + +- **Island central panel**: `RADIUS_LG` (16 px) + `Shadow::elevated()`. +- **Identity Home hero**: gradient `DashColors::DASH_BLUE` (#008de4) → `DashColors::PLATFORM_PURPLE` (#8250dc) at 14 % opacity over `DashColors::surface(dark_mode)`. Radius `RADIUS_XL` (20 px). +- **Other cards**: `DashColors::surface(dark_mode)` + `Shadow::medium()`, `RADIUS_MD` (12 px). +- **Pill badges**: `RADIUS_FULL` (255 px). Identity-type pill colors: + - User identity = `DashColors::DASH_BLUE` (#008de4) fill at 12 % opacity, 1 px stroke. + - Masternode identity = `DashColors::PLATFORM_PURPLE` (#8250dc) fill at 12 % opacity, 1 px stroke. + - Evonode identity = `DashColors::HIGHLIGHT_GOLD` (#9b870c) fill at 12 % opacity, 1 px stroke. +- **Avatars (social profile set)**: 96 px circle, 2 px `DashColors::DASH_BLUE` ring at 20 % opacity. Fallback = `DashColors::DASH_BLUE` fill with first letter of display name in `DashColors::WHITE`. +- **Identities without a social profile**: type-glyph monogram in same ring (person glyph for User, masternode glyph for Masternode, evonode glyph for Evonode). +- **Monospace**: only on copyable identifiers (Identity ID, addresses, TxIDs, Masternode ID) and in Developer Mode JSON dumps. +- **Spacing**: `Spacing::XXL` (48 px) between major sections; `Spacing::MD` (16 px) inside cards; `Spacing::SM` (8 px) between label / value pairs. +- **Send / Receive sheets**: `Shadow::elevated()` with `DashColors::modal_overlay()` (rgba 0,0,0,120) backdrop. Send primary button focus state uses `Shadow::glow()` (rgba 0,141,228,30). + +**Type-glyph monogram design decision**: user identities display a single-person silhouette +glyph (Unicode U+1F464 or SVG equivalent); masternode identities display an abstract node / +server glyph to signal infrastructure; evonode identities display a diamond glyph matching +the existing evonode icon in the codebase. All three render at 40 px within a 96 px circle. + +--- + +## F. Wireframe Reference + +`wireframe.html` renders 8 sequential frames on one scrolling page: + +| Frame | Caption | Subtitle | +|---|---|---| +| 1 | Onboarding empty state | First-time welcome with two primary CTAs and a Developer Mode footer band. Breadcrumb shows `(no wallet yet)` and `(no identity yet)` placeholders. | +| 2 | Identity picker | Grid of identity cards shown when ≥ 2 identities are loaded. Four identity cards plus an "Add a new identity" card. Breadcrumb shows wallet pill + `(choose an identity)` placeholder. | +| 3 | Identity Home | Hero with avatar and display name, quick actions, onboarding checklist, recent activity. Canonical render: Alex with social profile. Annotation callout documents the no-profile state (see §B.3). | +| 4 | Contacts | Populated contacts page: 2 received requests (amber accent), 5 active contacts, 2 sent requests (blue accent). | +| 5 | Activity | Unified timeline: one expanded row, one failed row, ten normal rows across all three filter categories. | +| 6 | Send sheet | Username resolution in progress, amount input, memo expander, fee preview card. Rendered over a dimmed Identity Home backdrop. | +| 7 | Settings | Social profile section, aliases, keys table, danger zone. Priya context with interactive breadcrumb (multi-wallet). | +| 8 | App chrome reference | Component reference: left nav, breadcrumb switcher variants A (Alex, subdued wallet pill), B (Priya, both pills interactive), C (onboarding placeholders). Moved to end — readers encounter real screens first. | + +CSS custom properties in `wireframe.html` mirror `src/ui/theme.rs` line-for-line. Every +variable maps to its Rust constant in comments. Exception: shadow alpha values are +intentionally brighter in the wireframe than in `theme.rs` — see §E for the rationale. + +--- + +## G. Open Questions — Closed + +| # | Question | Decision | +|---|---|---| +| G1 | Nav label: `My Profile` vs `Identities`? | **`Identities`** — existing codebase label preserved; minimizes churn for existing users. Approved by user this session. | +| G2 | DashPay brand prominence? | **Descriptor only** — kept in onboarding copy, power-user tooltips, and contact-related empty states. Removed only as nav label. | +| G3 | Bulk identity creation (IDN-011) placement? | **Quiet tertiary link** in Add-another-identity chooser modal, Developer Mode only. | +| G4 | Memo storage on sends? | **Split**: DashPay-routed payments store memo as part of the DashPay payment document; raw-address sends store memo locally. | +| G5 | Unload vs. delete identity in danger zone? | **Unload only** exposed. No "delete permanently" note shown — Platform does not support it today and surfacing a note implying it will exist is premature. | +| G6 | Identity pill dropdown drag-reorder? | **Deferred** to a later iteration. Default ordering: Local nickname → DPNS username → shortened Identity ID. Inline search at 7+ identities. | +| G7 | Local identity alias (`QualifiedIdentity.alias`) vs. DPNS aliases? | **Preserved as `Local nickname`** in §B.8 Settings. The field is not deprecated; it is renamed to disambiguate from DPNS on-chain aliases. Migration: existing `alias` values display as-is; no data loss. | +| G8 | `Request payment` (catalog #45) — UI placement? | **Future feature**. Catalog entry #45 is retained as a disabled-state tooltip ("Payment requests are coming soon.") for now. No active UI row in the wireframe. | +| G9 | Secondary Home actions visibility gating? | **All personas, no `.adv` gate**. Add funds / Send to wallet / Send to another identity are visible to Alex, Priya, and Jordan. Alex's funding path defaults to `UseWalletBalance` (§B.9); advanced funding methods are gated inside the wizard, not on the Home row. | diff --git a/docs/ai-design/2026-04-22-identity-dashpay-redesign/wireframe.html b/docs/ai-design/2026-04-22-identity-dashpay-redesign/wireframe.html new file mode 100644 index 000000000..22913e4e7 --- /dev/null +++ b/docs/ai-design/2026-04-22-identity-dashpay-redesign/wireframe.html @@ -0,0 +1,2897 @@ + + + + + +Identity + DashPay Redesign — Wireframe + + + + + + + + +
+ +
+ +
+
+ + +
+
+
+

Frame 1 — Onboarding empty state

+

First-time welcome when no identities are loaded on the current network.
+ Breadcrumb shows placeholder segments — no wallet, no identity yet.

+
+
+
+ +
+
+ +
+
+
+ +

Welcome to Identities.

+

An identity is your account on Dash Platform. With one you can pick a username, send and receive Dash by name, and — if you choose — connect with people through DashPay.

+

You only need a small amount of Dash from your wallet to get started.

+
+ + + Start the short setup: pick a username, fund the identity from your wallet, and confirm. + + + + Enter the identity ID and private key to import an existing identity into this device. + +
+ +
+

Developer tools:

+
+ + + Create a batch of identities for testing. Each one is funded and registered automatically. + + · + +
+
+
+
+
+
+
+ + +
+
+
+

Frame 2 — Identity picker

+

Shown when the user clicks the Identities nav item and more than one identity is loaded.
+ Breadcrumb: wallet selected, identity segment shows "(choose an identity)" placeholder.
+ Clicking a card selects the identity and navigates to Home (F3).

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + +
+
+ +
+
+

Pick an identity

+

Each identity has its own balance, keys, and optional social profile. Choose one to open it, or add a new identity.

+ +
+ + + +
+ User identity + +

Alex Torres

+

@alex.dash

+

0.75 DASH

+

≈ 45.25 USD

+

Opens Identity Home →

+
+ Open this identity. You can switch between identities anytime from the breadcrumb. +
+ + + +
+ User identity + +

Priya Nakamura

+

@priya.dash

+

12.5 DASH

+

Opens Identity Home →

+
+ Open this identity. You can switch between identities anytime from the breadcrumb. +
+ + + +
+ Masternode identity + +

mn-east-01.dash

+

Masternode identity

+

0.10 DASH

+

Opens Identity Home →

+
+ Open this identity. You can switch between identities anytime from the breadcrumb. +
+ + + +
+ User identity + +

Fx1Kj…9Tt

+

User identity

+

0.01 DASH

+

Opens Identity Home →

+
+ Open this identity. You can switch between identities anytime from the breadcrumb. +
+ + + +
+ +

Add a new identity

+

Create a new identity or load one you already own.

+
+ Create a new identity or load one you already own. +
+ +
+
+
+
+
+
+ + +
+
+
+

Frame 3 — Identity Home

+

Hero with avatar and display name, quick actions, onboarding checklist, recent activity.
+ Alex with social profile set is the canonical render. See annotation for the no-profile state.

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + + + + Fetch the latest identity data from the network. + +
+
+ +
+
+ + + +
+
+
A
+
+

Alex Torres

+

@alex.dash

+
+ + User identity + A regular identity used for payments, DPNS, and DashPay. + + + Mainnet + You are on Mainnet. Identities and balances are separate per network. + +
+
+
+
+ +

2.450 DASH

+ This is the Dash held by your identity. Your wallet balance is shown on the Wallet screen. +
+ +

≈ 214.30 USD

+ An estimated value in USD. Rates can change. +
+
+
+ +
+ + + Send Dash to a contact, username, or address. + + + + Show a QR code or your username so someone can pay you. + + + + Find someone by username and add them to your contacts. + +
+ +
+ + + Move Dash from your wallet into this identity. + + + + Convert your identity balance back to spendable Dash in your wallet. + + + + Transfer Dash directly from this identity to another identity. + +
+ +
+
+

Finish setting up your identity

+ + + Hide the setup checklist. You can find these actions on Settings and Contacts anytime. + +
+
+
+

✓ Pick a username

+

You are @alex.dash.

+
+
+

○ Set a display name

+

This is how you appear to contacts.

+ +
+
+

○ Add your first contact

+

Add someone by username to send with one click.

+ +
+
+
+ +
+

Recent activity

+ +
+
+
+ +
+

Received 0.500 DASH from @bob.dash

+

via contact

+
+

Today, 14:32

+
+
+ +
+

Registered the username @alex.dash

+

Identity update

+
+

Mar 14

+
+
+ +
+

Added 3.000 DASH to your identity

+

From your wallet

+
+

Mar 12

+
+
+ +
+ Advanced details +
+

Identity ID

+
+ 7Bn4qRm9…Xk2pWc + + + Copy the full identity ID to your clipboard. + +
+

Version 3 · Last updated Mar 14

+
+
+ + +
+
+
+
+
+ + +
+
+
+

Frame 4 — Contacts

+

Populated contacts page for @alex.dash. Three sections: received requests (2, amber accent), + active contacts (5), sent requests (2, blue accent). See design-spec §B.4.

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + +
+
+ +
+
+ +
+

Contacts

+
+ + + Find someone by their Dash username or identity ID and add them as a contact. + + + + Use a camera or paste a QR image to add a contact. + + + + Show a QR code so someone nearby can add you or pay you. + +
+
+ + +
+
+

Received requests — awaiting your approval

+ 2 new +
+
+ +
+
+
M
+
+

Marco Reyes

+

@marco.dash

+
+

2 hours ago

+
+
+ + + Accept this contact request. You will appear in each other's contact list. + + + + Decline this request. The other person will not be notified. + +
+
+ +
+
+
L
+
+

Lina Park

+

@lina.dash

+
+

Yesterday

+
+
+ + + Accept this contact request. You will appear in each other's contact list. + + + + Decline this request. The other person will not be notified. + +
+
+
+
+ + +
+
+

Active contacts · 5

+ +
+ +
+
B
+
+

Bilal Okafor

+

@bilal.dash · last paid 3 days ago

+
+ + + Send Dash to Bilal Okafor. + + + + More actions for this contact. + +
+
+
S
+
+

Sofia Romero

+

@sofia.dash · last paid last week

+
+ + + Send Dash to Sofia Romero. + + + + More actions for this contact. + +
+
+
D
+
+

Devon King

+

@devon.dash · no payments yet

+
+ + + Send Dash to Devon King. + + + + More actions for this contact. + +
+
+
T
+
+

Tomomi Kato

+

@tomomi.dash · last paid today

+
+ + + Send Dash to Tomomi Kato. + + + + More actions for this contact. + +
+
+
R
+
+

Raj Patel

+

@raj.dash · last paid 2 months ago

+
+ + + Send Dash to Raj Patel. + + + + More actions for this contact. + +
+ + +
+ + +
+

Sent requests — waiting for acceptance

+
+ +
+
+
K
+
+

Kai Andersen

+

@kai.dash · You sent 1 day ago

+
+ + Pending + Waiting for Kai Andersen to respond. + +
+ + + Cancel the request. Kai Andersen will not be notified. + +
+ +
+
+
E
+
+

Elena Costa

+

@elena.dash · You sent 5 days ago

+
+ + Pending + Waiting for Elena Costa to respond. + +
+ + + Cancel the request. Elena Costa will not be notified. + +
+
+
+ +
+
+
+
+
+ + +
+
+
+

Frame 5 — Activity

+

Unified timeline: one expanded row, one failed row, ten normal rows across all three filter categories.

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + + + + Fetch the latest identity data from the network. + +
+
+ +
+
+ +
+

Activity

+ + + Save your activity as a CSV file. + +
+ +
+ + + Show all activity. + + + + Shows money you sent or received. + + + + Shows when you added Dash to your identity, sent Dash back to your wallet, or moved Dash between identities. + + + + Shows identity changes like usernames, keys, and contracts. + +
+ +
+
+ +
+

Sent 0.250 DASH

+

To @carol.dash · via username

+
+

Yesterday

+ + + Show details for this activity. + +
+
+
+
+

Amount

+

0.250 DASH (≈ 21.84 USD)

+

Network fee: 0.00002 DASH

+

Status: Confirmed

+
+
+

To

+

@carol.dash

+

via username · Apr 21, 11:47

+
+
+
+

Transaction ID

+
a1b2c3d4e5f6…0a1b
+
+
+
+ +
+ +
+

Could not send 1.000 DASH to @dave.dash

+

The network did not accept this payment. Your balance is unchanged.

+
+ + + Try sending this payment again. Your balance has not been touched. + +
+ +
+ +

Received 0.500 DASH from @bob.dash

via contact · Today, 14:32

+

Today

+ Show details for this activity. +
+
+ +

Added 5.000 DASH to your identity

From your wallet

+

Apr 20

+ +
+
+ +

Sent 2.100 DASH to @eve.dash

via contact · Apr 19

+

Apr 19

+ +
+
+ +

Registered the username @alex.dash

Identity update · Mar 14

+

Mar 14

+ +
+
+ +

Received 0.100 DASH from @frank.dash

via username · Mar 10

+

Mar 10

+ +
+
+ +

Sent 1.000 DASH to your wallet

To wallet · Mar 8

+

Mar 8

+ +
+
+ +

Sent 0.050 DASH to @grace.dash

via contact · Mar 5

+

Mar 5

+ +
+
+ +

Added a new key to your identity

Platform · Feb 28

+

Feb 28

+ +
+
+ +

Received 3.200 DASH from @henry.dash

via contact · Feb 20

+

Feb 20

+ +
+
+ +

Added 10.000 DASH to your identity

From your wallet

+

Feb 15

+ +
+
+
+
+
+
+ + +
+
+
+

Frame 6 — Send sheet

+

Username resolution in progress, amount input, memo expander, fee preview card. Shown over a dimmed Identity Home backdrop.

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + + + + Fetch the latest identity data from the network. + +
+
+ + + +
+
+
+ + +
+
+
+

Frame 7 — Settings

+

Social profile section, aliases, keys table (Priya), danger zone. Priya context with multi-wallet interactive breadcrumb.

+
+
+
+ +
+
+ +
+ + + You are connected and up to date. + + + + Fetch the latest identity data from the network. + +
+
+ +
+
+
+ +
+

Social profile

+

This information is visible to everyone on Dash Platform.

+
+
P
+ + + Upload a square image. Other apps will see this avatar. + +
+
+ + +

You can change this anytime.

+
+
+ + +

46 / 200

+
+
+ + + Remove the display name, bio, and avatar from DashPay. Your identity, usernames, and balance stay. + + + + There are no changes to save. + +
+
+ +
+

Username

+
+ @priya.dash + + + Primary + Your primary username is what people see by default. + +
+

Aliases

+

Extra usernames that also point to your identity.

+
+ @priya-node.dash + + + Use this username as your main one. Your old primary will become an alias. + + + + Remove this alias. You will keep your other usernames. + +
+ + + Register another DPNS name that points to this identity. + +
+
+ + +
+ + Advanced + Keys, raw identifiers, and identity type. + +
+ +
+ + Masternode identity + An identity tied to a Dash masternode. It can vote on name contests. + +
+
+

Identity ID

+
+ 9Xk2pWcBn4qRm…7Qmn + + + Copy the full identity ID to your clipboard. + +
+
+
+ +

Masternode ID

+ The masternode identifier on the Dash Core chain. +
+
+ Abc123Def456…Xyz + + + Copy the masternode ID to your clipboard. + +
+
+ +

Keys

+

Keys let this identity sign actions. Most people never need to manage these directly.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Purpose + What this key is allowed to do (authenticate, transfer, decrypt, vote). + + + + Type + The cryptographic algorithm for this key. + + + + Status + Whether this key is active, disabled, or revoked. + + Actions
AuthenticationECDSA_SECP256K1Active
TransferECDSA_SECP256K1Active
VotingECDSA_SECP256K1Active
+ + + Register a new key for this identity. You will choose its purpose and type. + + +
+ + + Fetch the latest state of this identity from the network. + + + + + + Choose whether to refresh only Core chain data, only Platform data, or both at once. + + +
+ +
+

Danger zone

+
+
+

Unload this identity from this device

+

It stays on Dash Platform — you can load it again later.

+
+ + + Remove this identity from this device. It remains on Dash Platform — you can load it again later. + +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+

Frame 8 — App chrome reference

+

Left nav, network indicator, and breadcrumb switcher variants — shown at zoom for component-level review.
+ Variant A: Alex — single wallet, subdued pill. Variant B: Priya — multi-wallet, interactive pills.
+ Variant C: onboarding empty state — both placeholder segments shown.

+
+
+ + +
Variant A — Alex (single wallet, subdued wallet pill)
+
+ +
+
+ +
+ + + You are connected and up to date. + +
+
+
+ Content area (see frames F1–F7 for real content) +
+
+
+ + +
Variant B — Priya (multi-wallet, both pills interactive)
+
+ +
+
+ +
+ + + You are connected and up to date. + +
+
+
+ Content area (see frames F1–F7 for real content) +
+
+
+ + +
Variant C — Onboarding empty state (no wallet, no identity — both placeholder segments)
+
+ +
+
+ +
+
+ Onboarding empty state shown here (see Frame 1 for full render) +
+
+
+ +
+ +
+
+ +
+

Identity + DashPay Redesign Wireframe — 8 frames — static visual reference only.

+

See design-spec.md for the full UX specification.

+
+ + + + diff --git a/docs/ai-design/2026-04-23-identity-hub-impl/01-requirements.md b/docs/ai-design/2026-04-23-identity-hub-impl/01-requirements.md new file mode 100644 index 000000000..ce860da2d --- /dev/null +++ b/docs/ai-design/2026-04-23-identity-hub-impl/01-requirements.md @@ -0,0 +1,167 @@ +# Phase 1a — Requirements + +Source: `docs/ai-design/2026-04-22-identity-dashpay-redesign/` (README, design-spec, wireframe). + +## Scope + +Implement a brand-new `Identities` UI section inside Dash Evo Tool 2, coexisting with (not +replacing) the existing `Identities` and `Dashpay` sections. The new section is a four-tab +hub: Home · Contacts · Activity · Settings — with an onboarding empty state and an identity +picker for multi-identity wallets. + +## Personas + +Three personas drive visibility rules (reused from `docs/personas/`): + +- **Alex Torres** — Everyday user. One wallet, one identity. Sees basic actions only. +- **Priya Nakamura** — Power user. Many wallets, many identities. Sees advanced expanders. +- **Jordan Kim** — Platform developer. Everything Priya sees plus Developer Mode tools. + +Personas are runtime-resolvable via `AppContext::is_developer_mode()` for Jordan and via +the loaded-wallet / loaded-identity counts for Alex / Priya distinction. No explicit persona +dropdown in the product; the UI adapts. + +## Functional requirements + +FR-1. New left-nav entry `Identities` (plural, kept label) routes to the new hub. Old +`Identities` and `Dashpay` left-nav entries remain visible during the coexistence period. + +FR-2. Hub entry point dispatches by loaded-identity count on the active network: +- 0 identities → onboarding empty state (§B.1). +- 1 identity → Identity Home for that identity (§B.2 / §B.3). +- ≥ 2 identities → Identity Picker grid (§B.14). + +FR-3. Breadcrumb switcher is always visible in the topbar of every tab: +`Identities › [wallet pill] › [identity pill]`. Pills are live switchers where more than +one option exists, subdued (non-interactive) where there is only one. Placeholders render +when a segment is empty. + +FR-4. Four tabs under the hub, rendered as a single tab-bar component: +- Home: identity hero, quick actions (Send · Receive · Add contact), secondary actions + (Add funds · Send to wallet · Send to another identity), optional onboarding checklist, + recent activity preview (top 5 rows), advanced expander with raw IDs. +- Contacts: populated state (received requests · active contacts · sent requests) OR + gated-state card when identity has no social profile. +- Activity: unified timeline with filter chips (All · Payments · Funding · Platform), + expandable rows, retry for failed rows. +- Settings: social profile (left column) · username + aliases (right column) · advanced + expander · danger zone. + +FR-5. Onboarding empty state renders two primary CTAs — `Create my first identity` and +`I already have an identity — load it` — plus a Developer Mode footer with +`Create multiple test identities` and `Load identity by ID`. + +FR-6. Identity picker renders a CSS-grid of identity cards plus an `Add a new identity` +card. Cards show avatar or type-glyph monogram, display name / DPNS / shortened ID, +identity-type badge, balance, fiat equivalent where available. + +FR-7. All user-facing strings are complete sentences with named placeholders per the i18n +rule in CLAUDE.md. No concatenation, no sentence-fragment joining. + +FR-8. Every tooltip from design-spec §D is wired with the correct `ResponseExt` variant +(`info_tooltip` · `clickable_tooltip` · `disabled_tooltip`) and persona visibility. + +FR-9. Nav-entry info tooltip: "Your identities on Dash Platform. Manage usernames, +balances, keys, and — if you set up a social profile — DashPay contacts and payments." + +## Non-functional requirements + +NFR-1. **Additive backend only**: No modifications to existing `BackendTask`, `WalletTask`, +`IdentityTask`, `DashPayTask`, `AppContext` methods, or database schemas. New additive +variants / methods are permitted only if unavoidable; default posture is zero backend +changes. (Locked decision #5.) + +NFR-2. **Feature-gate unsupported capabilities**: For any UI affordance whose backend +dispatch does not exist today, hide the affordance behind a compile-time `cfg(feature = ...)` +flag or runtime predicate that defaults off. Document each gated capability in the PR +description. + +NFR-3. **Theme reuse**: Every color, spacing, radius, shadow, and typography value pulled +from `src/ui/theme.rs`. No new token constants. The shadow-alpha realignment (design-spec §E) +is a separate concern tracked in the dev plan but is **out of scope** for this PR. + +NFR-4. **Component reuse**: Every new widget must be placed flat inside +`src/ui/components/` alongside existing shared components, following +`docs/COMPONENT_DESIGN_PATTERN.md` (private fields, builder methods, `ComponentResponse` +trait, light+dark mode). + +NFR-5. **Tests**: Each new component has unit tests covering validation, response struct +methods, and state transitions. Each tab plus the onboarding state has one `kittest` +integration test. + +NFR-6. **Formatting and linting**: Zero clippy warnings with `--all-features --all-targets +-- -D warnings`. `cargo +nightly fmt --all` produces a clean diff. + +NFR-7. **Progressive disclosure**: Advanced sections collapse by default. Developer Mode +tools only render when `app_context.developer_mode` is true. Persona-specific content uses +the `FeatureGate` predicate pattern established in +`src/model/feature_gate.rs` where applicable (memcan memory `a3628faa`). + +NFR-8. **Graceful degradation**: When an identity has no social profile, the UI does not +crash — it falls back to monograms, shortened IDs, and the gated-state cards per §B.3 and +§B.4.1. + +## Data needs & processing rules + +- Loaded identities per network: `AppContext.qualified_identities()` (existing accessor). +- Active wallet + loaded wallets: `AppContext.wallets` (existing `RwLock`). +- Social profile presence: existing DashPay Profile query path — if none, render the + no-profile state. +- Identity-type classification: existing `QualifiedIdentity.identity_type` enum (User / + Masternode / Evonode) drives the type-glyph monogram and badge pill choice. +- DPNS primary username: existing accessor; render `No username yet` fallback when empty. +- Local nickname: `QualifiedIdentity.alias` (renamed in UI copy to `Local nickname` — see + design-spec §G7). Stored field unchanged; label only changes. +- Recent activity: reuse existing activity-aggregation path if present; otherwise, render + empty-state copy and gate the Activity tab behind a `recent_activity_feed` feature flag + until a backend aggregator is added. + +## User stories (to add to `docs/user-stories.md`) + +US-IDH-001 `[Implemented]` **Alex — first-time setup** + As Alex, I want to open the Identities section on a fresh device and be offered a clear + single-step path to create my first identity, so I can start using Dash Platform without + understanding what an identity is first. + +US-IDH-002 `[Implemented]` **Alex — identity home at a glance** + As Alex, when I have one identity, opening Identities shows me my balance, username, a + big Send button, and my recent activity, without any jargon. + +US-IDH-003 `[Implemented]` **Priya — switch between many identities** + As Priya, with multiple wallets and identities, I can switch between them from the + breadcrumb pill on any tab in under two clicks. + +US-IDH-004 `[Implemented]` **Alex — opt in to DashPay** + As Alex, setting up a social profile to unlock DashPay contacts is clearly optional and + I can keep using payments and usernames without doing it. + +US-IDH-005 `[Implemented]` **Jordan — bulk test identities** + As Jordan in Developer Mode, I have a single entry point to create many test identities + without leaving the Identities section. + +US-IDH-006 `[Gap-follow-up]` **Unified activity timeline** + As any persona, my payments, funding movements, and platform actions all live in one + Activity tab with filters, not in separate screens. Full aggregation depends on a + backend follow-up; the tab shell ships with filter chips and a gated-state message + pointing to the existing identity-specific history views. + +## Acceptance criteria + +- AC-1. Running the app shows three `Identities`-group entries in the left nav (old + `Identities`, old `Dashpay`, new hub). Clicking the new one opens the appropriate landing + state per FR-2 without regressing either old screen. +- AC-2. `cargo build` is green on default features and on `--all-features`. +- AC-3. `cargo clippy --all-features --all-targets -- -D warnings` reports zero warnings. +- AC-4. `cargo test --all-features --workspace` is green. +- AC-5. At least one `kittest` test per tab plus onboarding asserts that the expected + labels, buttons, and placeholders render. +- AC-6. No file under `src/ui/identities/`, `src/ui/dashpay/`, or `src/backend_task/` is + modified by this PR (except to add additive backend variants if strictly required, which + must be documented in the PR body). +- AC-7. `docs/user-stories.md` updated with stories US-IDH-001 .. US-IDH-006. + +--- + +Revision: 1 +Authored: 2026-04-23 +Author: Claudius the Magnificent (single-agent execution) diff --git a/docs/ai-design/2026-04-23-identity-hub-impl/02-ux-plan.md b/docs/ai-design/2026-04-23-identity-hub-impl/02-ux-plan.md new file mode 100644 index 000000000..608d000b2 --- /dev/null +++ b/docs/ai-design/2026-04-23-identity-hub-impl/02-ux-plan.md @@ -0,0 +1,140 @@ +# Phase 1b — UX Plan + +Derived from Phase 1a (Requirements) and the authoritative design spec at +`docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md`. Nothing here overrides +the design spec; this document is the implementation-side interpretation. + +## Journey 1 — First-time user (Alex, no identity yet) + +1. App opens, `Identities` nav clicked. +2. `IdentityHubScreen` loads, inspects `AppContext.qualified_identities()` for the active + network: empty → onboarding state. +3. Breadcrumb renders `(no wallet yet) › (no identity yet)` when no wallet is loaded; if a + wallet is loaded but no identity, renders `[wallet pill] › (no identity yet)`. +4. Central island shows avatar silhouette + heading + two buttons + developer-mode footer. +5. `Create my first identity` routes to the existing `AddNewIdentityScreen`. `I already + have an identity — load it` routes to the existing `AddExistingIdentityScreen`. +6. Returning to the hub after successful identity creation lands on Identity Home + (journey 2). + +**Interaction patterns**: two vertically-stacked primary buttons, info tooltip on the nav +entry (tt-1), developer-mode chip footer hidden for Alex / Priya, shown for Jordan. + +## Journey 2 — Identity Home (Alex, one identity, social profile set) + +1. `IdentityHubScreen` inspects identity count: 1 → Identity Home directly. +2. Active identity is the only one; breadcrumb identity pill is interactive but dropdown + has just that one identity + `+ Add another identity` footer. +3. Tab bar rendered at the top of the island: Home · Contacts · Activity · Settings. +4. Home layout zones render top-to-bottom: hero card · quick-actions row · secondary- + actions row · onboarding checklist (if not dismissed) · recent activity preview · + advanced expander (collapsed for Alex). + +**Interaction patterns**: +- Hero card avatar is a `StyledCard` with gradient background overriding the default + surface. +- Quick-actions row uses three `StyledButton::primary` instances with equal width. +- Secondary-actions row uses three `StyledButton::ghost` instances. +- Onboarding checklist dismiss button uses tt-20. +- Recent activity preview: each row is a new `ActivityRow` component with compact 48px + height; last row has footer link `See all activity` that switches to the Activity tab. + +## Journey 3 — Multi-identity switcher (Priya, three identities) + +1. Hub detects ≥ 2 identities → picker grid (§B.14). +2. User clicks a card → selects that identity in breadcrumb + navigates to Home tab. +3. On any tab, user clicks the identity pill → listbox dropdown of all identities in the + active wallet + grouped section for imported-by-ID identities + footer + `+ Add another identity`. +4. Selecting a different identity updates breadcrumb + refreshes the current tab. + +**Interaction patterns**: +- Picker grid uses egui layout equivalent of CSS `repeat(auto-fill, minmax(260px, 1fr))` + — computed dynamically from the available width. +- Identity card is a new `IdentityPickerCard` component. `role=button` / focusable. +- `Add a new identity` card uses the same dimensions with dashed-border styling. + +## Journey 4 — Contacts (populated, social profile set) + +1. User on Home → clicks Contacts tab. +2. `ContactsTab` queries existing `ContactsList` backend path (no new backend task). +3. Layout: tab header row (Contacts title + `+ Add by username` / `Scan QR` / `Show my QR` + buttons), then three sections: Received requests (amber left-border), Active contacts + (with search input + row list), Sent requests (blue left-border, muted). +4. Clicking an active contact row opens a right-side detail drawer (Frame 4 detail panel). + +**Interaction patterns**: +- `RequestCard` component with `kind: Received | Sent` variant (drives color + buttons). +- `ContactRow` component — avatar + handle + last-payment hint + Send button + `•••` + overflow. +- Search input uses existing `egui::TextEdit::singleline` with `search` placeholder copy + from tooltip catalog. +- Detail drawer is a right-anchored egui `SidePanel::right` inside the central panel with + `RADIUS_LG` rounded corners. + +## Journey 5 — Contacts (gated, no social profile) + +1. `ContactsTab` renders a gated-state card when `current_identity.social_profile()` is + `None`. +2. Heading + body copy from §B.4.1 verbatim. +3. Primary `Add a display name` button switches to Settings tab + scrolls to social profile + section. Secondary `Why?` toggles an inline explanation. + +## Journey 6 — Activity + +1. Filter chips: All (default on) · Payments · Funding · Platform. Alex sees Payments and + Funding; Platform collapsed under `More`. Priya / Jordan see all three. +2. Timeline renders up to N rows paginated; each row expandable. +3. Failed row (red left-border) shows `Retry` small button with tt-44. +4. Empty state: `No activity yet. Your payments, additions, and identity changes will + appear here.` + +**MVP constraint**: the unified aggregator over DashPay payments + funding + platform ops +does not exist today. The tab ships with filter chips and a gated-state body saying +"Unified activity is coming soon. For now, view activity on the existing DashPay Payments +screen." Cargo feature flag: `identity-hub-activity-feed`, off by default. + +## Journey 7 — Settings (Priya, multi-wallet) + +1. Settings tab: two-column at ≥ 1024px width (left: social profile, right: username + + aliases). Single-column fallback. +2. Advanced expander below (open by default for Priya / Jordan, collapsed for Alex). +3. Danger zone at the bottom of Advanced — confirmation dialog on `Unload this identity + from this device`. + +## Accessibility notes + +- Every icon-only button has `WidgetInfo::selected(WidgetType::Button, enabled, selected, + accessible_name)` set (pattern already used in `left_panel.rs`). +- Breadcrumb nav uses `aria-current="page"` on the active identity pill — in egui, this + maps to `WidgetInfo::selected(WidgetType::Link, ..., true, ...)`. +- Focus rings: rely on egui's built-in `visuals.widgets.active.bg_stroke`; for the picker + card, use a 3px Dash-blue outline on `response.has_focus()`. +- Color contrast: all pill-on-gradient combinations reviewed against WCAG 2.2 AA using the + tokens already in `theme.rs`. + +## DX notes + +- Every component lives in its own file under `src/ui/components/` and is re-exported from + `src/ui/components/mod.rs`. +- Each component has a `new()` constructor with required args only. All optional + configuration goes through builder methods. +- Each component exposes a `Response` struct implementing `ComponentResponse`. Consumers + never touch component internals. +- Components render correctly in both light and dark mode — a single `dark_mode: bool` + computed from `ctx.style().visuals.dark_mode` drives color token selection. + +## Out-of-scope for this PR + +- Shadow-alpha realignment in `theme.rs` (design-spec §E). Tracked as a separate follow-up + — would affect every screen, not just the new hub. +- Real unified activity aggregator. Tab shell only. +- Auto-accept contact requests proof generation. UI toggle added, backend wiring deferred. +- Pick-a-username contest detection flow (§B.13). Hub routes to the existing + `RegisterDpnsNameScreen` which already handles this. +- Contested name browse-alternatives suggestion. Deferred. + +--- + +Revision: 1 +Authored: 2026-04-23 diff --git a/docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md b/docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md new file mode 100644 index 000000000..74d6648c8 --- /dev/null +++ b/docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md @@ -0,0 +1,205 @@ +# Phase 1c — Test Case Specification + +Specifications only. Code lives in `tests/kittest/` and inline `#[cfg(test)]` modules. + +## Unit tests (per component) + +### UT-BPILL-01 — `BreadcrumbPill::new` stores props + +**Preconditions**: construct with `BreadcrumbPill::new("Main Wallet")`. +**Steps**: read `label()` accessor. +**Expected**: returns the exact label string. + +### UT-BPILL-02 — `BreadcrumbPill::subdued` flag + +**Preconditions**: construct, call `.subdued(true)`. +**Steps**: inspect `is_subdued()`. +**Expected**: returns `true`. Render path does not draw a chevron in this mode. + +### UT-BPILL-03 — `BreadcrumbPill::placeholder` renders italic + +**Preconditions**: construct with `BreadcrumbPill::placeholder("(no wallet yet)")`. +**Steps**: inspect response after render. +**Expected**: `response().is_interactive == false`, `response().placeholder == true`. + +### UT-BSWITCH-01 — `BreadcrumbSwitcher` composition + +**Preconditions**: build switcher with three segments: plain link, wallet pill, identity +pill. +**Steps**: call `.show(ui)`. +**Expected**: response struct reports which segment was clicked (if any). + +### UT-IDPILL-01 — Identity pill label priority + +**Preconditions**: identity has `local_nickname=Some("dev")`, `dpns_handle=Some("alex.dash")`, +`identity_id="Fx1Kj…9Tt"`. +**Steps**: compute display label via `IdentityPill::display_label`. +**Expected**: returns `"dev"`. (Local nickname wins.) + +### UT-IDPILL-02 — Identity pill label priority — no nickname + +**Preconditions**: identity has `local_nickname=None`, `dpns_handle=Some("alex.dash")`, +`identity_id="..."`. +**Expected**: returns `"alex.dash"`. + +### UT-IDPILL-03 — Identity pill label priority — raw ID fallback + +**Preconditions**: identity has `local_nickname=None`, `dpns_handle=None`, `identity_id="Fx1Kj…9Tt"`. +**Expected**: returns `"Fx1Kj…9Tt"` (shortened, monospace). + +### UT-PICKER-01 — `IdentityPickerCard::heading` priority matches pill + +**Preconditions**: identity has `display_name=Some("Alex")`, `dpns_handle=Some("alex.dash")`. +**Expected**: heading = `"Alex"`; sub-line = `"@alex.dash"`. + +### UT-PICKER-02 — `IdentityPickerCard` no display name + +**Preconditions**: `display_name=None`, `dpns_handle=Some("mn-east-01.dash")`. +**Expected**: heading = `"mn-east-01.dash"`; sub-line = the identity-type label. + +### UT-PICKER-03 — `IdentityPickerAddCard` has dashed border + +**Preconditions**: default construction. +**Steps**: inspect render settings. +**Expected**: border style reports dashed; hover switches to solid Dash-blue. + +### UT-TABS-01 — `IdentityHubTabBar` selection + +**Preconditions**: tab bar with all four tabs, selected = Home. +**Steps**: click the Contacts tab via kittest. +**Expected**: response returns `Some(IdentityHubTab::Contacts)`; internal selection +updated. + +### UT-CHECKLIST-01 — Onboarding checklist completion + +**Preconditions**: checklist with three steps, `Pick a username` marked complete. +**Steps**: render. +**Expected**: first step rendered with check mark; remaining two with empty circle. + +### UT-CHECKLIST-02 — Dismiss persists + +**Preconditions**: checklist rendered; user clicks dismiss button. +**Expected**: response reports `dismissed == true`; caller must persist via settings. + +### UT-ACTIVITY-ROW-01 — Failed row has retry + +**Preconditions**: `ActivityRow::new` with status `Failed`. +**Expected**: render includes a `Retry` small button; row border color = danger. + +### UT-REQUEST-CARD-01 — Received vs Sent styling + +**Preconditions**: two cards, `RequestCard::received` and `RequestCard::sent`. +**Expected**: received has amber left-border + Accept/Decline buttons. Sent has blue +left-border + Pending pill + Cancel request button. + +### UT-CONTACT-ROW-01 — Clickable surface + +**Preconditions**: row with handle and display name. +**Steps**: click the row body. +**Expected**: response `clicked == true` with the contact id carried in the response. + +### UT-GATE-01 — No-social-profile gate card + +**Preconditions**: gate card rendered with `@{handle}` placeholder. +**Expected**: interpolates the handle correctly; primary button = `Add a display name`. + +### UT-HERO-01 — Identity hero, social profile set + +**Preconditions**: identity with display name + handle + balance. +**Expected**: render emits the avatar with initials fallback when no image; `text_secondary` +for handle line; tabular numerals for balance. + +### UT-HERO-02 — Identity hero, no social profile + +**Preconditions**: same identity with `display_name=None`. +**Expected**: render emits type-glyph monogram instead of avatar; no display-name line. + +## Integration tests (kittest, one per tab + onboarding) + +### IT-ONBOARD-01 — onboarding empty state renders + +**File**: `tests/kittest/identity_hub_onboarding.rs`. +**Preconditions**: `AppContext` with zero loaded identities on Testnet. +**Steps**: mount `IdentityHubScreen::new(&app_context)`, run one frame. +**Expected**: +- Heading text `Welcome to Identities.` is present. +- Both primary buttons present: `Create my first identity`, `I already have an identity + — load it`. +- Developer Mode footer absent (Alex persona, developer mode off). + +### IT-HOME-01 — Home tab renders with one identity + +**File**: `tests/kittest/identity_hub_home.rs`. +**Preconditions**: `AppContext` with one loaded User identity (fake in-memory test doubles). +**Steps**: mount `IdentityHubScreen`, run one frame. +**Expected**: +- Breadcrumb `Identities` link + wallet pill + identity pill present. +- Tab bar with exactly four tab labels: Home, Contacts, Activity, Settings. +- Home tab selected by default. +- Quick-actions row has three buttons: Send, Receive, Add contact. +- Secondary-actions row has three ghost buttons: Add funds, Send to wallet, Send to + another identity. + +### IT-CONTACTS-01 — Contacts tab gated when no social profile + +**File**: `tests/kittest/identity_hub_contacts.rs`. +**Preconditions**: `AppContext` with one identity, no social profile. +**Steps**: mount hub, switch to Contacts tab, run one frame. +**Expected**: +- Heading `Set up a social profile first.` present. +- Primary button `Add a display name` present. +- No request cards or active contacts list rendered. + +### IT-ACTIVITY-01 — Activity tab shell renders + +**File**: `tests/kittest/identity_hub_activity.rs`. +**Preconditions**: one identity, Cargo feature `identity-hub-activity-feed` off. +**Steps**: mount hub, switch to Activity tab. +**Expected**: +- Filter chips present: All, Payments, Funding. +- Gated message present: `Unified activity is coming soon.` + +### IT-SETTINGS-01 — Settings tab renders sections + +**File**: `tests/kittest/identity_hub_settings.rs`. +**Preconditions**: one identity, social profile set. +**Steps**: mount hub, switch to Settings tab. +**Expected**: +- Section heading `Social profile` present. +- Section heading `Username` present. +- Section heading `Aliases` present. +- Advanced expander present. + +### IT-NAV-01 — new left-nav entry is present and coexists with old entries + +**File**: `tests/kittest/identity_hub_nav.rs`. +**Preconditions**: default app mount. +**Steps**: inspect left panel nav buttons. +**Expected**: +- Old `Identities` nav entry present (legacy). +- Old `Dashpay` nav entry present (legacy). +- New `Identities` hub entry present (distinct variant). + +## Traceability + +| Requirement | Tests | +|---|---| +| FR-1 (new nav entry, coexists) | IT-NAV-01 | +| FR-2 (dispatch by identity count) | IT-ONBOARD-01, IT-HOME-01 | +| FR-3 (breadcrumb switcher) | UT-BPILL-*, UT-BSWITCH-01, UT-IDPILL-*, IT-HOME-01 | +| FR-4 (four tabs) | IT-HOME-01, IT-CONTACTS-01, IT-ACTIVITY-01, IT-SETTINGS-01, UT-TABS-01 | +| FR-5 (onboarding CTAs + dev footer) | IT-ONBOARD-01 | +| FR-6 (picker grid) | UT-PICKER-*, (manual visual — separate kittest added once picker lands) | +| FR-7 (i18n-ready strings) | enforced by review, not test | +| FR-8 (tooltips wired) | verified by `InfoPopup` / `ResponseExt` unit tests | +| FR-9 (nav tooltip) | verified by reading `add_left_panel` diff | +| NFR-1 (no backend mods) | verified by reviewing git diff against `src/backend_task/` | +| NFR-2 (feature gating) | per-flag cfg checks in module headers | +| NFR-5 (tests present) | this document | +| NFR-6 (lint clean) | CI | +| NFR-7 (progressive disclosure) | UT-TABS-01 persona matrix (future), visual review | + +--- + +Revision: 1 +Authored: 2026-04-23 diff --git a/docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md b/docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md new file mode 100644 index 000000000..308926cc2 --- /dev/null +++ b/docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md @@ -0,0 +1,259 @@ +# Phase 1d — Development Plan + +Derived from 01-requirements.md, 02-ux-plan.md, 03-test-case-spec.md. + +## Architecture + +### Layers + +1. **`src/ui/identity/`** — new submodule. Root screen `IdentityHubScreen`, four tab + submodules (`home.rs`, `contacts.rs`, `activity.rs`, `settings.rs`), onboarding + (`onboarding.rs`), picker (`picker.rs`), and a local `mod.rs` with the + `IdentityHubTab` enum + shared tab state. + +2. **`src/ui/components/`** — new shared widgets (flat placement): + - `breadcrumb_pill.rs` — label + icon + optional chevron; `subdued` / `interactive` / + `placeholder` modes. + - `breadcrumb_switcher.rs` — composes plain link + two `BreadcrumbPill`s into the + `Identities › wallet › identity` row. + - `identity_pill.rs` — thin wrapper over `BreadcrumbPill` with the label priority rule. + - `identity_hub_tab_bar.rs` — horizontal tab bar specific to the hub (does NOT replace + the existing `*_subscreen_chooser_panel.rs` family — those stay). + - `identity_picker_card.rs` + `identity_picker_add_card.rs`. + - `identity_hero_card.rs` — gradient hero with avatar + handle + balance. + - `onboarding_checklist.rs` — three-step list with check marks + dismiss. + - `activity_row.rs` — 48px compact row; has a `Failed` variant with Retry. + - `request_card.rs` — Received / Sent variants. + - `contact_row.rs` — avatar + handle + Send button + overflow. + - `social_profile_gate_card.rs` — the no-profile gate card used on Contacts tab. + +3. **`src/model/identity_hub.rs`** — new small module holding UI-layer value types: + `IdentityHubTab` enum, `SocialProfileState`, `HubLanding`, persistence for + `start_tab_on_hub` user preference (optional). + +4. **`src/ui/RootScreenType`** — add `RootScreenIdentityHub` variant. + `ScreenType::IdentityHub`. `Screen::IdentityHubScreen`. `ScreenType::create_screen` + dispatch. All three existing enums extended. + +5. **`src/app.rs`** — register the new screen in `AppState::new()` `main_screens` BTreeMap. + +6. **`src/ui/mod.rs`** — extend `RootScreenType::from_int` / `to_int` mapping + (next free integer). No schema change. `src/database/settings.rs` consumes the + mapping but does not own it — only add tests there if persistence behaviour changes. + +7. **`src/ui/components/left_panel.rs`** — add a third nav button `Identities · Hub` (new + label distinct from the legacy `Identities`) using the `identity.png` icon for now. + Gate: always visible (no `FeatureGate`). + +### Tech choices + +- No new crate dependencies. Everything built from existing `egui`, `egui_extras`, + `dash-sdk`, and the project's theme module. +- Kittest: follow existing patterns in `tests/kittest/*`. +- Async: tabs dispatch existing backend tasks (e.g. `DashPayTask::LoadContacts`) — the hub + does not introduce new backend variants in the default scope. + +## Task breakdown + +Batched for one-agent serial execution. Each task ends with a commit and `cargo build` + +`cargo clippy` + `cargo test --lib` green. + +### T1 — Planning artifacts (DONE) + +Phase 1 documents committed under `docs/ai-design/2026-04-23-identity-hub-impl/`. + +### T2 — Feature flag + RootScreenType variant + +- Add `identity-hub` feature to `Cargo.toml` (default-enabled so the hub is visible + by default; can be disabled for quick compile). +- Add `identity-hub-activity-feed` feature (default off) — gates the unified activity + aggregator (stub tab content when off). +- Extend `RootScreenType` with `RootScreenIdentityHub` (to_int / from_int mapping uses + integer 27 — next free). +- Add `ScreenType::IdentityHub` variant + `PartialEq` + `create_screen` arm that returns + a placeholder stub screen. +- Add `Screen::IdentityHubScreen(IdentityHubScreen)` variant. + +Unit tests: `database::settings` round-trip test covering the new integer. + +Deliverable: `cargo build --all-features` green. + +Commit: `feat(identity-hub): add feature flag, RootScreenType variant, screen enum wiring` + +### T3 — Scaffold `src/ui/identity/` + +- `src/ui/identity/mod.rs` — module root, re-exports. +- `src/ui/identity/hub_screen.rs` — `IdentityHubScreen` struct implementing `ScreenLike` + with an empty body rendered inside `island_central_panel`. Holds tab state. +- `src/ui/identity/tabs.rs` — `IdentityHubTab` enum (Home / Contacts / Activity / Settings). +- `src/ui/identity/landing.rs` — `HubLanding` state machine: `Onboarding | Home | Picker` + computed from loaded-identity count. + +Unit tests: state-machine transitions for HubLanding. + +Commit: `feat(identity-hub): scaffold hub screen module and tab state` + +### T4 — Breadcrumb switcher + pill components + +- `src/ui/components/breadcrumb_pill.rs` — `BreadcrumbPill` + `BreadcrumbPillResponse`. + Builder methods: `.with_icon(...)`, `.subdued(bool)`, `.interactive(bool)`, `.placeholder()`. +- `src/ui/components/identity_pill.rs` — `IdentityPill` with label priority + (Local nickname → DPNS → shortened ID). +- `src/ui/components/breadcrumb_switcher.rs` — composes plain-text `Identities` link + + wallet pill + identity pill. `BreadcrumbSwitcherResponse` reports which segment was + activated. + +Unit tests: UT-BPILL-01..03, UT-IDPILL-01..03, UT-BSWITCH-01. + +Commit: `feat(identity-hub): add breadcrumb switcher and pill components` + +### T5 — Tab bar component + onboarding + +- `src/ui/components/identity_hub_tab_bar.rs` — horizontal bar, four tab buttons, selected + state uses existing theme tokens. +- `src/ui/identity/onboarding.rs` — onboarding empty state UI. +- Wire `HubLanding::Onboarding` in `IdentityHubScreen`. + +Unit tests: UT-TABS-01. Kittest: IT-ONBOARD-01. + +Commit: `feat(identity-hub): add tab bar and onboarding empty state` + +### T6 — Left-nav entry + AppState wiring + +- Add `identity_hub.png` icon reference (reuse `identity.png` temporarily with a TODO to + create a distinct people-silhouette asset). +- Extend `left_panel.rs` buttons array with the new entry. +- Extend `app.rs` `main_screens` construction. +- Verify legacy `Identities` and `Dashpay` entries still work. + +Kittest: IT-NAV-01. + +Commit: `feat(identity-hub): wire left-nav entry and AppState registration` + +### T7 — Identity picker + +- `src/ui/components/identity_picker_card.rs` + `identity_picker_add_card.rs`. +- `src/ui/identity/picker.rs` — grid rendering, click handling, "Add a new identity" routes + to existing `AddNewIdentityScreen`. + +Unit tests: UT-PICKER-01..03. + +Commit: `feat(identity-hub): add identity picker grid` + +### T8 — Identity hero + onboarding checklist + Home tab + +- `src/ui/components/identity_hero_card.rs`. +- `src/ui/components/onboarding_checklist.rs`. +- `src/ui/identity/home.rs` — full Home tab with hero, quick actions, secondary actions, + checklist, recent activity preview (stubbed to existing backend data), advanced expander. + +Unit tests: UT-HERO-01..02, UT-CHECKLIST-01..02. +Kittest: IT-HOME-01. + +Commit: `feat(identity-hub): add Home tab with hero and onboarding checklist` + +### T9 — Contacts tab (gated + populated shells) + +- `src/ui/components/social_profile_gate_card.rs`. +- `src/ui/components/request_card.rs`. +- `src/ui/components/contact_row.rs`. +- `src/ui/identity/contacts.rs` — gated and populated states. Populated state delegates + to existing DashPay backend task for contacts list; no new backend work. + +Unit tests: UT-GATE-01, UT-REQUEST-CARD-01, UT-CONTACT-ROW-01. +Kittest: IT-CONTACTS-01. + +Commit: `feat(identity-hub): add Contacts tab with gated + populated states` + +### T10 — Activity tab shell + +- `src/ui/components/activity_row.rs`. +- `src/ui/identity/activity.rs` — filter chip row + gated empty state + Retry wiring. + Guards the full timeline behind `identity_hub_activity_feed` feature. + +Unit tests: UT-ACTIVITY-ROW-01. +Kittest: IT-ACTIVITY-01. + +Commit: `feat(identity-hub): add Activity tab shell with filter chips` + +### T11 — Settings tab + +- `src/ui/identity/settings.rs` — two-column layout with social profile (left), username + + aliases (right), advanced expander, danger zone confirmation. +- Delegates edit / save actions to existing backend tasks (`DashPayTask::UpdateProfile`, + `IdentityTask::AddAlias`, etc.) — additive nothing. + +Kittest: IT-SETTINGS-01. + +Commit: `feat(identity-hub): add Settings tab` + +### T12 — docs + PR prep + +- Update `src/ui/components/README.md` with new components. +- Update `docs/user-stories.md` with US-IDH-001..006. +- Write PR body. + +Commit: `docs(identity-hub): update components reference and user stories` + +### T13 — Polish + QA pass + +- `cargo +nightly fmt --all`. +- `cargo clippy --all-features --all-targets -- -D warnings` — fix all. +- `cargo test --all-features --workspace` — fix regressions. +- Self-review against test-case spec. + +Commit: `chore(identity-hub): formatting and clippy cleanup` (as needed). + +### T14 — Push + PR + ci-dance + +- Push feature branch. +- Open draft PR against `v1.0-dev`. +- Run `claudius:ci-dance` until green or the retry budget is exhausted. + +## Task → Requirement traceability + +| Task | Requirements satisfied | +|------|------------------------| +| T2 | FR-1, NFR-1 (additive only) | +| T3 | FR-2, FR-4 | +| T4 | FR-3 | +| T5 | FR-4, FR-5 | +| T6 | FR-1 (coexistence) | +| T7 | FR-6 | +| T8 | FR-4 Home, FR-7 (i18n strings) | +| T9 | FR-4 Contacts, FR-8 (gated-state tooltip) | +| T10 | FR-4 Activity, NFR-2 (feature flag) | +| T11 | FR-4 Settings | +| T12 | AC-7 | +| T13 | AC-2, AC-3, AC-4, NFR-6 | +| T14 | N/A — delivery | + +## Feature-flag inventory + +| Flag | Kind | Default | Gates | +|------|------|---------|-------| +| `identity-hub` | Cargo feature | on | left-nav `Identity Hub` entry + hub registration in `main_screens` | +| `identity-hub-activity-feed` | Cargo feature | off | unified activity aggregator rendering | +| `developer_mode` | runtime (`AppContext::is_developer_mode`) | off | dev footer, throwaway identity, multi-identity create | + +Runtime gates use existing `FeatureGate` predicates where applicable; introduce new +predicate variants only if a gate is reused across two or more components. + +## Risk register + +| # | Risk | Mitigation | +|---|------|------------| +| R1 | Enum exhaustiveness — adding `RootScreenType` variant breaks every `match` with `_ => ...` OR requires updating hand-enumerated arms | Search `rg "RootScreenType::"` before committing; add explicit arms where the compiler complains; use `#[allow(clippy::enum_variant_names)]` already in place | +| R2 | `Screen` enum is already large; `large_enum_variant` lint | Box the new variant body if needed | +| R3 | Unit tests that need an `AppContext` | Use the existing test fixtures in `tests/kittest/*` — reuse mount pattern from `identities_screen.rs` test | +| R4 | kittest lifetime — existing test mount has specific setup | Copy-adapt `tests/kittest/identities_screen.rs` | +| R5 | Feature flag accidentally disables something users rely on | Default-on for the new-feature flag; default-off for the activity aggregator (explicitly experimental) | + +## Model selection + +- Single-agent execution, serial. No spawning. + +--- + +Revision: 1 +Authored: 2026-04-23 diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/README.md b/docs/ai-design/2026-05-18-platform-wallet-migration/README.md new file mode 100644 index 000000000..63889fcae --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/README.md @@ -0,0 +1,67 @@ +# DET → platform-wallet: Clean-Slate Backend Rewrite + +## Executive Summary + +DET becomes a thin adapter: `platform-wallet` owns chain sync, HD wallet management, identity lifecycle, DashPay, asset locks, and persistence. DET's `src/spv/` is deleted entirely — `SpvRuntime` inside `platform-wallet` drives all of it. The `CoreBackendMode` RPC/SPV dual-path disappears; there is one backend. The upgrade path is a one-time from_/into_ migration on first launch (backup → re-register wallets from seed → migrate identity/DashPay state → drop legacy tables). Single-key wallets are mocked with a clean swap boundary for a future upstream non-HD type. + +> **STATUS** +> +> P0 — DONE (GREEN). P0.5 — DONE (GREEN). P1 — DONE (GREEN). P2 — DONE (GREEN). +> P3a — DONE (GREEN, commit `6d348566`). P3b — DONE (GREEN, commit `d5a3e51b`; `classify_contact` + 7 tests now DEAD, deleted in P3c). +> P3c–P3e — DONE (GREEN). P4-partial done. +> P3 re-scoped 2026-05-18: drop backwards compatibility, upstream-only DashPay derivation, no quarantine. Run continuing to completion + push. +> P4 split into P4a (UI data-path rewire, blocks P4a.5) → P4a.5 (fund-safety spend-path completion: 3 paths, `FundWithUtxo` removed w/ disclosure, blocks P4b) → P4b (mechanical prune, only after P4a.5; narrowed: `src/database/utxo.rs` + `utxos` table RETAINED — Decision #7 single-key carve-out; P5 adds mandatory single-key regression lane). P5 + Smythe gate pending. +> P4a.5 inserted (P2-completeness gap): `AssetLockKind::Shielded` + shielded Path 1 rewired to `WalletBackend::create_asset_lock_proof`; `FundWithUtxo` variants removed (no upstream funding-outpoint API at #3625 head) w/ disclosure via post-migration notice; `received_transaction_finality` slimmed to asset-lock-finality-only (ZMQ retained). P4b gated on P4a.5 exit. +> P5 gains release-blocking Smythe double-spend/fund-safety audit: invariants I1–I6 must all pass before push. +> Fund-safety spend path is upstream-authoritative from P2 — display gap addressed in P4a; spend-path correctness gap addressed in P4a.5. +> Only release-blocking gates: simplified Stage-B engine + QA lane (P3c–P3e) + post-migration UI data-path test + P5 Smythe I1–I6 audit. +> Supersedes the prior incremental plan (architecture.md, migration-plan.md, spv-rpc-correctness.md, verification.md — all deleted). +> Verified at PR #3625 head `738091f734e05c7a1b822bb1ebff336c93b67891`. + +## Load-Bearing Confirmed Assumption + +**Decision #1 — `platform-wallet` owns SPV internally: CONFIRMED at source. Not contradicted.** + +`packages/rs-platform-wallet/Cargo.toml` declares `dash-spv` as a direct dependency. `SpvRuntime` (`packages/rs-platform-wallet/src/spv/runtime.rs`) constructs `DashSpvClient::new()` internally, owns `PeerNetworkManager` + `DiskStorageManager`, and exposes `run(config, cancel_token)` — its own sync loop. There is no host-feed API. `PlatformWalletManager` owns the `SpvRuntime`. DET's `src/spv/` is deletable; only a thin ConnectionStatus display adapter remains. See [upstream-reality.md](upstream-reality.md) for the full evidence chain. + +## Gate Posture (Updated) + +With Decision #1 (pin to #3625 head now) and Decision #2 (G2 downgraded via `PersistedWalletLoader` seam), implementation is no longer upstream-blocked. + +**G1 — PR #3625 — now a release-hardening track, not a start blocker.** +DET is pinned to PR #3625 head now. P0–P2 proceed immediately. G1 resolves to: track #3625 until it merges, then bump pin to a released rev before shipping P3+. See [phasing.md § Combined Gate Posture](phasing.md#combined-gate-posture). + +**G2 — `Wallet::from_persisted` gap — downgraded to deferred swap.** +`ClientStartState.wallets` is not reconstructed by `persister.load()` at PR head (`LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"]` in `rs-platform-wallet-storage/src/sqlite/persister.rs`). Mitigated by the `PersistedWalletLoader` seam: `SeedReregistrationLoader` ships now with seed-re-registration behavior; `UpstreamFromPersisted` is a one-line swap when upstream ships `Wallet::from_persisted`. G2 is no longer a gate. See [g2-mock-boundary.md](g2-mock-boundary.md) and [upstream-reality.md § G2 Caveat](upstream-reality.md#g2-caveat--walletfrom_persisted-load-gap). + +**Only release-blocking gate: simplified Stage-B engine + QA lane (P3c–P3e).** +The upstream-only DashPay derivation path must be implemented and QA-proven before P3+P4 ship. Quarantine apparatus WITHDRAWN (user decision 2026-05-18). See [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off" and [phasing.md](phasing.md). + +## Table of Contents + +| File | Description | +|---|---| +| [upstream-reality.md](upstream-reality.md) | Verified upstream facts: what `platform-wallet` owns, the `src/spv/`-deletion answer, G2 caveat | +| [backend-architecture.md](backend-architecture.md) | New `src/wallet_backend/` module, `AppContext` placement, threading, event flow replacing reconcile, error model | +| [backendtask-contract.md](backendtask-contract.md) | Full kept/modified/removed/new `BackendTask` table; net frontend impact | +| [data-model-and-migration.md](data-model-and-migration.md) | Conversion table, one-time migration procedure with backup/fail-safe, dead fields | +| [removal-inventory.md](removal-inventory.md) | DELETE vs RETAIN lists; RPC backend mode fate; thin Core-RPC mining utility | +| [single-key-mock.md](single-key-mock.md) | `SingleKeyBackend` trait boundary, stub behavior, user message, isolation | +| [phasing.md](phasing.md) | P0–P5 phase table (including P0.5 compile floor, P4a.5 fund-safety spend-path completion, P5 Smythe release-blocking audit) with gates; skills/agents/crew; QA matrix; highest-risk assumption verdict | +| [g2-mock-boundary.md](g2-mock-boundary.md) | `PersistedWalletLoader` seam design — seed-re-registration now, one-line swap when upstream `Wallet::from_persisted` lands | +| [dip14-migration-hardstop.md](dip14-migration-hardstop.md) | SUPERSEDED 2026-05-18 — quarantine apparatus WITHDRAWN; retained as historical record only. See data-model-and-migration.md "Accepted fund-accessibility trade-off". | +| [open-questions.md](open-questions.md) | All 8 decisions — now fully RESOLVED | +| [feature-coverage.md](feature-coverage.md) | Supporting analysis: RPC-vs-SPV capability matrix; DET features absent from `platform-wallet` | + +## Decisions — All Resolved + +See [open-questions.md](open-questions.md) for full resolutions: + +- **#1** G1 timing — RESOLVED: pin to #3625 head now; release-hardening only +- **#2** G2 seed-re-registration — RESOLVED: `PersistedWalletLoader` seam; G2 downgraded +- **#3** ZMQ listener — RESOLVED: audit before P4; delete if wallet-only +- **#4** Devnet identity discovery — RESOLVED: DET-permanent +- **#5** DashPay scope boundary — RESOLVED: hybrid split confirmed +- **#6** DIP-14/15 parity policy — RE-RESOLVED 2026-05-18: drop backwards compatibility, upstream-only derivation, quarantine apparatus WITHDRAWN (see [open-questions.md #6](open-questions.md#decision-6--dip-1415-parity-policy) and [data-model-and-migration.md](data-model-and-migration.md)) +- **#7** Single-key timeline — RESOLVED: mock now, swap later +- **#8** Removed tasks grace — RESOLVED: hard-remove immediately diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/backend-architecture.md b/docs/ai-design/2026-05-18-platform-wallet-migration/backend-architecture.md new file mode 100644 index 000000000..4718920fb --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/backend-architecture.md @@ -0,0 +1,185 @@ +# Backend Architecture + +**Purpose:** New `src/wallet_backend/` module design — `AppContext` placement, `WalletBackend` boundary, threading/async model, event flow replacing `reconcile_spv_wallets`, error model. + +[← back to README](README.md) + +--- + +> **Deltas from this spec (as shipped).** This document is a dated design record; three points below were superseded during implementation and are NOT repealed here — they're flagged so a reader absorbs current behavior, not a withdrawn rule: +> +> - **Type opacity mandate** (below, and again under "Read Accessors"): "no `WalletManager` / `PlatformWallet` / `IdentityManager` type ever escapes it" is **superseded** by **M-PLATFORM-WALLET-FIRST-PARTY** — `wallet_backend` is NOT a type-translation layer; upstream types appear freely on its public surface by design. See `src/wallet_backend/mod.rs` module header and [2026-06-02-jit-secret-access/design.md § 8.3](../2026-06-02-jit-secret-access/design.md). +> - **Module diagram — `loader`**: `SeedReregistrationLoader` is gone; the G2 swap this diagram anticipated shipped as `UpstreamFromPersisted`. See [g2-mock-boundary.md](g2-mock-boundary.md). +> - **Module diagram — `seed_store` / `single_key_stub`**: replaced by the JIT `SecretAccess`/`secret_store` chokepoint and a real `SingleKeyView`, respectively. See [2026-06-02-jit-secret-access/design.md](../2026-06-02-jit-secret-access/design.md) and [single-key-mock.md](single-key-mock.md). + +--- + +## B. Target Backend Architecture + +### New Module: `src/wallet_backend/` + +One owner, one boundary. + +``` +AppContext + └── wallet_backend: Arc // the ONLY wallet entry point + ├── pwm: PlatformWalletManager + │ // upstream — owns SpvRuntime+sync+identity+dashpay+assetlocks + ├── DetPersister: Arc + │ // = upstream SqliteWalletPersister (PR #3625); no DET-authored persister + ├── EventBridge: Arc + │ // DET-authored; receives upstream events → TaskResult channel + ├── loader: Arc + │ // = SeedReregistrationLoader now; UpstreamFromPersisted when G2 closes + │ // see g2-mock-boundary.md + ├── seed_store: encrypted-seed access (DET-retained, secret boundary) + └── single_key_stub: SingleKeyBackend (mock — see single-key-mock.md) +``` + +### Placement in `AppContext` + +`AppContext` holds `wallet_backend: Arc` replacing today's: +- `spv_manager: Arc` (`src/context/mod.rs:97`) +- `wallets: RwLock>` and associated wallet fields +- `core_client` / `core_backend_mode` wallet fields + +`WalletBackend` is the single seam (rust-best-practices M-DONT-LEAK-TYPES, C-NEWTYPE-HIDE): no `WalletManager` / `PlatformWallet` / `IdentityManager` type ever escapes it. It exposes DET-shaped methods and DET-shaped result types only. + +**Migration marker fields in `AppContext`/settings inventory:** + +Two settings keys are added by the two-stage migration (see [data-model-and-migration.md — Migration execution model](data-model-and-migration.md#migration-execution-model--two-stage-marker-gated-ratified)): + +| Key | Type | Meaning | +|---|---|---| +| `platform_wallet_migration_pending` | bool (0/1) | Set by Stage-A v35 tx; cleared by Stage B only when every wallet re-registered AND every identity added AND all contacts re-established AND legacy tables dropped. Authoritative "pending" signal — the backup file's existence is NOT the signal. | +| `dashpay_dip14_quarantine_active` | bool (0/1) | INERT/RESERVED — column added in Stage A (commit `6d348566`) but the quarantine apparatus was WITHDRAWN (user decision 2026-05-18). Never set to 1 by the simplified Stage-B engine. Removal deferred to P4's batched dead-column cleanup. | + +**`ensure_wallet_backend` as the Stage-B seam (`src/context/mod.rs:634`):** + +`AppContext::ensure_wallet_backend` is the post-unlock async entry point and the sole invocation site for Stage-B migration. It is called after seed unlock, when `seed + SDK + persister + WalletBackend` are all available. Stage B runs here, behind an `AppContext`-owned `tokio::sync::Mutex` acquired BEFORE the `platform_wallet_migration_pending` marker check, guaranteeing exactly one Stage-B execution process-wide even under reentrant or concurrent callers. Stage B completes before `WalletBackend` is published to its `ArcSwapOption`, so no task ever observes a partially-migrated backend. If the user never unlocks, Stage B never runs; the marker persists and the app operates in `WalletBackendNotYetWired`-degraded state. + +### `BackendTask` Dispatch — Unchanged Shape + +`AppContext::run_backend_task()` (`src/backend_task/mod.rs:409`) still matches the `BackendTask` enum and dispatches. Wallet/identity/DashPay task arms now call `self.wallet_backend.()` instead of `spv_manager` / `run_wallet_task` / `reconcile`. The action→channel→`TaskResult`→`display_task_result` loop (CLAUDE.md "App Task System") is preserved verbatim — that is the frozen frontend contract. See [backendtask-contract.md](backendtask-contract.md) for the full task-by-task mapping. + +### Threading / Async Model + +`PlatformWalletManager` runs its own tokio tasks: +- `SpvRuntime` sync loop via `spawn_in_background` +- Identity-sync (per interval) +- Platform-address sync (~15s) +- Optional shielded sync (~60s) +- Internal event-adapter task + +DET no longer spawns sync or reconcile tasks. `WalletBackend` is `Clone` via `Arc` (M-SERVICES-CLONE), `Send + Sync`. + +### Event Flow Back to Frontend + +This replaces `reconcile_spv_wallets` and the SPV handlers. + +**1. EventBridge construction.** +`WalletBackend` constructs a DET `EventBridge` implementing `platform_wallet::PlatformEventHandler` (sync trait, object-safe — confirmed in `packages/rs-platform-wallet/src/events.rs`). Registered via `PlatformEventManager::add_handler` (lock-free `ArcSwap`). + +**2. Upstream events emitted.** +- `on_platform_address_sync_completed(&PlatformAddressSyncSummary)` +- `on_shielded_sync_completed(&ShieldedSyncPassSummary)` +- Plus `EventHandler` supertrait callbacks: SPV sync/network/wallet/progress/error — the same `dash-spv` event surface DET's `SpvEventHandler` consumes today. + +**3. EventBridge callbacks.** +Callbacks are sync and must not block. Each maps the upstream event into a DET `TaskResult::{Success(Refresh)/...}` and sends it on the existing MPSC `task_result_sender` (the same channel `AppState::update()` polls each frame). For state queries it does a quick read off `WalletBackend` accessors. + +**4. Frame loop — unchanged.** +`AppState::update()` continues to poll `task_result_receiver.try_recv()` and route to `display_task_result()`. `ConnectionStatus` gains a thin adapter fed by the sync/progress callbacks + `SpvRuntime::sync_progress()` / `tip_block_time()` instead of DET-owned SPV atomics. + +**`PersistedWalletLoader` step (between construction and `start()`).** +After constructing `PlatformWalletManager` (which does not auto-start), `WalletBackend::new()` calls `loader.wallets_to_register(ctx)` to obtain `Vec`. For each registration it calls `create_wallet_from_seed_bytes` then `load_persisted()` (rehydrates identity/contact/address deltas from the persister), then calls `PlatformWalletManager.start()`. This is the G2 seam: today `loader` is `SeedReregistrationLoader`; the one-line swap to `UpstreamFromPersisted` requires zero other changes. See [g2-mock-boundary.md](g2-mock-boundary.md). + +**Prose sequence (end to end):** + +``` +WalletBackend::new() + → PlatformWalletManager constructed (not yet started) + → loader.wallets_to_register() → Vec + → for each: create_wallet_from_seed_bytes + load_persisted() + → PlatformWalletManager.start() + → SpvRuntime spawns sync loop + → blocks/filters processed internally + → wallet/identity/dashpay state mutates inside PlatformWalletInfo + → upstream changeset pipeline persists via DetPersister (upstream SQLite persister) + → PlatformEventHandler fires + → DET EventBridge + → TaskResult MPSC + → AppState::update() + → Screen::display_task_result() + → repaint +``` + +The 230-line `reconcile_spv_wallets` is deleted; its job is now upstream's changeset + event pipeline. + +### WalletBackend Read-Accessor Surface + WalletSnapshot Push Model + +#### Read Accessors + +`WalletBackend` exposes four DET-typed read accessors. No upstream types (`PlatformWallet`, `WalletManager`, `IdentityManager`, etc.) cross the boundary — only DET view models come out (M-DONT-LEAK-TYPES, C-NEWTYPE-HIDE): + +```rust +fn wallet_balance(wallet_id: WalletId) -> DetWalletBalance +// DetWalletBalance { confirmed: u64, unconfirmed: u64, total: u64 } + +fn transaction_history(wallet_id: WalletId) -> Vec +// WalletTransaction — existing DET view model, retained (detached from Wallet/DB) + +fn utxos(wallet_id: WalletId) -> Vec +// DetUtxo { outpoint: OutPoint, value: u64, script_pubkey: ScriptBuf, address: Address } + +fn address_balances(wallet_id: WalletId) -> BTreeMap +``` + +Sources inside `WalletBackend`: `PlatformWalletManager` / `CoreWallet` / `WalletBalance` / `SpvRuntime` / the upstream `DetPersister`. + +The `TransactionRecord`→`WalletTransaction` mapping (the surviving piece of the deleted `reconcile_spv_wallets`) lives in `WalletBackend`, not in the UI or in any DB query layer. + +#### WalletSnapshot Push Model + +`WalletBackend` holds one `WalletSnapshot` per wallet behind an `ArcSwap`, consistent with the existing `wallet_backend: ArcSwapOption` at `src/context/mod.rs:130` and the `ConnectionStatus` push model: + +```rust +struct WalletSnapshot { + balance: DetWalletBalance, + transactions: Vec, + utxos: Vec, +} +// Held inside WalletBackend as: ArcSwap> +``` + +**Update flow:** The `EventBridge` (`src/wallet_backend/event_bridge.rs`), on the `PlatformEventHandler` callbacks it already handles (`on_platform_address_sync_completed`, `on_shielded_sync_completed`, SPV supertrait callbacks), recomputes the affected wallet's snapshot off the four read accessors above and atomically swaps it in via `ArcSwap::store`. It then emits the existing `TaskResult::Refresh` on the MPSC channel. + +**Read flow:** UI reads the snapshot synchronously via `app_context.wallet_backend()`. The load is lock-free and infallible — the egui frame thread never awaits, never calls upstream directly, and never blocks. A pre-first-sync snapshot is empty, which maps to the existing "syncing" state, not an error. + +#### FUND-SAFETY MANDATE — Display-Only Snapshot + +> **A04 — Reintroducing snapshot-based coin selection recreates the double-spend exposure the architecture eliminated. This is a P4a reviewer gate.** + +The `WalletSnapshot` is **DISPLAY-ONLY**. It exists to drive the wallets screen (balance, transaction list, UTXO list) without blocking the UI thread. + +Coin selection and transaction construction **MUST** go through: +- `WalletBackend::send_payment` — uses the upstream-authoritative live UTXO set at send time +- `WalletBackend::create_asset_lock_proof` — same; covers all asset-lock kinds including `AssetLockKind::Shielded` (added in P4a.5) + +Both are already implemented in P2 (`src/wallet_backend/mod.rs:362,390`). No code path may select spendable inputs from `WalletSnapshot`. Any PR that routes coin selection through the snapshot must be rejected at review. + +**`AssetLockKind::Shielded` (added P4a.5):** The `AssetLockKind` enum gains a `Shielded` variant, wiring `src/backend_task/shielded/bundle.rs:463,478` through `WalletBackend::create_asset_lock_proof` instead of the legacy `generic_asset_lock_transaction` + `select_unspent_utxos_for` path. This closes the last spend path that could select inputs from a legacy `Wallet.utxos` snapshot. + +**No funding-outpoint API at #3625 head — `FundWithUtxo` removed, not emulated.** `platform-wallet` at PR #3625 head provides no API to fund an identity from a caller-supplied external outpoint. All asset-lock funding is upstream-authoritative wallet-managed selection via `WalletBackend::create_asset_lock_proof`. The `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` variants are removed in P4a.5 with disclosure via the one-time post-migration notice. They are not emulated, stubbed, or preserved behind a feature flag. + +**`received_transaction_finality` — asset-lock-finality-only (P4a.5).** `context/transaction_processing.rs::received_transaction_finality` is slimmed in P4a.5 to handle only asset-lock finality. The `Wallet.utxos` / `address_balances` / legacy-`utxos`-table write branches are deleted; upstream / `WalletSnapshot` owns wallet-UTXO bookkeeping. The asset-lock detection and registration branch (`store_asset_lock_transaction` + the finality-wait channel that `broadcast_and_commit_asset_lock` / `wait_for_asset_lock_proof` depend on) is retained. ZMQ call sites at `app.rs:1267,1285` stay — ZMQ is still needed for asset-lock detection. + +--- + +### Seed / Secret Boundary + +The seed/secret boundary is enforced at source: `SeedReregistrationLoader` uses in-memory `Zeroizing` seed material (`src/wallet_backend/loader.rs`) and never writes seeds or private keys to the persister. Only public material (contact xpub, established-contact mapping, P2PKH addresses, identity ids) is written through the upstream `SqliteWalletPersister`. No automated `secrets_scan` test exists in the repository (add as future hardening). See `SECRETS.md` and `data-model-and-migration.md` conversion table (`WalletSeed`/`ClosedKeyItem` row). + +### Error Model + +`PlatformWalletError` and `PersistenceError` are wrapped into dedicated typed `TaskError` variants with `#[source]` (rust-best-practices error rules; CLAUDE.md "Never store user-facing strings in error variants"). No catch-all `String` variant. Every error gets a dedicated variant enabling structural matching, clean `Display`/`Debug` separation, and testable user-facing text. 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 new file mode 100644 index 000000000..54f84cfe5 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md @@ -0,0 +1,65 @@ +# BackendTask Contract + +**Purpose:** Full mapping of every DET `BackendTask` variant to its disposition in the rewrite — kept, modified, removed, or new — and the net frontend impact. + +[← back to README](README.md) + +--- + +## Transitional State: P0.5 → P2 + +Between P0.5 and P2, the `BackendTask` enum shape is preserved but wallet/identity/DashPay dispatch arms are inert. Specifically: + +- **`TaskError::WalletBackendNotYetWired`** — new typed fieldless variant introduced at P0.5 for all wallet/identity/DashPay task arms that have been delete-and-stub'd (Cluster C in the [P0.5 Compile-Floor Task List](phasing.md#p05-compile-floor-task-list)). These arms return this error from P0.5 until P2 replaces them with real `WalletBackend` calls. User-facing message: *"This action is being upgraded and is temporarily unavailable. Please use the previous version of the app to transact, or wait for the next update."* UI renders a calm `MessageBanner`; no action required from the user. + +- **`TaskError::SingleKeyWalletsUnsupported`** — typed fieldless variant for all single-key task arms, introduced at P0.5. Permanent from P0.5 onward (not replaced in P2; swap happens only when upstream ships a non-HD wallet type). See [single-key-mock.md](single-key-mock.md). + +Both variants follow the error taxonomy rules (CLAUDE.md): dedicated fieldless variants, message via `#[error("…")]`, no `String` fields, no raw technical details in the user-facing message, `Debug` repr via `BannerHandle::with_details`. + +--- + +## C. BackendTask Contract Mapping + +The `BackendTask` enum (`src/backend_task/mod.rs:92-100`) and the action/channel/`TaskResult`/`display_task_result` loop are preserved. Signatures are modified only where the wallet model type changes. Verified DET task surface: `WalletTask` (`src/backend_task/wallet/mod.rs`), `CoreTask` (`src/backend_task/core/mod.rs:44`), `IdentityTask` (`src/backend_task/identity/mod.rs`), `DashPayTask` (`src/backend_task/dashpay.rs`). + +### Task Table + +| Task variant | Disposition | Rationale / frontend impact | +|---|---|---| +| `WalletTask::GenerateReceiveAddress` | **modified** | `wallet_backend.next_receive_address(wallet_id)` → upstream `PlatformWallet`. `seed_hash` → `WalletId` (DET-opaque newtype). UI address-display screens unchanged. | +| `WalletTask::FetchPlatformAddressBalances` | **kept** | Platform addresses are upstream "Outside scope" (persister excludes them); DET keeps `fetch_platform_address_balances.rs` via DAPI. | +| `WalletTask::TransferPlatformCredits` | **kept** | Platform L2, DAPI/SDK. | +| `WalletTask::FundPlatformAddressFromAssetLock` | **modified** | Asset-lock state from upstream `AssetLockManager`/`TrackedAssetLock`; DET orchestrates funding. Result variant unchanged. | +| `WalletTask::WithdrawFromPlatformAddress` | **kept** | DAPI/SDK. | +| `WalletTask::FundPlatformAddressFromWalletUtxos` | **modified** | UTXO selection via upstream wallet; `CoreBackendMode` branch removed. | +| `CoreTask::SendWalletPayment` | **modified** | Single arm: `wallet_backend.send_payment(...)` → upstream build/sign + `SpvRuntime::broadcast_transaction`. RPC arm removed. Result `WalletPayment{txid,...}` unchanged. | +| `CoreTask::SendSingleKeyWalletPayment` | **modified → stub** | Single-key mock (see [single-key-mock.md](single-key-mock.md)): returns `TaskError::SingleKeyWalletsUnsupported`. UI shows not-supported banner. | +| `CoreTask::MineBlocks` | **kept (separate utility)** | No `platform-wallet` equivalent (full-node `generatetoaddress`). Moves to thin `Core-RPC` utility outside `WalletBackend` (see [removal-inventory.md § RPC Fate](removal-inventory.md#rpc-backend-mode--fate)). Regtest/Devnet only. Contract unchanged. | +| `CoreTask::RefreshWalletInfo` | **modified** | SPV arm (`reconcile_spv_wallets`) deleted; becomes no-op-or-light query — upstream syncs continuously and pushes events. RPC arm removed. May be demoted to UI "request refresh." Refresh button still works; returns faster. | +| `CoreTask::RefreshSingleKeyWalletInfo` | **modified → stub** | Single-key mock. The former `refresh_single_key_wallet_info.rs` handler has since been deleted; the dispatch arm now returns `SingleKeyWalletsUnsupported` inline in `src/backend_task/core/mod.rs:216-217`. | +| `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::*` (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. | +| `BackendTask::SwitchNetwork{start_spv}` | **modified** | `start_spv` semantics → `PlatformWalletManager.start()`/`shutdown()`. `set_core_backend_mode_volatile` removed. | +| `BackendTask::ReinitCoreClientAndSdk` | **modified** | Core client only relevant to thin RPC mining utility; SDK reinit stays. | +| Token / contested-voting / document / contract / shielded / grovestark tasks | **kept as-is** | Out of `platform-wallet` scope. Zero change. | + +### UI Display Data-Path — No BackendTask Changes + +The wallets UI tx/balance/UTXO display data-path rewire (P4a) introduces **no `BackendTask` variant changes**. It is a read-path relocation behind the `WalletBackend` seam: display is fed by the `WalletSnapshot` (updated via the existing `EventBridge` `TaskResult::Refresh`), not by task results. `WalletTransaction`-row helpers continue to take `&WalletTransaction` unchanged. The action/channel contract is frozen; no frontend plumbing changes for this gap. + +See [backend-architecture.md § WalletBackend Read-Accessor Surface + WalletSnapshot Push Model](backend-architecture.md#walletbackend-read-accessor-surface--walletsnapshot-push-model) for the snapshot design. + +### Net Frontend Impact + +Result variants and the action/channel contract are preserved — UI screens are largely unchanged. Concrete UI changes: + +1. Remove RPC-mode toggle, Core-wallet picker, and "Local Dash Core node" settings (`network_chooser_screen`). +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. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/data-model-and-migration.md b/docs/ai-design/2026-05-18-platform-wallet-migration/data-model-and-migration.md new file mode 100644 index 000000000..1c7a11a70 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/data-model-and-migration.md @@ -0,0 +1,108 @@ +# Data Model and Migration + +**Purpose:** Conversion table of DET types to `platform-wallet` targets, one-time migration procedure with backup/fail-safe, and dead fields to delete. + +[← back to README](README.md) + +--- + +Relates to: [phasing.md § P3](phasing.md#phase-table) — P3 implements the migration procedure; [g2-mock-boundary.md](g2-mock-boundary.md) — `PersistedWalletLoader` seam (seed re-registration); [dip14-migration-hardstop.md](dip14-migration-hardstop.md) — SUPERSEDED; migrate-or-quarantine apparatus WITHDRAWN (see "Accepted fund-accessibility trade-off" below). + +## DEV note — on-disk schema break on this branch (08b0ed9 pin) + +The `08b0ed9` `platform-wallet-storage` bump changed the on-disk storage schema (`kv_store` → `meta_*` tables) with a **divergent `V001`** migration. A `platform-wallet.sqlite` written by the prior pin (`17653ba`) will not open — refinery aborts on the divergent checksum (`WalletStorageError::Migration`), which DET maps to the `WalletDataIncompatible` error. During development, reset the local DET wallet databases (`/spv//platform-wallet.sqlite` and `/det-app.sqlite`) to continue. Full instructions and paths: [kv-keys.md § DEV: on-disk schema break](../../kv-keys.md#dev-on-disk-schema-break-on-this-branch--reset-local-wallet-dbs). Related platform todo: `f5897abd`. + +## D. Data Model and Conversions + +> **⚠ SUPERSEDED (migration mechanism only).** The "One-Time Migration", +> "Conversion Surface", and "Migration execution model — two stage, +> marker-gated" subsections below describe a **seed-driven** re-registration +> design that **never shipped**. They reference machinery that does not exist +> in the codebase: `create_wallet_from_seed_bytes` re-registration, the +> `SeedReregistrationLoader`, the `platform_wallet_migration_pending` SQL +> marker, the Stage-A v35 SQL migration, and `src/database/migration_pw.rs`. +> +> The **live** mechanism is the cold-start `FinishUnwire` task +> (`src/backend_task/migration/finish_unwire.rs`), driven once per network at +> launch by `AppState`: +> - Idempotency is a **per-network k/v sentinel** in `det-app.sqlite` +> (`det:migration:finish_unwire::v1`), **not** a SQL marker, and **not** +> a destructive legacy-table DROP — the legacy `data.db` is left intact. +> - Wallets are **not** re-registered from seed. They come back **seedless / +> watch-only** via the upstream `load_from_persistor` rehydration path; see +> [`docs/ai-design/2026-06-02-rehydration-rewire/design.md`](../../2026-06-02-rehydration-rewire/design.md) +> and the W1/W2 persistor-writer fix in +> [`docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md`](../../2026-06-08-wallet-reregistration-fix/design.md). +> - `FinishUnwire` copies seed envelopes + single-key rows into the encrypted +> vault and mirrors wallet metadata / shielded sidecars; the k/v layout it +> targets is documented in [`docs/kv-keys.md`](../../kv-keys.md). +> +> The data-flow intent of the tables below (what stays DET-side vs. what +> upstream re-derives) is still broadly accurate; only the **mechanism** is +> superseded. Read the sections below as historical design, not as a guide to +> the shipped code. + +### One-Time Migration + +Runs on first launch post-upgrade. Idempotent and fail-safe (A04). See procedure below. + +### Conversion Surface + +| DET type | `platform-wallet` target | Direction | Lossy / dropped | +|---|---|---|---| +| `model::wallet::Wallet` (`src/model/wallet/mod.rs:342`) + encrypted seed | Re-register via `create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions)` | DET → upstream (seed-driven, per G2) | `Wallet.{address_balances, utxos, transactions, confirmed/unconfirmed/total_balance, known/watched_addresses}` all dropped — upstream re-derives and re-syncs. Only seed + alias + `is_main` migrate. | +| `model::wallet::WalletSeed`/`ClosedKeyItem` (encrypted seed, salt, nonce, password_hint) | DET-retained seed store — NOT into persister (secret boundary, `SECRETS.md`) | Stays DET | None | +| `QualifiedIdentity` bincode blob (`src/database/identities.rs:157`) | Retained as-is (upstream "Outside scope") + seed `IdentityManager` via `add_identity` | DET → upstream | DET keeps blob; upstream `ManagedIdentity` sync-metadata starts empty, repopulated on first sync | +| `WalletTransaction` (`mod.rs:581`) | Upstream `TransactionRecord` (re-synced) | Dropped | Transaction history re-synced from chain | +| UTXO / balance rows (`database/utxo.rs`, wallet balance cols) | Upstream UTXO set (re-synced) | Dropped | Re-derived | +| DashPay contacts / profile (`database/dashpay.rs`, `database/contacts.rs`) | Upstream `EstablishedContact` / `ContactRequest` / `DashPayProfile` via `add_*` | DET → upstream | Established-contact derivation re-derived from seed + identities; DET payment-history / avatar cache retained DET-side | +| Platform addresses, token balances | DET-retained tables (upstream "Outside scope") | Stays DET | None | +| `SingleKeyWallet` (`model/wallet/single_key.rs`) | No target (see [single-key-mock.md](single-key-mock.md)) | Stays DET, stubbed | Preserved in legacy table, not migrated, surfaced as unsupported | +| Settings (`database/settings.rs`) incl. `core_backend_mode` | DET settings minus `core_backend_mode` / `use_local_spv_node` / `auto_start_spv` | Stays DET | Those columns dead (RPC mode gone) — drop in migration | + +### Migration execution model — two stage, marker-gated (RATIFIED) + +The DET DB-migration framework (`src/database/initialization.rs::initialize` → `try_perform_migration` → `apply_version_changes(version, tx:&Connection,…)` `:121,:350,:386`) is synchronous, SQL-only, single-rusqlite-transaction, runs at DB-init BEFORE wallet unlock. The platform-wallet migration needs unlocked seed + async + WalletBackend — none available there. Hence two stages: + +**Stage A — SQL migration v35** (`apply_version_changes` arm `35`; `DEFAULT_DB_VERSION` `34`→`35` `:38`). Sync, in-tx, idempotent via version bump. Actions: (1) set persistent marker `settings.platform_wallet_migration_pending=1` inside the v35 tx; (2) NO destructive step. The retained `data.db.premigration` backup is created POST-commit (NOT inside the live write-tx — use SQLite online-backup API or guarded post-`commit()` copy keyed off the marker), distinct from rolling `backups/data_backup_*.db` (`backup_db` `:463`). The MARKER (not the backup file's existence) is the authoritative "pending" signal; retained backup is (re)created idempotently on first post-marker launch even if the user never unlocks. + +**Stage B — async post-unlock one-shot** (`src/database/migration_pw.rs`), invoked from `AppContext::ensure_wallet_backend` (`src/context/mod.rs:634`, async, post-unlock — seed+SDK+persister+WalletBackend available). Gated by `platform_wallet_migration_pending`. Guarded by a `tokio::sync::Mutex` owned by `AppContext`, acquired BEFORE the marker check (exactly one Stage-B run process-wide under reentrant/concurrent `ensure_wallet_backend`). Strictly lazy: if user never unlocks, Stage B never runs, marker persists across unbounded launches, app fully usable in P2 `WalletBackendNotYetWired`-degraded state. + +Stage-B steps (each idempotent; marker-gated; legacy DROP strictly last): +1. Backup precondition: `data.db.premigration` exists, or re-create idempotently. +2. Re-register every wallet via `SeedReregistrationLoader`/`create_wallet_from_seed_bytes` (no-op if registered). +3. `add_identity` each `QualifiedIdentity` blob (no-op if present; blob+platform-address+token tables RETAINED, upstream "Outside scope"). +4. Re-establish DashPay contacts on upstream `derive_contact_xpub`/`derive_contact_payment_address(es)` ONLY — no DET re-derivation, no comparison, no classify. Upsert-keyed `(owner,contact)`. No quarantine path. +5. Finalize — **single fork**: + - **SUCCESS:** durable flush → drop legacy `wallet` and `wallet_transactions` tables → clear `platform_wallet_migration_pending` → `premigration` retired per policy. The `utxos` table is RETAINED (not dropped) — it is the single-key wallet load path under Decision #7 (`src/database/single_key_wallet.rs` → `get_utxos_by_address`); dropping it would be fund-data loss. See phasing.md P4b carve-out. + - **EXCEPTION** (crash/kill/power-loss/new-persister corruption/seed-decrypt failure): do NOT clear marker; do NOT drop legacy tables; next launch restore from `data.db.premigration` if new persister corrupt, then re-run from marker. Restore ONLY on exception, never otherwise. + +**Simplified marker lifecycle:** Only `platform_wallet_migration_pending` is live. It clears ⇔ all wallets re-registered AND all identities added AND all contacts re-established upstream AND legacy tables dropped. `dashpay_dip14_quarantine_active` (column added in commit `6d348566`) is now INERT/RESERVED — removal is DEFERRED to P4's batched dead-column cleanup (do NOT add a P3 migration to drop it). + +**Single-key wallets:** rows preserved untouched, flagged unsupported ([single-key-mock.md](single-key-mock.md)). + +### Accepted fund-accessibility trade-off (user decision, 2026-05-18) + +- **Affected:** DashPay contact-payment receive addresses derived via DET's legacy DIP-14 path (`src/backend_task/dashpay/dip14_derivation.rs:18,176`). Mainnet/account-0 addresses coincide with upstream (unaffected); testnet/devnet (coin-type `1'` vs DET `5'`) and non-account-0 contacts derive to different addresses under upstream. + +- **Impact:** Funds received at non-reproduced legacy addresses (non-mainnet OR non-account-0 DashPay contacts) are NOT visible/spendable in this version. NOT destroyed — still derivable from seed via the old path; accessible by running the previous app version against the retained `data.db.premigration` backup. Mainnet+main-account DashPay and all non-DashPay funds are unaffected. + +- **Withdrawn apparatus:** This exposure was previously handled by `dip14-migration-hardstop.md` §6.1–6.4. That apparatus is WITHDRAWN and the exposure ACCEPTED, with the mandatory one-time notice below as the sole compensating control. + +- **Mandatory one-time informational notice** (info `MessageBanner`, shown once after successful Stage-B completion, gated by a new one-shot `settings.platform_wallet_migration_notice_shown`, dismissible, unconditional for EVERY migrated user since the affected subset can no longer be computed, jargon-free, technical detail to logs only). Exact user-facing text: + + > *"This update changes how DashPay contact payment addresses are calculated. Payments you received from DashPay contacts on Testnet or Devnet, or on a secondary account, may not appear in this version. Your funds are not lost — your previous data has been saved as a backup, and you can still access these payments using the previous version of the app. Mainnet payments on your main account are unaffected. Funding an identity directly from a scanned external payment is no longer supported; receive the payment into your wallet first, then fund the identity from your wallet balance."* + +- **Notice must be unconditional:** Removing the quarantine net removed the only per-user detector, so the notice MUST be unconditional for all migrated users and MUST NOT be downgraded to optional/conditional during implementation (A04 fail-safe → fail-informed; the notice is the sole compensating control). + +### Dead Fields / Types → Deleted + +After migration these become dead and are deleted in P4: + +- Most of `Wallet` struct: `address_balances`, `utxos`, `transactions`, balance columns, `known_addresses`, `watched_addresses` +- `WalletSeedHash`-keyed reconcile maps +- `core_wallet_name` (RPC) +- `core_backend_mode`, `use_local_spv_node`, `auto_start_spv` settings +- `WalletTransaction` struct and DB table +- `src/model/wallet/utxos.rs` (HD-only model; distinct from `src/database/utxo.rs`) +- **NOT** `src/database/utxo.rs` or the `utxos` DB table — these are RETAINED under Decision #7 (single-key load path). See phasing.md P4b carve-out. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/dip14-migration-hardstop.md b/docs/ai-design/2026-05-18-platform-wallet-migration/dip14-migration-hardstop.md new file mode 100644 index 000000000..fbce98041 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/dip14-migration-hardstop.md @@ -0,0 +1,129 @@ +> **SUPERSEDED 2026-05-18 by the "drop backwards compatibility" decision.** The migrate-or-quarantine / hard-stop apparatus described below is WITHDRAWN and NOT implemented. Retained as historical design record only. See `data-model-and-migration.md` → "Accepted fund-accessibility trade-off" and `open-questions.md` #6. + +# DIP-14/15 Contact-Derivation Migration and Hard-Stop + +**Purpose:** Full design for the DIP-14/15 derivation migration — per-contact migrate-or-quarantine policy, hard-stop behavior, escalation (user + developer), P0 probe reclassification, revised P4 gate, and secret boundary. + +[← back to README](README.md) + +--- + +Resolves [open-questions.md § Decision #6](open-questions.md#decision-6--dip-1415-parity-policy) — RESOLVED: migrate or hard-stop + escalate. + +> **SUPERSEDES / WITHDRAWS** the soft fallback ("keep DET derivation for existing contacts, use upstream for new contacts") from earlier versions of this spec. Dual-derivation coexistence is no longer permitted — it would ship two engines indefinitely and silently tolerate divergence. The sole sanctioned outcome is: every contact provably migrated OR quarantined with user and maintainers loudly informed and legacy data preserved. + +--- + +## 6.1 — What "Migrate" Means + +For every existing established DashPay contact: prove upstream derivation reproduces the EXACT payment address set DET historically derived and used, then record the upstream contact xpub + address mapping into upstream `IdentityManager`/persister. + +**Procedure:** + +1. Enumerate established contacts + params (owner identity id, contact identity id, account) + historical highest-used index from `src/database/dashpay.rs`, `src/database/contacts.rs`. +2. DET-derive reference for `index ∈ [0, highest_used]` via `derive_dashpay_incoming_xpub → derive_payment_address` (`src/backend_task/dashpay/hd_derivation.rs:22,35`; `ckd_priv_256` `src/backend_task/dashpay/dip14_derivation.rs:18,186`; P2PKH `hd_derivation.rs:54`) = ground truth. +3. Upstream-derive candidate using same params via `derive_contact_xpub → derive_contact_payment_address(_es)` (`packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs`). +4. On success: record upstream mapping via `EstablishedContact`/`ContactRequest` path (`packages/rs-platform-wallet/src/lib.rs`), persisted through `SqliteWalletPersister` (PR #3625). + +**Success criterion:** A contact is migrated if and only if for EVERY `index ∈ [0, highest_used_index]`: +- Upstream `Address` is byte-identical to DET `Address` (script + network + encoding) +- Contact `ExtendedPubKey` bytes match + +Per-contact: all-or-nothing. + +**Index range (normative, cross-ref):** The historical index range is the UNION of the receive range `[0, highest_receive_index]` and the send range `[0, next_send_index−1]` (saturating), both sourced from `Database::get_contact_address_indices` (`src/database/dashpay.rs:649`). No sampled prefix. This supersedes any earlier description of "highest-used index" as a single scalar. Authoritative integration is in [data-model-and-migration.md — Stage-B step 4](data-model-and-migration.md#migration-execution-model--two-stage-marker-gated-ratified). + +**Quarantine as terminal classification (normative, cross-ref):** Quarantine is a SUCCESSFUL TERMINAL CLASSIFICATION, not a failure. When ≥1 contact is quarantined, Stage B clears `platform_wallet_migration_pending` (nothing left to attempt) and sets `settings.dashpay_dip14_quarantine_active=1`. Legacy DashPay/contact tables and `data.db.premigration` are retained while the quarantine flag is set. Restore-from-premigration does NOT occur because contacts are quarantined — restore is reserved for irreconcilable exceptions (crash, corruption). See §6.3 and [data-model-and-migration.md](data-model-and-migration.md). + +## 6.2 — Possible vs Impossible + +**Structural finding from P0 (CONFIRMED — state as fact, not risk):** + +DET hardcodes the derivation path `m/9'/5'/15'/{account}'` for all networks (`dip14_derivation.rs:176`). Upstream `AccountType::DashpayReceivingFunds` uses `m/9'/{coin}'/15'/0'/(sender)/(recipient)`, where coin-type varies by network (testnet coin=1') and account is fixed at 0' rather than appearing in the path. + +On Mainnet with full-256-bit inputs and account 0, addresses at indices 0–5 are **byte-identical** between DET and upstream — the `CKDpriv256` primitive converges. Divergence is confined to: path coin-type differences, account placement in the path, xpub version bytes, and account-reference encoding. Migration is mechanically tractable via re-derivation on the upstream path; no upstream crypto fix is required. + +Expected post-migration residue: non-mainnet contacts and non-account-0 contacts may be quarantined. P0 probe divergence on the full-256-bit class is recorded as a **release-blocking finding** — execution continues, but the quarantine machinery is the sole safety net until an upstream crypto fix or explicit acceptance is recorded. + +**Identifier classes:** +- **Low-index** (first 28 bytes zero, `is_index_less_than_2_32` true, `dip14_derivation.rs:205`) — expected migratable but still asserted. +- **Full-256-bit** (high bytes set) — class where path divergence is most visible; focus of P0 probe and runtime audit. + +DET's `index_to_child_number` (`src/backend_task/dashpay/dip14_derivation.rs:213-240`) collapses the 256-bit index to 31 bits via `sha256(index)[0..4] & 0x7FFFFFFF` ONLY where a legacy `ChildNumber` is stored; the derived key uses the full 256-bit index in both implementations (`ckd_priv_256` in DET, `ChildNumber::Normal256` upstream). Both agree on identifier-to-index encoding (raw 32 bytes, not hashed) and P2PKH address format. + +| Predicate | Definition | +|---|---| +| **Migratable(contact)** | ∀ index ∈ [0, highest_used]: `upstream_addr == det_addr` AND `upstream_contact_xpub == det_contact_xpub` | +| **Impossible(contact)** | ∃ index: `upstream_addr != det_addr` — a historically-transacted address upstream cannot reproduce → funds unreachable via new backend | + +Migratability is NEVER inferred from identifier class. It is computed per contact over the real historical index range. + +## 6.3 — Hard-Stop Behavior + +On ANY impossible contact: quarantine the affected contact(s) AND block DashPay backend cutover. Do NOT abort migration of other data. Do NOT silently proceed. Do NOT auto-mutate or delete user data. + +**A04 fail-secure ordering (extends the `*.db.premigration` design in [data-model-and-migration.md](data-model-and-migration.md)):** + +1. Back up legacy DB → `*.db.premigration` before any destructive step. +2. Attempt per-contact migration — migratable contacts are recorded into upstream/persister; impossible contacts are collected into the quarantine set and left intact in legacy `dashpay`/`contacts` tables. +3. On any impossible contact: do NOT drop legacy DashPay/contact tables (HD-wallet/UTXO/SPV legacy tables may still drop if cleanly migrated, but ALL DashPay/contact legacy tables are retained while any quarantine entry is non-empty). Refuse DashPay backend cutover — the app starts, wallet/identity/non-DashPay flows proceed normally on the new backend, but DashPay send/receive to quarantined contacts is blocked. Mark the upgrade incomplete via a persistent flag, re-evaluated on each launch until the quarantine is cleared. Preserve `*.db.premigration` while any quarantine flag is set. +4. Surface escalation (see §6.4). + +**Rationale:** Wholesale abort would strand wallets and identities on old unsupported code. Quarantine isolates the fund-risk surface, is reversible, and is the least-harm reading of hard-stop — stop the affected fund flows loudly and reversibly, not the whole product. + +## 6.4 — Escalation — Two Audiences + +### User-Facing + +Blocking non-ignorable `MessageBanner` (`src/ui/components/message_banner.rs`) on launch while any contact is quarantined: + +> "One or more of your DashPay contacts could not be upgraded to the new wallet engine. Your funds are safe and unchanged. Affected contact: ``. While this is unresolved, payments to and from this contact are paused; all your other wallets and contacts work normally. You can keep using the previous version of the app to transact with this contact, or wait for an update that resolves this. Your previous data has been preserved." + +Requirements met (CLAUDE.md error-message rules): +- Blocking only for the affected DashPay flows, not the whole app. +- Base58 id is a copyable handle (CLAUDE.md rule 6 — Base58 identifiers are allowed in messages). +- Funds-safe stated explicitly. +- Two self-resolvable actions provided. +- Preservation of prior data stated. +- No technical detail in the message; `Debug` repr via `BannerHandle::with_details`. + +### Developer-Facing + +Structured `tracing` error per impossible contact with fields: +- `owner_identity_id`, `contact_identity_id`, `account_index` +- `identifier_class`, `first_divergent_index` +- `det_address`, `upstream_address` +- `det_contact_xpub_fp`, `upstream_contact_xpub_fp` + +(M-LOG-STRUCTURED; NO seeds/keys/raw identifiers beyond public Base58 — A09.) + +Machine-readable `dip14-quarantine-report.json` in app data directory (same fields, all quarantined contacts). + +**Typed error:** `TaskError::DashPayContactDerivationIrreconcilable { contact: Identifier }` — no string-stuffed variants; message via `#[error("…")]`. No `String` field (CLAUDE.md: never store user-facing strings in error variants). + +## 6.5 — P0 Probe and Phasing Interaction + +Two independent required gates (not redundant): + +**P0 golden-vector probe** (static, pre-impl): synthetic seeds, both identifier classes, both networks. Asserts byte-equality of contact xpub + payment addresses + account-reference. This is the existing P0 lane in [phasing.md](phasing.md). + +- If divergence on the full-256-bit class: the P0 probe does NOT silently pass — it becomes a release-blocking finding, forcing either (i) an upstream `dip14.rs` fix to converge (preferred) or (ii) acceptance that the runtime migrate-or-hard-stop is the sole safety net (permitted ONLY because that path provably never allows funds to become silently unreachable). +- P0 divergence escalates the requirement on the runtime path and forbids any "assume equivalent" shortcut. It does NOT permanently block the project. + +**Revised P4 gate** (deletion of `src/backend_task/dashpay/dip14_derivation.rs` / `hd_derivation.rs`): + +SAFE regardless of P0 outcome; CONDITIONED on runtime migrate-or-hard-stop being implemented and proven. Rationale: the runtime path re-derives every existing contact via both engines using DET logic AT migration time, before deletion. It records upstream mappings only for matches; hard-stops, quarantines, and preserves legacy data for non-matches. After migration, the artifact (recorded mappings + quarantine report + retained legacy tables) is the source of truth, not the DET code — so DET derivation is deletable. + +Revised P4 gate: "DET DashPay derivation may be deleted once the one-time migration (§6.1) has executed for all users in the migration path AND the quarantine/hard-stop path (§6.3–6.4) is implemented and QA-covered." + +NOT gated on "zero P0 divergence." Gated on "safety net exists and is proven." + +**QA lane** (extends P4 correctness gate, release-blocking, runs P3 + P4): + +Fixtures with: low-index contact (expect migratable), deliberately-divergent full-256-bit contact (expect quarantine), mixed set. Asserts: +- Migratable → persister byte-identical mapping +- Divergent → quarantined + legacy DashPay/contact tables retained + `*.db.premigration` preserved + blocking banner + structured diagnostic + app starts with non-DashPay flows intact + DashPay to quarantined contacts blocked + +## 6.6 — Secret Boundary + +No contradiction with `SECRETS.md`. Re-derivation (DET reference + upstream candidate) uses in-memory seed via DET existing encrypted-seed unlock (zeroize-on-drop), as in steady-state today. Only PUBLIC material is persisted: upstream `ContactXpubData`/`EstablishedContact` mapping (public xpub + P2PKH) through the changeset pipeline into `SqliteWalletPersister` (public-only, CI-enforced by `tests/secrets_scan.rs`). The quarantine report contains only public addresses and identity ids — no seeds, keys, or xpriv (A09/ASVS V14.2). Seeds never enter the persister. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/feature-coverage.md b/docs/ai-design/2026-05-18-platform-wallet-migration/feature-coverage.md new file mode 100644 index 000000000..572d0fe4c --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/feature-coverage.md @@ -0,0 +1,117 @@ +# Feature Coverage + +**Purpose:** Two analyses — (1) the RPC-vs-SPV capability matrix classifying what is fundamentally RPC-only, fixably SPV-unwired, or equivalent in both modes; (2) the reverse gap: DET features absent from `platform-wallet` at PR #3625 head, including the corrected upstream export surface that widens Phase-4 deletion opportunity. + +> **Note:** Supporting analysis for the clean-slate rewrite spec — see [README.md](README.md). The incremental-phase references in this file (e.g. "Phase 1–4") are from the prior incremental plan and are superseded. The substantive capability and gap analysis remains valid background for the rewrite. + +[← back to README](README.md) + +--- + +## Section 1 — RPC-vs-SPV Capability Matrix + +Categories: +- **1 = Fundamentally impossible under a light client** — protocol constraint; cannot be fixed by wiring. +- **2 = Possible under SPV but RPC-only in current code** — a wiring gap, not a protocol gap; fixable independently of this migration. +- **3 = Equivalent in both modes** — already works identically regardless of backend. + +### Category 1 — Genuinely RPC-Only / Impossible Under SPV + +| Feature | Entry point | Protocol reason | +|---|---|---| +| Mining / `generatetoaddress` | `MineBlocks`, `src/backend_task/core/mod.rs:346` | Block production (template, mempool assembly, PoW) is a full-node function. Always RPC; Regtest/Devnet-only feature. | +| Arbitrary historical tx lookup by txid | `get_raw_transaction`, `src/backend_task/core/recover_asset_locks.rs:21` | BIP157/158 SPV only sees transactions matching a registered filter. DET sidesteps needed cases via DAPI, not Core. | +| Retrospective UTXO scan over an arbitrary address set | `recover_asset_locks` RPC mechanism, `src/backend_task/core/recover_asset_locks.rs:20-24` | Needs node-side address index; SPV must have been watching as blocks arrived. | +| `importaddress` into Core's own wallet | `src/model/wallet/mod.rs:1202` (the former `refresh_single_key_wallet_info.rs` handler has since been deleted) | No server-side wallet exists under SPV. | +| Named multi-wallet Core RPC | `core_client_for_wallet` `src/context/mod.rs:686`; `Wallet.core_wallet_name` `src/model/wallet/mod.rs:390` | Presupposes a Core node with `-rpcwallet` namespaces. | + +> NOTE on the "recover asset locks" entry: the **user-facing goal** (recover known asset locks) IS achieved under SPV via live InstantLock/ChainLock event reconciliation (`src/context/wallet_lifecycle.rs:619`). The SPV arm of `recover_asset_locks` returns zero-count success (`src/backend_task/core/recover_asset_locks.rs:30-39`). Only the RPC retrospective-scan *technique* is category 1 — not the feature itself. + +### Category 2 — The ONE Fixable User-Facing Gap + +Single-key / non-HD wallet **balance and UTXO refresh** hard-errors under SPV: + +Present state: the `refresh_single_key_wallet_info.rs` handler has been deleted; the `CoreTask::RefreshSingleKeyWalletInfo` dispatch arm now returns `Err(TaskError::SingleKeyWalletsUnsupported)` inline in `src/backend_task/core/mod.rs:216-217`. + +A single arbitrary P2PKH address is matchable by BIP158 compact block filters. The SPV path simply never registers these ad-hoc keys — it is a wiring gap, not a protocol impossibility. Fixable independently of this migration (it never touches `WalletManager`). + +### Category 3 — Equivalent in Both Modes + +| Feature | Evidence | +|---|---| +| HD wallet refresh / send | `src/backend_task/core/mod.rs:230`, `src/context/wallet_lifecycle.rs:353` | +| Single-key *send* (broadcast is mode-aware) | `src/context/transaction_processing.rs:22-51`; the former `send_single_key_wallet_payment.rs` handler has since been deleted — `CoreTask::SendSingleKeyWalletPayment` now returns `SingleKeyWalletsUnsupported` inline in `src/backend_task/core/mod.rs:306-309` | +| Raw broadcast | `src/backend_task/core/mod.rs:510-512` | +| Asset-lock create + finality | `src/backend_task/core/create_asset_lock.rs:42,95` | +| Generate receive address | `src/backend_task/wallet/generate_receive_address.rs:20` | +| Fund platform from UTXOs | `src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs:114-134` | +| Shielded post-timeout refresh | `src/backend_task/shielded/bundle.rs:575` (degraded mode, not impossible) | +| All identity / DashPay / Platform ops | Backend-agnostic via DAPI (`self.sdk`); verified zero matches for `SpvManager`/`WalletManager` in `src/backend_task/identity/` and `src/backend_task/dashpay/` — see [backend-architecture.md](backend-architecture.md) | +| Mining (Regtest/Devnet) | `src/backend_task/core/mod.rs:330-358` — always RPC; `CoreBackendMode` already branches here, SPV mode returns early | + +### Bottom Line + +An SPV-heavy future costs essentially **one fixable feature** (single-key wallet refresh — category 2) plus mining (developer-only). No production user-facing capability is permanently lost to SPV. + +The `platform-wallet` migration is orthogonal to this gap. `dash-spv` stays the only light-client engine; the RPC-only Core paths never touch `WalletManager`, so the generic swap neither widens nor narrows the SPV/RPC gap. + +--- + +## Section 2 — Reverse Gap: DET Features Absent from `platform-wallet` + +Verified at PR #3625 head `738091f734e05c7a1b822bb1ebff336c93b67891`. + +### Important: Upstream Provides More Than First Surveyed + +> **This is a consequential finding for Phase 4.** The upstream `lib.rs` at PR head exports a significantly broader surface than the initial architecture survey credited. Several DET (b)-class orchestration files can shrink further in Phase 4 than originally planned. This **widens the deletion opportunity** — it does NOT add a gate or reshape G1/G2. + +Confirmed upstream exports at PR head include: + +- `PlatformWalletManager` +- `SpvRuntime` — owns `DashSpvClient` and drives its own sync loop; `PlatformWalletManager` owns `SpvRuntime`; DET `src/spv/` is fully deletable (see [upstream-reality.md](upstream-reality.md)) +- `broadcaster` +- `AssetLockManager` +- `CoreWallet` / `WalletBalance` +- `IdentityManager` / `ManagedIdentity` +- `IdentityWallet` — SDK handle covering identity-lifecycle AND DashPay operations +- Full DashPay type set: `ContactRequest`, `EstablishedContact`, `DashPayProfile` +- Full DashPay derivation functions: `derive_contact_xpub`, `derive_contact_payment_address(_es)`, `derive_auto_accept_private_key`, `calculate_account_reference`, `calculate_avatar_hash`, `calculate_dhash_fingerprint` +- `DpnsNameInfo` — read-only data type; no register flow upstream +- `IdentityFunding` / `TopUpFundingMethod` +- `TokenBalanceChangeSet` / `IdentityTokenSyncInfo` — balance sync only, not token administration +- `PlatformAddressSyncManager` + +This means DashPay derivation functions (`derive_contact_xpub`, `derive_contact_payment_address(_es)`, `calculate_account_reference`) are **upstream-full** — the DET hand-rolled equivalents (`src/backend_task/dashpay/dip14_derivation.rs`, `hd_derivation.rs`) are P4 deletion targets, conditioned on one-time migration execution. The migrate-or-quarantine apparatus is WITHDRAWN; see [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off". + +### Feature Gap Table + +| DET feature / domain | Upstream status | Class | DET files that stay | Upstream ref | +|---|---|---|---|---| +| **SPV chain sync** | Full — `SpvRuntime` constructs `DashSpvClient` internally and runs its own sync loop; `PlatformWalletManager` owns `SpvRuntime`; no host-feed API exists | (a→deleted) | DET `src/spv/**` and `reconcile_spv_wallets` are deleted; only a thin `ConnectionStatus` adapter remains — see [upstream-reality.md](upstream-reality.md) and [removal-inventory.md](removal-inventory.md) | `SpvRuntime`, `PlatformWalletManager` | +| **Shielded / zk** | None | (a) | `src/backend_task/shielded/*`, `src/model/wallet/shielded.rs`, `src/context/shielded.rs`, `src/database/shielded.rs`, `src/model/grovestark_prover.rs` | — | +| **DPNS registration + contested-name / masternode voting** | `DpnsNameInfo` read-only type only; no register flow | (a) | `src/backend_task/identity/register_dpns_name.rs`, `src/backend_task/contested_names/*`, `src/database/scheduled_votes.rs` | `DpnsNameInfo` | +| **Token administration** (17 files) | `TokenBalanceChangeSet`/`IdentityTokenSyncInfo` for balance sync only | (a) | `src/backend_task/tokens/*` | Balance sync types only | +| **Document / data-contract CRUD + generic ST broadcast** | None | (a) | `src/backend_task/{document,contract,register_contract,update_data_contract,broadcast_state_transition}.rs` | — | +| **Fee estimation** | None | (a) | `src/model/fee_estimation.rs` | — | +| **Persister-excluded DET persistence** | Explicitly out of upstream scope | (a) | `QualifiedIdentity` blob (`src/database/identities.rs:157`), platform-address balances (`src/backend_task/wallet/fetch_platform_address_balances.rs`), token balances | Upstream trait doc "Outside scope" section | +| **GUI / MCP / CLI / settings / ZMQ** | None | (a) | `src/ui/**`, `src/mcp/**`, `src/bin/det_cli/**`, `src/context/settings_db.rs`, `components/core_zmq_listener` | — | +| **Identity lifecycle orchestration** (register/topup/transfer/withdraw/add-key ST flow) + `QualifiedIdentity` model | `IdentityWallet`, `IdentityManager`, `ManagedIdentity` — upstream primitives; DET orchestration and blob stay | (b) | `src/backend_task/identity/*` (shrinks in Phase 4), `src/database/identities.rs` | `IdentityWallet`, `IdentityManager` | +| **DashPay orchestration** (contact-request/accept/auto-accept/incoming-payment/avatar I/O) | `IdentityWallet` covers lifecycle + DashPay ops; full type set and derivation functions upstream | (b) | `src/backend_task/dashpay/*` except DIP-14/15 derivation (deleted P4, conditioned on migration execution — quarantine apparatus WITHDRAWN; see [data-model-and-migration.md](data-model-and-migration.md) "Accepted fund-accessibility trade-off") | `ContactRequest`, `EstablishedContact`, `DashPayProfile`, `derive_contact_xpub`, etc. | +| **Asset-lock funding-flow orchestration** | `AssetLockManager` upstream | (b) | Orchestration wiring in `src/backend_task/core/create_asset_lock.rs`, `src/context/transaction_processing.rs` | `AssetLockManager` | +| **Token-balance display** | `TokenBalanceChangeSet`/`IdentityTokenSyncInfo` for sync | (b) | Display/UI layer, token balance DB | Balance sync types | +| **DashPay ECDH encryption** | `derive_auto_accept_private_key` upstream; full ECDH pending Phase-0 parity confirmation | (b) | `src/backend_task/dashpay/encryption.rs` | `derive_auto_accept_private_key` | +| **Single-key / non-HD wallets** | None — no non-HD wallet type at PR head; not excluded by any documented scope boundary | **(c)** | `src/model/wallet/single_key.rs`, `src/database/single_key_wallet.rs`; the former `core/{send_single_key_wallet_payment,refresh_single_key_wallet_info}.rs` handlers have since been deleted — both `CoreTask` arms now return `SingleKeyWalletsUnsupported` inline in `src/backend_task/core/mod.rs` | — | + +### Classes Defined + +- **(a) Out of upstream scope by design** — DET owns permanently; no migration action. +- **(b) Partial** — upstream provides a primitive or type; DET orchestration stays but may shrink in Phase 4 given the expanded upstream surface. +- **(c) In-scope but genuinely missing at this rev** — exactly one entry. + +### The One Category-(c) Item: Single-Key / Non-HD Wallets + +`PlatformWalletInfo` / `ManagedWalletInfo` is HD-seed-based. Upstream has no non-HD wallet type at PR head, and no documented scope boundary excludes it. + +**Treatment in the rewrite:** Single-key wallets are mocked — operations return a typed `TaskError::SingleKeyWalletsUnsupported`; existing data is preserved and surfaced read-only. The `SingleKeyBackend` trait boundary makes a future swap a one-file construction change. See [single-key-mock.md](single-key-mock.md) and [open-questions.md #7](open-questions.md). + +This also explains the category-2 SPV gap from Section 1: the single-key refresh gap is rendered moot by the stub — the former `refresh_single_key_wallet_info.rs` handler has since been deleted, and the `CoreTask::RefreshSingleKeyWalletInfo` arm now returns `SingleKeyWalletsUnsupported` inline in `src/backend_task/core/mod.rs:216-217`. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/g2-mock-boundary.md b/docs/ai-design/2026-05-18-platform-wallet-migration/g2-mock-boundary.md new file mode 100644 index 000000000..a502275ea --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/g2-mock-boundary.md @@ -0,0 +1,88 @@ +# G2 Mock Boundary — Persisted-Wallet Load + +**Purpose:** Design of the `PersistedWalletLoader` seam that resolves Decision #2 — the seam is mocked with seed-re-registration now and swappable for real persisted-load when upstream `Wallet::from_persisted` lands. + +[← back to README](README.md) + +--- + +Resolves [open-questions.md § Decision #2](open-questions.md#decision-2--g2-seed-re-registration-ux) — RESOLVED: mock it. + +"Mock it" means: the SEAM is mocked; the BEHAVIOR behind it is the upstream-prescribed seed-re-registration, swappable for real persisted-load when upstream `Wallet::from_persisted` lands. G2 is downgraded from a hard implementation gate to a deferred one-line swap. + +## G2.1 — The Seam + +DET-internal object-safe trait `PersistedWalletLoader` (mirrors `SingleKeyBackend` in [single-key-mock.md](single-key-mock.md)): + +```rust +fn wallets_to_register(&self, ctx: &StartupContext) -> Result, TaskError> +``` + +Returns DET-opaque descriptors (seed handle + network + alias + `is_main`), NOT `platform-wallet` types (M-DONT-LEAK-TYPES, C-NEWTYPE-HIDE). + +Two impls: +- **`SeedReregistrationLoader`** — ships now. Reads DET retained encrypted-seed store, yields one `WalletRegistration` per wallet. +- **`UpstreamFromPersisted`** — ships when upstream `Wallet::from_persisted` + `persister.load()` wallets land. Delegates to reconstructed `ClientStartState.wallets`. + +`WalletBackend` holds `Arc`. + +**Plug point** (see [backend-architecture.md § WalletBackend](backend-architecture.md)): runs BETWEEN `PlatformWalletManager` construction and `start()`. The constructor (`packages/rs-platform-wallet/src/manager/mod.rs`) takes `sdk`/`persister`/`app_handler` and does not auto-start sync — "call start after wallets are registered." For each `WalletRegistration`, the backend calls: +1. `create_wallet_from_seed_bytes(network, seed_bytes, WalletAccountCreationOptions)` +2. `load_persisted()` — rehydrates identity/contact/address deltas (`packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs`) +3. `PlatformWalletManager.start()` — spawns `SpvRuntime` sync + +## G2.2 — What the Mock Does at Startup (Decisive) + +**Option chosen:** The mock IS the seed-re-registration path — it mocks the SEAM, not the BEHAVIOR. + +`SeedReregistrationLoader` re-derives each wallet from DET retained encrypted seed (the seed the user already unlocks, as today). Upstream `load_persisted()` layers identity/DashPay/UTXO/asset-lock deltas. `SpvRuntime` re-confirms on sync. + +**Rejected option:** Return empty / force re-add from scratch — rejected (data-loss UX, A04). + +`wallets_to_register()` returns one `WalletRegistration{seed_handle, network, alias, is_main}` per wallet in DET seed store. App behavior is identical to today: password prompt on launch, repopulate from persister + first sync. The only change is that the logic lives behind the trait for zero-blast-radius later deletion. + +## G2.3 — User-Facing Surface + +Transparent. No banner or alert (unlike single-key, which has a partial capability gap: send and balance/UTXO refresh are unsupported and return `TaskError::SingleKeyWalletsUnsupported`; import, list, and sign are fully operational via `SecretStore`). The seed-unlock prompt already exists today; adding a "re-registering" message is over-messaging (CLAUDE.md rules — messages are what-happened + what-to-do; nothing actionable here). Debug `tracing` only (M-LOG-STRUCTURED). The sole error surface is the existing seed-decrypt failure path — already a typed `TaskError` with a calm banner, unchanged. + +## G2.4 — Swap Path + +When upstream `Wallet::from_persisted` ships and `persister.load()` populates `ClientStartState.wallets` (the `LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"]` gap in `packages/rs-platform-wallet-storage/src/sqlite/persister.rs` closes): + +1. **One-line construction swap:** `Arc::new(SeedReregistrationLoader::new(...))` → `Arc::new(UpstreamFromPersisted::new(...))` in `WalletBackend::new()`. +2. `UpstreamFromPersisted` maps reconstructed wallets into `WalletRegistration`. +3. No migration, no-op, persister DB unchanged, seed store retained (secret boundary). +4. Old impl deleted (M-NO-TOMBSTONES). +5. Zero blast radius — only the loader impl + one construction line. `WalletBackend` API, `BackendTask`, UI, persister DB, and seed store are all untouched. + +## G2.5 — Gate Impact (Key Consequence) + +**G2 CLOSED (PROJ-010, 2026-06-02).** The seedless watch-only +`UpstreamFromPersisted` shipped against PR #3692 head `ddfa66ed`, and +`SeedReregistrationLoader` was removed. The swap point reserved in G2.4 +is now the live load path; the trait slot stays object-safe with one +shipping impl. See `docs/ai-design/2026-06-02-rehydration-rewire/design.md` +for the re-wire. The two design deviations forced by upstream reality (the +WalletId↔seed bridge is keyed on the persisted BIP44 account xpub, not a +seedless root-WalletId derivation; and the identity-funding re-provision +moved off the watch-only load path onto the post-unlock asset-lock +chokepoint) are recorded in the re-wire design notes. + +Historical context (G1/G2 gating against PR #3625) follows. + +**G2 was REMOVED as a hard implementation gate.** + +G1 (PR #3625 merge + pin bump) remains; G2 is downgraded to a deferred swap-in. `SeedReregistrationLoader` is complete, shippable, and behaviorally correct. The project ships on G1 alone. With Decision #1 pinning to the #3625 head now, even G1 is not a start blocker — it becomes a release-hardening item. See [phasing.md § Combined Gate Posture](phasing.md#combined-gate-posture). + +## G2.6 — Phasing and QA + +**When built:** P1, alongside `WalletBackend` skeleton + `EventBridge`. The seam must exist before P2. `UpstreamFromPersisted` is NOT built now (no upstream API) — the trait slot is reserved, mirroring the single-key reserved-impl pattern. + +**P1 deliverables include:** `PersistedWalletLoader` trait + `SeedReregistrationLoader` impl. + +**QA lane (P1, P5 regression):** +- Mock yields exactly N `WalletRegistration` for N seed-store wallets; backend registers all N and `start()` succeeds. +- Swap-boundary compiles with an alternate `StubFromPersisted` impl (proves zero-blast swap path). +- Negative: seed-decrypt failure surfaces existing typed `TaskError`, no panic/data-loss. + +**Secret boundary:** `SeedReregistrationLoader` reads the DET encrypted-seed store, feeds `seed_bytes` to upstream `create_wallet_from_seed_bytes` in memory. Seeds NEVER enter the persister (`SECRETS.md`, ASVS V14.2). `WalletRegistration` carries a zeroize-on-drop in-memory seed handle, not persisted material. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/open-questions.md b/docs/ai-design/2026-05-18-platform-wallet-migration/open-questions.md new file mode 100644 index 000000000..e686be672 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/open-questions.md @@ -0,0 +1,86 @@ +# Open Questions + +**Purpose:** Record of the eight decisions needed before implementation. All eight are now RESOLVED. + +[← back to README](README.md) + +--- + +All 8 decisions resolved. Implementation is unblocked. See [phasing.md § Combined Gate Posture](phasing.md#combined-gate-posture) for the updated gate state. + +--- + +## Decision #1 — G1 Timing + +**RESOLVED: Pin to PR branch now.** + +DET pins its platform dep to dashpay/platform PR #3625 head and starts P0–P2 immediately, accepting rework if the PR changes before merge. G1 is reclassified from a start blocker to a release-hardening item: track #3625 until it merges, then bump the pin to a released rev before shipping. + +~~Options (a) wait / (b) pin~~: option (b) confirmed for all phases; option (a) applies only at release time. + +--- + +## Decision #2 — G2 Seed-Re-Registration UX + +**RESOLVED: Mock it via `PersistedWalletLoader` seam.** + +The `PersistedWalletLoader` DET-internal trait provides the seam. `SeedReregistrationLoader` (ships now) performs seed-re-registration exactly as upstream prescribes — behavior is identical to today. `UpstreamFromPersisted` (ships when `Wallet::from_persisted` lands) is a one-line construction swap. G2 is downgraded from a hard gate to a deferred swap-in. + +Full design: [g2-mock-boundary.md](g2-mock-boundary.md). + +--- + +## Decision #3 — ZMQ Listener + +**RESOLVED: Audit before P4; delete only if no non-wallet consumer.** + +`components/core_zmq_listener` usage audit runs before P4. If no non-wallet consumer is found, the listener is deleted in P4. If a non-wallet consumer exists (e.g. ChainLock notifications for UI), it is retained with scope trimmed to that consumer. No decision possible before the audit; the audit is a P4 precondition. See [removal-inventory.md § RETAIN](removal-inventory.md#retain). + +--- + +## Decision #4 — Devnet Identity Discovery + +**RESOLVED: Keep DET path permanently.** + +`discover_identities.rs` Devnet branch stays DET-owned indefinitely. Upstream has no Devnet timeline. The branch is on `network`, not `CoreBackendMode`, so it coexists cleanly with the new backend. + +--- + +## Decision #5 — DashPay Scope Boundary + +**RESOLVED: Hybrid split confirmed.** + +| Owner | Owns | +|---|---| +| Upstream (`IdentityWallet`, derivation functions) | Contact-request/established-contact state, DashPay profile, derivation crypto | +| DET (unchanged) | Avatar I/O (`avatar_processing.rs`), auto-accept proof, incoming-payment detection, payment-history cache | + +See [backendtask-contract.md § DashPayTask](backendtask-contract.md) for the task-level mapping. + +--- + +## Decision #6 — DIP-14/15 Parity Policy + +**RESOLVED 2026-05-18 — drop backwards compatibility; upstream-only DashPay derivation. The migrate-or-quarantine/hard-stop apparatus is WITHDRAWN. Accepted fund-accessibility trade-off recorded in data-model-and-migration.md with a mandatory one-time user notice. Supersedes the prior 'migrate-or-hard-stop' resolution and dip14-migration-hardstop.md.** + +The soft fallback ("keep DET derivation for existing contacts, use upstream for new") is WITHDRAWN. Dual-derivation coexistence is not permitted. + +The per-contact migrate-or-quarantine apparatus (previously documented in [dip14-migration-hardstop.md](dip14-migration-hardstop.md)) is WITHDRAWN. DashPay contacts are re-established on upstream derivation unconditionally. The fund-accessibility trade-off this introduces is accepted and documented in [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off (user decision, 2026-05-18)". The mandatory one-time informational notice to migrated users is the sole compensating control. + +Historical quarantine design: see [dip14-migration-hardstop.md](dip14-migration-hardstop.md) (retained as historical record only). + +--- + +## Decision #7 — Single-Key Timeline + +**RESOLVED: Ship mock now, swap later (confirmed).** + +Single-key wallets ship as read-only + clear message for at least one release. Data preserved. `SingleKeyBackend` trait boundary makes the future swap a one-line change. See [single-key-mock.md](single-key-mock.md). + +--- + +## Decision #8 — One-Release No-Op Grace for Removed Tasks + +**RESOLVED: Hard-remove immediately.** + +`CoreTask::RecoverAssetLocks` and `CoreTask::ListCoreWallets` and their UI entry points are deleted in the same release — no one-release no-op grace. `AssetLockManager` continuous tracking makes explicit recovery obsolete; named Core wallets have no meaning without RPC mode. See [backendtask-contract.md](backendtask-contract.md) (updated rows) and [removal-inventory.md](removal-inventory.md). 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 new file mode 100644 index 000000000..0d827d66a --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md @@ -0,0 +1,392 @@ +# Phasing + +**Purpose:** P0–P5 phase table with goals, gates, effort, and risk; skills/agents/crew assignments; QA matrix; highest-risk assumption verdict. + +[← back to README](README.md) + +--- + +Gates G1 and G2 are defined in [README.md § Gate Posture](README.md#gate-posture-updated) and detailed in [upstream-reality.md § G2 Caveat](upstream-reality.md#g2-caveat--walletfrom_persisted-load-gap). + +## Combined Gate Posture + +With Decision #1 (pin to #3625 head now) and Decision #2 (G2 downgraded via `PersistedWalletLoader` seam), **implementation is no longer upstream-blocked**. + +**G1 — release-hardening track, not a start blocker.** +DET is pinned to PR #3625 head. P0–P2 start immediately. G1 resolves to: track #3625 until it merges, bump pin to a released rev before shipping P3+. Not a gate on any phase start. + +**G2 — deferred swap, not a gate.** +`SeedReregistrationLoader` ships in P1 with correct behavior. `UpstreamFromPersisted` is reserved for when upstream `Wallet::from_persisted` lands — one-line construction swap, zero blast radius. See [g2-mock-boundary.md](g2-mock-boundary.md). + +**Only release-blocking gate: P3c–P3e simplified Stage-B engine + QA lane.** +The simplified Stage-B engine (upstream-only derivation, no quarantine) must be implemented (P3c) and QA-proven (P3d–P3e) before the release ships. See [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off" and [open-questions.md #6](open-questions.md#decision-6--dip-1415-parity-policy). + +## G. Phasing (From-Scratch Rewrite) + +Each phase is independently reviewable. Do not collapse phases. + +### Phase Table + +| Phase | Goal | Files | Effort | Risk | Frozen contract | +|---|---|---|---|---|---| +| **P0 Spike & verify** (DONE — PROCEED) | Stand up `PlatformWalletManager` + upstream `SqliteWalletPersister` in a harness; prove `SpvRuntime` drives sync end-to-end; run DIP-14/15 golden-vector parity probe + `DiskStorageManager` behavior; confirm event surface. Pin to #3625 head (Decision #1). G2 confirmed open (load() returns empty `ClientStartState.wallets`) — `PersistedWalletLoader`/`SeedReregistrationLoader` premise validated. **Harness-shape constraint: pre-P0.5 spike harnesses MUST be standalone crates, not `tests/*.rs`** — those link the SDK-drift-broken lib. | Standalone crate harness only; `Cargo.toml` (feature-gated dep) | M | Med | Verified upstream API + probe results; DIP-14/15 divergence recorded as release-blocking finding | +| **P0.5 Compile Floor** | Atomically bump `dash-sdk` + `rs-sdk-trusted-context-provider` `54048b…`→`738091f734…`; add `platform-wallet` (feature `serde`) + `platform-wallet-storage` git deps at `738091f…`; then DELETE/STUB/FIXUP exactly enough of the old wallet stack to reach green `cargo build` + `cargo clippy --all-features --all-targets -- -D warnings`. Tests need NOT pass — failing tests are left failing or marked `#[ignore]` + `// TODO(P0.5): re-enable in P{1,2,3}`. No production wallet behavior is expected; wallet ops are inert or removed. **Co-land constraint:** the pin bump is NOT separable from the deletions — no compiling intermediate exists. P0.5 IS the atomic floor commit (or a tight commit series on the branch). P1+ build green on top of it. See [P0.5 Compile-Floor Task List](#p05-compile-floor-task-list) below. | `Cargo.toml:21+`, `src/spv/**`, `src/context/wallet_lifecycle.rs:619-985`, `src/backend_task/core/mod.rs` (heavy), `src/model/wallet/mod.rs` (heavy), `src/model/qualified_identity/mod.rs` (fixup only), `src/backend_task/shielded/bundle.rs` (fixup only), identity/contract tasks (fixup only) | M–L | Medium (over-deletion / under-stubbing) | Workspace compiles; clippy-clean; P1+ build on this floor | +| **P1 WalletBackend skeleton + EventBridge** | New `src/wallet_backend/` wrapping `PlatformWalletManager`; `EventBridge`: `PlatformEventHandler` → `TaskResult` MPSC; `PersistedWalletLoader` trait + `SeedReregistrationLoader` impl (G2 seam — see [g2-mock-boundary.md](g2-mock-boundary.md)); no DET wiring yet (parallel to old path, behind a feature). Builds on the P0.5 green floor. | New `src/wallet_backend/*`, `src/backend_task/error.rs` (typed variants) | L | Med | `WalletBackend` public method set; `EventBridge`→`TaskResult` mapping; `PersistedWalletLoader` seam | +| **P2 BackendTask rewire** | Point wallet/identity/DashPay task arms at `WalletBackend`; replace P0.5 stubs with real `WalletBackend` calls; extract Core-RPC mining utility. | `src/backend_task/{mod,core,wallet,identity,dashpay}/*`, `src/context/*`, new `src/core_rpc_util.rs` | L | High | `BackendTask` result variants stable (frontend contract) | +| **P3 One-time migration** | Two-stage marker-gated migration (see P3a–P3e below). Ratified architecture: Stage A SQL v35 (sync, pre-unlock, sets marker) + Stage B async post-unlock engine at `ensure_wallet_backend`. Re-scoped 2026-05-18: drop backwards compatibility, upstream-only DashPay derivation, no quarantine. Gated on G1. | New `src/database/migration_pw.rs`, `database/initialization.rs` | L | High | Migration forward-only, fail-safe, idempotent; two-stage design ratified | +| **P4a Tx/UTXO/Balance UI data-path rewire** | Add `DetWalletBalance`/`DetUtxo` view models; retain `WalletTransaction` (detach from `Wallet`/DB); add 4 `WalletBackend` accessors + `TransactionRecord`→`WalletTransaction` mapping (relocated from deleted reconcile); add `WalletSnapshot` (`ArcSwap`) + `EventBridge` recompute/swap/emit-`Refresh`; rewire HD reads in wallets UI to read snapshot via `app_context.wallet_backend()`. Single-key paths untouched. **Blocks P4b.** | `src/wallet_backend/mod.rs`, `src/ui/wallets/wallets_screen/mod.rs`, `src/ui/wallets/address_table.rs`, `src/ui/screens/create_asset_lock_screen.rs` | L | Med | Post-migration HD wallets screen shows correct balance/tx/utxo from upstream. Reviewer gate: no spendable-input selection from snapshot. | +| **P4a.5 Fund-safety spend-path completion** | Three spend-path correctness tasks gated between P4a and P4b. **(1) Add `AssetLockKind::Shielded`** and rewire `src/backend_task/shielded/bundle.rs:463,478` (Path 1 — real coin-selection) from `generic_asset_lock_transaction` / `select_unspent_utxos_for`-over-`Wallet.utxos` → `WalletBackend::create_asset_lock_proof` (upstream-authoritative selection at construction time). **(2) Remove `RegisterIdentityFundingMethod::FundWithUtxo` + `TopUpIdentityFundingMethod::FundWithUtxo` variants**, QR-direct-fund UI, and the three functions `registration_asset_lock_transaction_for_utxo` / `top_up_asset_lock_transaction_for_utxo` / `asset_lock_transaction_for_utxo_from_private_key` (Path 2 — no upstream funding-outpoint API exists at #3625 head; cannot be preserved; removed with disclosure via post-migration notice). **(3) Slim `context/transaction_processing.rs::received_transaction_finality`** to asset-lock-finality-only: delete the `Wallet.utxos` / `address_balances` / legacy-`utxos`-table writes; RETAIN the asset-lock detection/registration branch (`store_asset_lock_transaction` + finality-wait channel that `broadcast_and_commit_asset_lock` / `wait_for_asset_lock_proof` depend on). ZMQ call sites `app.rs:1267,1285` stay. Tests per §4.4. **Exit:** zero live readers of `Wallet.{utxos,address_balances,transactions}`; zero callers of `select_unspent_utxos_for` / `generic_asset_lock_transaction` / `*_for_utxo*`. **Blocks P4b.** | `src/backend_task/shielded/bundle.rs`, `src/backend_task/identity/mod.rs`, `src/context/transaction_processing.rs`, funding-method enums + UI, `AssetLockKind` enum | M | Med | Exit: zero live readers of legacy wallet-UTXO fields; zero callers of deleted functions; asset-lock finality channel intact | +| **P4b Mechanical dead-code/UI prune** | Remove RPC-mode toggle, Core-wallet picker, local-node settings UI; delete 8 dead HD `Wallet` fields + ~7 orphaned methods (`src/model/wallet/mod.rs`); `src/model/wallet/utxos.rs` (HD-only, entire); `database/wallet.rs` HD-only balance columns + save/load paths; batched `settings`-column migration (`dashpay_dip14_quarantine_active` + RPC-era dead columns, existence-guarded, `settings`-table-only — object-disjoint from P3c); ZMQ-listener audit + drop-if-unused. **RETAIN `src/database/utxo.rs` + `utxos` table (Decision #7 single-key carve-out — see §P4b below).** **Only after P4a.5 exit criteria are met.** 5-commit structure C1–C5 (see §P4b). P5 adds mandatory single-key regression lane. | `src/ui/**`, `src/database/wallet.rs`, `src/model/wallet/mod.rs`, `src/model/wallet/utxos.rs`, `src/context/**`, remaining dead code from P0.5 stubs | L | Med | Final state; docs + user-stories updated with dropped back-compat + accepted trade-off | +| **P5 Hardening** | Single-key swap-readiness, `ConnectionStatus` adapter polish, full QA matrix including migration lane + post-migration UI data-path test; §2(d) notice regression; **Smythe double-spend/fund-safety audit (RELEASE-BLOCKING — see below)**; single push to #860. User-stories/docs note dropped back-compat + accepted trade-off. | Cross-cutting | M | Low | Release-ready; Smythe audit green | + +**Sequencing:** P0 done (PROCEED). P0.5 done (GREEN). P1 done (GREEN). P2 done (GREEN). P3 is the highest-risk phase (irreversible data migration) — two-stage marker-gated design ratified (see P3a–P3e); re-scoped 2026-05-18 (drop back-compat, upstream-only, no quarantine). P3a (GREEN, commit `6d348566`). P3b (GREEN, commit `d5a3e51b`; `classify_contact` + 7 tests now DEAD — deleted in P3c). P3c–P3e complete (GREEN). P4 split into P4a (UI data-path rewire, blocks P4a.5) and P4a.5 (fund-safety spend-path completion, blocks P4b) and P4b (mechanical prune, only after P4a.5); P4-partial done. P4b narrowed (Decision #7 single-key carve-out): `src/database/utxo.rs` + `utxos` table RETAINED — single-key load path depends on them; see §P4b below. P5 pending. Run continuing. Fund-safety spend path is upstream-authoritative from P2; the display-only gap is addressed in P4a; the remaining spend-path correctness tasks (Path 1 shielded, Path 2 FundWithUtxo removal, Path 3 finality slim) are addressed in P4a.5 before P4b's prune. P3 ships only after G1 resolves to a released rev. The only release-blocking gate is the simplified Stage-B engine + QA lane (P3c–P3e), plus the P5 Smythe fund-safety audit. + +--- + +## P3 Sub-Steps (Ratified Two-Stage Design, re-scoped 2026-05-18) + +P3 is decomposed into five sequenced sub-steps. Do not collapse them. + +**P3a — Stage-A SQL v35 + markers + premigration backup. DONE (commit `6d348566`).** +SQL migration arm `35` in `apply_version_changes`: set `settings.platform_wallet_migration_pending=1` inside the v35 transaction. Post-commit, create `data.db.premigration` using the SQLite online-backup API (NOT inside the live write-tx). Added `settings.dashpay_dip14_quarantine_active` column (now INERT/RESERVED — see data-model-and-migration.md; removal deferred to P4). Tests: v35 idempotency, marker set, premigration file created post-commit and internally consistent, no destructive step executes. + +**P3b — Stage-B DIP-14/15 predicate — TDD. DONE (commit `d5a3e51b`).** +`classify_contact` and its 7 tests are now DEAD — the predicate is not used by the simplified Stage-B engine. DELETED in P3c (same commit as the engine). + +**P3c — Simplified Stage-B engine wired at `ensure_wallet_backend`.** +Implement `src/database/migration_pw.rs` with the single-branch model: (1) backup precondition; (2) re-register wallets via `SeedReregistrationLoader`/`create_wallet_from_seed_bytes` (idempotent); (3) `add_identity` each `QualifiedIdentity` blob (idempotent); (4) re-establish DashPay contacts on upstream `derive_contact_xpub`/`derive_contact_payment_address(es)` ONLY — no DET re-derivation, no comparison, no classify, upsert-keyed `(owner,contact)`; (5) finalize SUCCESS fork only (exception fork handled by marker-not-cleared). Delete `classify_contact` and its 7 tests in the same commit. Wire at `AppContext::ensure_wallet_backend` (`src/context/mod.rs:634`) behind the retained `AppContext` `tokio::sync::Mutex`, gated by `platform_wallet_migration_pending` marker. Legacy DROP (step 5) NOT yet enabled in this sub-step. + +**P3d — Restore/idempotency invariants + finalize (no quarantine).** +Dedicated tests before enabling finalize: backup-before-destroy invariant; DROP strictly last + post-durable-flush; crash at each sub-step + relaunch is idempotent; reentrant `ensure_wallet_backend` runs exactly one Stage-B; restore ONLY on exception (never otherwise); user-never-unlocks (marker persists, backup exists, app usable). Enable single-fork finalize (step 5) only after these tests pass. + +**P3e — QA lane (standalone-crate harness).** +Fixtures MUST cover: Stage-B crash at each sub-step + relaunch idempotency; restore-from-premigration on injected new-persister corruption; user-never-unlocks (marker persists, app usable, backup exists); reentrant `ensure_wallet_backend` (single Stage-B run); send-side-only contact. No quarantine fixtures — quarantine apparatus is WITHDRAWN. PLUS a mandatory release-blocking test: legacy-address-abandonment notice shows exactly once, one-shot `settings.platform_wallet_migration_notice_shown` gates it, dismissible, jargon-free, shown to all migrated users. Release-blocking; runs alongside P3d and P5 regression. + +--- + +## P4 Sub-Steps + +P4 is split into two sequenced sub-steps. P4b must not start before P4a exit criteria are met. + +### P4a — Tx/UTXO/Balance UI Data-Path Rewire (blocks P4b) + +**Goal:** eliminate all wallets-screen reads from the legacy `Wallet` model and DB tables that P3c's migration drops. The fund-safety spend path is already upstream-authoritative (P2) — this sub-step is display-only. + +**Deliverables:** + +1. **New view models:** `DetWalletBalance { confirmed: u64, unconfirmed: u64, total: u64 }` and `DetUtxo { outpoint, value, script_pubkey, address }`. `WalletTransaction` is retained as-is; detached from `Wallet`/DB. + +2. **Four `WalletBackend` accessors:** `wallet_balance`, `transaction_history`, `utxos`, `address_balances` — DET types only across the boundary (see [backend-architecture.md § WalletBackend Read-Accessor Surface + WalletSnapshot Push Model](backend-architecture.md#walletbackend-read-accessor-surface--walletsnapshot-push-model)). + +3. **`TransactionRecord`→`WalletTransaction` mapping** relocated from the deleted `reconcile_spv_wallets` into `WalletBackend`. + +4. **`WalletSnapshot` + `EventBridge` push:** `WalletSnapshot` (`ArcSwap`) per wallet; `EventBridge` recomputes and atomically swaps on upstream callbacks; emits existing `TaskResult::Refresh`. + +5. **UI rewire:** HD reads in `src/ui/wallets/wallets_screen/mod.rs` (`:451,469,517,527,1141,1156,1227,1245,1478-1589,1845,1861,2593`), `src/ui/wallets/address_table.rs:120`, `src/ui/screens/create_asset_lock_screen.rs` — all read from `app_context.wallet_backend()` snapshot. `WalletTransaction`-row helpers unchanged. Single-key paths untouched (Decision #7 stubbed). + +**Exit criteria:** post-migration HD wallets screen shows correct balance, transaction history, and UTXO set from the upstream snapshot — not blank, not stale. + +**Reviewer gate (A04):** no code path selects spendable inputs from `WalletSnapshot`. Any such path must be rejected. The spend path remains `WalletBackend::send_payment` / `create_asset_lock_proof` (upstream live UTXO set, P2). + +### P4a.5 — Fund-Safety Spend-Path Completion (blocks P4b) + +**Goal:** close the three open spend-path correctness gaps before P4b's mechanical prune. P4a is the prerequisite; P4b must not start before P4a.5 exit criteria are met. + +**Path 1 — Shielded asset-lock coin-selection (real coin-selection).** +Add `AssetLockKind::Shielded` to the `AssetLockKind` enum. Rewire `src/backend_task/shielded/bundle.rs:463,478` from the legacy path (`generic_asset_lock_transaction` + `select_unspent_utxos_for` over the snapshot `Wallet.utxos`) to `WalletBackend::create_asset_lock_proof`, which delegates coin-selection to the upstream wallet at construction time (authoritative live UTXO set, no snapshot-based selection). This closes the last path where DET could perform coin-selection from a snapshot. + +**Path 2 — FundWithUtxo removal (no upstream funding-outpoint API).** +No funding-outpoint API exists in `platform-wallet` at PR #3625 head. The `FundWithUtxo` path cannot be preserved or emulated. Remove: +- `RegisterIdentityFundingMethod::FundWithUtxo` variant +- `TopUpIdentityFundingMethod::FundWithUtxo` variant +- All QR-direct-fund UI that surfaces these variants +- `registration_asset_lock_transaction_for_utxo` +- `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)). + +**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. + +**Tests (§4.4):** +- Post-migration asset-lock via upstream (`WalletBackend::create_asset_lock_proof`) end-to-end. +- Path 3: asset-lock finality detection without any `Wallet` mutation (no `Wallet.utxos` / `address_balances` write). +- Crash-retry no-double-broadcast: store-before-broadcast + upstream dedup. + +**Exit criteria:** zero live readers of `Wallet.{utxos,address_balances,transactions}`; zero callers of `select_unspent_utxos_for` / `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`; asset-lock finality channel verified intact. + +--- + +### P4b — Mechanical Dead-Code/UI Prune (only after P4a.5) + +**Goal:** delete all code whose last readers were relocated by P4a. These are deletable ONLY after P4a exits — P4a is the prerequisite. + +#### What P4b RETAINS (carve-outs — do NOT delete) + +- **`src/database/utxo.rs` + the `utxos` table — RETAINED.** Decision #7 single-key load path: `src/database/single_key_wallet.rs:211` → `get_utxos_by_address`; rationale already in-source at `src/database/utxo.rs:22-24`, commit `1089b568`. Single-key migration is out of scope this run; revisit when single-key moves to upstream. **P4b must NOT drop the `utxos` table — dropping it would be fund-data loss.** +- **`database/wallet.rs` `wallet_addresses`-driven `known_addresses`/`watched_addresses` reconstruction (`:601-771`) and `platform_address_balances`→`platform_address_info` reconstruction (`:999`)** — these are column-independent and survive byte-for-byte. +- **`Wallet` core identity/seed/address fields** — untouched. + +#### What P4b DELETES + +**Dead HD `Wallet` fields** (`src/model/wallet/mod.rs`) — 8 fields: +`utxos`, `address_balances`, `address_total_received`, `transactions`, `confirmed_balance`, `unconfirmed_balance`, `total_balance`, `spv_balance_known`. + +**Dead HD `Wallet` methods** — ~7 orphaned methods: +`total_balance_duffs`, `confirmed_balance_duffs`, `has_balance`, `max_balance`, `update_spv_balances`, `set_transactions`, `update_address_balance`. +Plus, if zero callers remain after P4a.5 exit (STOP-flag and verify before deleting): +`select_unspent_utxos_for`, `select_utxos_with_fee_retry`, `remove_selected_utxos`, `generic_asset_lock_transaction`, `build_multi_recipient_payment_transaction`, `registration_asset_lock_transaction_for_utxo`, `top_up_asset_lock_transaction_for_utxo`, `asset_lock_transaction_for_utxo_from_private_key` (and any other `*_asset_lock_transaction_for_utxo*` variants not already removed). + +**`src/model/wallet/utxos.rs`** — entire file (HD-only; not the same as `src/database/utxo.rs`). + +**`database/wallet.rs` HD-only balance columns + paths:** +- The 3 balance columns from `INSERT INTO wallet` (`:55-70`) +- Their reads in `get_wallets` (`:490,505-507,580-583`) +- `add_wallet_balance_columns` (`:275-293,357`) +- `update_wallet_balances` (`:302-311`) +- `replace_wallet_transactions` (`:413`) +- The `wallet.transactions.push` reconstruction (`:931`) + +**Batched dead-`settings`-column schema migration** — new DB version (verify the live `DEFAULT_DB_VERSION` at impl time; do not hardcode): +drops `dashpay_dip14_quarantine_active` + RPC-era dead settings columns; existence-guarded + idempotent (mirror the inverted `add_wallet_balance_columns:278` `pragma_table_info` check pattern). This migration mutates ONLY the `settings` table — object-disjoint from P3c's conditional `wallet`/`wallet_transactions` table DROP (the `utxos` table is NOT dropped by P3c — see P4b carve-out). No double-drop, no ordering hazard. Runs at the normal sync DB-init migration point. + +**UI:** RPC-mode toggle, Core-wallet picker, local-node settings controls. + +**Other:** Remove `TaskError::DashPayContactDerivationIrreconcilable` if no other caller. ZMQ-listener usage audit: drop if no non-wallet consumer remains. + +**M-NO-TOMBSTONES:** delete, do not comment out. + +#### P4b Commit Structure + +Five commits, in order — do not collapse: + +- **C1** — model fields/methods: delete the 8 dead HD `Wallet` fields and ~7 orphaned methods (STOP-flag any with callers, check before proceeding). +- **C2** — `database/wallet.rs` save/load prune: remove the HD-only balance columns, insert paths, and reconstruction paths; surviving `wallet_addresses`-driven reconstruction (`:601-771`) and `platform_address_balances`→`platform_address_info` reconstruction (`:999`) untouched. +- **C3** — dead-`settings`-column idempotent migration: batched schema cleanup (`dashpay_dip14_quarantine_active` + RPC-era dead settings columns); new DB version; existence-guarded; object-disjoint from P3c (mutates `settings` table only, not `wallet`/`utxos`/`wallet_transactions`). +- **C4** — UI prune: RPC-mode toggle, Core-wallet picker, local-node settings controls. +- **C5** — tests: delete untestable HD-field tests; `tests/backend-e2e/tx_is_ours.rs` retarget to `WalletSnapshot` or mark `#[ignore]` + TODO (do NOT delete); PRESERVE all `database/utxo.rs` tests and single-key tests. + +#### P5 — Single-Key Regression Lane (release-blocking, not optional) + +A mandatory release-blocking test lane must be added in P5, proving the `utxo.rs` carve-out: + +Seed the `utxos` table (via `#[cfg(test)] insert_utxo`) and a `single_key_wallet` row. Load single-key wallets. Assert `SingleKeyWallet.utxos` is populated via the RETAINED `get_utxos_by_address` path AND that the Decision #7 stub still surfaces `TaskError::SingleKeyWalletsUnsupported`. This proves the `utxo.rs` carve-out is intact — mandatory, not a nicety. Add this lane to the P5 Smythe gate; no push to #860 until it passes. + +**SEC-001 test-ordering requirement:** The single-key regression test MUST run the Stage-B migration DROP first (dropping `wallet` and `wallet_transactions`), then load the single-key wallet. The prior isolated seed+load approach was tautological and failed to prove that the `utxos` table survives the migration DROP — which is the actual SEC-001 regression being pinned. + +P5 STOP-for-Smythe I1–I6 unchanged (see [P5 — Smythe Double-Spend / Fund-Safety Audit](#p5--smythe-double-spend--fund-safety-audit-release-blocking-gate) below). + +**Crew assignment:** Correctness reviewer mandatory. DIP-14/15 parity gate must be green before this sub-step ships. + +--- + +## P5 — Smythe Double-Spend / Fund-Safety Audit (RELEASE-BLOCKING gate) + +The Smythe security audit is a **release-blocking gate** at P5. No push to #860 until all six invariants below are confirmed green. A single failing invariant = no push. + +**Scope — Invariants I1–I6:** + +| ID | Invariant | Pass condition | +|---|---|---| +| **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. | +| **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. | + +**P5 audit test lanes (in addition to the standard QA matrix):** + +| Lane | What | +|---|---| +| Post-migration asset-lock via upstream | `WalletBackend::create_asset_lock_proof` succeeds end-to-end; no `select_unspent_utxos_for` / `generic_asset_lock_transaction` call in the hot path. | +| Path 3 asset-lock finality without Wallet mutation | `received_transaction_finality` correctly detects an asset lock and fires the finality-wait channel; no write to `Wallet.utxos` / `address_balances` / `utxos` table occurs. | +| Crash-retry no-double-broadcast | Simulated crash between store and broadcast; relaunch retries broadcast; upstream dedup prevents double-spend; test asserts single on-chain tx. | + +--- + +## P0.5 Compile-Floor Task List + +This section is the authoritative checklist for P0.5. Work through the seven clusters in order. The pin bump (Step 0) must land in the same atomic commit (or series) as the deletions — no intermediate that compiles with the old deps and the old code. + +### Step 0 — Dependency Bump + +`Cargo.toml:21+` (P0 confirmed zero version conflicts, no `[patch]`, all DET SDK features still present): + +- Bump `dash-sdk` + `rs-sdk-trusted-context-provider` from `54048b…` to `738091f734…`. +- Add `platform-wallet` (feature `serde`) + `platform-wallet-storage` git deps at `738091f…`. + +### Cluster A — `src/spv/` (8 errors in manager.rs) + +**Classification: DELETE** + +DELETE the entire `src/spv/` module tree: `manager.rs`, `error.rs`, `mod.rs`, `tests.rs`. Remove `mod spv;` declaration and all `crate::spv::*` imports throughout the workspace. + +Rationale: chain sync is owned by `platform-wallet`'s `SpvRuntime`. No DET sync code is needed. + +### Cluster B — `src/context/wallet_lifecycle.rs:619-985` (3 errors) + +**Classification: DELETE** + +Delete the following functions from `wallet_lifecycle.rs:619-985`: +- `reconcile_spv_wallets` +- `sync_spv_account_addresses` +- `spv_setup_finality_listener` +- `spv_setup_reconcile_listener` +- `handle_spv_finality_event` + +Also delete the `spv_manager()` accessor and its field wiring in `context/mod.rs:97,295-360`. + +### Cluster C — `src/backend_task/core/mod.rs` (8 errors) + +**Classification: DELETE / STUB (mixed)** + +**Delete:** +- `send_wallet_payment_via_spv` +- `build_spv_unsigned_transaction_multi` (`core/mod.rs:677`) +- `sign_spv_transaction` (`core/mod.rs:900`) +- `send_wallet_payment_via_rpc` +- `CoreBackendMode` enum and all `core_backend_mode()` branch sites +- `core_client_for_wallet` for wallet ops (`context/mod.rs:686`) +- RPC arms of `refresh_wallet_info` and `recover_asset_locks` + +**Stub** (return `TaskError::WalletBackendNotYetWired`): + +All retained dispatch arms whose implementation is being replaced in P2 return the new typed variant from P0.5 onward: + +```rust +#[error("This action is being upgraded and is temporarily unavailable. \ + Please use the previous version of the app to transact, \ + or wait for the next update.")] +WalletBackendNotYetWired, +``` + +Specifically: `run_wallet_task` / `send_wallet_payment` / `RefreshWalletInfo` / `GenerateReceiveAddress` / `CreateAssetLock` / `FundPlatformAddress*`. + +**Keep as-is:** +- `CoreTask::MineBlocks` on thin Core-RPC client (Regtest/Devnet only). + +**Single-key arms:** return `TaskError::SingleKeyWalletsUnsupported` from P0.5 onward (see [single-key-mock.md](single-key-mock.md)). + +### Cluster D — `src/model/wallet/mod.rs` (9 errors) + +**Classification: DELETE / RETAIN-MINIMAL (mixed)** + +**Delete:** +- Dead balance/UTXO/tx surface +- `utxos.rs` +- `update_spv_balances` +- `reconcile` maps + +**Retain minimal** (required by P3 migration + `database/wallet.rs` read path): +- Seed handle +- `WalletSeed` / `ClosedKeyItem` +- Alias, `is_main`, `seed_hash`, network + +**Highest over-deletion risk.** If the retained skeleton is wider or narrower than needed, P3 migration will fail. Flag any uncertainty as a blocking finding before proceeding. + +### Cluster E — `src/model/qualified_identity/mod.rs` (4 errors) + +**Classification: SDK-DRIFT-FIXUP — RETAIN, do NOT stub or delete** + +Fix the following mechanically: +- `sign` / `sign_create_witness` lifetime annotations +- `AddressProvider` member updates +- Missing associated types `Tag` / `Address` + +These are upstream API drift fixes, not migration deletions. Mis-classifying this cluster as a stub candidate would cause silent capability loss (A04 violation). If any fix proves too expensive within budget, escalate as a BLOCKING finding — do not replace with `unimplemented!()`. + +### Cluster F — `src/backend_task/shielded/bundle.rs` (6 errors) + +**Classification: SDK-DRIFT-FIXUP — RETAIN, do NOT stub or delete** + +Fix the following: +- `AddressKey` → `AddressOps` rename +- `async ?`-on-`Pin>` usage +- Trait signature updates + +If any fix is unfixable within budget: escalate as a BLOCKING finding. Do NOT stub shielded ops — silent capability loss is forbidden (A04). Never use `unimplemented!()` for retained code. + +### Cluster G — Identity / Contract Tasks (4 errors) + +**Classification: SDK-DRIFT-FIXUP — RETAIN, do NOT stub or delete** + +Mechanical signature and import updates only. Never stub identity or contract tasks. + +--- + +### Disambiguation Summary + +Three distinct operations that MUST NOT be confused: + +a. **DELETE** (Clusters A, B, C-delete, D-delete): code that belongs to the old SPV/RPC wallet stack, fully replaced by `platform-wallet`. Recoverable via git history on the branch — no tombstones, no commented-out code (M-NO-TOMBSTONES). + +b. **STUB** (Cluster C-stub): retained dispatch arms that will be rewired in P2, rendered temporarily inert with a typed error. Stubs exist from P0.5 onward; they disappear in P2 when real `WalletBackend` calls replace them. + +c. **SDK-DRIFT-FIXUP** (Clusters E, F, G): code that is out of the migration scope entirely, broken only because upstream API signatures changed. Fix these; never delete or stub them. **This is the highest-risk mis-classification** — if E/F/G code ends up behind `unimplemented!()`, capability is silently lost. + +**Data recoverability:** P0.5 and P4 deletions are recoverable via git history on the branch. No commented-out code is left in place (M-NO-TOMBSTONES). P0.5 and P4 touch code only — no DB schema change before P3, no user data is at risk. P3 adds `*.db.premigration` (retained until successful migration). Consistent with A04 fail-safe ordering. + +--- + +## I. Skills, Agents, and QA Matrix + +### Governing Workflow + +Standard Requirements → Architecture (this spec) → Implementation → QA → Review, per phase. All 8 decisions resolved (see [open-questions.md](open-questions.md)). Implementation is unblocked. + +### Crew Assignments + +| Phase | Lead crew | Mandatory reviewers | Skills enforced | +|---|---|---|---| +| P0 | Research/spike agent + Architect | — | rust-best-practices (M-PRIOR-ART, M-STATIC-VERIFICATION) | +| P0.5 | Rust impl agent | Architect (delete/stub/fixup classification review) | rust-best-practices (M-NO-TOMBSTONES); security (A04 over-deletion check) | +| P1 | Rust impl agent | Architect (boundary/frozen-contract review) | rust-best-practices (M-DONT-LEAK-TYPES, C-NEWTYPE-HIDE, M-SERVICES-CLONE) | +| P2 | Rust impl agent | Architect (BackendTask contract) | rust-best-practices (error taxonomy, M-APP-ERROR); security (A09 error wrapping) | +| P3 | Rust impl agent | Data-integrity reviewer — mandatory | security (A08 safe deserialization, A04 fail-safe migration, ASVS V14.2 secret boundary — seeds never enter persister) | +| P4a | Rust impl agent | Architect (fund-safety reviewer gate — no snapshot-based coin selection) | rust-best-practices (M-DONT-LEAK-TYPES); security (A04 — no snapshot-based spend path) | +| P4a.5 | Rust impl agent | Smythe security reviewer (mandatory — I1–I6 invariants; FundWithUtxo removal disclosure; asset-lock finality channel intact) | security (A04 double-spend, no parallel spend engine); rust-best-practices (M-NO-TOMBSTONES) | +| P4b | Rust impl agent | Correctness reviewer — mandatory (DIP-14/15 parity gate green; P4a.5 exit green) | rust-best-practices (M-NO-TOMBSTONES, test quality) | +| P5 | Rust impl agent + Architect + Smythe (release-blocking audit) | — | Full static verification; Smythe I1–I6 audit | + +### QA Matrix + +All phases run the standard baseline: +- `cargo clippy --all-features --all-targets -- -D warnings` +- `cargo +nightly fmt --all` +- `cargo test --all-features --workspace` + +Plus targeted lanes: + +| Lane | Phases | What | +|---|---|---| +| **Compile-floor verification** | P0.5 | `cargo build` + `cargo clippy` green. Tests need not pass — failing tests left with `#[ignore]` + `// TODO(P0.5): re-enable in P{1,2,3}`. | +| **SpvRuntime-owned-sync verification** | P0 | Prove `PlatformWalletManager.start()` → blocks sync → balances/tx appear via `PlatformEventHandler` with **no DET sync code running**. This is the load-bearing assumption of the whole spec — it must be confirmed before P1. | +| **DIP-14/15 golden-vector parity** | P0 (probe), P4 (regression) | Byte-equality of contact xpub + payment addresses + `calculate_account_reference`, both identifier classes (low / full 256-bit), both networks. Full-256-bit divergence = accepted structural finding (recorded trade-off in data-model-and-migration.md); no longer release-blocking in the prior sense. See [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off". | +| **`PersistedWalletLoader` seam** | P1, P5 (regression) | Mock yields exactly N `WalletRegistration` for N seed-store wallets; backend registers all N and `start()` succeeds. Swap-boundary compiles with alternate `StubFromPersisted`. Seed-decrypt failure surfaces existing typed `TaskError`. See [g2-mock-boundary.md §G2.6](g2-mock-boundary.md#g26--phasing-and-qa). | +| **Simplified Stage-B migration lane (release-blocking)** | P3c–P3e, P4 | Fixtures: wallets re-registered, identities added, contacts re-established on upstream derivation, legacy tables dropped on SUCCESS, backup exists, exception path restores, marker clears ⇔ complete success, reentrant single-run, user-never-unlocks. No quarantine fixtures — quarantine apparatus WITHDRAWN. PLUS: legacy-address-abandonment notice (shows exactly once, one-shot flag, dismissible, all migrated users). See [data-model-and-migration.md](data-model-and-migration.md). | +| **One-time-migration lane** | P3 | Synthetic legacy DB (HD + single-key + identities + DashPay) → migrate → assert: wallets re-registered via `SeedReregistrationLoader`, identities present, contacts re-established upstream, legacy HD/UTXO/SPV/DashPay/contact tables dropped, single-key preserved+flagged, backup file exists, failure path restores. | +| **Fund-safety spend-path (P4a.5 test lanes)** | P4a.5, P5 | Post-migration asset-lock via `WalletBackend::create_asset_lock_proof` (no legacy coin-selection in hot path); Path 3 asset-lock finality without `Wallet` mutation; crash-retry no-double-broadcast. All three lanes release-blocking at P5 Smythe audit. | +| **Backend E2E (testnet, `tests/backend-e2e/`)** | P2, P4 | Wallet load, balance, send, identity register/top-up, DashPay contact — through `WalletBackend`. | +| **ConnectionStatus adapter** | P4, P5 | UI sync-progress visually matches former SPV behavior, fed from `SpvRuntime::sync_progress()`. | +| **Single-key stub** | P2, P5 | Stub returns typed error + correct banner; swap-boundary trait compiles with a no-op alternate impl. | +| **Post-migration UI data-path test (release-blocking)** | P4a, P5 | Synthetic legacy DB → P3c migrate (tables dropped) → assert wallets screen shows correct balance + tx history + UTXO set from the upstream snapshot (not blank, not stale); assert snapshot updates on an `EventBridge` `TaskResult::Refresh`. Assert empty pre-sync snapshot renders as "syncing", not a zero-balance bug. Runs alongside §2(d) migration-notice regression + migration crash/restore lane + clippy `-D warnings` + `+nightly fmt` + full workspace + backend-e2e. Single push to #860 at P5 end. | + +`docs/user-stories.md` updated at P4 (RPC mode removed, single-key degraded). `claudius:lessons-learned` invoked at each phase close. + +--- + +## Highest-Risk Assumption — Explicit Verdict + +**Decision #1 ("platform-wallet owns SPV internally") is CONFIRMED, not contradicted, at PR #3625 head.** + +Evidence: `Cargo.toml` direct `dash-spv` dep; `SpvRuntime` constructs/owns `DashSpvClient` and runs its own sync loop (`run`/`spawn_in_background`); `PlatformWalletManager` owns the `SpvRuntime`; no host-feed API exists. DET's `src/spv/` is deletable. The only residue is a thin DET-side `ConnectionStatus` display adapter fed by upstream events — not chain sync. + +**Remaining live risks (updated for resolved decisions):** + +1. **G1 — release-hardening track.** DET is now pinned to #3625 head (Decision #1). G1 is no longer a start blocker; it resolves at release time to a merged + released platform rev for P3+. +2. **G2 — deferred swap.** Mitigated by `PersistedWalletLoader` seam ([g2-mock-boundary.md](g2-mock-boundary.md)). Not a gate. +3. **DIP-14/15 migration — simplified Stage-B QA lane.** The simplified upstream-only migration path must be implemented (P3c) and QA-proven (P3d–P3e). P0 full-256-bit probe divergence is now an ACCEPTED trade-off (user decision 2026-05-18) — see [data-model-and-migration.md](data-model-and-migration.md) "Accepted fund-accessibility trade-off". Quarantine apparatus WITHDRAWN. +4. **P0.5 mis-classification risk.** Highest risk: treating Clusters E/F/G (SDK-drift fixups) as stub candidates. Any `unimplemented!()` in retained code is a silent capability loss. Escalate expensive fixups; never stub retained code. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/removal-inventory.md b/docs/ai-design/2026-05-18-platform-wallet-migration/removal-inventory.md new file mode 100644 index 000000000..33a56697c --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/removal-inventory.md @@ -0,0 +1,111 @@ +# Removal Inventory + +**Purpose:** Definitive DELETE vs RETAIN lists; RPC backend mode fate and the thin Core-RPC mining utility that survives it. + +[← back to README](README.md) + +--- + +Cross-references: [backendtask-contract.md](backendtask-contract.md) for task-level disposition; [feature-coverage.md § Section 2](feature-coverage.md#section-2--reverse-gap-det-features-absent-from-platform-wallet) for the full reverse-gap analysis. + +## E. Destructive Removal Inventory + +### DELETE + +**Chain sync infrastructure — fully delegated to upstream `SpvRuntime`:** +- Entire `src/spv/` — `manager.rs` (1528L), `error.rs`, `mod.rs`, `tests.rs` +- `reconcile_spv_wallets` + `sync_spv_account_addresses` + `spv_setup_finality_listener` + `spv_setup_reconcile_listener` + `handle_spv_finality_event` (`src/context/wallet_lifecycle.rs:619-985`) + +**RPC wallet path:** +- `send_wallet_payment_via_rpc` +- RPC arms of `refresh_wallet_info` +- `recover_asset_locks` RPC body +- `core_client_for_wallet` for wallet ops (`src/context/mod.rs:686` — see RPC Fate below for what survives) +- `bootstrap_wallet_addresses` RPC branch (`wallet_lifecycle.rs:353`) +- `try_import_address` (`model/wallet/mod.rs:1202`) + +**Backend mode machinery:** +- `CoreBackendMode` enum +- `FeatureGate::{SpvBackend,RpcBackend}` +- Every `core_backend_mode()` branch (~34 sites) +- `set_core_backend_mode*` (`src/context/mod.rs:427-502`) + +**SPV `ConnectionStatus` plumbing — replaced by thin event-fed adapter:** +- `dash_spv::sync::*` imports +- SPV atomics + `set_spv_status` (`src/context/connection_status.rs:8,55,168` etc.) + +**Wallet model and DashPay derivation:** +- Most of `src/model/wallet/mod.rs` (`Wallet` struct minus what migration reads) +- `src/model/wallet/utxos.rs`, balance/UTXO/tx logic +- `src/backend_task/dashpay/dip14_derivation.rs` + `hd_derivation.rs` (delegated upstream, conditioned on one-time migration execution — quarantine apparatus WITHDRAWN; see [data-model-and-migration.md](data-model-and-migration.md) "Accepted fund-accessibility trade-off" and [phasing.md QA matrix](phasing.md#qa-matrix)) + +**DET wallet/UTXO/tx persistence:** +- `src/database/wallet.rs` — `wallet`/`wallet_transactions` tables + balance writers (`utxos` table NOT dropped — see carve-out below) +- `src/database/utxo.rs` — RETAINED, not deleted. Decision #7 single-key carve-out: `src/database/single_key_wallet.rs` → `get_utxos_by_address` depends on `src/database/utxo.rs` and the `utxos` table; dropping them would be fund-data loss. Revisit when single-key wallet moves to upstream. See phasing.md P4b carve-out. + +**SPV context wiring:** +- `src/context_provider_spv.rs` — SPV provider wiring +- `spv_context_provider`/`rpc_context_provider` switching + +**Dead settings:** +- `core_backend_mode`, `use_local_spv_node`, `auto_start_spv` (`database/initialization.rs:511-512`) + +### RETAIN + +**UI** — `src/ui/**` minus: RPC-mode toggle, Core-wallet picker, "Local Dash Core node" settings (`network_chooser_screen`). SPV progress UI rewired to upstream `sync_progress()` source. + +**MCP / CLI** — `src/mcp/**`, `src/bin/det_cli/**`. `ensure_spv_synced` rewired to `SpvRuntime::sync_progress()`. + +**Shielded / zk** — `src/backend_task/shielded/*`, `model/wallet/shielded.rs`, `context/shielded.rs`, `database/shielded.rs`, `model/grovestark_prover.rs`. Out of scope (or upstream shielded feature, future). + +**Contested-name voting / scheduled votes.** + +**Token administration** — 17 files in `src/backend_task/tokens/*`. + +**Document / contract CRUD + generic state-transition broadcast.** + +**Fee estimation** — `src/model/fee_estimation.rs` (CLAUDE.md: centralized fee logic, never inline). + +**Identity persistence** — `QualifiedIdentity` model + `database/identities.rs` blob; upstream "Outside scope." + +**Platform-address + token-balance tables** — upstream "Outside scope." + +**Single-key wallet** — `model/wallet/single_key.rs`, `database/single_key_wallet.rs`, single-key task paths — preserved, stubbed, not dropped. See [single-key-mock.md](single-key-mock.md). + +**Settings / network config** — minus dead columns. + +**ZMQ listener** — `components/core_zmq_listener` — audit before P4; delete only if no non-wallet consumer exists (Decision #3 — see [open-questions.md #3](open-questions.md)). + +--- + +## Retained Code Requiring SDK-Rev Signature Updates at P0.5 + +The following clusters appear in the P0.5 error list but are **explicitly NOT migration deletions**. They are retained code broken only by upstream API drift. Future readers must not mistake these drift fixes for removal inventory items. + +| Cluster | Location | Classification | Notes | +|---|---|---|---| +| **E — Qualified identity** | `src/model/qualified_identity/mod.rs` | SDK-DRIFT-FIXUP — RETAIN | Fix `sign`/`sign_create_witness` lifetimes, `AddressProvider` members, missing assoc types `Tag`/`Address`. Out of migration scope. | +| **F — Shielded bundle** | `src/backend_task/shielded/bundle.rs` | SDK-DRIFT-FIXUP — RETAIN | Fix `AddressKey`→`AddressOps`, async `?`-on-`Pin>`, trait sigs. If unfixable in budget, escalate as BLOCKING; do not stub. | +| **G — Identity/contract tasks** | `src/backend_task/identity/**`, `src/backend_task/contract/**` | SDK-DRIFT-FIXUP — RETAIN | Mechanical signature and import updates. Never stub. | + +**Rule:** Never place `unimplemented!()` or a stub error in Cluster E, F, or G code. Silent capability loss on retained code is an A04 violation. If a fixup is too expensive, it surfaces as a blocking finding — not a quiet stub. + +--- + +## RPC Backend Mode — Fate + +`CoreBackendMode` collapses entirely. `platform-wallet` is SPV-internal only — no RPC/full-node wallet option (`Cargo.toml` has `dash-spv` only; `SpvRuntime` is the sole chain backend). With chain sync owned by `platform-wallet`, the dual RPC/SPV wallet mode disappears: enum, settings, UI toggle, all RPC wallet call sites — deleted. + +**What was RPC-only and is now simply gone (no replacement needed):** +- `importaddress` — no server-side wallet under the new backend +- Named-wallet RPC (`-rpcwallet` namespaces, `core_client_for_wallet`, `Wallet.core_wallet_name`) +- `list_unspent` / `get_raw_transaction` retrospective scans — obsolete once `AssetLockManager` tracks continuously +- `recover_asset_locks` RPC body — replaced by `AssetLockManager` continuous tracking + +**What was RPC-only and is genuinely still needed:** + +Mining (`generatetoaddress`, Regtest/Devnet only) is the sole capability DET still needs that has no `platform-wallet` equivalent. Cross-reference: [feature-coverage.md § Category 1](feature-coverage.md#category-1--genuinely-rpc-only--impossible-under-spv). + +**Spec: thin Core-RPC mining utility.** + +Extract a standalone `src/core_rpc_util.rs` — approximately one Core RPC client + `generate_to_address`. Outside `WalletBackend`. Gated to Regtest/Devnet. Invoked only by `CoreTask::MineBlocks`. Not a wallet backend; no `CoreBackendMode`; no named-wallet support. Contract for `CoreTask::MineBlocks` is unchanged. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/single-key-mock.md b/docs/ai-design/2026-05-18-platform-wallet-migration/single-key-mock.md new file mode 100644 index 000000000..5e3dd2455 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/single-key-mock.md @@ -0,0 +1,63 @@ +# Single-Key Mock Design + +**Purpose:** `SingleKeyBackend` trait boundary, stub behavior, user-facing message, and isolation from the HD wallet migration. + +[← back to README](README.md) + +--- + +Cross-references: [open-questions.md #7](open-questions.md) — Decision #7: ship mock now, swap to `SingleKeyPlatformWallet` when upstream ships a non-HD wallet type; [backendtask-contract.md](backendtask-contract.md) — `SendSingleKeyWalletPayment` and `RefreshSingleKeyWalletInfo` dispositions; [removal-inventory.md § RETAIN](removal-inventory.md#retain) — why the legacy table is not dropped; [g2-mock-boundary.md](g2-mock-boundary.md) — `PersistedWalletLoader` follows this same swappable-stub pattern; [phasing.md § Cluster C](phasing.md#cluster-c----srcbackend_taskcoremodrs-8-errors) — P0.5 routes single-key dispatch arms to `TaskError::SingleKeyWalletsUnsupported` as part of the compile floor. + +> **Phasing note:** The stub boundary is established at **P0.5**, not P1. Single-key task arms (Cluster C of the P0.5 compile-floor work) return `TaskError::SingleKeyWalletsUnsupported` from the compile floor onward. The `SingleKeyBackend` trait and `SingleKeyStub` type are formalized in P1 but the observable behavior (typed error, user-facing banner) is present from P0.5. + +## F. Single-Key Mock Design + +### Trait Boundary + +A `SingleKeyBackend` trait — DET-internal, object-safe — with the operations the UI needs: + +- `refresh(key_id)` — refresh balance/UTXOs +- `send(key_id, recipient, amount)` — send payment +- `list()` — list keys +- `import(wif_key)` — import a key + +Two impls anticipated: +- `SingleKeyStub` — now (this spec) +- `SingleKeyPlatformWallet` — when upstream ships a non-HD wallet type + +`WalletBackend` holds `Arc`. Swapping is a one-line construction change. Trait is justified here (M-DI-HIERARCHY: two impls anticipated, object-safe, avoids a future blast-radius change). + +### Stub Behavior + +Every operation returns `Err(TaskError::SingleKeyWalletsUnsupported)`. + +`TaskError::SingleKeyWalletsUnsupported` is a dedicated fieldless variant — typed, no `String` field; `#[error("…")]` carries the user-facing message. No catch-all string (CLAUDE.md error taxonomy rules). + +### User-Facing Message + +Shown via `MessageBanner` on single-key screens: + +> "Single-key wallets are not supported in this version. Your single-key wallet data is preserved and will work again in a future update. To manage funds now, use an HD (recovery-phrase) wallet." + +Requirements met (CLAUDE.md error-message rules): +- Audience: Everyday User persona — no jargon +- Structure: what happened + what to do (self-resolvable) +- Tone: calm, direct, not alarming +- No technical details in the message itself; no "contact support" +- Action available: use an HD wallet + +### UI Rendering + +Single-key screens support import, list, and sign operations — these are backed +by the upstream `SecretStore` and work in full in this release. + +Sending funds (`RefreshSingleKeyWalletInfo`, `SendSingleKeyWalletPayment`) return +`TaskError::SingleKeyWalletsUnsupported`. When those arms fire, the banner is +displayed automatically by the `TaskError` → `MessageBanner` path — no per-screen +handling needed. + +### Isolation + +- The legacy `single_key_wallet` table is **not migrated and not dropped** — [data-model-and-migration.md](data-model-and-migration.md) drops only HD wallet/UTXO/SPV tables. +- The single-key code path never touches `WalletBackend` / `PlatformWalletManager`. +- When upstream lands a non-HD wallet type: implement `SingleKeyPlatformWallet`, add a migration step, flip construction in `WalletBackend::new()`. Zero blast radius elsewhere. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/upstream-reality.md b/docs/ai-design/2026-05-18-platform-wallet-migration/upstream-reality.md new file mode 100644 index 000000000..2616acdd0 --- /dev/null +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/upstream-reality.md @@ -0,0 +1,104 @@ +# Upstream Reality + +**Purpose:** Verified facts about what `platform-wallet` owns at PR #3625 head — the definitive answer on DET `src/spv/` deletion, the complete upstream export surface, and the G2 caveat. + +[← back to README](README.md) + +--- + +All facts verified at PR #3625 head `738091f734e05c7a1b822bb1ebff336c93b67891`. Sources listed at end. + +## A. Decision #1 — `platform-wallet` Owns SPV Internally: CONFIRMED + +Evidence chain: + +**1. Direct `dash-spv` dependency.** +`packages/rs-platform-wallet/Cargo.toml` declares `dash-spv = { workspace = true }` as a direct dependency. The crate links the SPV engine itself. + +**2. `SpvRuntime` owns and drives sync.** +`packages/rs-platform-wallet/src/spv/runtime.rs` — `SpvRuntime { event_manager, wallet_manager, client: RwLock>, background_cancel }`. It constructs `DashSpvClient::new()` internally, owns `PeerNetworkManager` + `DiskStorageManager`. Exposes: +- `run(config, cancel_token)` — its own sync loop +- `spawn_in_background(config)` +- `start`, `stop`, `sync_progress`, `tip_block_time` +- `clear_storage`, `update_config` +- `broadcast_transaction`, `get_quorum_public_key` + +There is no host-feed API. The host does not push blocks, filters, mempool, or reorgs. The crate drives all of it. + +**3. `PlatformWalletManager` owns `SpvRuntime`.** +`packages/rs-platform-wallet/src/manager/mod.rs` — `PlatformWalletManager` owns: +- `spv_manager: Arc` +- `platform_address_sync_manager` +- `identity_sync_manager: IdentitySyncManager

` +- `optional shielded_sync_manager` +- `persister: Arc

` +- SDK handle +- Internal event-adapter task (`event_adapter_cancel`/`event_adapter_join`) + +Constructor signature: `new(sdk: Arc, persister: Arc

, app_handler: Arc)`. Constructor does not auto-start sync — "call start after wallets are registered." + +## Definitive Answer: DET `src/spv/` Is Deletable + +The following DET code is deletable and fully delegated to `platform-wallet`: + +- `src/spv/` — `manager.rs` (1528L), `error.rs`, `mod.rs`, `tests.rs` +- `reconcile_spv_wallets` + `sync_spv_account_addresses` + `spv_setup_finality_listener` + `spv_setup_reconcile_listener` + `handle_spv_finality_event` (`src/context/wallet_lifecycle.rs:619-985`) +- SPV-specific `ConnectionStatus` push plumbing: `dash_spv::sync::*` imports, `set_spv_status`, SPV atomics (`src/context/connection_status.rs:8`) + +**Residue that must stay (not chain sync):** A thin `ConnectionStatus`-style projection that subscribes to `platform-wallet`'s `PlatformEventHandler` callbacks and maps `sync_progress()` into the existing UI connection-state model. DET owns displaying status; `platform-wallet` owns producing it. See [backend-architecture.md § Event Flow](backend-architecture.md#event-flow-back-to-frontend). + +## What `platform-wallet` Owns at PR Head + +Confirmed from `packages/rs-platform-wallet/src/lib.rs` exports: + +- **Chain sync** — `SpvRuntime` +- **HD wallet** — `PlatformWallet`, `WalletManager` +- **Identity lifecycle + DashPay** — `IdentityWallet`, `IdentityManager`, `ManagedIdentity`, `ContactRequest`, `EstablishedContact`, `DashPayProfile` +- **DashPay derivation** — `derive_contact_xpub`, `calculate_account_reference`, `calculate_avatar_hash`, `derive_auto_accept_private_key`, `derive_contact_payment_address(_es)`, `calculate_dhash_fingerprint` +- **Asset locks** — `AssetLockManager`, `TrackedAssetLock` +- **Platform-address sync** — `PlatformAddressSyncManager` +- **Identity/token-balance sync** — `IdentitySyncManager`, `TokenBalanceChangeSet` +- **Persistence** — `PlatformWalletPersistence` trait (`store`/`flush`/`load`/`get_core_tx_record`) +- **Top-level handle** — `PlatformWalletManager` +- **Broadcast** — `broadcaster` +- **Event model** — `PlatformEventHandler` / `PlatformEventManager` +- **DPNS** — `DpnsNameInfo` (read-only data type only; no register flow) +- **Identity funding** — `IdentityFunding`, `TopUpFundingMethod` + +For the reverse gap (what DET keeps permanently), see [feature-coverage.md § Section 2](feature-coverage.md#section-2--reverse-gap-det-features-absent-from-platform-wallet) and [removal-inventory.md § RETAIN](removal-inventory.md#retain). + +## G2 Caveat — `Wallet::from_persisted` / `load()` Gap + +Confirmed at PR head in `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`: + +``` +LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"] +``` + +Rustdoc: "Partial reconstruction caveat — leaves `ClientStartState::wallets` empty — the latter requires an upstream `Wallet::from_persisted` constructor that doesn't exist yet." `load()` populates only `platform_addresses`. + +**Upstream prescribed workaround:** `manager/wallet_lifecycle.rs` — `create_wallet_from_seed_bytes → load_persisted()` re-initializes platform/identity state from the persister around a freshly seed-derived wallet. + +**DET consequence:** DET must retain the encrypted seed and re-register each wallet from seed on every launch. The persister supplies identity/contact/UTXO/asset-lock deltas around the freshly derived wallet. This is the frozen Phase-2↔Phase-3 contract and shapes the one-time migration design in [data-model-and-migration.md](data-model-and-migration.md). See [phasing.md](phasing.md) for gate timing. + +**Mitigated (Decision #2 resolved):** The `PersistedWalletLoader` seam ([g2-mock-boundary.md](g2-mock-boundary.md)) wraps seed-re-registration behind an object-safe trait. `SeedReregistrationLoader` ships now with identical behavior; `UpstreamFromPersisted` is a one-line construction swap when upstream closes this gap. G2 is downgraded from a hard implementation gate to a deferred swap-in. + +## Provenance + +Upstream @ `738091f734…`: +- `packages/rs-platform-wallet/Cargo.toml` — direct `dash-spv` dep +- `packages/rs-platform-wallet/src/spv/runtime.rs` — `SpvRuntime` owns `DashSpvClient`, own sync loop +- `packages/rs-platform-wallet/src/spv/mod.rs` +- `packages/rs-platform-wallet/src/manager/mod.rs` — `PlatformWalletManager` owns `SpvRuntime` + persister + sync managers +- `packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs` — `create_wallet_from_seed_bytes` / `load_persisted` +- `packages/rs-platform-wallet/src/events.rs` — `PlatformEventHandler` sync object-safe trait, `PlatformEventManager::add_handler` +- `packages/rs-platform-wallet/src/lib.rs` — export surface +- `packages/rs-platform-wallet/src/changeset/traits.rs` — `PlatformWalletPersistence`, "Outside scope" +- `packages/rs-platform-wallet-storage/src/sqlite/persister.rs` — `LOAD_UNIMPLEMENTED wallets` gap +- PR #3625 metadata: open, draft, not merged, base `v3.1-dev` (`54322f7a…`), head `738091f734…`, 17 commits, +6630/-24, milestone v3.1.0 + +DET @ `v1.0-dev`: +- `src/spv/manager.rs` — 1528L, owns `WalletManager` +- `src/context/wallet_lifecycle.rs:619-985` — reconcile + SPV listeners +- `src/context/mod.rs:97,427-686` +- `src/context/connection_status.rs:8` — SPV atomics diff --git a/docs/ai-design/2026-05-28-migration-tool/notes.md b/docs/ai-design/2026-05-28-migration-tool/notes.md new file mode 100644 index 000000000..e045163f0 --- /dev/null +++ b/docs/ai-design/2026-05-28-migration-tool/notes.md @@ -0,0 +1,448 @@ +# DET data migration tool — TODO notes + +**Status:** living document. Append-only — earlier entries set context for later ones. +**Audience:** the future migration-tool author. +**Background:** DET's pre-platform-wallet `data.db` (at `/data.db`) is being unwired +from the new code path in branch `refactor/platform-wallet-loose-seam` (PR #860). Existing users +will see an empty UI after the unwire ships for the migrated domains; this migration tool, planned +as a separate library, will import their legacy `data.db` data into the new storage layout +(upstream `platform-wallet-storage` + DET's k/v abstraction). + +--- + +## Reference commit SHAs (for the migration-tool author) + +- **Last commit with all DET data.db read/write code paths**: + `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a` (PR #860's pre-unwire tip — bumps the platform + dep to the k/v-aware SHA but still reads/writes every domain from `data.db`). Check this + SHA out in a worktree to read the legacy code. +- **Pre-unwire DET v1.0-dev HEAD**: `87ba5b711839219f5e1c7aee8f9de36d038866e3`. +- **Upstream platform pin during unwire**: `17653ba8f9448edc569487b85bb35b27c5f6a14c` + (`rs-platform-wallet-storage` with k/v store at + `packages/rs-platform-wallet-storage/src/kv.rs` and SecretStore at + `packages/rs-platform-wallet-storage/src/secrets/`). + +--- + +## Domains deferred from PR #860 unwire + +These two domains were investigated during PR #860 and intentionally left on `data.db`. They +are out of scope for the unwire and must be addressed in their own follow-up PRs before the +migration tool can fully drain `data.db`. + +### Shielded (deferred in C8a) + +`commitment_tree` is upstream grovedb-owned relational state spread across four tables on the +shared SQLite connection at `data.db`: + +- `commitment_tree_shards` +- `commitment_tree_cap` +- `commitment_tree_checkpoints` +- `commitment_tree_checkpoint_marks_removed` + +These are written by `dash-spv-shielded`'s grovedb backend, which expects ambient relational +SQL access. They cannot move to the per-wallet k/v store without an upstream grovedb API +change that exposes a single-blob or kv-shaped persistence interface. Until that upstream +work happens, `data.db` remains load-bearing for any user with shielded pool activity. + +Other shielded artefacts investigated and resolved: + +- **Spending-key derivation**: spending keys are derived in-memory via ZIP32 from the wallet + seed; no migration needed because the seed is already in upstream SecretStore via Stage-B + (commit `e2f83466`). +- **Shielded overlay table** (the small per-account index): can be migrated to the upstream + k/v store, but is blocked behind `commitment_tree` because both must move together to be + consistent. +- **Sync cursor**: already migrated as part of the per-wallet k/v cursor commit (`ed6ea588`). + +### DashPay (deferred in C9, completed in D1–D4d) + +**Status update (2026-05-29):** the DashPay deferral closed in commits S1 (shielded retire) +and D1–D4d (DashPay unwire) on branch `feat/unwire-deferred-domains` stacked on PR #860. +Upstream `ManagedIdentity` now owns contacts / requests / profiles / payments, and a +per-network DET k/v sidecar owns DashPay overlays (private memo, blocked / rejected +markers, timestamps, address index, address mapping). The DET tables +(`dashpay_profiles`, `dashpay_contacts`, `dashpay_contact_requests`, +`dashpay_payments`, `dashpay_contact_address_indices`, `dashpay_address_mappings`, +`contact_private_info`) are no longer created on fresh installs and have no live readers +or writers in DET code. Pre-D4d installs keep the dormant rows; the migration tool drains +them at its leisure. + +The original C9 investigation notes (kept for the migration-tool author — DET DashPay state +is still readable at SHA `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a`): + +1. **SecretStore is not wired into AppContext.** DET does not currently consume `SecretStore` + directly anywhere — Stage-B uses upstream `SqlitePersister`'s `secrets_backend` config, not + a DET-held SecretStore handle. A "wire SecretStore into AppContext" commit is needed before + private-memo migration (private memos are upstream's only encrypted-payload feature, and + they require a SecretStore handle to read/write). +2. **State-machine vocabulary mismatch.** DET carries explicit lifecycle vocabulary that + upstream does not: + - `StoredContactRequest.status ∈ {pending, accepted, rejected, expired}` + - `StoredContact.contact_status ∈ {pending, accepted, blocked}` + - `StoredPayment.status ∈ {pending, confirmed, failed}` + Upstream is presence-based: a contact is "accepted" iff it appears in + `contacts_established`, "pending" iff it appears in `contact_requests_outgoing`, etc. + Bridging requires a multi-screen UI redesign and corresponding backend-task changes. +3. **Schema shape mismatch.** DET's `contact_private_info` is + `(nickname, notes, is_hidden, created_at, updated_at)`. Upstream's equivalent is + `(encrypted_bytes, hidden_flag)`. Either DET adopts the upstream schema (lossy: loses the + structured nickname/notes split) or upstream extends its schema (out of DET's control). +4. **Backend-task API redesign.** DET's `DashPayTask::AcceptContactRequest` and friends key + by an i64 PK on the contact-request row. Upstream keys by `(owner_id, sender_id)`. Every + DashPay backend task signature needs to change to the upstream key shape. +5. **Memo loss accepted.** Per user direction, the migration may drop DET's per-payment memos + on the floor — no k/v memo overlay is required. This simplifies migration but does not + simplify the UI re-platform. + +Until these prerequisites land, every DashPay-related table on `data.db` stays load-bearing: + +- `dashpay_profiles` +- `dashpay_contacts` +- `dashpay_contact_requests` +- `dashpay_contact_address_indices` +- `dashpay_address_mappings` +- `dashpay_payments` +- `contact_private_info` + +### `asset_lock_transaction` (code deleted) + +The `asset_lock_transaction` table had no live writers since commit `5cc6e893` (loose-seam +refactor). The entire `src/database/asset_lock_transaction.rs` module has now been deleted, +including the `CREATE TABLE` on fresh installs. Existing `data.db` rows on legacy installs +remain inert. The migration tool drains those rows by retrieving the deleted module via git +history at SHA `35eb07bf` (the last commit where the module compiled). No DROP TABLE is +emitted from DET — the legacy table is left in place until the migration tool ships and +idempotency is confirmed. + +--- + +## Per-table migration list + +### `wallet` (DET source file: `src/database/wallet.rs`) + +- **Source:** `wallet` in `data.db`, columns: see `wallet.rs` +- **Destination:** `wallet_metadata` in `platform-wallet-storage` +- **Mapping:** One row per wallet; carry seed/keys and derivation metadata +- **Per-network split:** Yes — DET single-db with `network` column → upstream per-network persister +- **Gotchas:** Stage-B (`e2f83466`) already mirrors newly created wallets to upstream; migration + tool may only need to handle wallets that pre-date Stage-B execution (i.e., wallets written + before the user first launched post-Stage-B code). Detect by checking whether the upstream db + already has a matching wallet entry before inserting. +- **Status:** DONE for new-install path — see commit `e2f83466` (Stage-B). Migration tool + still needs to import pre-Stage-B installs. + +--- + +### `wallet_addresses` (DET source file: `src/database/wallet.rs`) + +- **Source:** `wallet_addresses` in `data.db` +- **Destination:** `account_address_pools` + `core_derived_addresses` in `platform-wallet-storage` +- **Mapping:** Derived address rows split across two upstream tables by address role +- **Per-network split:** Yes +- **Gotchas:** DET's `total_received` column has no upstream equivalent. Either recompute from + upstream UTXOs post-migration or drop it — SPV will repopulate balances on next sync. +- **Status:** DONE for new-install path — see commit `09a7dfb7` (signer-driven flows on + upstream wallet). Migration tool still needs to import legacy rows. + +--- + +### `wallet_transactions` (DET source file: `src/database/wallet.rs`) + +- **Source:** `wallet_transactions` in `data.db` +- **Destination:** `core_transactions` in `platform-wallet-storage` +- **Mapping:** Direct row transfer; pure cache +- **Per-network split:** Yes +- **Gotchas:** Pure cache — migration may be skipped if acceptable to re-fetch via SPV on cold + start. Decide based on expected cache rebuild cost vs migration complexity. +- **Status:** DONE for new-install path — see commit `09a7dfb7`. Migration tool may skip + (cache is regenerable from SPV). + +--- + +### `utxos` (DET source file: `src/database/utxo.rs`) + +- **Source:** `utxos` in `data.db` +- **Destination:** `core_utxos` in `platform-wallet-storage` +- **Mapping:** Direct row transfer; pure cache +- **Per-network split:** Yes +- **Gotchas:** Pure cache — SPV re-fetch on cold start is an acceptable alternative. Same + defer-vs-migrate decision as `wallet_transactions`. +- **Status:** DONE for new-install path — see commit `09a7dfb7`. Migration tool may skip + (cache is regenerable from SPV). + +--- + +### `asset_lock_transaction` (DET source file: deleted; retrieve via git history) + +- **Source:** `asset_lock_transaction` in `data.db` on legacy installs +- **Destination:** `asset_locks` in `platform-wallet-storage` +- **Mapping:** Row-for-row transfer +- **Per-network split:** Yes +- **Gotchas:** The DET module `src/database/asset_lock_transaction.rs` has been deleted as + part of the unwire cleanup. Fresh installs no longer have the table. To read schema + details and CRUD shape for the migration tool, retrieve the deleted module via git + history at SHA `35eb07bf` (last compiling revision). Do not emit DROP TABLE from DET + itself — the legacy table is left dormant until the migration tool ships and idempotency + is confirmed. +- **Status:** DEFERRED — module deleted; rows inert on legacy installs. + +--- + +### `platform_address_balances` (DET source file: `src/database/`) + +- **Source:** `platform_address_balances` in `data.db` +- **Destination:** `platform_addresses` in `platform-wallet-storage` +- **Mapping:** Direct row transfer; pure cache +- **Per-network split:** Yes +- **Gotchas:** Pure cache — Platform re-fetch acceptable as alternative to migration. +- **Status:** DONE for new-install path — see commit `ed6ea588` (wallet platform-address-info + + sync cursor → per-wallet k/v). Migration tool may skip (cache is regenerable from Platform). + +--- + +### `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. +- **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. + +--- + +### `identity_token_balances` (DET source file: `src/database/tokens.rs`) + +- **Source:** `identity_token_balances` in `data.db` +- **Destination:** `token_balances` in `platform-wallet-storage` +- **Mapping:** Direct row transfer; pure cache +- **Per-network split:** Yes +- **Gotchas:** Pure cache — Platform re-fetch acceptable. +- **Status:** DONE for new-install path — see commit `b14bf32c`. Migration tool may skip + (cache is regenerable from Platform). + +--- + +### DashPay tables (DET source file: `src/database/dashpay.rs`) + +> **⚠ MIGRATION-TOOL N/A (2026-06-16 cross-check).** v0.9.3 is the latest release. DashPay +> was a "Coming Soon" placeholder in every shipped DET release — `src/database/dashpay.rs` +> and the tables below never shipped in any released binary. No legacy user has DashPay data +> in their `data.db`. The migration tool can skip all DashPay tables entirely. +> (Reference: PROJ-032 CLOSED/N-A in `docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md`.) + +Tables: `dashpay_profiles`, `dashpay_contacts`, `dashpay_contact_requests`, +`dashpay_payments`, `dashpay_contact_address_indices`, `dashpay_address_mappings`, +`contact_private_info` + +- **Source:** Above tables in `data.db` +- **Destination:** `dashpay_profiles`, `contacts_*`, `dashpay_payments_overlay` in + `platform-wallet-storage` +- **Mapping:** Pending column-by-column parity audit — do not implement until audit is complete +- **Per-network split:** Yes +- **Gotchas:** Some DET-only address-index tables (e.g., `dashpay_contact_address_indices`, + `dashpay_address_mappings`) may have no upstream equivalent — confirm during audit. Do not + assume 1:1 column parity; DET and upstream evolved independently. +- **Status:** DONE (D1–D4d unwire on `feat/unwire-deferred-domains`, stacked on PR #860). + **Migration-tool action: SKIP (no user data to migrate — see N/A note above).** + S1 retired the shielded data.db code path; D1 introduced the `DashpayView` adapter; D2 + wired sidecar reads/writes for DET-only overlays; D3 added blocked/rejected/timestamp + markers; D4a–D4c migrated every DashPay read and write off the DET tables; D4d deletes + `src/database/dashpay.rs` (894 LOC) and `src/database/contacts.rs` (356 LOC), drops all + `CREATE TABLE` entries from `database/initialization.rs`, collapses 3 UI dual-writes to + sidecar-only writes, and extends `AppContext::clear_network_database` with a + `det:dashpay:` prefix sweep on the per-network k/v sidecar. The migration tool reads + DET DashPay rows at SHA `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a` (pre-unwire) and + writes upstream-owned state into `ManagedIdentity` plus DET overlays into the + `det:dashpay:*` k/v namespace. + +--- + +### `settings` (DET source file: `src/database/settings.rs`) + +> **CONFIRMED REAL (2026-06-16 cross-check, PROJ-034).** v0.9.3 persisted the `settings` +> table (network, theme, custom dash_qt_path, start screen, …). Real user data is silently +> lost on upgrade without a migration. **Priority: MEDIUM** (UX friction — network reset to +> Mainnet, theme/paths/onboarding reset; behind scheduled votes in urgency). + +- **Source:** `settings` in `data.db` +- **Destination:** upstream k/v store (k/v shipped in upstream PR #3625 at SHA + `8c4a88a2cc7bead81e8441883afb7f69d3bf59cb`) +- **Mapping:** Each DET settings key → corresponding k/v entry; fields include network, + fee multiplier, theme, developer mode, etc. +- **Per-network split:** Some settings are global (theme, dev mode); others may be per-network. + Audit at implementation time. +- **Gotchas:** Mock the k/v shape until the upstream pin bump lands in DET. Do not hardcode + key names — derive them from the same constant definitions the library exports. +- **Status:** DONE for new-install path — see commits `e4ff9621` (`AppSettings` user prefs + → upstream k/v) and `02537507` (selected-wallet hashes → per-network k/v). The DET + `settings` table now only carries the migration-runner version counter; migration tool + still needs to import legacy preference rows from pre-C3 installs. + +--- + +### `identity_order`, `token_order` + +- **Source:** `identity_order`, `token_order` in `data.db` +- **Destination:** upstream k/v store (lightweight UI state) +- **Mapping:** Encode current ordering as a k/v entry per network +- **Per-network split:** Likely yes — DET stores these with a `network` column +- **Gotchas:** Low priority. UI order is cosmetic; missing it is not user-data loss. +- **Status:** DONE for new-install path — see commit `b14bf32c`. Migration tool may skip + (cosmetic ordering is acceptable to lose). + +--- + +### `contract` cache, `token` cache + +- **Source:** `contract`, `token` tables in `data.db` +- **Destination:** LRU in-memory cache (not persisted) +- **Mapping:** Do not migrate — regenerable from Platform on cold start +- **Per-network split:** N/A +- **Gotchas:** None expected. Migration tool should explicitly skip these tables and document why. +- **Status:** DONE — see commits `e8bc5a6a` (`contract` → per-network k/v) and `b14bf32c` + (`token` removed). Migration tool explicitly skips both per "regenerable cache" rule. + +--- + +### `top_up` (DET source file: `src/database/top_ups.rs`) + +- **Source:** `top_up` in `data.db` +- **Destination:** TBD — candidate is `dashpay_payments_overlay` or a new k/v entry +- **Mapping:** Pending schema decision +- **Per-network split:** Likely yes +- **Gotchas:** No clear upstream home yet. Schema decision required before implementation. +- **Status:** DONE for new-install path — see commit `7778eb64` (`top_ups` → k/v). Migration + tool still needs to import legacy rows. + +--- + +### `contested_name`, `contestant` (DPNS) + +- **Source:** `contested_name`, `contestant` in `data.db` +- **Destination:** DET-only domain — either remain in `data.db` long-term or move to DET k/v sidecar +- **Mapping:** Pending decision +- **Per-network split:** Yes +- **Gotchas:** DPNS contest data has no upstream analog in `platform-wallet-storage`. Migration + tool author must decide whether to carry these to a DET-specific sidecar store or leave them + in the old `data.db` under a "legacy section" with a clear ownership comment. +- **Status:** DONE for new-install path — see commit `e8bc5a6a` (`contested_names` → + per-network k/v). Migration tool still needs to import legacy rows. + +--- + +### `scheduled_votes` + +> **CONFIRMED REAL — HIGHEST PRIORITY (2026-06-16 cross-check, PROJ-034).** v0.9.3 +> persisted `scheduled_votes` (identity_id, contested_name, vote_choice, time, executed, +> network). Silently dropped votes can mean **missed votes** for masternode voters within +> their vote window. This is the highest-priority sub-item in PROJ-034: +> scheduled votes > app settings > top-up history. + +- **Source:** `scheduled_votes` in `data.db` +- **Destination:** DET k/v sidecar (masternode vote queuing — DET-only) +- **Mapping:** Encode as k/v entries keyed by vote identity + target +- **Per-network split:** Yes +- **Gotchas:** No upstream analog. DET-specific feature; stays in DET storage layer. +- **Status:** DONE for new-install path — see commit `7778eb64` (`scheduled_votes` → k/v). + Migration tool still needs to import legacy rows. **Implement this migration first.** + +--- + +### `proof_log` + +- **Source:** `proof_log` in `data.db` +- **Destination:** None +- **Mapping:** Do not migrate +- **Per-network split:** N/A +- **Gotchas:** Diagnostic-only table. Recommend deleting rows (not the schema) as part of cleanup + post-migration. Do not carry to new storage. +- **Status:** DONE — see commit `7778eb64` (proof_log → tracing). Migration tool explicitly + skips and may drop legacy rows. + +--- + +### `single_key_wallet` + +- **Source:** `single_key_wallet` in `data.db` +- **Destination:** Two stores — private bytes to `SecretStore` label `single_key_priv.` scoped to `SINGLE_KEY_NAMESPACE_ID` (a fixed constant, not a per-HD-wallet `WalletId`); enumerable metadata (`address`, `alias`, `network`) to `det-app.sqlite` under `:single_key_meta:`. +- **Mapping:** One row → one `SecretStore` entry + one `DetKv` entry. See `src/wallet_backend/single_key.rs`. +- **Per-network split:** Yes — the metadata sidecar key carries the `:` prefix; `SecretStore` uses the fixed `SINGLE_KEY_NAMESPACE_ID` scope for all networks. +- **Gotchas:** `SINGLE_KEY_NAMESPACE_ID` is shared across all imported keys regardless of network — `:` in the sidecar key is the partition. Do not confuse with per-HD-wallet `WalletId`. The `platform-wallet-storage` label allowlist rejects colons — the label uses a dot: `single_key_priv.`. +- **Status:** DONE for new-install path — D-2 decision in `docs/ai-design/2026-05-29-finish-unwire/notes.md`. See `src/wallet_backend/single_key.rs` (T-SK-01 + T-SK-02). Migration tool still needs to import legacy `single_key_wallet` rows into the two-store layout. + +--- + +### `shielded_notes`, `shielded_wallet_meta`, `commitment_tree_*` + +- **Source:** `shielded_notes`, `shielded_wallet_meta`, and the four `commitment_tree_*` + tables in `data.db` +- **Destination:** upstream shielded subsystem (k/v overlay + future grovedb API) +- **Mapping:** Pending parity check +- **Per-network split:** Likely yes +- **Gotchas:** Upstream shielded-storage parity audit not yet done. `commitment_tree_*` is + blocked behind an upstream grovedb API change (see "Domains deferred" section). +- **Status:** DEFERRED — see "Domains deferred" section above. Cannot migrate + `commitment_tree_*` without upstream grovedb API change; the rest move together with it. + +--- + +## Cross-cutting concern: per-network split + +DET stores all networks in one `data.db` with a `network` column. Upstream +`platform-wallet-storage` is per-network: one database per network at +`/spv//platform-wallet.sqlite`. + +Migration tool must route each row to the correct per-network persister. Write a helper that, +given a `network` string from DET, resolves the upstream persister instance. Test with all three +networks (mainnet, testnet, devnet) before shipping. + +--- + +## Open questions for the migration-tool author + +- DashPay column-by-column parity audit: incomplete. Required before any DashPay table + can be migrated. +- Shielded-storage upstream parity: incomplete. Required before shielded tables can be migrated. +- Per-network vs single-instance scope for cross-network tables (`settings`, + `identity_order`, `token_order`): confirm which keys are global vs per-network. +- DET k/v sidecar location: `/det-kv.sqlite` (single global) or + `/spv//det-kv.sqlite` (per-network)? Pending user decision. +- `single_key_wallet` long-term plan: decided — keep via `SecretStore` + `det-app.sqlite` sidecar (D-2 in finish-unwire ADR). Drop-with-sunset is deferred pending user-population data. +- `top_up` schema destination: `dashpay_payments_overlay` or new k/v entry? +- `contested_name` / `contestant` permanent home: old `data.db` legacy section, DET k/v + sidecar, or something else? +- Backup retention policy for `.premigration2` snapshots (see "Operational decisions" below). +- What constitutes a "successful launch" for purposes of snapshot pruning? + +--- + +## Operational decisions still open + +- Backup retention policy (`.premigration2` snapshots): keep indefinitely, prune after N + successful launches, or user-configurable? +- Should migration run automatically on first launch after upgrade, or require user confirmation + via a one-shot dialog? +- What does the UI show during the migration step? (Spinner with progress, modal blocker, banner?) +- Failure-mode UX: what does the user see if migration fails halfway? (Banner with guidance, + auto-rollback message, or "old DB still intact, click to retry" prompt?) + +--- + +## Change log + +- 2026-05-28: Initial document created. Seeded all known tables and open questions from + planning session. Branch `refactor/platform-wallet-loose-seam` (PR #860). +- 2026-05-29: Closed out PR #860. Annotated every per-table entry with the unwire commit SHA + for the new-install path. Added "Reference commit SHAs" section pointing at the pre-unwire + tip (`35eb07bf`), the pre-unwire `v1.0-dev` HEAD (`87ba5b71`), and the upstream platform + pin (`17653ba8`). Added "Domains deferred" section explaining why shielded and DashPay + stay on `data.db` after PR #860. diff --git a/docs/ai-design/2026-05-29-finish-unwire/notes.md b/docs/ai-design/2026-05-29-finish-unwire/notes.md new file mode 100644 index 000000000..24836a81b --- /dev/null +++ b/docs/ai-design/2026-05-29-finish-unwire/notes.md @@ -0,0 +1,250 @@ +# ADR — Finish data.db Unwire + +**Date:** 2026-05-29 +**Status:** Draft (PR not merged) +**Stacks on:** PR #860 (platform-wallet loose seam), PR #861 (deferred-domains unwire) +**Related:** `docs/ai-design/2026-05-28-migration-tool/notes.md` + +--- + +## 1. Context + +PR #860 unwired identities, tokens, contracts, DashPay overlays, and the shielded commitment +tree from `data.db`. PR #861 closed the DashPay deferral (D1–D4d). Remaining live paths: +`wallet`, `wallet_addresses`, `wallet_transactions`, `utxos`, `single_key_wallet`, +`shielded_notes`, and residual `settings` helpers. + +Nagatha's audit identified six `wallet.rs` helpers and two `single_key_wallet.rs` helpers with +no live callers, and `initialization.rs` still creating removed-domain tables on fresh installs. +Diziet's UX doc (Phase 1a/1b) defined D-1..D-5 decisions and three user journeys (Alex / Priya +/ Jordan). This ADR records those decisions, the resulting architecture, and the floor SHAs the +migration-tool author needs. + +--- + +## 2. Decisions + +### D-1 — Migration banner UX + +**Decision:** Non-blocking `Info` banner ("Updating storage… your wallet is safe") that +auto-dismisses on success. No modal blocker. + +**Rationale:** Common cold-start is post-Stage-B, completing in under one second; a modal +is overhead. Silent mode (alt a) hides activity from Alex; a modal blocker (alt b) is +disproportionate for the common case. + +**Implemented by:** T-MIG-02. + +--- + +### D-2 — Single-key wallet fate + +**Decision:** Keep via upstream `SecretStore` (file backend at `/secrets/`). Legacy +`single_key_wallet` rows migrate to labels `single_key_priv.` per `WalletId`. UI +shows "Imported key — \" with a distinct badge. + +**Rationale:** Priya actively spends from cold-storage imported keys; silent removal leaks +user trust. `SecretStore` wraps key material with minimal surface area. Drop-with-sunset (alt a) +is future work when user population data is available; a DET-only sidecar (alt b) adds a +persistence layer `SecretStore` already replaces. + +**Implemented by:** T-SK-01 + T-SK-02 + T-SK-03. + +--- + +### D-3 — Shielded notes storage + +**Decision:** Per-network sqlite sidecar at `/spv//det-shielded.sqlite`. +Lazy-created on first insert (zero-shielded users see no file). Schema mirrors legacy +`shielded_notes` 1:1. + +**Rationale:** Stage-B and DashPay established the per-network sqlite sibling pattern +(`src/wallet_backend/shielded.rs`, `dashpay.rs`). Co-locating with grovedb's +`commitment_tree` under `spv//` keeps all shielded state in one directory and +simplifies rollback. Per-wallet k/v (alt a) splits shielded state across paths; waiting for +upstream grovedb parity (alt b) has no scheduled milestone. + +**Implemented by:** T-SH-01 + T-SH-02 + T-SH-03. + +--- + +### D-4 — `data.db` file fate after cutover + +**Decision:** Leave dormant on disk, untouched. The separate migration-tool PR drains it later. + +**Rationale:** Preserves NFR-1 and matches the migration-tool plan (see `notes.md` +§asset_lock_transaction). Rename-to-`data.db.legacy` (alt a) breaks the tool's path +assumption — defer until the tool ships. Delete (alt b) is irreversible; rejected. + +**Reference:** `src/database/initialization.rs` — `legacy_detected()` gate added here. + +--- + +### D-5 — Backward-compat reference SHAs + +**Decision:** Record two floor SHAs. The migration-tool author reads legacy code from the +earlier SHA and the new wallet API from this PR's merge SHA. + +- `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a` — last commit with all DET `data.db` read/write + paths intact (PR #860 pre-unwire tip). Use this to read every legacy code path in context. +- `b0fecacb` — PR #861 merge point; last commit with deferred-domain code before DashPay and + shielded unwire landed. +- **[PLACEHOLDER — fill at merge time]** — replace this bullet with the squash-merge commit + SHA of the finish-unwire PR once it lands on `v1.0-dev`. That SHA is the new floor for + wallet-state paths. Do not leave this placeholder in place after merging: it will mislead + the migration-tool author into thinking no post-Stage-B floor SHA exists. + +**Rationale:** Two SHAs are the minimum to cover pre-Stage-B reads (legacy schema) and +post-Stage-B wallet-row migration. A single SHA (`35eb07bf` only, alt a) forces the tool +author to reconstruct post-Stage-B schema changes from history. A release tag (alt b) is +heavier than necessary. + +--- + +## 3. Architectural Shifts + +### Storage layers post-finish-unwire + +``` ++--- upstream (authoritative) -----------------------------------+ +| PlatformWalletManager wallet / utxo / tx / address | +| SqlitePersister per-network: platform-wallet.sqlite | +| SecretStore per-data-dir: secrets/det-secrets.* | ++--- DET sidecars ------------------------------------------------+ +| det-app.sqlite cross-network k/v (migration sentinel) | +| spv//det-shielded.sqlite per-network shielded notes | +| spv//commitment_tree (grovedb-owned, data.db for now) | ++--- dormant -----------------------------------------------------+ +| data.db legacy rows; no live DET readers | ++----------------------------------------------------------------+ +``` + +### Key structural changes + +- `WalletBackend` gains `secret_store()` + `single_key() -> SingleKeyView` + + `shielded(network) -> ShieldedView`. Same `DashpayView` adapter pattern. +- New `BackendTask::MigrationTask::FinishUnwire`; `MigrationStatus` enum (Idle/Running/ + Success/Failed) in `src/context/migration_status.rs`. +- `initialization.rs` gates `CREATE TABLE` for all six removed tables behind `legacy_detected()`. +- Completion sentinel key `det:migration:finish_unwire:v1` in `det-app.sqlite`. + +--- + +## 4. Out of Scope (deferred to other PRs) + +Per Nagatha §9: migration tool itself (separate PR); dropping single-key entirely (D-2 = keep; +revisit with user-population data); grovedb shielded-storage parity (blocked upstream); +`initialization.rs` tombstone CREATEs for pre-#860 tables (`proof_log`, `contestant`, +`contract`, …) — separate ADR; renaming `data.db.legacy` (D-4 = leave untouched; defer); +multi-account UTXO; OS-keychain SecretStore; encrypted-at-rest sidecar. + +--- + +## 5. Test Coverage Reference + +The full 64-case specification was drafted as a scratch file and was never committed; the +table below is the retained summary. TC-DEV-001/002/003's normative text is inlined in +`tests/legacy_table_surface.rs`, the only ID range still enforced by a committed test. + +| Domain | TC IDs | Count | +|--------|--------|-------| +| Wallet state (FR-1.x) | TC-W-001..010 | 10 | +| Single-key / SecretStore (FR-2.x) | TC-SK-001..010 | 10 | +| Shielded sidecar (FR-3.x) | TC-SH-001..009 | 9 | +| Migration mechanics + UX (FR-4/5.x) | TC-MIG-001..014 | 14 | +| Developer experience (FR-6.x) | TC-DEV-001..009 | 9 | +| Performance (NFR-5) | TC-PERF-001..004 | 4 | +| Accessibility (§2.3) | TC-A11Y-001..008 | 8 | + +Known gaps (feature-flag-only or manual-only fixture size): + +- **TC-SK-010** — D-2 alt drop path; requires non-default build flag. +- **TC-A11Y-008** — Focus-trap in modal export dialog; same D-2 alt build dependency. +- **TC-PERF-003** — 30 s worst-case migration with 10k-UTXO fixture; nightly or manual. + +--- + +## 6. Migration Tool Inputs + +For the migration-tool author. + +**Legacy code:** check out `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a` — all DET `data.db` +schemas and ORM helpers are intact there. + +**Tables to drain:** + +| Table(s) | Destination | Note | +|----------|-------------|------| +| `wallet` | upstream `wallet_metadata` per-network | Stage-B mirrors post-`e2f83466`; only pre-Stage-B rows need migration | +| `wallet_addresses` | upstream HD address cache | Re-derive from seed; verify before inserting | +| `wallet_transactions` | upstream tx store | Carry `block_hash`, `block_height`, `is_coinbase` | +| `utxos` | upstream `core_utxos` | Confirm against live SPV scan; stale rows must not override | +| `single_key_wallet` | `SecretStore` label `single_key_priv.` scoped to `SINGLE_KEY_NAMESPACE_ID` (fixed constant, shared across all imported keys) + `det-app.sqlite` sidecar `:single_key_meta:` | See `src/wallet_backend/single_key.rs` | +| `shielded_notes`, `shielded_wallet_meta` | `spv//det-shielded.sqlite` | Schema mirror 1:1; cursor row in same sidecar | +| `asset_lock_transaction` | drop — rows are inert | Module deleted at `733f9e23` | +| `contact_private_info`, `dashpay_*` | upstream `ManagedIdentity` / DET DashPay k/v | Closed in PR #861; see `notes.md` §DashPay | + +**Migration completion sentinel:** `det:migration:finish_unwire:v1` in `det-app.sqlite` +(via `AppContext::app_kv()`). Read before migrating to ensure idempotency. + +**Shielded sidecar path:** `/spv//det-shielded.sqlite` +(`` = `mainnet` | `testnet` | `devnet`). + +--- + +## 7. KV Keyspace Reference (canonical) + +All keyspaces active after this PR lands. For the complete reference with field-level detail see `docs/kv-keys.md`. + +### `det-app.sqlite` — cross-network store + +| Key pattern | Scope | Value type | Owner module | +|-------------|-------|------------|--------------| +| `det:settings:v1` | `None` | `AppSettings` | `src/context/settings_db.rs` | +| `:wallet_meta:` | `None` | `WalletMeta` | `src/wallet_backend/wallet_meta.rs` | +| `:single_key_meta:` | `None` | `ImportedKey` | `src/wallet_backend/single_key.rs` | +| `det:migration:finish_unwire:v1` | `None` | `MigrationCompletion` | `src/backend_task/migration/finish_unwire.rs` | + +### `platform-wallet.sqlite` — per-network store (`/spv//`) + +| Key pattern | Scope | Value type | Owner module | +|-------------|-------|------------|--------------| +| `det:selected_wallet:v1` | `None` | `SelectedWallet` | `src/wallet_backend/mod.rs` | +| `det:identity:` | `None` | `StoredQualifiedIdentity` | `src/context/identity_db.rs` | +| `det:identity_order:v1` | `None` | `Vec<[u8;32]>` | `src/context/identity_db.rs` | +| `det:top_ups:` | `None` | `BTreeMap` | `src/context/identity_db.rs` | +| `det:scheduled_vote::` | `None` | `StoredScheduledVote` | `src/context/identity_db.rs` | +| `det:contested_name:` | `None` | `StoredContestedName` | `src/context/contested_names_db.rs` | +| `det:contract:` | `None` | `StoredContract` | `src/context/contract_token_db.rs` | +| `det:token:` | `None` | `StoredToken` | `src/context/contract_token_db.rs` | +| `det:token_balance::` | `None` | `u64` | `src/context/contract_token_db.rs` | +| `det:token_order:v1` | `None` | `Vec<([u8;32],[u8;32])>` | `src/context/contract_token_db.rs` | +| `det:platform_addr:` | `Some(&wallet_seed_hash)` | `StoredPlatformAddressInfo` | `src/context/platform_address_db.rs` | +| `det:platform_sync:v1` | `Some(&wallet_seed_hash)` | `StoredPlatformSyncInfo` | `src/context/platform_address_db.rs` | +| `det:dashpay:blocked:` | `None` | `()` | `src/wallet_backend/dashpay.rs` | +| `det:dashpay:rejected:` | `None` | `()` | `src/wallet_backend/dashpay.rs` | +| `det:dashpay:timestamps:` | `None` | `(i64, i64)` | `src/wallet_backend/dashpay.rs` | +| `det:dashpay:private::` | `None` | `ContactPrivateInfo` | `src/wallet_backend/dashpay.rs` | +| `det:dashpay:address_index::` | `None` | `ContactAddressIndex` | `src/wallet_backend/dashpay.rs` | +| `det:dashpay:addr_map::

` | `None` | `([u8;32], u32)` | `src/wallet_backend/dashpay.rs` | + +### `SecretStore` — encrypted file vault (`/secrets/det-secrets.*`) + +Not a `KvStore`; values are raw encrypted bytes, not the `DetKv` version-byte envelope. + +| Scope (`WalletId`) | Label pattern | Value | Owner module | +|--------------------|---------------|-------|--------------| +| `WalletId(seed_hash)` | `envelope.v1` | bincode(`StoredSeedEnvelope`) | `src/wallet_backend/wallet_seed_store.rs` | +| `SINGLE_KEY_NAMESPACE_ID` (fixed) | `single_key_priv.` | 32 raw key bytes | `src/wallet_backend/single_key.rs` | + +--- + +## 8. Change Log + +``` +2026-05-29 — Initial ADR. Companion to T-* commits: + e761cb7c (T-SK-01), fefba738 (T-SH-01), + c32918a8 (T-MIG-01), 68ec561e (T-DEV-01), + 465a0684 (T-SK-02), a546c379 (T-SH-02). + [Further commits appended on merge.] +``` diff --git a/docs/ai-design/2026-06-01-pr860-gap-audit/gaps-report.json b/docs/ai-design/2026-06-01-pr860-gap-audit/gaps-report.json new file mode 100644 index 000000000..d19b7380a --- /dev/null +++ b/docs/ai-design/2026-06-01-pr860-gap-audit/gaps-report.json @@ -0,0 +1,539 @@ +{ + "schema_version": "3.0.0", + "metadata": { + "project": "dash-evo-tool", + "date": "2026-06-10", + "commit": "a0d5034a0b573847b0786e3d538a335ef57e1281" + }, + "executive_summary": { + "overall_assessment": "PR #860 platform-wallet rewrite holds broad feature parity with v0.10-dev, but the 2026-06-10 full-surface parity pass surfaced 3 HIGH functional regressions (asset-lock QR funding soft-lock, incoming DashPay payment detection gone, and a shielded nullifier-cursor unit-mismatch regression introduced by the same-day #3828 re-pin; the last is RESOLVED 2026-06-10 in 39433dac), 7 new MEDIUM gaps, and 9 new LOW gaps on top of the 11 previously-open audit items. No funds-loss path was found: every broken path fails closed. PROJ-005 (unreleased platform pin) remains the sole release-gate merge-blocker; PROJ-026/027 are should-fix-before-ship; PROJ-028 (HIGH) and its sibling PROJ-030 (MEDIUM) are RESOLVED 2026-06-10 (39433dac, Smythe QA: SHIP)." + }, + "summary_statistics": { + "total_findings": 45, + "open_findings": 9, + "severity_counts_open": { + "CRITICAL": 0, + "HIGH": 1, + "MEDIUM": 3, + "LOW": 5, + "INFO": 0 + }, + "note": "2026-06-16: PROJ-032 moved to closed_na (DashPay never persisted in any release). PROJ-034 confirmed real per v0.9.3 cross-check." + }, + "findings": [ + { + "title": "Merge-blocking and upstream release gates", + "category": "project", + "findings": [ + { + "id": "PROJ-005", + "risk": 0.6, + "impact": 0.7, + "scope": 1.0, + "title": "Platform pin tracks unreleased dev rev (release gate G1)", + "location": "Cargo.toml:21-35", + "description": "The `dash-sdk` / `platform-wallet` / `platform-wallet-storage` git pins track an **unreleased** dashpay/platform dev rev — currently `4f432c9baf10eeb051e70bc0370b1b7505b7d9c5`, the 2026-06-10 re-pin to dashpay/platform#3828 (commit `4247c360`; previously `9e1248cb…`, `ddfa66ed…`, `35e4a2f6…`, originally `17653ba8…`). Project policy (Decision #1) classifies this as a release-hardening blocker that gates merge-to-ship. The #3828 re-pin also introduced the PROJ-028 nullifier-cursor regression.", + "recommendation": "Bump the pin to a released platform rev (tagged release) before shipping. Track upstream #3828 stabilization; re-run the shielded/nullifier QA suite on each pin move." + }, + { + "id": "PROJ-017", + "risk": 0.25, + "impact": 0.3, + "scope": 1.0, + "title": "register_identity_funding_account absent upstream — DET carries contained exception", + "location": "src/wallet_backend/mod.rs:1407", + "description": "`rs-platform-wallet` has no funding-account registrar sibling to `register_contact_account`, so DET carries `provision_identity_funding_account` (`src/wallet_backend/mod.rs:1407`) / `ensure_identity_funding_accounts` (`:1489`) as a contained exception, called live from register/topup paths (`:1261,1325,1371`). Upstream-contribution tracked (`9cdcfb25`); persister-load recurrence (`a5538dc8`).", + "recommendation": "Land the upstream registrar contribution and delete the DET-side exception once available. Keep the call sites funneled through the two helpers so the swap stays mechanical." + } + ] + }, + { + "title": "Functional parity gaps (v0.10-dev → PR #860)", + "category": "project", + "findings": [ + { + "id": "PROJ-026", + "risk": 0.65, + "impact": 0.75, + "scope": 1.0, + "title": "Create-Asset-Lock QR funding flow soft-locks at 'Waiting for funds…'", + "location": "src/ui/wallets/create_asset_lock_screen.rs:650-666", + "description": "v0.10-dev polled `funding_common::capture_qr_funding_utxo_if_available` every frame (OLD `create_asset_lock_screen.rs:519`, helper OLD `src/ui/identities/funding_common.rs:56-94`) to flip `WaitingOnFunds → FundsReceived`. In PR #860 the poll is deleted and the **only** remaining transition is the `display_task_result` arm matching `CoreItem::ReceivedAvailableUTXOTransaction` (flip at `create_asset_lock_screen.rs:659`) — a CoreItem with **zero producers**: `src/backend_task/core/mod.rs:106` is definition-only, `received_transaction_finality` always returns `Ok(Vec::new())` (`src/context/transaction_processing.rs:17-30`), and the ZMQ listener never spawns (PROJ-012). The screen is reachable (Asset Locks tab → 'Create Asset Lock', `src/ui/wallets/wallets_screen/asset_locks.rs:69`). The user is shown a QR and 'Waiting for funds...' **forever**, even after sending real DASH — funds land in normal SPV balance, but the flow dead-ends after a real payment was solicited. Not covered by the CHANGELOG 'QR-code wallet import flow' removal line.", + "impact_description": "A payment flow instructs the user to move real funds and then cannot complete. Funds recoverable, but the entire create-asset-lock screen is inoperable.", + "recommendation": "Detect the arriving UTXO from the `WalletBackend` snapshot at the funding address (the `snapshot_balance` pattern `add_new_wallet_screen.rs` already uses), or emit the event from `EventBridge`. Also remove the dead sibling match arms in `add_new_identity_screen/mod.rs:1144` and `top_up_identity_screen/mod.rs:488`." + }, + { + "id": "PROJ-027", + "risk": 0.7, + "impact": 0.8, + "scope": 1.0, + "title": "Incoming DashPay contact payments are never detected, recorded, or credited", + "location": "src/backend_task/dashpay/incoming_payments.rs:349", + "description": "v0.10-dev's ZMQ tx-finality path auto-detected payments to DashPay contact receive addresses, credited the UTXO, advanced the receive index, and recorded a 'received' payment-history row (OLD `transaction_processing.rs:183-227`). In PR #860 the detection chain is dead code with **zero callers**: `process_incoming_payment` (`incoming_payments.rs:349`) and `mirror_incoming_payment_to_backend` (`payments.rs:592`) are never invoked; the new `received_transaction_finality` handles asset-lock waiters only; `EventBridge` has no DashPay hook (`src/wallet_backend/event_bridge.rs:121-160`); `RegisterDashPayAddresses` writes contact receive addresses only into DET's in-memory model (`incoming_payments.rs:298-314`), feeding nothing into the upstream SPV watch set; and `WalletBackend::register_dashpay_contact` (`src/wallet_backend/mod.rs:1250`) has zero callers. Strictly wider than the disclosed PROJ-009 DIP-14 carve-out (non-mainnet/non-account-0 only). Contradicts design docs stating incoming-payment detection is retained DET-side (`backendtask-contract.md:46`, `open-questions.md:56`).", + "impact_description": "Funds a contact sends arrive at addresses DET no longer watches — invisible in wallet balance and absent from payment history, on all networks and accounts. Not destroyed (derivable from seed) but invisible/unspendable in this version.", + "recommendation": "Call `register_dashpay_contact` when contacts are established so upstream watches the DIP-15 account, and wire a finality/EventBridge hook to `process_incoming_payment` — or delegate detection fully upstream and mirror results into payment history." + }, + { + "id": "PROJ-012", + "risk": 0.45, + "impact": 0.5, + "scope": 1.0, + "title": "Entire ZMQ subsystem is dead code; 'Disable ZMQ' checkbox is a placebo", + "location": "src/app.rs:845-847", + "description": "Re-scoped 2026-06-10: the whole producer→consumer chain is dead, not just health events. `spawn_zmq_listener` returns `None` before spawning because `FeatureGate::RpcBackend.is_available()` is hardcoded `false` (`src/app.rs:845-847`; `src/model/feature_gate.rs:78`), so the `sx_zmq_status` clone at `src/app.rs:852` is unreachable. `rx_zmq_status` (`src/context/mod.rs:79`) is never drained; `ConnectionStatus::set_zmq_status` (`src/context/connection_status.rs:159`) has zero callers; the CoreItem consumption loop (`src/app.rs:1502-1540`) can never receive events. The 'Disable ZMQ (requires restart)' checkbox is still rendered and persisted (`src/ui/network_chooser_screen.rs:764-775`; `src/model/settings.rs:72`) but controls nothing.", + "recommendation": "Either wire the chain when/if an RPC backend returns, or remove the whole producer → channel → setter chain plus the placebo checkbox and the dead consumption loop (channel pair constructed as a unit at `src/context/mod.rs:204`). Note this also removes the only conceivable producer for PROJ-026's CoreItem." + }, + { + "id": "PROJ-029", + "risk": 0.5, + "impact": 0.5, + "scope": 1.0, + "title": "'Subtract fee from amount' and the Max button are guaranteed-error paths for Core sends", + "location": "src/ui/wallets/send_screen.rs:2383-2411", + "description": "Backend hard-rejects `subtract_fee_from_amount || override_fee.is_some()` with typed `TaskError::WalletPaymentOptionUnsupported` (`src/backend_task/core/mod.rs:266-268`, deliberate 'no silent ignore'). But the UI still offers the option: checkbox at `send_screen.rs:2405-2411` and `wallets_screen/dialogs.rs:220` (sent at `:1116`), and the **Max button auto-enables it** (`send_screen.rs:2383-2389`). So 'Send Max' from a Core wallet and any ticked-checkbox send always fail. v0.10-dev implemented subtract-fee in both RPC and SPV send backends. No funds at risk (rejected before broadcast). Not in CHANGELOG Known Limitations.", + "recommendation": "Remove/disable the checkbox and the Max auto-enable for Core sends until upstream `send_to_addresses` supports fee deduction; disclose the lost send-max capability in CHANGELOG Known Limitations (DOC-001)." + }, + { + "id": "PROJ-031", + "risk": 0.55, + "impact": 0.55, + "scope": 1.0, + "title": "Shield-from-Core silently ignores the selected source address (coin control lost; UI and MCP misleading)", + "location": "src/context/shielded.rs:242-252", + "description": "Dispatch discards the parameter (`source_address: _` — 'coin selection is delegated to the upstream wallet's live UTXO set'). v0.10-dev threaded it into asset-lock UTXO selection (OLD `shielded.rs:613-625`). Yet the UI still lets the user pick a specific Core address, shows 'Available address balance' for it and validates the amount against that address (`src/ui/wallets/shield_screen.rs:786-812`), then sends `source_address: None` (`:974-985`); the MCP tool `shielded_shield_from_core` still accepts and silently drops the param (`src/mcp/tools/shielded.rs:33,86-108`). Not in CHANGELOG Known Limitations.", + "impact_description": "Privacy-expectation violation in a privacy feature: the asset lock may link UTXOs from addresses the user explicitly tried to exclude; amount caps are computed against the wrong balance.", + "recommendation": "If per-address selection cannot be honored upstream, remove the address selector / per-address balance display and the MCP param and disclose the limitation; otherwise thread the parameter through to upstream coin selection." + }, + { + "id": "PROJ-032", + "risk": 0.0, + "impact": 0.0, + "scope": 1.0, + "status": "closed_na", + "verdict": "2026-06-16 cross-check confirmed N/A: v0.9.3 (latest release) had DashPay as a 'Coming Soon' placeholder (dashpay_coming_soon_screen.rs); zero DashPay tables or persistence ever shipped in any DET release. Precondition for the migration was never true. TODO removed from finish_unwire.rs.", + "title": "Legacy DashPay user data not migrated: payment history, nicknames/notes/hidden flags, send-address indices", + "location": "src/backend_task/migration/finish_unwire.rs", + "description": "CLOSED/N-A. The original finding assumed src/database/dashpay.rs represented previously-shipped persistence — it did not. The file was developed on the feature branch and deleted on the same branch; it never appeared in a released binary. v0.9.3 cross-check confirms DashPay was a placeholder screen only. No user data exists to migrate for any release.", + "impact_description": "No impact — no DashPay user data was ever persisted in any shipped release.", + "recommendation": "No action required. No migration needed; no CHANGELOG disclosure needed." + }, + { + "id": "PROJ-033", + "risk": 0.4, + "impact": 0.45, + "scope": 1.0, + "title": "Dash-Qt launcher unreachable while its settings cluster survives", + "location": "src/ui/network_chooser_screen.rs:646-736", + "description": "`CoreTask::StartDashQT` still exists (`src/backend_task/core/start_dash_qt.rs`) but has **zero UI callers** — both v0.10-dev launch paths (RPC-mode Connect button, connection-indicator click) were removed with RPC mode. Meanwhile Settings still renders the full Dash-Qt cluster: 'Dash Core Executable Path' Select File/Clear (`:646-736`), 'Overwrite dash.conf' (`:744`), 'Close Dash-Qt when DET exits' (`:899-933`) — three live settings configuring a feature that can never fire (path autodetect was even improved in `837fc6bd`).", + "recommendation": "Either re-wire a launch affordance (useful for regtest/devnet workflows) or remove the settings cluster and the orphaned task." + }, + { + "id": "PROJ-034", + "risk": 0.5, + "impact": 0.45, + "scope": 1.0, + "status": "open", + "verdict": "2026-06-16 cross-check CONFIRMED REAL: v0.9.3 persisted all three — settings (network, theme, dash_qt_path, start screen), top_up (identity_id, top_up_index, amount), scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network). Real user data is silently lost on upgrade. Follow-up priority: scheduled votes (vote-window deadline risk) > app settings (UX friction) > top-up history (audit trail).", + "title": "App settings, top-up history, and scheduled DPNS votes all reset/empty on upgrade (no non-wallet data migration)", + "location": "src/backend_task/migration/finish_unwire.rs", + "description": "Settings moved from the legacy `data.db` `settings` table to k/v `det:settings:v1` in `det-app.sqlite` (`src/model/settings.rs:63-88`; `src/context/settings_db.rs`) with **no importer** — `db.get_settings` has zero callers and the migration drains only `LEGACY_TABLES` (wallets/secrets). Commit `e4ff9621` disclosed 'migration tool will import in a later PR' — that PR never landed. Top-up history and scheduled DPNS votes likewise start empty (commit `7778eb64`). Disclosed in commit messages only — not in CHANGELOG. Confirmed real by v0.9.3 cross-check (2026-06-16).", + "impact_description": "At upgrade: selected network resets to Mainnet (a testnet user relaunches into mainnet), theme/paths/onboarding/user-mode reset, and silently dropped scheduled DPNS votes can mean **missed votes** for masternode voters. One-time, no fund loss. Highest-priority sub-item: scheduled votes (vote-window deadline).", + "recommendation": "Add a legacy-settings/k-v importer to the cold-start migration (at minimum: network, theme, onboarding flag, scheduled votes), or disclose prominently in CHANGELOG (DOC-001) and show a first-launch reminder for scheduled-vote users. Priority: scheduled votes > settings > top-up history." + }, + { + "id": "PROJ-035", + "risk": 0.3, + "impact": 0.3, + "scope": 1.0, + "title": "In-app copy directs users to the removed 'Local Dash Core node' setting; phantom 'Refresh' button referenced", + "location": "src/ui/wallets/wallets_screen/single_key_view.rs:13", + "description": "Two strings still instruct: 'open Settings, switch to Expert mode, and select Local Dash Core node' (`single_key_view.rs:13` `SINGLE_KEY_REQUIRES_CORE`, shown as banner + disabled-Send tooltip; `src/ui/components/tools_subscreen_chooser_panel.rs:141`) — but no such setting exists in `network_chooser_screen.rs` (RPC mode removed). `single_key_view.rs:128` says \"Click 'Refresh' to load UTXOs from Core\" while refresh is stubbed (PROJ-007) and no such button is rendered.", + "recommendation": "Rewrite the three strings to reflect the current capability (single-key send/refresh unsupported this release; receive works), per the i18n-ready-strings and error-message conventions." + }, + { + "id": "PROJ-036", + "risk": 0.25, + "impact": 0.2, + "scope": 1.0, + "title": "Wallet 'Refresh' in Core-Only mode is a silent no-op", + "location": "src/backend_task/core/mod.rs:191-213", + "description": "v0.10-dev `RefreshWalletInfo` ran `reconcile_spv_wallets()` (SPV) or a full RPC re-poll. In PR #860 Core state is push-based via EventBridge; the arm only ensures the backend exists and optionally refreshes Platform balances. The UI still offers the 3-state refresh-mode toggle ('Core + Platform' / 'Core Only' / 'Platform Only', `src/ui/wallets/wallets_screen/mod.rs:77-100`); a 'Core Only' refresh reports success with no observable effect.", + "recommendation": "Hide the 'Core Only' refresh mode, or repurpose the click as a forced reconcile when upstream exposes one." + }, + { + "id": "PROJ-037", + "risk": 0.25, + "impact": 0.2, + "scope": 0.5, + "title": "Send-dialog 'Memo (optional)' field goes nowhere (pre-existing, now provably dead)", + "location": "src/ui/wallets/wallets_screen/dialogs.rs:224-225", + "description": "The memo travels in `WalletPaymentRequest.memo` but was never consumed by the HD core-send path in v0.10-dev either (verified: no `memo` reads in OLD send paths). PR #860 still renders the field and still drops it (`src/backend_task/core/mod.rs:255-303` never reads it). Now provably dead — upstream `send_payment` has no memo parameter.", + "recommendation": "Remove the field, or wire it to local transaction annotation." + }, + { + "id": "PROJ-038", + "risk": 0.3, + "impact": 0.35, + "scope": 1.0, + "title": "Failed wallet-funded identity registration leaves no visible local record; retry-adoption semantics changed", + "location": "src/backend_task/identity/register_identity.rs:209-231", + "description": "v0.10-dev derived the real identity id from the asset-lock proof up front, persisted `PendingCreation` before submit, and flipped to `FailedCreation` (red row) on every failure path (OLD `register_identity.rs:258-295,382-423`); on retry it fetched the identity and silently adopted it if a previous attempt had actually succeeded. PR #860's wallet-funded paths run through upstream `register_identity_with_funding`: the real id is only known on success, the local mirror holds an all-zeros placeholder, and on error the insert is deliberately skipped (`:23` `is_placeholder_identity`, `:209-231`); only the Platform-addresses path still persists `FailedCreation` (`:394`). `PlatformWalletError::IdentityAlreadyExists` maps to the generic `TaskError::WalletBackend` bucket (`src/wallet_backend/mod.rs:1910-1922`), so retry-after-partial-success likely shows an error instead of adopting (needs live verification — PROJ-015/016 family). Funds are safe: the asset lock stays tracked and resumable via the 'Unused Asset Lock (recommended)' picker.", + "recommendation": "Surface failed attempts (persist a failed-attempt marker or point the error banner at the unused-asset-lock resume path), and map `IdentityAlreadyExists` to an adopt-or-instruct flow. Re-test retry-after-crash on a live network." + }, + { + "id": "PROJ-040", + "risk": 0.35, + "impact": 0.25, + "scope": 1.0, + "title": "DashPay offline caches dropped — contacts/requests/profiles/avatars need network on every open", + "location": "src/ui/dashpay/contacts_list.rs:111-134", + "description": "v0.10-dev rendered instantly from `data.db` (`load_contacts_from_database`, `load_requests_from_database`, avatar-bytes cache, negative-profile caching). PR #860 screens auto-dispatch Platform fetches on first render (`contacts_list.rs:67,111-134`, `contact_requests.rs:295-297`); avatar bytes are re-downloaded each open; negative-profile caching is gone. Contacts/payments do persist upstream (`DashpayView`), so this is a cache-locality change, not data loss — but the offline-UX consequence and the contradiction with `data-model-and-migration.md:58` (avatar cache 'retained DET-side') are undocumented.", + "recommendation": "Render last-known upstream state before dispatching the network refresh; restore an avatar byte cache (or document the trade-off and fix the design-doc claim)." + }, + { + "id": "PROJ-041", + "risk": 0.35, + "impact": 0.2, + "scope": 1.0, + "title": "'Stop tracking balance' undone by 'Refresh My Tokens'; watch set became identities × all-known-tokens", + "location": "src/backend_task/tokens/query_my_token_balances.rs:39-44", + "description": "`stop_tracking_token_balance` (`:62-83`) unwatches upstream and prunes the order — but `query_my_token_balances` / `query_token_balance` register the full local token registry for **every** identity (`known_token_ids`, `:39-44,100-105`), deliberately re-watching stopped pairs ('Refresh all my tokens … deliberately re-tracks everything known') and creating rows for identity×token pairs the user never tracked. v0.10-dev refreshed only pairs already present in `identity_token_balances`, so a removed row stayed gone. Disclosed in code comments only.", + "recommendation": "Persist the user's dismissed pairs and exclude them from the refresh re-watch, or scope refresh to currently-watched pairs (the v0.10-dev semantics)." + }, + { + "id": "PROJ-042", + "risk": 0.5, + "impact": 0.55, + "scope": 0.5, + "title": "Non-identity asset-lock flows bypass upstream orchestration: shield-from-asset-lock falsely confirmed on a post-broadcast confirmation failure; platform-address top-ups never mark the lock Consumed", + "location": "src/backend_task/shielded/bundle.rs (shield_from_asset_lock, post-broadcast wait_for_response); src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs (top_up); src/backend_task/wallet/fund_platform_address_from_asset_lock.rs (top_up)", + "description": "Identity asset-lock flows route through upstream IdentityWallet::*_with_funding (resolve -> submit_with_cl_height_retry -> consume_asset_lock). Three non-identity flows built the asset lock upstream but submitted the Platform transition manually. shield_from_asset_lock swallowed a post-broadcast wait_for_response failure as warn!+.ok() and fell through to Ok(shield_amount_credits) -- a confirmation failure reported success even though the single-use asset lock backs funds that may not have reached the pool. The two platform-address top_up paths propagated submit failures via ? (no false success) but never marked the tracked lock Consumed on success and had no CL-height submit retry. RESOLVED in PR (both halves): the shield false-success maps to dedicated typed TaskError::ShieldedConfirmationUnknown (#[source] Box, no String message field), unit-tested in bundle.rs. The two platform-address flows now branch on UPSTREAM pool membership: WalletBackend::platform_address_in_pool resolves the wallet and asks upstream's own ManagedPlatformAccount::contains_platform_address (account 0) -- the exact generation-based check the orchestrator's pre-flight runs (fund_from_asset_lock.rs:474-482). An in-pool destination routes through the new WalletBackend::fund_platform_address -> PlatformAddressWallet::fund_from_asset_lock, which owns resolve -> submit_with_cl_height_retry -> IS->CL fallback -> consume_asset_lock. Wallet-UTXO uses AssetLockFunding::FromWalletBalance (orchestrator builds+broadcasts the lock; manual two-step dropped on this branch); tracked-lock uses FromExistingAssetLock{out_point}. Orchestrator errors map to TaskError::PlatformAddressFundRejected (#[source] Box, no String message field). Membership semantics, the mixed-ownership routing rule, and error mapping are unit-tested. No DET-side index window or magic constant: the gate is upstream's actual pool.", + "recommendation": "RESOLVED in PR. The earlier fee-from-wallet residual is now CLOSED: funding your OWN in-pool address with FEE-FROM-WALLET also routes through the orchestrator. The branch sources an in-pool, watched platform-payment change recipient from the wallet's own watched set (every candidate is inside DET's synced provider window -- the 0a64be55 funds-safety invariant) and gates each through the same WalletBackend::platform_address_in_pool check the orchestrator's pre-flight runs. When a distinct in-pool change exists, the two-output map ({destination: Some(amount_credits), change: None} with ReduceOutput(change_index)) satisfies the orchestrator's exactly-one-None / all-in-pool contract, so the whole fee-from-wallet flow gains orchestrated recovery (CL-height retry, IS->CL fallback, consume_asset_lock). The lock is sized to amount + estimated_fee so the change absorbs exactly the fee budget -- identical amount/fee semantics to the manual path. No reveal or pool advance: only already-revealed (watched + in-pool) addresses are used. Change selection is a pure, unit-tested helper (select_in_pool_change). Residuals that remain: (1) BY DESIGN -- a foreign / non-pool destination (funding an address the wallet does not watch, reachable via advanced send and MCP/CLI) keeps the manual TopUpAddress path; the orchestrator's pre-flight requires recipients to be in this wallet's platform-payment pool, and funding an unwatched address is an advanced footgun users are trusted to take. (2) NARROW EDGE -- a fee-from-wallet top-up to an in-pool destination when the wallet has no OTHER watched, in-pool platform-payment address to serve as change falls back to the manual path (which derives a fresh change address); this only occurs when the wallet's single revealed platform-payment address is the destination itself, and revealing any second receive address closes it. Both manual fallbacks still propagate submit failures via ? (no false success)." + }, + { + "id": "PROJ-043", + "risk": 0.25, + "impact": 0.35, + "scope": 0.4, + "title": "Four sibling shielded spend fns swallow the same post-broadcast confirmation failure, so notes are marked spent for spends that may never confirm", + "location": "src/backend_task/shielded/bundle.rs:334-339 (shield_credits), :429-436 (shielded_transfer), :520-527 (unshield_credits), :737-744 (shielded_withdrawal); src/context/shielded.rs:755-757 (mark_notes_spent), :867 (mark_shielded_note_spent persist)", + "description": "PROJ-042 fixed the post-broadcast confirmation swallow only in shield_from_asset_lock. The four sibling spend fns carried the identical pattern: they warn!+.ok() the wait_for_response failure after a successful broadcast and returned Ok(...) (shield_credits returns Ok(()); the other three return Ok(spent_nullifiers)). The caller then took the Ok branch and called mark_notes_spent (src/context/shielded.rs), persisting each nullifier via mark_shielded_note_spent. So notes were marked spent for spends whose state transition may never have confirmed (shield_credits instead bumped the platform-address nonce). This self-heals on the next nullifier/note resync (check_nullifiers) which reconciles spent state against the chain, so it was a temporary local-state divergence, not permanent note or fund loss. RESOLVED in PR: each fn now maps the post-broadcast wait_for_response failure to a dedicated typed error -- TaskError::ShieldCreditsConfirmationUnknown / ShieldedTransferConfirmationUnknown / UnshieldConfirmationUnknown / ShieldedWithdrawalConfirmationUnknown (each #[source] Box, no String message field, jargon-free actionable 'wait then refresh' message) -- and propagates it via ?. Because callers commit side effects only on Ok, propagating skips mark_notes_spent (transfer/unshield/withdrawal) and the nonce bump (shield_credits) on an unverified spend, so the balance is briefly OVERSTATED rather than understated. Safety: if the broadcast actually landed, a retry re-spends the same notes and consensus rejects the already-revealed nullifier (double-spend), and the next check_nullifiers resync observes the on-chain nullifier and marks the notes spent -- self-correcting through routine sync. Mirrors the PROJ-042 shield_from_asset_lock precedent; minimal swallow->propagate change, does not touch the resync state machine. Unit-tested in bundle.rs (each mapper yields its typed variant; every message is actionable and jargon-free).", + "recommendation": "RESOLVED in PR. No further action: the four spend paths now surface a confirmation-unknown error instead of falsely confirming, and the temporary overstate self-corrects on the next shielded resync." + } + ] + }, + { + "title": "Deferred-by-design (open)", + "category": "project", + "findings": [ + { + "id": "PROJ-007", + "risk": 0.45, + "impact": 0.55, + "scope": 1.0, + "title": "Single-key refresh/send stubbed; password-protected single-key wallets silently vanish post-upgrade", + "location": "src/backend_task/migration/finish_unwire.rs:120-134", + "description": "Core deferral (Decision #7): `CoreTask::RefreshSingleKeyWalletInfo` / `SendSingleKeyWalletPayment` return `SingleKeyWalletsUnsupported` (`src/backend_task/core/mod.rs:218,303`); import/sign/list/hydrate are real. **Extended + bumped LOW→MEDIUM 2026-06-10:** (a) import now rejects any non-empty per-key password (`src/ui/wallets/import_mnemonic_screen.rs:118-126`; v0.10-dev `SingleKeyWallet::from_wif` supported them); (b) the cold-start migration **skips** `uses_password=1` single-key rows (`finish_unwire.rs:120-134,377-389`, `skipped_password_protected`) and hydration lists only vault-persisted keys (`src/wallet_backend/single_key.rs:363`), so a skipped wallet **disappears from the wallet list entirely** post-upgrade with no in-app access or explanation. Key material is preserved in the untouched legacy `data.db` (no data loss). Neither caveat is in CHANGELOG Known Limitations (DOC-001).", + "recommendation": "Use the now-shipped PROJ-008 JIT prompt seam to unlock and migrate `uses_password=1` rows inline (T-SK-03), or at minimum list the skipped wallets with an explanatory state and document both caveats in CHANGELOG." + }, + { + "id": "PROJ-009", + "risk": 0.4, + "impact": 0.5, + "scope": 1.0, + "title": "DIP-14 back-compat dropped (non-mainnet / non-account-0 legacy contact addresses) — description incomplete; subject function has zero callers", + "location": "src/wallet_backend/mod.rs:1250", + "description": "Decision #6 deliberately drops reproduction of legacy contact addresses derived outside mainnet account 0 (disclosed in CHANGELOG Known Limitations). **Flagged 2026-06-10:** the entry as written understates the situation — its subject `register_dashpay_contact` (`src/wallet_backend/mod.rs:1250`) has **zero callers**, and the general incoming-payment loss is far wider than the carve-out: see PROJ-027 (HIGH, all networks/accounts).", + "recommendation": "Keep the Decision #6 deferral for the back-compat slice, but resolve PROJ-027 first — wiring `register_dashpay_contact` is a prerequisite for both." + }, + { + "id": "PROJ-011", + "risk": 0.2, + "impact": 0.2, + "scope": 1.0, + "title": "identity CREATE TABLE still on fresh installs — tombstone ADR pending", + "location": "src/database/initialization.rs:845-866", + "description": "`legacy_detected()` gates legacy wallet tables behind `include_legacy`, but the `identity` empty placeholder (`:851`) is still created unconditionally for legacy cold-start reads. Documented 'separate ADR' carve-out (T-DEV-02b, `finish-unwire/notes.md` §4).", + "recommendation": "Write the tombstone ADR and gate/drop the placeholder table on fresh installs." + }, + { + "id": "PROJ-022", + "risk": 0.2, + "impact": 0.25, + "scope": 1.0, + "title": "UpstreamPlatformAddresses reserved swap-target — read methods unimplemented!()", + "location": "src/wallet_backend/platform_address.rs:245-307", + "description": "Reserved swap target for reading per-address Platform funds straight from upstream; NOT selected (active impl is `KvCachedPlatformAddresses`, `src/wallet_backend/mod.rs:593`). Read methods are `unimplemented!()` pending upstream `e817b66a` (public per-address balance+nonce reader). Cannot panic in any live path while the cached impl is active.", + "recommendation": "Swap in once upstream lands the reader; until then keep the cached impl selected and the seam documented." + } + ] + }, + { + "title": "Project conventions", + "category": "project", + "findings": [ + { + "id": "PROJ-039", + "risk": 0.25, + "impact": 0.2, + "scope": 1.0, + "title": "Rust Debug ({:?}) rendered in user-facing unused-asset-lock picker; address column dropped", + "location": "src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs:75", + "description": "The picker now lists upstream `TrackedAssetLock`s showing `Status: {:?}` of `AssetLockStatus` — a Rust Debug repr in a user-facing label (also `top_up_identity_screen/by_using_unused_asset_lock.rs:61-65`), violating the i18n-ready-strings convention (CLAUDE.md). v0.10-dev showed TxID / Address / Amount / 'InstantLock: Yes-No'; the address is no longer shown.", + "recommendation": "Map `AssetLockStatus` to user-facing copy (complete sentences/labels, no Debug), and consider restoring the address column." + } + ] + }, + { + "title": "Documentation gaps", + "category": "documentation", + "findings": [ + { + "id": "PROJ-018", + "risk": 0.4, + "impact": 0.4, + "scope": 1.0, + "title": "External user docs (dashpay/docs) not yet filed", + "location": "docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md:1", + "description": "A full external-docs draft exists targeting `dashpay/docs` → `docs/user/network/dash-evo-tool/`, with a Tracking section and an explicit TODO to file the issue post-merge. The gap stays OPEN until the PR/issue is actually filed against `dashpay/docs` after #860 merges.", + "recommendation": "File the dashpay/docs PR/issue linking to PR #860 immediately after merge; fold in the new disclosure items from DOC-001." + }, + { + "id": "PROJ-019", + "risk": 0.2, + "impact": 0.2, + "scope": 1.0, + "title": "ADR floor SHA placeholder unfilled (merge-time action)", + "location": "docs/ai-design/2026-05-29-finish-unwire/notes.md:92", + "description": "`[PLACEHOLDER — fill at merge time]` — the wallet-state floor must record this PR's squash-merge SHA; cannot close pre-merge.", + "recommendation": "Fill the placeholder with the squash-merge SHA at merge time." + }, + { + "id": "DOC-001", + "risk": 0.4, + "impact": 0.5, + "scope": 1.0, + "title": "CHANGELOG disclosure sweep — Removed / Known Limitations / Fixed sections incomplete", + "location": "CHANGELOG.md:33-56", + "description": "(a) **Removed** lists only Proof Log + QR import flow — missing: the RPC Core-backend connection mode (a whole connection mode users could previously choose; `removal-inventory.md:95-111`), the 'Total Received (DASH)' address-table column, and `RecoverAssetLocks` / `ListCoreWallets`. (b) **Known Limitations** omits: single-key per-key-password import rejection + post-upgrade invisibility of password-protected single-key wallets (PROJ-007), subtract-fee/send-max unsupported (PROJ-029), shield-from-Core source-address ignored (PROJ-031), DashPay history / settings / scheduled-votes not migrated (PROJ-032/PROJ-034). (c) **Fixed** omits the signed-message envelope change — Key Info now emits the standard recoverable Dash signed-message format instead of the old non-standard `0x20`-prefixed compact signature (`src/backend_task/wallet/sign_message_with_key.rs:13-21`); external tooling consuming the OLD format breaks.", + "recommendation": "Add the missing Removed lines, extend Known Limitations with the five undisclosed limitations, and add a Fixed line for the signing-envelope change. Mirror into the external-docs draft (PROJ-018)." + }, + { + "id": "DOC-002", + "risk": 0.2, + "impact": 0.25, + "scope": 1.0, + "title": "Proof-log removal: stale DEV-002 [Implemented] user story and persona references", + "location": "docs/user-stories.md:878", + "description": "The Proof Log screen + persistence removal is disclosed (`CHANGELOG.md:50`) but `docs/user-stories.md:878` still tags DEV-002 'View proof request log' as `[Implemented]` — violating the repo's own user-stories maintenance rule — and `docs/personas/platform-developer.md:27,75` still lists the proof log as an available tool. Proof errors now go to tracing target `\"proof_log\"` only (no restart-surviving history, not viewable in-app).", + "recommendation": "Flip DEV-002 to a removed/gap tag, fix the two persona references, and note the tracing-only replacement." + }, + { + "id": "DOC-003", + "risk": 0.2, + "impact": 0.2, + "scope": 1.0, + "title": "Promised one-time post-migration notice (invariant I3) never shipped", + "location": "docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md:194", + "description": "Three doc sites commit to in-app disclosure of the `FundWithUtxo` / QR-direct-fund removal via a one-time post-migration notice (`backendtask-contract.md:43,65`; `phasing.md:194` invariant I3 'The one-time post-migration notice text ships in the release build'; `docs/user-stories.md` IDN-014 rationale). The only post-migration banner is the generic 'Storage update complete — your wallet is ready.' (`src/app.rs:1130-1137`); no notice text about QR/scanned-outpoint funding exists in `src/`. CHANGELOG.md:51 does disclose it, but the I3 invariant as written is unmet.", + "recommendation": "Either ship the notice text in the post-migration banner, or amend I3 and the three doc sites to say 'disclosed via CHANGELOG'." + } + ] + }, + { + "title": "Test gaps", + "category": "code_quality", + "findings": [ + { + "id": "PROJ-015", + "risk": 0.3, + "impact": 0.3, + "scope": 1.0, + "title": "TC-012 receive-address reuse — unverified from DET source", + "location": "src/wallet_backend/mod.rs:593", + "description": "`next_receive_address` delegates to upstream; whether used-marking prevents address reuse cannot be proven from this tree. Now testable since PROJ-001 is resolved; needs a live-network re-test.", + "recommendation": "Add a backend-e2e live test for receive-address advancement after funds arrive." + }, + { + "id": "PROJ-016", + "risk": 0.25, + "impact": 0.3, + "scope": 1.0, + "title": "TC-066 key-not-visible-after-broadcast (flake-vs-bug) — no deterministic repro", + "location": "tests/backend-e2e/README.md:1", + "description": "Tracked-only; no isolated code surface or deterministic repro in tree. Re-classify after a live run.", + "recommendation": "Reproduce on live network post-merge; either file as a bug with a repro or close as flake." + } + ] + } + ], + "triage": { + "triaged_by": "user", + "triaged_at": "2026-06-10T12:55:18.797953+00:00", + "decisions": [ + { + "finding_id": "PROJ-027", + "action": "fix", + "rationale": "delegate fully upstream, we don't store local history other than trough platform-wallet", + "ticket": "910f8833 dc94bba6" + }, + { + "finding_id": "PROJ-026", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: ReceivedAvailableUTXOTransaction wired; QA Max reserve fix in 26c13385", + "ticket": "fe01febb 26c13385" + }, + { + "finding_id": "PROJ-005", + "action": "false_positive", + "rationale": "keep it as it is for now, we'll do it before merge" + }, + { + "finding_id": "PROJ-031", + "action": "fix", + "rationale": "wire with upstream or ask for decision if wiring is not possible", + "ticket": "08c895a8 26c13385" + }, + { + "finding_id": "PROJ-029", + "action": "fix", + "rationale": "This should be implemented, check also https://github.com/dashpay/platform/pull/3554 , if not - let me know.", + "ticket": "918b8e5f 26c13385" + }, + { + "finding_id": "PROJ-007", + "action": "fix", + "rationale": "PARTIAL 2026-06-11: T1 import-consolidation + T2 data-loss-gate + T6 password-restore shipped; T3/T4/T5 refresh/send PARKED on upstream platform-wallet register_watch_only_wallet", + "ticket": "fba925ec 01f2bb26 690d92b3 3a0e5909" + }, + { + "finding_id": "PROJ-012", + "action": "fix", + "rationale": "remove zmq", + "ticket": "255aa018 23b81718" + }, + { + "finding_id": "PROJ-009", + "action": "fix", + "rationale": "RESOLVED-WONTFIX 2026-06-11: the non-mainnet/non-account-0 legacy contact-address class never existed (account 0 hardcoded upstream); nothing stranded. PROJ-027 resolved separately.", + "ticket": "d504d09e" + }, + { + "finding_id": "DOC-001", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: CHANGELOG Removed and Known Limitations sections updated", + "ticket": "1871c59f 23b81718" + }, + { + "finding_id": "PROJ-033", + "action": "fix", + "rationale": "remove", + "ticket": "255aa018" + }, + { + "finding_id": "PROJ-038", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: recovery trail and stale copy updated", + "ticket": "1871c59f" + }, + { + "finding_id": "PROJ-035", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: stale copy and dead recovery-trail controls removed", + "ticket": "1871c59f" + }, + { + "finding_id": "PROJ-040", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: offline contact/profile reads and avatar cache implemented", + "ticket": "467dc807 dc94bba6" + }, + { + "finding_id": "PROJ-017", + "action": "fix", + "rationale": "use upstream", + "ticket": "26675766" + }, + { + "finding_id": "PROJ-036", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: dead Core-Only refresh mode removed", + "ticket": "1871c59f" + }, + { + "finding_id": "PROJ-039", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: AssetLockStatus mapped to user-facing copy", + "ticket": "1871c59f" + }, + { + "finding_id": "DOC-002", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: DEV-002 user story tag flipped and persona references corrected", + "ticket": "1871c59f" + }, + { + "finding_id": "PROJ-011", + "action": "fix", + "rationale": "Drop legacy database code, we'll handle migrations separately", + "ticket": "255aa018" + }, + { + "finding_id": "PROJ-037", + "action": "fix", + "rationale": "RESOLVED 2026-06-11: dead memo field removed", + "ticket": "1871c59f" + }, + { + "finding_id": "PROJ-032", + "action": "close_na", + "rationale": "2026-06-16 cross-check: DashPay was a Coming Soon placeholder in v0.9.3 — zero tables or persistence ever shipped. Precondition was never true. TODO removed from finish_unwire.rs." + }, + { + "finding_id": "PROJ-034", + "action": "defer", + "rationale": "2026-06-16 cross-check CONFIRMED REAL: v0.9.3 persisted settings, top_up, and scheduled_votes. 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." + }, + { + "finding_id": "PROJ-018", + "action": "defer", + "rationale": "" + }, + { + "finding_id": "PROJ-015", + "action": "defer", + "rationale": "See if https://github.com/dashpay/platform/pull/3770 addresses the issue; if not - escalate" + }, + { + "finding_id": "PROJ-041", + "action": "defer", + "rationale": "File a feature request in dashpay/platform " + }, + { + "finding_id": "DOC-003", + "action": "defer", + "rationale": "" + }, + { + "finding_id": "PROJ-016", + "action": "defer", + "rationale": "Blocked on PROJ-007 single-key send (parked on upstream); TC-066 has no deterministic repro in tree. Re-test once single-key send lands." + }, + { + "finding_id": "PROJ-019", + "action": "defer", + "rationale": "Merge-time action: the ADR wallet-state floor SHA (notes.md:92 placeholder) can only be filled with this PR's squash-merge SHA. Cannot close pre-merge." + }, + { + "finding_id": "PROJ-022", + "action": "accept_risk", + "rationale": "By design: UpstreamPlatformAddresses is a reserved swap-target seam; the unimplemented!() read arms are intentional until the upstream platform-address swap lands (parallels PROJ-010)." + }, + { + "finding_id": "PROJ-043", + "action": "fix", + "rationale": "RESOLVED 2026-06-15: shield_credits / shielded_transfer / unshield_credits / shielded_withdrawal now propagate the post-broadcast confirmation failure as dedicated typed *ConfirmationUnknown errors via ? instead of warn!+.ok(); callers skip mark_notes_spent / nonce-bump on the unverified spend (overstate self-corrects on resync, double-spend rejected by consensus). Mirrors PROJ-042 shield_from_asset_lock; unit-tested in bundle.rs." + } + ] + } +} 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 new file mode 100644 index 000000000..598940870 --- /dev/null +++ b/docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md @@ -0,0 +1,1066 @@ +# PR #860 Platform-Wallet Rewrite — Consolidated Gap Audit + +**Audit date:** 2026-06-01 — **Refreshed:** 2026-06-10 — **Triage pass:** 2026-06-11 +**Head SHA (refresh):** `a0d5034a0b573847b0786e3d538a335ef57e1281` +**Prior refresh heads:** `954ea3f8` (2026-06-08), `39e459ff` +**Original audit head:** `686430a4d2b83596fbbe716acc183a424859e11d` +**PR #860 base:** `v1.0-dev` @ `87ba5b711839219f5e1c7aee8f9de36d038866e3` +**Baseline (expanded 2026-06-10):** `origin/v0.10-dev` @ `b93b6b17` ≈ `v1.0-dev` — the +2026-06-10 pass swept **every** v0.10-dev user-facing feature for parity, not just the +seeded stub inventory. Four domain audits (wallets/core, identity/DPNS/contracts, +DashPay/tokens/shielded, MCP/settings/withdrawals) were consolidated into this document. +**Auditor:** project-reviewer-adams (READ-ONLY; this refresh touches gaps.md only) + +PR #860 rips out DET's home-grown SPV stack (`src/spv/**` deleted) and `data.db` wallet +schema and re-seats wallet/identity/DashPay/shielded state on the upstream +`dashpay/platform` `platform-wallet` crate behind a `WalletBackend` seam. It was built in +phases (P0.5 "compile floor" → P5). Several seams were intentionally landed inert +("compiles, returns `Ok(())`, wire later"). This document catalogues every such gap — dead +stubs, deferred-by-design scope cuts, real bugs, test holes, upstream blockers, and doc +gaps — each verified against the live tree with `file:line` citations. + +**2026-06-02 refresh:** re-checked every gap against current source. Eight items landed +between the original audit and `450214e5` and were verified against actual code, not commit +messages. The single CRITICAL merge-blocker (PROJ-001) is resolved on-branch. Of the six +original functional gaps, four are now RESOLVED (PROJ-003, PROJ-004, PROJ-006, plus SEC-001 +which was found and fixed in the same window). One new deferred-by-design seam (PROJ-022) +and one pre-existing convention violation (PROJ-023) were surfaced in the fresh sweep. + +I checked the inventory against the actual code. I did not take commit messages on faith, +and several "fixed" items were re-derived from the source line by line. + +**2026-06-02 disposition update:** PROJ-002 (dead `add_contact` / `remove_contact` free +functions + orphaned `NotSupported` variant) is now RESOLVED — removed by a sibling commit. +PROJ-012 was re-filed: it was mis-scoped as a benign deferred-by-design seam, but the source +shows a live wiring gap (ZMQ connection-health events flow into a void), so it moved to the +functional-gaps section and was bumped LOW → MEDIUM. + +**2026-06-03 refresh:** five more items verified RESOLVED against source at `f39b085d`, +each re-derived line by line (not taken on commit-message faith): + +- **PROJ-008** (sign-time passphrase prompt UX, issue #90) — RESOLVED. The JIT secret-access + refactor (commit range `2272bae0..43f412cf`, 24 commits) landed a per-secret prompt seam: + `src/wallet_backend/secret_prompt.rs` (the `SecretPrompt` trait + `SecretScope` / + `RememberPolicy` / retry types), `src/ui/components/secret_prompt_host.rs` (egui host), and + `src/ui/components/passphrase_modal.rs` (modal). The passphrase is requested just-in-time per + operation, keyed by scope (`HdSeed { seed_hash }` / `SingleKey { address }`), with an + optional "keep unlocked until app close" remember policy. The HD seed is decrypted on demand + and dropped immediately. +- **PROJ-013** (large-stack e2e harness) — RESOLVED (`2a9161d3`, `93a20769`). 32 MB-stack runtime + confirmed load-bearing; `#[stack_size]` rejected (recursion runs on tokio threads); `RUST_MIN_STACK` moot. +- **PROJ-020 / PROJ-021** (single-key-mock prose / CHANGELOG known-limits) — RESOLVED (`f39b085d`). +- **PROJ-023** (add-contact string error matching) — RESOLVED (`d852ce99`). A sibling + occurrence of the same anti-pattern survives in `contact_requests.rs` and is filed as the + new PROJ-025. + +PROJ-018 / PROJ-019 stay OPEN (partials that can only close at/after merge). PROJ-005 remains +the sole open merge-blocker. The tally below is recomputed **from the actual body entries**, +which are the verifiable ground truth. + +**2026-06-10 feature-parity pass (head `a0d5034a`):** four domain agents diffed the full +v0.10-dev feature surface against the working tree; every finding below was re-verified +against live source before recording. Net adds: **3 HIGH** (PROJ-026 asset-lock QR +soft-lock, PROJ-027 incoming-DashPay-payment detection gone, PROJ-028 nullifier-cursor +unit-mismatch **regression introduced by the 2026-06-10 #3828 re-pin**, commits +`4247c360`+`a0d5034a`), **7 MEDIUM** (PROJ-029..034, DOC-001), **9 LOW** +(PROJ-035..041, DOC-002, DOC-003). Corrections: PROJ-012 re-scoped (the *whole* ZMQ chain +is dead, not just health events), PROJ-007 extended + bumped LOW→MEDIUM (password-protected +single-key wallets silently vanish post-upgrade), PROJ-009 flagged incomplete +(`register_dashpay_contact` has zero callers — see PROJ-027), seed #15 cross-referenced to +the deleted QR UI tabs, and the disclosed-removal set is now itemised (RecoverAssetLocks, +ListCoreWallets, SPV peer source, Proof Log). + +--- + +## Executive summary + +| Severity | Open | Resolved | Total | +|----------|------|----------|-------| +| CRITICAL | 0 | 1 | 1 | +| HIGH | 1 | 6 | 7 | +| MEDIUM | 4 | 11 | 15 | +| LOW | 5 | 15 | 20 | +| INFO | 0 | 0 | 0 | +| **Total** | **10** | **33** | **43** | + +Open by category: upstream/release-gate = 1 (PROJ-005); +functional/unwired = 1 (PROJ-041); +deferred/partial = 2 (PROJ-007 PARTIAL, PROJ-022 accepted); +deferred-with-TODO = 1 (PROJ-034 OPEN — confirmed real data loss per v0.9.3 cross-check); +test = 2 (PROJ-015, PROJ-016); +doc = 3 (PROJ-018, PROJ-019, DOC-003 deferred-with-TODO). +Sum = 10 open (PROJ-032 CLOSED/N-A: DashPay was never persisted in any v0.9.3 release — net count unchanged, PROJ-032 removed from open and added to resolved). +(2026-06-15: PROJ-042's platform-address half moved PARTIAL → RESOLVED in PR — wallet-owned +destinations now route through the upstream orchestrator; non-owned destinations keep the +manual path by design. The finding was already in the resolved tally for its actionable +shield-half fix, so the counts are unchanged. Follow-up same day: the fee-from-wallet residual +on owned in-pool destinations is now CLOSED — that case also routes through the orchestrator +using an in-pool, watched platform-payment change recipient. Only the by-design +foreign-destination footgun and a narrow no-spare-change edge remain on the manual path; counts +unchanged.) +(2026-06-15: PROJ-043 moved OPEN → RESOLVED in PR — the four sibling shielded spend fns now +propagate the post-broadcast confirmation failure as dedicated typed `*ConfirmationUnknown` +errors via `?` instead of swallowing it, so the caller no longer marks notes spent (or bumps +the platform-address nonce) on an unverified spend. LOW open 6 → 5; total open 11 → 10.) +(2026-06-11 triage pass: 17 findings moved to RESOLVED/WONTFIX — PROJ-026/027/029/031/040/017/012/033/011/035/036/037/038/039/009/DOC-001/DOC-002; +PROJ-007 PARTIAL; PROJ-022 accepted-risk. See Resolution log.) + +(Note: the pre-2026-06-10 table under-counted HIGH at 3/13-LOW — PROJ-010 (HIGH) had been +mis-bucketed as LOW. Recomputed from body entries: 1 CRITICAL + 4 HIGH resolved-or-open +existing + the new findings above.) + +### Merge-blocker verdict (called out up top) + +**No CRITICAL merge-blocker remains open.** The release gate stands, joined by two open +HIGH parity regressions that should be fixed before ship (PROJ-028 RESOLVED 2026-06-10): + +1. **PROJ-001 (CRITICAL)** — **RESOLVED on-branch (`36f5a982`).** SPV / platform-address / + identity sync is now started across all four caller paths. See PROJ-001 section + Resolution log. +2. **PROJ-005 (HIGH)** — release gate G1: the `dash-sdk` / `platform-wallet` pin (`Cargo.toml`) + tracks an **unreleased** platform dev rev. Project policy (Decision #1) classifies this as + a release-hardening blocker, not a start blocker — but it gates *merge-to-ship*. The pin + moved again — now `rev = 4f432c9baf10eeb051e70bc0370b1b7505b7d9c5` (the 2026-06-10 re-pin + to dashpay/platform#3828, `4247c360`; was `9e1248cb…`, `ddfa66ed…`, `35e4a2f6…`, + originally `17653ba8…`) — still a dev rev, not a released tag. +3. **PROJ-026 / PROJ-027 (HIGH, 2026-06-10)** — **RESOLVED 2026-06-11**: PROJ-026 (`fe01febb` + + `26c13385`) and PROJ-027 (`910f8833` + `dc94bba6`). The third sibling, **PROJ-028** + (#3828 re-pin's shielded nullifier-cursor unit mismatch), was **RESOLVED 2026-06-10** + (`39433dac`). No open HIGH parity regressions remain. + +Everything else is fixable post-merge or is a disclosed scope cut. + +--- + +## Merge-blocking gaps + +| ID | Title | Location | Sev | Status | What's missing | +|----|-------|----------|-----|--------|----------------| +| PROJ-001 | SPV sync never driven — dead `start()`, inert `start_spv()` | `src/context/wallet_lifecycle.rs:103,130`; `src/wallet_backend/mod.rs:462-479` | CRITICAL | **RESOLVED (`36f5a982`)** | See Resolution log 2026-06-01 | +| PROJ-005 | Pin tracks unreleased platform rev (G1) | `Cargo.toml:21,31,32,35` (`rev = 9e1248cb…`) | HIGH | OPEN | Pin must move to a released platform rev before shipping. Still a dev rev. | + +(PROJ-017 — `register_identity_funding_account` absent upstream — **RESOLVED 2026-06-11** (`26675766`): load() comments scoped to per-index top-up; upstream-contribution path documented in code. Moved from this table.) + +--- + +## Functional gaps (dead stubs, no-op UI, unwired drivers, real bugs) + +### PROJ-001 — SPV / sync coordinators never started *(CRITICAL — RESOLVED)* + +Original finding (head `686430a4`): `AppContext::start_spv()` was literally `Ok(())`; +`WalletBackend::start()` was the sole site spawning the upstream sync coordinators and had +**zero callers**. Net effect was dead chain/platform-address/identity sync in every path. + +**RESOLVED 2026-06-02 verify** (`42388c4b`, `3165f98c`, `36f5a982`). Confirmed against source: +- `start_spv()` (`src/context/wallet_lifecycle.rs:103`) now drives `WalletBackend::start()`; + the chokepoint `ensure_wallet_backend_and_start_spv()` (`:130`) wires-then-starts. +- `WalletBackend::start()` (`src/wallet_backend/mod.rs:462`) latches via `start_latch.try_begin()` + and spawns `spv_arc().spawn_in_background()`, `platform_address_sync_arc().start()`, + `identity_sync_arc().start()`. +- All four caller paths funnel through the chokepoint: GUI boot (`src/app.rs:494,890,1198`), + Connect button via new `AppAction::StartSpv` (`src/app.rs:275,1613-1625`; + `src/ui/network_chooser_screen.rs:390`), MCP (`src/mcp/resolve.rs:129`), network switch + (`src/backend_task/mod.rs:559`). +- Start-path gated by four offline tests (`src/context/wallet_lifecycle.rs:561,583,617,649`). + +### PROJ-002 — DashPay `add_contact` / `remove_contact` dead free functions *(MEDIUM — RESOLVED (removed))* + +Original finding: `src/backend_task/dashpay/contacts.rs` carried two free functions — +`add_contact` and `remove_contact` — that ignored all args and returned +`DashPayError::NotSupported`, with a stale "TODO: Steps to implement" comment. + +**RESOLVED (removed 2026-06-02, sibling commit).** These were dead orphans from PR #464 +(`82399a26`): they had **zero callers** in the live tree and were superseded by the real +`DashPayTask::SendContactRequest` dispatch path. The sibling commit deletes both free +functions from `src/backend_task/dashpay/contacts.rs` along with the now-orphaned +`DashPayError::NotSupported` variant in `src/backend_task/dashpay/errors.rs` — the variant +had no other producers once the stubs were gone. No functional surface is lost: there was no +backend-task dispatch wired to either function. (Removal SHA omitted; the lead will reconcile +against the actual sibling commit if needed.) + +### PROJ-012 — entire ZMQ subsystem is dead code; "Disable ZMQ" checkbox is a placebo *(MEDIUM — RESOLVED 2026-06-11; `255aa018` + `23b81718`)* + +**RESOLVED 2026-06-11** (`255aa018` + `23b81718`): the dead ZMQ subsystem (listener, channel pair, placebo checkbox, dead consumption loop), the Dash-Qt launcher, and the legacy identity table were all removed. CHANGELOG updated in `23b81718`. Original finding follows. + +**Correction (2026-06-10):** the earlier description ("the ZMQ status producer is live but +health events flow into a void") was stale — the *whole* producer→consumer chain is dead, +not just the health-event leg: + +- **Listener never spawns:** `spawn_zmq_listener` returns `None` before spawning because + `FeatureGate::RpcBackend.is_available()` is hardcoded `false` + (`src/app.rs:845-847`; `src/model/feature_gate.rs:78` — "never active … retained as a + gate so RPC/ZMQ-only UI is hidden"). The `sx_zmq_status` clone at `src/app.rs:852` is + therefore unreachable; no `Connected` / `Disconnected` event is ever produced. +- **Unread receiver:** `rx_zmq_status` (`src/context/mod.rs:79`) is stored on `AppContext` + (`:348`) but never drained — no `recv` / `try_recv` anywhere in the tree. +- **Zero-caller setter:** `ConnectionStatus::set_zmq_status` + (`src/context/connection_status.rs:159`) has zero callers. +- **Dead consumption loop:** the islock/chainlock CoreItem consumption loop + (`src/app.rs:1502-1540`) can never receive events. +- **Placebo setting:** the "Disable ZMQ (requires restart)" checkbox is still rendered and + persisted (`src/ui/network_chooser_screen.rs:764-775`; `AppSettings.disable_zmq`, + `src/model/settings.rs:72`) but controls nothing (was GAPCMP-D-04). + +The channel pair is constructed as a unit at `src/context/mod.rs:204`. The binary fix is +unchanged in shape but wider in scope: either **wire** the chain when/if an RPC backend +returns, **or remove** the whole producer → channel → setter chain *plus* the placebo +checkbox and the dead consumption loop. This also feeds PROJ-026: with the listener gated +off, no `CoreItem` event producer exists at all. + +### PROJ-003 — `update_payment_status` is a logging no-op *(MEDIUM — RESOLVED)* + +Original: `payments.rs` `update_payment_status` was a `// TODO: Update payment record in +database` / "Would update payment…" no-op returning `Ok(())`. + +**RESOLVED 2026-06-02** (`3ac9b3b0`). Verified at `src/backend_task/dashpay/payments.rs:462`: +the function now reads the existing `PaymentEntry` from `dashpay_view().payments(owner)`, +rebuilds it (counterparty, amount, memo, direction, mapped status) and persists via +`backend.dashpay_record_payment(owner, tx_id, entry)`, stamping `confirmed_at` on +confirmation via `dashpay_set_payment_timestamps`. Signature changed +(`owner, tx_id, status`). The adjacent confirmation path is now `check_address_usage` +(`:653`) — documented BLOCKED-BY-DESIGN (see PROJ-024). + +### PROJ-004 — DashPay outgoing contact-request derivation used placeholder seed *(HIGH — RESOLVED)* + +Original: xpub derivation built from `let wallet_seed = sender_private_key;` with a "For +now… In production this would derive from the wallet's HD seed" comment. + +**RESOLVED 2026-06-02** (`6c520a33`). Verified at `src/backend_task/dashpay/contact_requests.rs:315-327`: +derivation now uses `first_open_wallet_seed(&identity)` (`:521`) → 64-byte HD seed → upstream +`crate::wallet_backend::derive_contact_xpub_material(&wallet_seed, …)` +(`src/wallet_backend/dashpay.rs:105`). A regression test proves HD-seed derivation differs +from the old private-key placeholder (`src/wallet_backend/dashpay.rs:2102-2134`). +**Follow-up SEC-001 (also resolved)**: the receive-side path hardcoded coin-type 5' on every +network, breaking testnet send/receive xpub agreement. Fixed in `450214e5` via +`coin_type_for_network()` (`src/model/wallet/mod.rs:50`) threaded through every DashPay HD +path (DIP-14 contact xpub, DIP-15 root, auto-accept proof, contact_info, contacts, +incoming_payments). Full broadcast→association still warrants a live-network test, but the +DET-side derivation is now production HD. + +### PROJ-006 — `context_provider_spv` activation-height TODO *(LOW — RESOLVED)* + +Original: `// TODO: wire actual activation height if needed` with `Ok(1)` for all networks. + +**RESOLVED 2026-06-02** (`7e2553e3`). Verified at `src/context_provider_spv.rs:128-139`: +`get_platform_activation_height()` now returns real per-network Core heights +(Mainnet 2_132_092, Testnet 1_090_319, Devnet/Regtest 1), mirroring the SDK's trusted +context provider; the previously-ignored `network` field is now used. + +### PROJ-026 — Create-Asset-Lock QR funding flow soft-locks at "Waiting for funds…" *(HIGH — RESOLVED 2026-06-11; `fe01febb` + `26c13385`)* + +**RESOLVED 2026-06-11** (`fe01febb` + QA fix `26c13385`): `ReceivedAvailableUTXOTransaction` is now emitted from the core module so the asset-lock funding flow advances on fund arrival. The QA fix also reserves Max against spendable balance. Original finding follows. + +- **v0.10-dev:** `src/ui/wallets/create_asset_lock_screen.rs:519` (OLD) polled + `funding_common::capture_qr_funding_utxo_if_available` (OLD + `src/ui/identities/funding_common.rs:56-94`) every frame; the helper watched + `wallet.utxos` at the displayed funding address and flipped the step machine + `WaitingOnFunds → FundsReceived`, which dispatched + `CoreTask::CreateRegistrationAssetLock` / `CreateTopUpAssetLock`. +- **PR #860:** the poll and helper are deleted. The **only** remaining + `WaitingOnFunds → FundsReceived` transition is the `display_task_result` arm matching + `CoreItem::ReceivedAvailableUTXOTransaction` + (`src/ui/wallets/create_asset_lock_screen.rs:650-666`, flip at `:659`) — and that + `CoreItem` variant has **zero producers** in the tree (`src/backend_task/core/mod.rs:106` + is definition-only; `received_transaction_finality` always returns `Ok(Vec::new())`, + `src/context/transaction_processing.rs:17-30`; the ZMQ listener that once produced + CoreItems never spawns — see PROJ-012). The screen is reachable: Asset Locks tab → + "Create Asset Lock" (`src/ui/wallets/wallets_screen/asset_locks.rs:69`). Net effect: + after entering an amount, the user is shown a QR + "Waiting for funds..." + (`create_asset_lock_screen.rs:558-560`) **forever** — even after they send real DASH to + the shown address. Funds are recoverable (they land in the wallet's normal SPV balance); + only the flow dead-ends, after a real payment was solicited. +- **Fix direction:** detect the arriving UTXO from the `WalletBackend` snapshot at the + funding address (the pattern `add_new_wallet_screen.rs` already uses via + `snapshot_balance`), or emit the event from `EventBridge`. +- **Related dead code:** sibling match arms on the same producer-less `CoreItem` survive in + `src/ui/identities/add_new_identity_screen/mod.rs:1144` and + `src/ui/identities/top_up_identity_screen/mod.rs:488` — unreachable but harmless there, + since the identity QR-funding tabs were removed (seed #15). +- **Disclosure status:** NOT covered by the CHANGELOG "QR-code wallet import flow" removal + line — this screen kept the QR UI and lost only the detection plumbing. + +### PROJ-027 — Incoming DashPay contact payments are never detected, recorded, or credited *(HIGH — RESOLVED 2026-06-11; `910f8833` + `dc94bba6`)* + +**RESOLVED 2026-06-11** (`910f8833` + `dc94bba6`): incoming contact payment detection wired and recording implemented; per-output payment keying and related QA fixes in `dc94bba6`. Original finding follows. + +**FOLLOW-UP 2026-06-17** (watch-registration faucet): the 2026-06-11 fix wired only the +*detection* half (`EventBridge` -> `detect_incoming_contact_payments`); the *watch-registration* +half it depends on was left unwired. `register_dashpay_contact` cannot be the mechanism: the DIP-15 +receiving path `m/9'/coin'/15'/0'/owner/friend` is hardened, so upstream +`IdentityWallet::register_contact_account` -> `derive_extended_public_key` requires `can_sign()` +and fails on the **watch-only** wallets DET loads at boot. Registration must derive the account +xpub from the JIT seed and insert the managed `DashpayReceivingFunds` account directly (contained +seed-bearing dual-insert, sibling to `provision_identity_funding_account`). Only newly-added +accounts trigger `bump_monitor_revision` to avoid spurious bloom-filter rebuilds on idempotent +re-runs. SPV watching caveat: `monitor_revision` bumps cause the dash-spv mempool sync manager to +rebuild the peer bloom filter on its next 100ms tick — but only when the manager is in +`SyncState::Synced`. If registration occurs *before* SPV sync completes, the accounts are already +in the wallet when `activate_all_peers` fires the initial `FilterLoad` at +`SyncEvent::FiltersSyncComplete`, so the addresses are watched regardless of sync phase. Wired into +`bootstrap_wallet_addresses_jit` (every cold boot / unlock, inside the existing seed scope — +locked wallets skipped, never prompts; idempotent; upstream stores the account in runtime state +only, so re-running every boot is the mechanism, not a redundancy). +Residual: per-contact *attribution* into DashPay payment history still depends on the DET sidecar +address-map (`dashpay_set_address_mapping`); balance/spendability does not. + +**FOLLOW-UP 2026-06-17 — QA-025 (HIGH — RESOLVED `d34ffae4`)**: `send_contact_request_with_proof` +and `accept_contact_request` called `sdk.document_create` but never wrote the sent request to +`ManagedIdentity.sent_contact_requests` locally. Consequence: the upstream auto-establishment gate in +`add_incoming_contact_request` only promotes to `established_contacts` when +`sent_contact_requests[peer]` already exists; since DET never recorded it, `dashpay_sync` never +auto-populated `established_contacts` → `established_contact_pairs()` always returned `[]` → +`register_contact_receiving_accounts` silently no-op'd → all users who sent a contact request from +DET had no addresses watched for incoming DashPay payments. Fix: after `document_create` succeeds, +call the new `WalletBackend::record_sent_contact_request(seed_hash, owner_id, contact_request)` which +acquires the wallet-manager write lock and calls `managed.add_sent_contact_request(…, persister)`. +`accept_contact_request` → `send_contact_request_with_proof` inherits the fix automatically. + +- **v0.10-dev:** the ZMQ tx-finality path auto-detected payments to DashPay contact receive + addresses, credited the UTXO, advanced the receive index, and recorded a "received" + payment-history row — OLD `src/context/transaction_processing.rs:183` (`insert_utxo`), + `:192` (`add_to_address_balance`), `:213` (`get_dashpay_address_mapping`), `:219` + (`update_highest_receive_index`), `:227` (`save_payment(..., "received")`), driven from + OLD `src/app.rs:1170,1188`. +- **PR #860:** the detection chain exists only as dead code with **zero callers**: + `process_incoming_payment` (`src/backend_task/dashpay/incoming_payments.rs:349`) and its + callee `mirror_incoming_payment_to_backend` (`src/backend_task/dashpay/payments.rs:592`) + are never invoked. The new `received_transaction_finality` + (`src/context/transaction_processing.rs:17-30`) handles asset-lock waiters only; the + upstream `EventBridge` has no DashPay hook (`src/wallet_backend/event_bridge.rs:121-160`). + `RegisterDashPayAddresses` writes contact receive addresses only into DET's **in-memory** + wallet model (`incoming_payments.rs:298-314`), which nothing feeds into the upstream SPV + watch set; and `WalletBackend::register_dashpay_contact` (`src/wallet_backend/mod.rs:1250`) + — the seam meant to register contact accounts upstream (Decision #6) — has **zero + callers**. +- **User impact:** funds a contact sends arrive at addresses DET no longer watches — + invisible in wallet balance and absent from payment history, on **all** networks and + accounts. Strictly wider than the disclosed PROJ-009 DIP-14 carve-out + (non-mainnet/non-account-0 only). Funds are not destroyed (derivable from seed) but are + invisible/unspendable in this version. Contradicts the design docs, which state + incoming-payment detection is retained DET-side + (`docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md:46`, + `open-questions.md:56`). +- **Fix direction:** call `register_dashpay_contact` when contacts are established (so + upstream watches the DIP-15 account) and wire a finality/EventBridge hook to + `process_incoming_payment`, or delegate detection fully upstream and mirror results. + +### PROJ-028 — Shielded nullifier-cursor unit mismatch: #3828 re-pin regression breaks spend detection for migrated wallets *(HIGH — RESOLVED; 2026-06-10, was GAPCMP-C-2)* + +**RESOLVED 2026-06-10** (`39433dac`; doc follow-up this commit). The migration no longer +carries the legacy block-height cursor: `finish_unwire.rs` now resets +`last_nullifier_sync_height` to 0 for migrated wallets (`SELECT … 0, 0`), so the next scan +re-walks the note tree from position 0 and re-derives the spent set. `tc_sh_002` flipped to +assert the reset; new `migrated_cursor_reset_lets_scan_flip_spent_note` pins the end-to-end +fix. Smythe QA: SHIP, funds-safe (reset-to-0 only flips notes to spent and is idempotent). + +**This is a regression introduced on-branch by the 2026-06-10 #3828 re-pin** +(`4247c360` + `a0d5034a`), NOT a pre-existing platform-wallet gap. It post-dates the +2026-06-08 refresh. Cross-link: follow-up todo `1ff97ad7` (post-#3828 QA follow-ups). + +- **v0.10-dev (and pre-re-pin) semantics:** `src/backend_task/shielded/nullifiers.rs:37-80` + (OLD) called `sdk.sync_nullifiers(&unspent_nullifiers, …, NullifierSyncCheckpoint { height, … })` + where `new_sync_height` was a **platform block height** (rs-sdk + `nullifier_sync/types.rs:104-117` @ `9e00c8b`), and the API re-checked the full + unspent-nullifier list with an internal full-rescan fallback. +- **PR #860 (post-re-pin):** the port reinterprets the **same persisted column** + `last_nullifier_sync_height` as a note-tree **position**: + `src/backend_task/shielded/nullifiers.rs:48` + (`aligned_start = (last_nullifier_sync_height / CHUNK_SIZE) * CHUNK_SIZE`), scanned via + `sync_shielded_notes(…, aligned_start, None)`; the cursor is re-written as + `aligned_start + total_notes_scanned` (`:84-86`). The T-SH-02 migration copies the legacy + value **verbatim** (`src/backend_task/migration/finish_unwire.rs:720-735`), and the test + `tc_sh_002_sync_cursor_preserved` (`:1988`) asserts `1_234_567` is preserved — the test + enshrines the bug. The re-pin commit message claims "the resume cursor is preserved"; + preserving it is precisely the defect. +- **User impact:** any wallet that ran nullifier sync under the old semantics carries a + block-height-scale cursor ≫ note-tree size. The scan starts past the tree tip, returns + zero notes, detects no spends, and re-persists the same bogus cursor — permanently. + Spent notes stay "unspent": shielded balance overstated, spends select burned notes and + fail at consensus. No self-heal, no surfaced error. +- **Fix direction:** version the cursor (reset to 0 / re-base when a legacy block-height + value is detected — e.g. one-time reset during T-SH-02, or a sidecar `cursor_kind` + column), and fix `tc_sh_002` to assert the *re-based* value. + +### PROJ-029 — "Subtract fee from amount" / Max button are guaranteed-error paths for Core sends *(MEDIUM — RESOLVED 2026-06-11; `918b8e5f` + `26c13385`)* + +**RESOLVED 2026-06-11** (`918b8e5f` + `26c13385`): client-side Max for Core sends implemented; unsupported subtract-fee option removed from UI. Original finding follows. + +- **v0.10-dev:** subtract-fee was implemented in both send backends (RPC + `build_multi_recipient_payment_transaction(..., subtract_fee_from_amount)`; SPV + amount-scaling fallback on `BuilderError::InsufficientFunds`). +- **PR #860:** `src/backend_task/core/mod.rs:266-268` hard-rejects + `subtract_fee_from_amount || override_fee.is_some()` with typed + `TaskError::WalletPaymentOptionUnsupported` (deliberate "no silent ignore", comment + `:255-265`). BUT the UI still offers the option: checkbox at + `src/ui/wallets/send_screen.rs:2405-2411` and `src/ui/wallets/wallets_screen/dialogs.rs:220` + (sent at `:1116`), and the **Max button auto-enables it** + (`send_screen.rs:2383-2389` — "When Max is clicked for Core wallet, automatically enable + subtract_fee"). "Send Max" from a Core wallet and any ticked-checkbox send therefore + always fail. No funds at risk (rejected before broadcast); the lost capability + (send-max / fee-from-amount) plus the guaranteed-dead-end affordance is the gap. Not in + CHANGELOG Known Limitations. +- **Fix direction:** remove/disable the checkbox and the Max auto-enable for Core sends + until upstream `send_to_addresses` supports fee deduction, and disclose the limitation. + +### PROJ-030 — Shielded "Resync Notes" keeps the nullifier watermark — previously-spent notes resurrect as unspent *(MEDIUM — RESOLVED; 2026-06-10, was GAPCMP-C-3)* + +**RESOLVED 2026-06-10** (`39433dac`; doc follow-up this commit). The resync handler +(`shielded_tab.rs`) now calls `delete_shielded_wallet_meta` alongside the note drop, resetting +the nullifier cursor to 0 so a resync re-derives the spent set from position 0. Regression test +`resync_sequence_resets_nullifier_cursor` pins the two-call sequence. Smythe QA: SHIP. + +- **v0.10-dev:** resync also kept the checkpoint, but the old API re-checked the explicit + unspent-nullifier set (with full-rescan fallback), so historical spends were re-flagged. +- **PR #860:** the resync handler deletes notes + the commitment tree but **not** the + cursor (`src/ui/wallets/shielded_tab.rs:744-772` — no `delete_shielded_wallet_meta` + call; that function is only invoked on wallet removal, `src/wallet_backend/mod.rs:806`). + Under the new scan-from-watermark model (PROJ-028, `nullifiers.rs:48`), rebuilt notes + come back `is_spent: false` and `check_nullifiers` scans only `[old tip, tip]`, missing + every historical spend → balance includes previously-spent notes; spends fail. +- **Fix direction:** one call — invoke `delete_shielded_wallet_meta` (it exists and is + tested: `src/wallet_backend/shielded.rs:255,738`) in the resync handler. Same root + family as PROJ-028. + +### PROJ-031 — Shield-from-Core silently ignores the selected source address (coin control lost; UI and MCP misleading) *(MEDIUM — RESOLVED 2026-06-11; `08c895a8` + `26c13385`)* + +**RESOLVED 2026-06-11** (`08c895a8` + `26c13385`): unsupported source-address selection removed from the shield UI and MCP tool. Original finding follows. + +- **v0.10-dev:** OLD `src/context/shielded.rs:613-625` threaded `source_address` into + asset-lock UTXO selection — shielding could be restricted to one Core address. +- **PR #860:** dispatch discards it — `src/context/shielded.rs:242-252` + (`source_address: _`, "coin selection is delegated to the upstream wallet's … + live UTXO set"). Yet the UI still lets the user pick a specific Core address, shows + "Available address balance" for it and validates the amount against that address + (`src/ui/wallets/shield_screen.rs:786-812`), then sends `source_address: None` + (`:974-985`); the MCP tool `shielded_shield_from_core` still accepts and silently drops + the param (`src/mcp/tools/shielded.rs:33,86-108`). +- **User impact:** privacy-expectation violation in a privacy feature — the asset lock may + link UTXOs from addresses the user explicitly tried to exclude; amount caps are computed + against the wrong balance. Not in CHANGELOG Known Limitations. +- **Fix direction:** if per-address selection cannot be honored upstream, remove the + selector/per-address balance display and the MCP param, and disclose; otherwise thread + the parameter through. + +### PROJ-032 — Legacy DashPay user data not migrated: payment history, nicknames/notes/hidden flags, send-address indices *(MEDIUM — CLOSED / RESOLVED-N-A)* + +**CLOSED 2026-06-16 — precondition never true in any shipped release.** + +v0.9.3 is the latest release. Cross-check of the v0.9.3 source tree confirms that DashPay +was a literal "Coming Soon" placeholder (`dashpay_coming_soon_screen.rs`). **Zero DashPay +tables or persistence code ever shipped in any DET release.** There is no user data to +migrate, for any user on any release. The TODO comment that anchored this finding +(`src/backend_task/migration/finish_unwire.rs`) has been removed. + +- **Original concern:** `src/database/dashpay.rs` (present in PR #860's pre-unwire ancestor + branch) was assumed to represent previously-shipped persistence. It did not — the file was + developed on the feature branch and deleted on the same branch; it never appeared in a + released binary. +- **`data-model-and-migration.md:58`** contradiction ("DET payment-history / avatar cache + retained DET-side") is stale design-intent prose from the pre-release plan, not a + description of shipped behaviour. No correction needed to docs because the design-intent + context is already marked superseded in that file. +- **No action required.** No migration needed; no CHANGELOG disclosure needed; no follow-up. + +### PROJ-033 — Dash-Qt launcher unreachable while its settings cluster survives *(MEDIUM — RESOLVED 2026-06-11; `255aa018`)* + +**RESOLVED 2026-06-11** (`255aa018`): Dash-Qt launcher task and settings cluster removed along with the rest of the dead ZMQ/RPC surface. Original finding follows. + +- **v0.10-dev:** two UI launch paths for `CoreTask::StartDashQT` — RPC-mode Connect button + (OLD `network_chooser_screen.rs:633-648`) and connection-indicator click (OLD + `top_panel.rs:168-195`). +- **PR #860:** `CoreTask::StartDashQT` still exists (`src/backend_task/core/start_dash_qt.rs`) + but has **zero UI callers** (verified: no `StartDashQT` reference outside + `src/backend_task/core/`). Meanwhile Settings still renders the full Dash-Qt cluster: + "Dash Core Executable Path" Select File/Clear (`src/ui/network_chooser_screen.rs:646-736`), + "Overwrite dash.conf" (`:744`), "Close Dash-Qt when DET exits" (`:899-933`) — three + settings configuring a feature that can never fire. +- **Fix direction:** re-wire a launch affordance (regtest/devnet workflows) or remove the + settings cluster + task. + +### PROJ-034 — App settings, top-up history, and scheduled DPNS votes all reset/empty on upgrade (no non-wallet data migration) *(MEDIUM — OPEN; deferred-with-TODO `727e8d6a`)* + +**CONFIRMED REAL per v0.9.3 cross-check (2026-06-16).** v0.9.3 persisted all three: +`settings` (network, theme, custom dash_qt_path, start screen, …), `top_up` (identity_id, +top_up_index, amount), and `scheduled_votes` (identity_id, contested_name, vote_choice, +time, executed, network). Real user data is silently lost on upgrade. **Follow-up priority: +scheduled DPNS votes (vote-window deadline risk) > app settings (UX friction) > top-up +history (audit trail).** + +- **v0.10-dev / v0.9.3:** settings persisted in `data.db` `settings` (network, root screen, + dash_qt_path, theme_mode, onboarding flag, user_mode, …; OLD `src/database/settings.rs`, + `src/model/settings.rs:28-45`); top-up history and scheduled DPNS votes in legacy SQLite. +- **PR #860:** settings moved to k/v `det:settings:v1` in `det-app.sqlite` + (`src/model/settings.rs:63-88`; `src/context/settings_db.rs`) with **no importer** + (`db.get_settings` has zero callers; the migration drains only + `LEGACY_TABLES` = wallets/secrets, `src/backend_task/migration/finish_unwire.rs`). + Commit `e4ff9621` discloses "Existing users get default AppSettings on first launch … + migration tool will import in a later PR" — that later PR never landed. Top-up history + and scheduled votes likewise start empty (commit `7778eb64`: "Existing users get empty + state for all three"). +- **User impact at upgrade:** selected network resets to **Mainnet** (a testnet user + relaunches into mainnet), theme/paths/onboarding/user-mode reset; silently dropped + **scheduled DPNS votes can mean missed votes** for masternode voters. One-time, no fund + loss. Disclosed in commit messages only — not in CHANGELOG (feeds DOC-001). +- **Fix direction:** add a settings/k-v importer to the cold-start migration (at minimum: + network, theme, onboarding flag, scheduled votes), or disclose prominently. Scheduled-vote + migration is highest priority given the vote-window deadline risk. + +### PROJ-042 — Non-identity asset-lock flows bypass upstream orchestration: post-broadcast recovery gap *(MEDIUM — RESOLVED IN PR: shield-from-asset-lock false-success fixed; platform-address funding now routes wallet-owned destinations through the upstream orchestrator)* + +Identity asset-lock flows route through upstream `IdentityWallet::*_with_funding`, which runs +the full resolve → `submit_with_cl_height_retry` → `consume_asset_lock` pipeline. Three +**non-identity** asset-lock flows built the asset lock upstream but then submitted the Platform +transition manually, missing the post-asset-lock / pre-final-accounting recovery upstream owns: + +- **`src/backend_task/shielded/bundle.rs` — `shield_from_asset_lock`:** built the Type 18 + `ShieldFromAssetLock`, broadcast it, then **swallowed** a post-broadcast `wait_for_response` + failure as a `warn!` + `.ok()`, falling through to `Ok(shield_amount_credits)`. A + confirmation failure thus reported success even though the credits may not have reached the + pool — and the locked Core funds back a single-use asset lock that resumes the same shield + on retry. +- **`src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs`** and + **`src/backend_task/wallet/fund_platform_address_from_asset_lock.rs`:** called the SDK + `TopUpAddress::top_up` directly. Submit failures **do** propagate via `?` (no false + success), but a successful top-up never marked the tracked lock `Consumed`, so it stayed + resumable, and there was no CL-height retry on a submit-time consensus 10506. + +- **Resolved in PR (shield):** the shield-from-asset-lock false-success. The post-broadcast + confirmation failure now maps to a dedicated typed `TaskError::ShieldedConfirmationUnknown` + (`#[source] Box`, no `String` message field) that surfaces "your funds were + sent but the confirmation could not be verified — wait, then refresh before sending again". + Unit-tested in `bundle.rs` (`confirmation_failure_maps_to_unknown_confirmation_error`, + `unknown_confirmation_message_is_actionable_and_jargon_free`). +- **Resolved in PR (platform addresses):** both platform-address funding flows now branch on + **upstream pool membership** — no DET-side index window or magic constant. + `WalletBackend::platform_address_in_pool` resolves the wallet and asks upstream's own + `ManagedPlatformAccount::contains_platform_address` (account 0), the exact generation-based + check the orchestrator's pre-flight runs. An in-pool destination routes through + `WalletBackend::fund_platform_address` → `PlatformAddressWallet::fund_from_asset_lock` (`pub` + on the public `PlatformWallet` at rev `4f432c9`), which owns the full resolve → + `submit_with_cl_height_retry` → IS→CL fallback → `consume_asset_lock` pipeline. DET reaches + it via the existing private `WalletBackend::resolve_wallet` + the public + `PlatformWallet::platform()` getter, with `DetPlatformSigner` as the `Signer` + and `DetSigner` as the `key_wallet::signer::Signer`. The wallet-UTXO path uses + `AssetLockFunding::FromWalletBalance` (orchestrator builds + broadcasts the lock — the manual + `create_asset_lock_proof` + `top_up` two-step is dropped on this branch); the tracked-lock + path uses `AssetLockFunding::FromExistingAssetLock { out_point }`. Orchestrator errors map to + a dedicated `TaskError::PlatformAddressFundRejected` (`#[source] Box`, + no `String` message field). Pool-membership semantics, the mixed-ownership routing rule, and + error mapping are unit-tested (`upstream_pool_membership_distinguishes_in_pool_from_foreign`, + `route_to_orchestrator` cases, `map_platform_address_fund_error_*`). +- **Resolved in PR (fee-from-wallet residual):** funding your **own in-pool** address with + **fee-from-wallet** now also routes through the orchestrator. The branch sources the change + recipient from the wallet's own **watched platform-payment addresses** (every one is inside + DET's synced provider window — the `0a64be55` funds-safety invariant) and gates each through + the same `WalletBackend::platform_address_in_pool` check the orchestrator's pre-flight runs. + When a distinct in-pool, watched change address exists, the two-output map + (`{destination: Some(amount_credits), change: None}` with `ReduceOutput(change_index)`) + satisfies the orchestrator's exactly-one-`None` / all-in-pool contract, so the whole + fee-from-wallet flow gains orchestrated recovery (CL-height retry, IS→CL fallback, + `consume_asset_lock`). The lock is sized to `amount + estimated_fee` so the change absorbs + exactly the fee budget — identical amount/fee semantics to the manual path. No reveal or pool + advance happens: only already-revealed (watched + in-pool) addresses are used as change. + Change selection is a pure, unit-tested helper (`select_in_pool_change`: + `change_picker_returns_first_non_destination_candidate`, `change_picker_skips_the_destination`, + `change_picker_returns_none_without_a_distinct_candidate`). +- **Two residuals on the manual fallback, stated honestly:** + - *By design (footgun):* a **foreign / non-pool** destination — funding an address the wallet + does not watch (advanced send, MCP/CLI) — keeps the manual `TopUpAddress` path. The + orchestrator's pre-flight requires recipients to be in this wallet's platform-payment pool, + and credits sent to an unwatched address are recoverable only by that key's holder. + - *Narrow edge:* a fee-from-wallet top-up to an in-pool destination when the wallet has **no + other** watched, in-pool platform-payment address to serve as change falls back to the manual + path (which derives a fresh change address). This only occurs for a wallet whose single + revealed platform-payment address is the destination itself; revealing any second receive + address closes it on the next attempt. + - Both manual fallbacks still propagate submit failures via `?` (no false success). + +### PROJ-043 — Sibling shielded spend fns marked notes spent on an unverified post-broadcast confirmation *(LOW — RESOLVED in PR)* + +PROJ-042 fixed the post-broadcast confirmation swallow only in `shield_from_asset_lock`. The +four sibling spend fns in the same file carried the identical pattern: they `warn!` + `.ok()` +the `wait_for_response` failure after a successful broadcast and returned `Ok(...)`. + +- **`src/backend_task/shielded/bundle.rs`:** `shield_credits` (returned `Ok(())`), + `shielded_transfer`, `unshield_credits`, `shielded_withdrawal` — the latter three returned + `Ok(spent_nullifiers)`. +- **Effect (before):** the caller path took the `Ok` branch and called `mark_notes_spent` + (`src/context/shielded.rs`), which persists each nullifier via `mark_shielded_note_spent`. + So notes were marked spent for spends whose state transition may never have confirmed + (`shield_credits` instead bumped the platform-address nonce on the unverified spend). +- **Why it was LOW (self-heals):** the divergence was local and temporary. The next + nullifier/note resync (`check_nullifiers`) reconciles spent state against the chain, so a + note wrongly hidden would resurface; no permanent note or fund loss. +- **Fix (this PR):** each fn now maps the post-broadcast `wait_for_response` failure through a + dedicated typed error and propagates it via `?` instead of swallowing it: + `TaskError::ShieldCreditsConfirmationUnknown`, `ShieldedTransferConfirmationUnknown`, + `UnshieldConfirmationUnknown`, `ShieldedWithdrawalConfirmationUnknown` (each `#[source] + Box`, no `String` message field; jargon-free, actionable "wait then refresh" + message). Because the callers commit side effects only on `Ok`, propagating the error means + `mark_notes_spent` is skipped (transfer/unshield/withdrawal) and the platform-address nonce + is not bumped (`shield_credits`). The notes stay unspent locally — the balance is briefly + *overstated* rather than understated. +- **Safety reasoning:** if the broadcast actually landed on-chain, a retry rebuilds a spend of + the same notes; consensus rejects the already-revealed nullifier (double-spend), so funds are + never at risk, and the next `check_nullifiers` resync observes the on-chain nullifier and + marks the notes spent — self-correcting the overstate through routine sync. This mirrors the + `shield_from_asset_lock` precedent (PROJ-042). The change is the minimal swallow→propagate + fix; it does not touch the resync state machine. +- **Tests:** `src/backend_task/shielded/bundle.rs` unit tests assert each mapper yields its + typed variant and that every message is actionable (wait + refresh) and jargon-free. + +### New LOW parity deltas (2026-06-10) + +| ID | Title | Location (PR #860) | v0.10-dev evidence | Status / what's missing | +|----|-------|--------------------|--------------------|--------------------------| +| PROJ-035 | In-app copy directs users to the removed "Local Dash Core node" setting; phantom "Refresh" button referenced | `src/ui/wallets/wallets_screen/single_key_view.rs:13` (`SINGLE_KEY_REQUIRES_CORE`), `:128`; `src/ui/components/tools_subscreen_chooser_panel.rs:141` | OLD settings exposed the RPC-vs-SPV `CoreBackendMode` selector | **RESOLVED 2026-06-11** (`1871c59f`) — stale copy updated and dead recovery-trail controls removed. (was GAPCMP-A-04) | +| PROJ-036 | Wallet "Refresh" in Core-Only mode is a silent no-op | `src/backend_task/core/mod.rs:191-213`; mode toggle `src/ui/wallets/wallets_screen/mod.rs:77-100` | OLD `RefreshWalletInfo` ran `reconcile_spv_wallets()` / RPC re-poll | **RESOLVED 2026-06-11** (`1871c59f`) — dead Core-Only refresh mode removed. (was GAPCMP-A-07) | +| PROJ-037 | Send-dialog "Memo (optional)" field goes nowhere (pre-existing) | `src/ui/wallets/wallets_screen/dialogs.rs:224-225`; `src/backend_task/core/mod.rs:255-303` never reads `memo` | OLD also never consumed `WalletPaymentRequest.memo` in HD sends | **RESOLVED 2026-06-11** (`1871c59f`) — dead memo field removed. (was GAPCMP-A-09) | +| PROJ-038 | Failed wallet-funded identity registration leaves no visible local record; retry-adoption semantics changed | `src/backend_task/identity/register_identity.rs:23,209-231` (placeholder-id skip; only the Platform-addresses path still persists `FailedCreation`, `:394`); `src/wallet_backend/mod.rs:1910-1922` (`IdentityAlreadyExists` → generic bucket) | OLD pre-derived the real id, persisted `PendingCreation`/`FailedCreation` rows (OLD `register_identity.rs:258-295,382-423`) and silently adopted an already-registered identity on retry | **RESOLVED 2026-06-11** (`1871c59f`) — recovery trail and UI copy updated to surface the unused-asset-lock resume path. (was GAPCMP-B-1/B-2) | +| PROJ-040 | DashPay offline caches dropped — contacts/requests/profiles/avatars need network on every open | `src/ui/dashpay/contacts_list.rs:67,111-134`; `contact_requests.rs:295-297`; avatar-bytes cache dropped (`profile_screen.rs` comment) | OLD rendered instantly from `data.db` (`contacts_list.rs:113-180`, `contact_requests.rs:162-250`, avatar bytes + negative-profile caching) | **RESOLVED 2026-06-11** (`467dc807` + `dc94bba6`) — offline contact/profile reads and avatar cache implemented; cache invalidation and bounds fixed in `dc94bba6`. (was GAPCMP-C-6) | +| PROJ-041 | "Stop tracking balance" undone by "Refresh My Tokens"; watch set became identities × all-known-tokens | `src/backend_task/tokens/query_my_token_balances.rs:39-44,100-105` (re-registers `known_token_ids` for every identity), `:62-83` (unwatch) | OLD refreshed only pairs already in `identity_token_balances` (OLD `:27-44`) | OPEN — dismissed rows reappear after any refresh; rows appear for never-tracked pairs. Disclosed in code comments only. Evolution of already-resolved #5. Deferred-with-TODO (`727e8d6a`). (was GAPCMP-C-7) | +| PROJ-042 | Non-identity asset-lock flows bypass upstream orchestration: shield-from-asset-lock falsely confirmed on a post-broadcast confirmation failure; platform-address top-ups never mark the lock `Consumed` | `src/backend_task/shielded/bundle.rs` (`shield_from_asset_lock`, post-broadcast `wait_for_response`); `src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs` (`top_up`); `src/backend_task/wallet/fund_platform_address_from_asset_lock.rs` (`top_up`) | Identity asset-lock flows route through `IdentityWallet::*_with_funding`; these three never did | **RESOLVED in PR** — shield-from-asset-lock false-success fixed (typed `TaskError::ShieldedConfirmationUnknown`, `#[source]`; unit-tested). Platform-address funding now gates on **upstream pool membership** (`WalletBackend::platform_address_in_pool` → upstream `ManagedPlatformAccount::contains_platform_address`, no DET-side constant); in-pool destinations route through `WalletBackend::fund_platform_address` → `PlatformAddressWallet::fund_from_asset_lock` (full resolve → `submit_with_cl_height_retry` → IS→CL → `consume_asset_lock`); errors map to `TaskError::PlatformAddressFundRejected` (`#[source]`). Membership + mixed-ownership routing + error mapping unit-tested. **Fee-from-wallet residual now CLOSED**: funding your own in-pool address with fee-from-wallet also routes through the orchestrator, sourcing an in-pool, watched platform-payment change recipient (gated through `platform_address_in_pool`) so the two-output map satisfies the orchestrator's one-`None` / all-in-pool contract; change selection is a pure, unit-tested helper (`select_in_pool_change`). One by-design residual remains (foreign/non-pool destinations — footgun) plus a narrow edge (an in-pool destination that is the wallet's only revealed platform-payment address has no distinct change → manual fallback). Manual submit failures still propagate via `?`, so no false-success remains. | +| PROJ-043 | Four sibling shielded spend fns swallowed the same post-broadcast confirmation failure, so notes were marked spent for spends that may never confirm | `src/backend_task/shielded/bundle.rs` (`shield_credits`, `shielded_transfer`, `unshield_credits`, `shielded_withdrawal`) — all `warn!`+`.ok()` then `Ok(...)`; `mark_notes_spent` at `src/context/shielded.rs` persists via `mark_shielded_note_spent` | Same swallow pattern PROJ-042 fixed for `shield_from_asset_lock` | **RESOLVED in PR** — the four fns now map the post-broadcast `wait_for_response` failure to dedicated typed errors (`ShieldCreditsConfirmationUnknown`, `ShieldedTransferConfirmationUnknown`, `UnshieldConfirmationUnknown`, `ShieldedWithdrawalConfirmationUnknown`; `#[source] Box`, no String message field) and propagate via `?`. Callers commit side effects only on `Ok`, so `mark_notes_spent` (transfer/unshield/withdrawal) and the nonce bump (`shield_credits`) are skipped on an unverified spend — balance is briefly overstated, not understated. Retry is double-spend-rejected by consensus and the next `check_nullifiers` resync reconciles the spent state, so funds are never at risk. Mirrors the PROJ-042 `shield_from_asset_lock` fix; unit-tested. | + +--- + +## Deferred-by-design / disclosed trade-offs + +Intentional scope cuts, recorded so reviewers do not mistake them for oversights. All trace +to a written decision in `docs/ai-design/2026-05-18-platform-wallet-migration/`. + +| ID | Title | Location | Sev | Status | Decision ref | +|----|-------|----------|-----|--------|--------------| +| PROJ-007 | Single-key refresh + SPV-send return `SingleKeyWalletsUnsupported`; password-protected single-key wallets silently vanish post-upgrade | `src/backend_task/core/mod.rs` (`CoreTask::RefreshSingleKeyWalletInfo` / `CoreTask::SendSingleKeyWalletPayment` arms); `src/ui/wallets/import_mnemonic_screen.rs:118-126`; `src/backend_task/migration/finish_unwire.rs:120-134,377-389`; `src/wallet_backend/single_key.rs:363` | MEDIUM (bumped 2026-06-10, was LOW) | **PARTIAL 2026-06-11** (`fba925ec` + `01f2bb26` + `690d92b3` + `3a0e5909`): T1 import-consolidation + T2 data-loss-gate + T6 password-restore shipped and security-reviewed; T3/T4/T5 refresh/send PARKED on upstream `platform-wallet register_watch_only_wallet`. | Decision #7 (`single-key-mock.md`) + T-SK-03 | +| PROJ-008 | SEC-002 sign-time passphrase prompt UX | `src/wallet_backend/secret_prompt.rs`; `src/ui/components/secret_prompt_host.rs`; `src/ui/components/passphrase_modal.rs` | MEDIUM | **RESOLVED (`2272bae0..43f412cf`)** | issue #90 — per-secret JIT prompt now shipped | +| PROJ-009 | DIP-14 back-compat dropped (non-mainnet / non-account-0 legacy contact addresses not reproduced) | `src/wallet_backend/mod.rs:722-724` (`register_dashpay_contact`, "Decision #6, back-compat dropped") | MEDIUM | **RESOLVED-WONTFIX 2026-06-11** (`d504d09e`): the non-mainnet/non-account-0 legacy contact-address class never existed — account 0' is hardcoded upstream and all legacy callers hardcoded account 0; nothing stranded. PROJ-027 resolved separately. | Decision #6 (`open-questions.md`) | +| PROJ-010 | Seedless loader is READ-ONLY; nothing populated the upstream persistor after `SeedReregistrationLoader` was deleted (`e6c6c017`) → empty watch set → received funds invisible. **Regression, now fixed.** | `src/wallet_backend/loader.rs`; `src/wallet_backend/mod.rs::{load_from_persistor_seedless, register_wallet_from_seed, ensure_upstream_registered}`; `src/context/wallet_lifecycle.rs::{register_wallet, bootstrap_wallet_addresses_jit}` | HIGH | **REGRESSION — FIXED** (W1/W2 persistor writers re-introduced) | `docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md` | +| PROJ-011 | `identity` `CREATE TABLE` still on fresh installs — tombstone ADR pending | `src/database/initialization.rs:845-866` | LOW | **RESOLVED 2026-06-11** (`255aa018`): legacy identity table removed. | T-DEV-02b; deferred to separate ADR (`finish-unwire/notes.md` §4) | +| PROJ-022 | `UpstreamPlatformAddresses` reserved swap-target — read methods `unimplemented!()` | `src/wallet_backend/platform_address.rs:245-307` | LOW | Open by design — **accepted-risk 2026-06-11**: by design, the `unimplemented!()` read arms are intentional until the upstream platform-address swap lands (parallels PROJ-010). Triage: `accept_risk`. | pending platform todo `e817b66a`; parallels PROJ-010 | + +Notes: + +- **PROJ-007** narrowed since the design docs: SEC-002 work (`6052dc72`, `48cdb8ad`) made + single-key **import / sign / list / hydrate** genuinely real + (`src/wallet_backend/single_key.rs`; `SingleKeyView::import_wif`). UI now imports via + `ImportSingleKeyDialog` (`src/ui/wallets/wallets_screen/mod.rs:42,157`; + `src/ui/wallets/import_single_key.rs`). Only balance/UTXO **refresh** and **SPV-based + send** remain stubbed. The `single-key-mock`/`g2-mock-boundary` "fully read-only mock" + claim has now been corrected in the design docs (see PROJ-020, RESOLVED). + **Extended + bumped LOW→MEDIUM 2026-06-10 (was GAPCMP-A-03):** two user-facing halves of + the T-SK-03 deferral were previously untracked. (a) Import now rejects any non-empty + per-key password (`src/ui/wallets/import_mnemonic_screen.rs:118-126` — "Per-key passwords + are not supported in this version"; v0.10-dev `SingleKeyWallet::from_wif(input, password, + alias)` supported them). (b) The cold-start migration **skips** `uses_password=1` + single-key rows (`src/backend_task/migration/finish_unwire.rs:120-134,377-389`, + `skipped_password_protected`) and the new hydration lists only vault-persisted keys + (`src/wallet_backend/single_key.rs:363`), so a skipped wallet **disappears from the + wallet list entirely** post-upgrade — no in-app access or explanation. Key material is + preserved in the untouched legacy `data.db` (no data loss). Neither caveat appears in + CHANGELOG Known Limitations, which says single-key import "works in this release" + (→ DOC-001). With PROJ-008's JIT prompt seam now shipped, the skipped rows could likely + be unlocked inline on the next migration pass. + **PARTIAL 2026-06-11** (`fba925ec` + `01f2bb26` + `690d92b3` + `3a0e5909`): T1 + import-consolidation + T2 data-loss-gate + T6 password-restore shipped and + security-reviewed. T3/T4/T5 refresh/send PARKED on upstream `platform-wallet + register_watch_only_wallet`. +- **PROJ-008 (RESOLVED this refresh):** the SEC-002 sign-time prompt UX that was deferred is + now shipped by the JIT secret-access refactor (`2272bae0..43f412cf`). `secret_prompt.rs` + defines the `SecretPrompt` async trait whose `request()` is asked per-secret, keyed by + `SecretScope::{HdSeed { seed_hash }, SingleKey { address }}`, carrying **no plaintext** — + only display copy and an optional `SecretPromptRetry::WrongPassphrase` re-ask reason. The + egui host (`secret_prompt_host.rs`) and modal (`passphrase_modal.rs`) collect the + passphrase; `RememberPolicy` (`None` default / `UntilAppClose` / `For(Duration)`) controls + caching. `NullSecretPrompt` cleanly cancels in headless/MCP/CLI. This is exactly the + "prompt at sign time, not an upfront session gate" UX issue #90 tracked as deferred. Moved + out of the open deferred set. +- **PROJ-010 (REGRESSION, now FIXED):** the earlier "Resolved (PR #3692)" status was wrong. + Swapping `SeedReregistrationLoader` for the read-only `UpstreamFromPersisted` loader + (`e6c6c017`) deleted the **only** code that ever wrote wallets into the upstream + `platform-wallet.sqlite` persistor (`create_wallet_from_seed_bytes` → `persister.store`). + The seedless `load_from_persistor` only READS that persistor, so when it was empty (fresh + install, post-reset, migrated/sidecar-only wallets) the wallet was never registered with the + upstream SPV manager, the watch set was empty, and received Core funds stayed invisible at + 100% sync (real repro: 1.0 DASH at block 1492173 to `m/44'/1'/0'/0/0`). It also explains the + backend-e2e "Timed out waiting for wallet to register with the upstream backend" timeout — + `is_wallet_registered` never flipped true because nothing populated the persistor. **Fix:** + re-introduce the persistor write at the two seed-bearing moments — `register_wallet_from_seed` + (W1, create/import) and `ensure_upstream_registered` (W2, cold-boot reconciliation through the + JIT chokepoint), both idempotent and routed through the account-xpub fund-routing gate, with a + genesis birth-height floor (`Some(0)`) for imported/recovered/migrated wallets so deposits made + before registration are still found. The seedless read path is unchanged; W1/W2 simply + guarantee its input is populated. PROJ-008 watch-only-at-boot is preserved (no launch-time + prompt for protected wallets). See `docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md`. +- **PROJ-011** (re-verified): `legacy_detected()` (`src/database/initialization.rs:146`) gates + `wallet` / `wallet_addresses` / `utxos` / `wallet_transactions` / `shielded_notes` behind + `include_legacy`. The `identity` empty placeholder (`:851`) is still created + unconditionally for legacy `database/wallet.rs` cold-start reads. `platform_address_balances` + (`:797,933`) is still live. Documented "separate ADR" carve-out. +- **PROJ-022:** `UpstreamPlatformAddresses` (`platform_address.rs:245`) is the reserved + swap target for reading per-address Platform funds straight from upstream. It is **NOT + selected** — the ACTIVE impl is `KvCachedPlatformAddresses` + (returned by `platform_addresses()`, `src/wallet_backend/mod.rs:593`). Its read methods (`get_address_info`, `all_address_info`, + `get_sync_info`) are `unimplemented!()` pending upstream `e817b66a` (a public per-address + balance+nonce reader + sync-cursor shape). Dead code by design; structurally identical to + the PROJ-010 G2 loader seam. Cannot panic in any live path while the cached impl is active. +- **PROJ-009 (RESOLVED-WONTFIX 2026-06-11, `d504d09e`):** the non-mainnet/non-account-0 + legacy contact-address class never existed — account 0' is hardcoded upstream and all + legacy callers hardcoded account 0. Nothing is stranded. The PROJ-027 general incoming- + payment gap is resolved separately. The deferral text still stands as a design note; the + finding is marked WONTFIX since there is nothing to migrate. +- **`FundWithUtxo` (seed item #15)** — the *removed* asset-lock funding path. Current active + funding task is `WalletTask::FundPlatformAddressFromWalletUtxos` + (`src/backend_task/wallet/mod.rs`), a different working path. No live broken surface. + **Cross-ref (2026-06-10):** the removal also covered the two QR-funding UI tabs — + `src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs` and + `src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs` (both deleted; + `FundingMethod::AddressWithQRCode` gone) — disclosed at `CHANGELOG.md:51` and + `docs/user-stories.md` IDN-014 `[Removed — upstream-only funding]`. Reviewers should not + re-flag those files. Dead `CoreItem::ReceivedAvailableUTXOTransaction` match arms remain + in both screens (see PROJ-026). The promised in-app one-time notice for this removal was + never shipped — see DOC-003. + +### Disclosed removals — itemised (2026-06-10) + +Previously implied by the RPC-mode removal but not individually auditable. All verified +absent in the working tree; none is a new gap. + +| Removed surface | v0.10-dev evidence | Disclosure | Notes | +|-----------------|--------------------|------------|-------| +| `CoreTask::RecoverAssetLocks` ("Search for Unused" button) | OLD `src/backend_task/core/recover_asset_locks.rs`; OLD `wallets_screen/mod.rs:2491,2660` | `removal-inventory.md:103` ("replaced by AssetLockManager continuous tracking") | Replaced by `WalletTask::ListTrackedAssetLocks` (`src/backend_task/wallet/mod.rs:85-87`). Was already a no-op in OLD SPV mode. | +| `CoreTask::ListCoreWallets` + Core-wallet auto-detect/picker | OLD `add_new_wallet_screen.rs:567-590`, `import_mnemonic_screen.rs:470-492`, RPC `-19` recovery | `removal-inventory.md:55,101` | Pure RPC-mode surface. (MCP `core_wallets_list` is a different, surviving tool.) | +| RPC Core-backend mode (Connection Type selector, Core RPC password UI, dashmate auto-update, RPC/ZMQ status rows, indicator-click Dash-Qt launch) | OLD `network_chooser_screen.rs:262-310,410-513,672-708`; OLD `top_panel.rs:168-195` | `removal-inventory.md` §"RPC Backend Mode — Fate" (`:95-111`) | Deliberate: platform-wallet is SPV-only. CHANGELOG omission → DOC-001. Dead `dashmate_password_input` field survives unrendered (`network_chooser_screen.rs:68,281`). Launcher fallout → PROJ-033; placebo ZMQ checkbox → PROJ-012. | +| 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. | + +--- + +## Test gaps + +| ID | Title | Location | Sev | Status | What's missing | +|----|-------|----------|-----|--------|----------------| +| PROJ-013 | Large-stack e2e harness — SDK deep-recursion stack overflow | `tests/backend-e2e/framework/task_runner.rs:17-78,153-160` | MEDIUM | **RESOLVED (`2a9161d3`, `93a20769`)** | Harness drives every SDK task on a dedicated 32 MB-stack runtime (the only mechanism that reaches tokio threads). `#[stack_size]` investigated and rejected; `RUST_MIN_STACK` now moot. Detail below. | +| PROJ-014 | `WalletBackend::start()` start-path test coverage | `src/context/wallet_lifecycle.rs:561,583,617,649` | HIGH | **RESOLVED (`3165f98c`, `36f5a982`)** | Four offline tests now gate the start path (`start_spv_errors_when_backend_not_wired`, `start_spv_starts_after_backend_wired`, `ensure_wallet_backend_and_start_spv_wires_then_starts`, `chokepoint_wiring_failure_flips_indicator_to_error`). Full live-SPV success path remains an e2e/network gap. | +| PROJ-015 | TC-012 receive-address reuse — unverified from DET source | `src/wallet_backend/mod.rs` (`next_receive_address` → upstream) | LOW | Unverified — needs follow-up | Depends on upstream used-marking; now testable since PROJ-001 is resolved. Re-test on live network. | +| PROJ-016 | TC-066 key-not-visible-after-broadcast (flake-vs-bug) | (tracked-only, no isolated code surface) | LOW | Unverified — needs follow-up | No deterministic repro in tree. Re-classify after live run. | + +**PROJ-013 — RESOLVED. Mechanism confirmed correct; `#[stack_size]` rejected.** Verified at +`tests/backend-e2e/framework/task_runner.rs`: `sdk_runtime()` (`:22`) builds a dedicated +multi-thread tokio runtime with `thread_stack_size(32 * 1024 * 1024)` (`SDK_THREAD_STACK_SIZE`, +`:17`); `run_task` (`:50`) drives every backend future through `drive_on_large_stack` (`:65`) — +the single chokepoint all e2e tasks pass through — which `block_on`s on a 32 MB blocking thread +so the deep synchronous SDK `block_on` (grovedb / drive-proof-verifier) cannot overflow the +default 8 MB stack, **regardless of `RUST_MIN_STACK`**. A deterministic, non-network smoke test +`large_stack_path_survives_deep_recursion` (`:153-160`) recurses ~12 MiB through the exact +`drive_on_large_stack` path, proving the mechanism is load-bearing without any env-var assist. + +- **`#[stack_size]` (`dash-platform-macros`) investigated and rejected.** The upstream + attribute enlarges only the single `std::thread` running the wrapped function body. The + backend-e2e tests are async (`#[tokio_shared_rt::test(shared, flavor = "multi_thread", …)]`) + and the SDK recurses *inside* the sync `ContextProvider::get_quorum_public_key` callback + (`src/context_provider_spv.rs`), which bridges to async via `tokio::task::block_in_place` — + a multi-thread-only construct. The recursion therefore lands on **tokio worker / blocking + threads**, which `#[stack_size]` cannot reach. The shared runtime built by `tokio-shared-rt` + carries no custom stack size, so `thread_stack_size` on a dedicated runtime is the only + mechanism that covers those threads. The dependency was deliberately **not** added. + +- **`RUST_MIN_STACK` now moot.** The harness owns the stack via the runtime, so callers no + longer set it. `tests/backend-e2e/main.rs` (`93a20769`) and `tests/backend-e2e/README.md` + agree on this. When CI re-enables the `#[ignore]` backend-e2e suite (run steps currently + commented in `.github/workflows/tests.yml:85,90`), no env-level stack bump is needed — + the runtime owns the stack for every task path (all flow through `run_task`). + +Recorded test-spec gaps from `finish-unwire/notes.md` §5 (feature-flag/manual only, not +counted as new open gaps): TC-SK-010, TC-A11Y-008, TC-PERF-003. + +--- + +## Upstream dependencies + +| ID | Title | Location | Sev | Status | Blocker | +|----|-------|----------|-----|--------|---------| +| PROJ-005 | platform pin tracks unreleased dev rev (G1) | `Cargo.toml:21,31,32,35` | HIGH | OPEN | Release gate; bump to released rev before ship. Current rev `4f432c9baf10eeb051e70bc0370b1b7505b7d9c5` — the 2026-06-10 re-pin to dashpay/platform#3828 (`4247c360`); was `9e1248cb…` at prior refresh, `ddfa66ed…` / `35e4a2f6…` before, `17653ba8…` at original audit. The re-pin also introduced PROJ-028. | +| PROJ-017 | `register_identity_funding_account` absent upstream — DET carries contained exception | `src/wallet_backend/mod.rs:1407` (`provision_identity_funding_account`) / `:1489` (`ensure_identity_funding_accounts`) | LOW | **RESOLVED 2026-06-11** (`26675766`): load() comments scoped to per-index top-up; upstream-contribution path documented in code. | + +--- + +## Doc gaps + +| ID | Title | Location | Sev | Status | What's missing | +|----|-------|----------|-----|--------|----------------| +| PROJ-018 | External user docs (dashpay/docs) not yet filed | `docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md` | MEDIUM | OPEN (deferred-with-TODO, `727e8d6a`) | Draft written; must still be filed as a PR/issue against `github.com/dashpay/docs` after #860 merges. | +| PROJ-019 | ADR floor SHA placeholder unfilled | `docs/ai-design/2026-05-29-finish-unwire/notes.md:92` (`[PLACEHOLDER — fill at merge time]`) | LOW | OPEN (deferred — merge-time action only) | Cannot close pre-merge — needs this PR's squash-merge SHA recorded as the wallet-state floor. Triage decision: `defer`. | +| PROJ-020 | Design docs claimed single-key is fully read-only mock — was stale | `docs/ai-design/2026-05-18-platform-wallet-migration/single-key-mock.md:51`; `g2-mock-boundary.md:46` | LOW | **RESOLVED (`f39b085d`)** | Prose corrected: `single-key-mock.md:51` now states import/list/sign work in full via `SecretStore`; `g2-mock-boundary.md:46` records the partial capability gap accurately. | +| 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) | + +**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` → +`docs/user/network/dash-evo-tool/`, with a **Tracking** section and an explicit +`TODO: file a dashpay/docs issue linking to PR #860 … once the merge commit is confirmed`. +The draft satisfies the "write the guidance" half of the gap; the gap stays OPEN until the +PR/issue is actually filed against `dashpay/docs` post-merge. + +**PROJ-019 (PARTIAL).** Verified at `docs/ai-design/2026-05-29-finish-unwire/notes.md:92`: +the placeholder is now explicit (`[PLACEHOLDER — fill at merge time]`) with a clear +instruction not to leave it in place after merge. The merge SHA cannot be filled pre-merge, +so the item stays OPEN as a merge-time action. + +--- + +## Project conventions + +| ID | Title | Location | Sev | Status | What's missing | +|----|-------|----------|-----|--------|----------------| +| PROJ-023 | String-based error matching in DashPay add-contact UI | `src/ui/dashpay/add_contact_screen.rs` | LOW | **RESOLVED (`d852ce99`)** | `display_task_result` no longer parses error strings; classification routes through typed `classify_send_error` matching `TaskError` / `DashPayError` variants. Verified: zero `.contains(` on error/message text in the file; 6 typed-error unit tests added. | +| PROJ-025 | String-based error matching in DashPay contact-requests UI | `src/ui/dashpay/contact_requests.rs` | LOW | **RESOLVED (`39e459ff`)** | Typed classification via `display_task_error` + `classify_request_error`; routes `TaskError::DashPay(DashPayError::Missing{En,De}cryptionKey)`. Old `message.contains("ENCRYPTION key")` / `"DECRYPTION key"` sites (`:844-851`, `:983-985`) removed. Dead `Message`-arm string-matching also gone. 4 unit tests added. | +| PROJ-039 | Rust `Debug` (`{:?}`) rendered in user-facing unused-asset-lock picker; address column dropped | `src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs:75`; `src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs:61-65` | LOW | **RESOLVED 2026-06-11** (`1871c59f`) — `AssetLockStatus` mapped to user-facing copy; address column restored. (was GAPCMP-B-7) | + +**PROJ-023 — RESOLVED.** Verified at `src/ui/dashpay/add_contact_screen.rs`: no `.contains(` +on error/message strings remains; `classify_send_error` (`:649`) matches typed +`TaskError::IdentityNotFound`, `TaskError::DashPay(DashPayError::{…})` variants and the +`display_task_result` path routes through it (`:616`). Six typed-error unit tests cover the +classification (`:700-761`) — they assert specific variant mapping, base58 fallback, and that +recoverable errors map through so retry is offered (not shallow "no error" checks). + +**PROJ-025 — RESOLVED (`39e459ff`).** `contact_requests.rs` now implements `display_task_error` +with a pure `classify_request_error` helper that routes typed `TaskError::DashPay(DashPayError::Missing{En,De}cryptionKey)` +onto the screen-local affordance. Missing-key variants drive the "Add Encryption Key" affordance +and suppress the duplicate global banner; everything else returns `None` so the global banner +reports it. Both `message.contains("ENCRYPTION key")` / `"DECRYPTION key"` sites and the dead +`Message`-arm string-matching body are gone; `git grep` returns zero hits. Four unit tests cover +the classification. Pattern mirrors PROJ-023 exactly; zero new `TaskError` variants were required. + +--- + +## Seed-list items found ALREADY RESOLVED (evidence) + +Recorded for completeness; **not** counted in the open-gap tally. + +1. **Seed #3 — eager wallet-backend init hard failure.** **Resolved.** `src/app.rs` eager init + warns + degrades to lazy fallback — no hard "Could not access wallet data" abort. +2. **Seed #7 — TC-019 inverted error precedence.** **Resolved / moot.** The + `CoreTask::RefreshSingleKeyWalletInfo` dispatch arm in + `src/backend_task/core/mod.rs` returns `SingleKeyWalletsUnsupported` + unconditionally — no seed-lookup branch left to invert. (The standalone + stub file cited in earlier revisions was dead and has been removed.) +3. **Seed #11 — QA-004 `core_backend_mode` inert plumbing.** **Field RETAINED, + not removed.** Correcting an earlier false claim of `rg core_backend_mode + src` = 0 hits: the field is still live in `src/model/settings.rs` (struct + field, default `1` = SPV, serde round-trip, tests) and is read at DB-init + in `src/database/initialization.rs`. It is **inert/reserved** now that RPC + backend mode is gone — the *behavioural* plumbing is dead, but the persisted + field itself was deliberately kept (dropping it is a settings-schema change + deferred to a later cleanup), so it is NOT "resolved by removal". +4. **Seed #19 — SPV readiness gate "all 5 managers Synced".** **Not present.** + `EventBridge::on_progress` keys off the single upstream `progress.is_synced()` predicate. +5. **Mock finding #1 — "Stop Tracking Balance" only pruned local ordering.** **Resolved** + (`5a047357`). `stop_tracking_token_balance` (`src/backend_task/tokens/query_my_token_balances.rs:62`) + now calls `unwatch_identity_token` upstream so the row stays gone after refresh. +6. **Appendix item — DashPay threshold-expiry "not yet wired".** **Resolved** (`a7327e7c`). + `expires_at` now derived `created_at + DASHPAY_REQUEST_EXPIRY_DAYS` via + `request_expires_at_ms` (`src/wallet_backend/dashpay.rs:429,520`), checked arithmetic, + two unit tests + an overflow test. +7. **Appendix item — MCP `platform_withdrawals_get` pagination/structured-data TODOs.** + **Resolved** (`5ba4554e`). `src/mcp/tools/platform.rs` now has `limit` / `start_after` / + `next_cursor` pagination (`:36,55,81,148-176`) and a structured response type. +8. **Appendix item — SPV sync UI read inert `SpvStatusSnapshot::default()`.** **Resolved** + (`bd0ed0e4`). `EventBridge::on_progress` now publishes live per-phase heights so the + progress bar/labels populate during sync. + +Seed items **unverifiable from this tree** (needs follow-up, not asserted): seed #17 (m/9' +identity-address SPV bloom registration — not found in DET; likely upstream), seed #18 +(DiskStorageManager byte-compat — lives in upstream `platform-wallet-storage`), seed #20 +(`/tmp/marvin-finish-unwire-exceptions.md` absent). + +--- + +## Resolution log + +- **2026-06-01 — PROJ-001 resolved** (`42388c4b`, `3165f98c`, `36f5a982`): `start_spv()` wired to + `WalletBackend::start()` with `StartLatch` idempotency; single async chokepoint + `ensure_wallet_backend_and_start_spv()` covers all four caller paths; wiring/start failures + surface via indicator + banner. QA-007 / QA-008 closed in `36f5a982`. +- **2026-06-01 — PROJ-014 largely resolved** (`3165f98c`, `36f5a982`): four offline tests gate + the start path; live-SPV success path remains an e2e/network gap. +- **2026-06-02 — PROJ-003 resolved** (`3ac9b3b0`): `update_payment_status` persists via + `dashpay_record_payment` + timestamp sidecar; `check_address_usage` documented BLOCKED-BY-DESIGN. +- **2026-06-02 — PROJ-004 resolved** (`6c520a33`): contact-request xpub derived from the real + 64-byte HD seed (`first_open_wallet_seed` → `derive_contact_xpub_material`); regression test + proves divergence from the old placeholder. Follow-up **SEC-001 resolved** (`450214e5`): + `coin_type_for_network()` threaded through all DashPay HD paths so send/receive xpubs agree + per network. +- **2026-06-02 — PROJ-006 resolved** (`7e2553e3`): real per-network platform activation heights. +- **2026-06-02 — PROJ-002 resolved (removed)**: dead `add_contact` / `remove_contact` free + functions (zero callers, orphaned from PR #464 `82399a26`, superseded by + `DashPayTask::SendContactRequest`) deleted by a sibling commit, along with the now-orphaned + `DashPayError::NotSupported` variant. +- **2026-06-02 — PROJ-012 re-filed (deferred-LOW → functional-MEDIUM)**: not benign dead + plumbing — the ZMQ status sender is live (`src/app.rs:819`) but `rx_zmq_status` is never + drained and `set_zmq_status` (`src/context/connection_status.rs:159`) has zero callers, so + ZMQ connection-health events flow into a void. Decision #3 P4-audit deferral does not excuse + the broken status path. Fix: wire `rx_zmq_status` → `set_zmq_status`, or remove the whole + producer→channel→setter chain (constructed as a unit at `src/context/mod.rs:161`). +- **2026-06-03 — refresh pass (head `f39b085d`)**: build/lint/test verified clean + (`cargo +nightly fmt --check`, `cargo clippy --all-features --all-targets -D warnings`, + `cargo build --tests --all-features`, the 6 `add_contact_screen` typed-error tests, and the + deterministic `large_stack_path_survives_deep_recursion` e2e smoke — all green). Dispositions: + - **PROJ-008 resolved** (`2272bae0..43f412cf`): the deferred SEC-002 sign-time passphrase + prompt UX (issue #90) is shipped — per-secret JIT `SecretPrompt` seam + (`secret_prompt.rs`), egui host (`secret_prompt_host.rs`), modal (`passphrase_modal.rs`), + keyed by `SecretScope`, with `RememberPolicy`. Moved out of the open deferred set. + - **PROJ-013 resolved** (`2a9161d3`): test-only; 32 MB-stack `sdk_runtime` + + `drive_on_large_stack` chokepoint + deterministic smoke test. Residual CI sub-item tracked + (commented `#[ignore]` step in `.github/workflows/tests.yml:85,90`; no `RUST_MIN_STACK` in + `.github/`; `.github/` edits blocked by tool policy). + - **PROJ-020 / PROJ-021 resolved** (`f39b085d`): single-key-mock / g2-mock-boundary prose + corrected to present state; CHANGELOG `### Known Limitations` added. + - **PROJ-023 resolved** (`d852ce99`): add-contact UI error classification moved off string + matching onto typed `classify_send_error`; 6 unit tests. Sibling occurrence in + `contact_requests.rs` filed as **new PROJ-025** (LOW, pre-existing, issue #660). + - **PROJ-018 / PROJ-019 stay OPEN** as merge-time partials; **PROJ-005** pin moved + `35e4a2f6…` → `ddfa66ed…` (still unreleased, stays the sole open merge-blocker). +- **2026-06-03 — tally reconciliation**: recomputed the Executive severity table and category + breakdown from the enumerable body. Now 24 total / 12 open / 12 resolved (was 23/17/6): + PROJ-025 added (+1 total, +1 open LOW); PROJ-008 (MEDIUM), PROJ-013 (MEDIUM), PROJ-020, + PROJ-021, PROJ-023 (LOW) flipped open→resolved. +- **2026-06-03 — PROJ-025 resolved** (`39e459ff`): contact-requests string-matching anti-pattern + replaced by typed `display_task_error` + `classify_request_error`; routes + `TaskError::DashPay(DashPayError::Missing{En,De}cryptionKey)`. Both keyword-sniffing sites and + the dead `Message`-arm removed; 4 unit tests added. Pattern mirrors PROJ-023; zero new variants. + Tally: 24 total / **11 open / 13 resolved** (LOW: open 8→7, resolved 5→6). +- **2026-06-08 — line-ref consolidation (head `954ea3f8`)**: re-pinned drifted refs against + source, line by line. PROJ-017 → `mod.rs:1407`/`:1489` (callers `:1261,1325,1371`; tracking + `9cdcfb25` / `a5538dc8`); PROJ-019 → `notes.md:92`; PROJ-022 active impl → `mod.rs:593`; + PROJ-007 arm → `mod.rs:218,303` (and two by-design markers added in source, `93a20769`). + PROJ-013 detail refreshed: `#[stack_size]` (`dash-platform-macros`) investigated and rejected + (recursion lands on tokio threads via `block_in_place`, which the single-thread macro cannot + reach), 32 MB-stack runtime confirmed the only load-bearing mechanism, `RUST_MIN_STACK` moot + (`main.rs` `93a20769` + README agree); residual CI sub-item folded into the entry. No tally change. +- **2026-06-10 — v0.10-dev feature-parity pass (head `a0d5034a`)**: four domain audits + (wallets/core, identity/DPNS/contracts, DashPay/tokens/shielded, MCP/settings/withdrawals) + swept the full v0.10-dev feature surface; every recorded finding re-verified against live + source. **Added 19 entries**: 3 HIGH (PROJ-026 asset-lock-QR soft-lock, PROJ-027 + incoming-DashPay-payment detection gone, PROJ-028 shielded nullifier-cursor unit-mismatch — + a **regression introduced by the same-day #3828 re-pin** `4247c360`+`a0d5034a`, cross-linked + to follow-up todo `1ff97ad7`); 7 MEDIUM (PROJ-029 subtract-fee/Max dead-end, PROJ-030 resync + keeps nullifier watermark, PROJ-031 shield source-address ignored, PROJ-032 DashPay data not + migrated [later CLOSED/N-A per v0.9.3 cross-check — DashPay never shipped], PROJ-033 + Dash-Qt launcher unreachable, PROJ-034 settings/top-up-history/scheduled-votes reset on + upgrade [confirmed real per v0.9.3 cross-check], DOC-001 CHANGELOG sweep); 9 LOW (PROJ-035..038, PROJ-040, PROJ-041, + PROJ-039 conventions, DOC-002 proof-log doc debt, DOC-003 I3 notice). **Corrected 4 existing + entries**: PROJ-012 re-scoped (whole ZMQ chain dead — listener spawn gated off by + `FeatureGate::RpcBackend=false`; placebo "Disable ZMQ" checkbox folded in), PROJ-007 extended + + bumped LOW→MEDIUM (password-protected single-key wallets silently vanish post-upgrade; + per-key-password import rejected), PROJ-009 flagged incomplete (zero callers of + `register_dashpay_contact`; wider loss → PROJ-027), PROJ-005 rev advanced to `4f432c9b…` + (#3828 re-pin, still unreleased). **Reconciled**: disclosed removals itemised + (RecoverAssetLocks, ListCoreWallets, RPC mode, SPV peer source, Proof Log, Total Received + column); seed #15 cross-referenced to the two deleted QR UI tab files; exec-summary + mis-bucketing of PROJ-010 (HIGH, not LOW) fixed. Preserved-feature coverage confirmed broad + parity everywhere else (identity/DPNS/voting/contracts/documents/tokens/MCP byte- or + behavior-identical). Tally: 43 total / 30 open / 13 resolved. +- **2026-06-11 — triage pass (18 findings resolved/partial/accepted):** 18 findings actioned + across the 18-commit triage program; 3 new triage decisions recorded (PROJ-016/019/022). + - **PROJ-026 RESOLVED** (`fe01febb` + `26c13385`): asset-lock QR funding now advances. + - **PROJ-027 RESOLVED** (`910f8833` + `dc94bba6`): incoming DashPay contact payments detected and recorded. + - **PROJ-029 RESOLVED** (`918b8e5f` + `26c13385`): Core Max implemented; subtract-fee UI removed. + - **PROJ-031 RESOLVED** (`08c895a8` + `26c13385`): shield source-address selector removed. + - **PROJ-040 RESOLVED** (`467dc807` + `dc94bba6`): DashPay offline caches + avatar cache. + - **PROJ-017 RESOLVED** (`26675766`): funding-shim load() comments scoped. + - **PROJ-012 + PROJ-033 + PROJ-011 RESOLVED** (`255aa018` + changelog `23b81718`): ZMQ subsystem, Dash-Qt launcher, and legacy identity table removed. + - **PROJ-035/036/037/038/039 + DOC-001/DOC-002 RESOLVED** (`1871c59f` + `23b81718`): UI copy / dead controls / recovery-trail / doc fixes. + - **PROJ-009 RESOLVED-WONTFIX** (`d504d09e`): non-mainnet/non-account-0 legacy contact-address class never existed; nothing stranded. + - **PROJ-007 PARTIAL** (`fba925ec` + `01f2bb26` + `690d92b3` + `3a0e5909`): T1/T2/T6 shipped; T3/T4/T5 PARKED on upstream. + - **PROJ-034/018/015/041 + DOC-003 deferred-with-TODO** (`727e8d6a`): TODO markers placed. + - **PROJ-032 CLOSED/N-A** (2026-06-16 cross-check): DashPay was a "Coming Soon" placeholder in v0.9.3 — zero tables or persistence ever shipped in any release; precondition for the migration was never true; TODO removed from source. + - **PROJ-016 triage: defer** — blocked on PROJ-007 single-key send; no deterministic repro. + - **PROJ-019 triage: defer** — merge-time action (ADR floor SHA). + - **PROJ-022 triage: accept_risk** — by design; unimplemented!() arms intentional until upstream swap. + Tally: 43 total / **11 open** / **32 resolved** (HIGH open 3→1, MEDIUM open 10→4, LOW open 15→6). + +- **2026-06-10 — PROJ-028 + PROJ-030 resolved** (`39433dac`; doc follow-up this commit): the + shielded nullifier-cursor unit-mismatch family. The #3828 re-pin made spend detection + scan-based off a note-tree POSITION cursor, but `last_nullifier_sync_height` held a legacy + platform BLOCK HEIGHT. **PROJ-028 (HIGH):** the migration carried the value verbatim, so a + migrated wallet scanned past the tree tip → spends never flipped → balance overstated. + `finish_unwire.rs` now resets the cursor to 0 (`SELECT … 0, 0`); `tc_sh_002` flipped to assert + the reset; new `migrated_cursor_reset_lets_scan_flip_spent_note` covers the end-to-end fix. + **PROJ-030 (MEDIUM):** "Resync Notes" kept the cursor, resurrecting spent notes; the resync + handler now also calls `delete_shielded_wallet_meta`, with `resync_sequence_resets_nullifier_cursor` + pinning it. Reset-to-0 is funds-safe: `check_nullifiers` only flips notes to spent and a from-0 + rescan is idempotent (no re-credit, no missed spend). Smythe QA: SHIP, 2 LOW non-blockers + (SEC-001 stale doc comment fixed here; SEC-002 shallow migration test tracked separately). + Tally: 43 total / **28 open / 15 resolved** (HIGH open 4→3, MEDIUM open 11→10). + +--- + +## Appendix: raw stub-signal hits not separately categorized + +So nothing is silently dropped. Deferred markers / inert-looking bodies that are (a) benign, +(b) pre-existing, or (c) rolled into a finding above. + +- `src/wallet_backend/mod.rs:1214` — exhaustive-match guard comment "keeping the match + exhaustive forces a review if a new variant appears" (intentional, benign). +- `src/wallet_backend/event_bridge.rs:173` — `on_shielded_sync_completed` left at upstream + no-op default (DET enables only `serde`; matches `start()` comment at `mod.rs:474-476`). +- `src/wallet_backend/platform_address.rs:256-303` — `UpstreamPlatformAddresses` write/delete + arms are intentional `Ok(())` no-ops; the panicking read arms are PROJ-022. +- `src/backend_task/migration/finish_unwire.rs:340,656,1655,1988` — password-protected + single_key rows **deferred** to T-SK-03 UX prompt; counted "skipped", not "failed". By + design; migration itself fully wired (`src/backend_task/migration/mod.rs`). With PROJ-008 + resolved, the JIT prompt seam that T-SK-03 depends on now exists — re-check on next migration + pass whether these rows can be unlocked inline. +- `src/backend_task/dashpay/payments.rs:210` — `#[allow(dead_code)]` payments helper (pre-existing). +- `src/ui/dashpay/send_payment.rs:743,754,776` — local in-memory display `PaymentRecord` list + uses `Identifier::new([0;32])` contact-id placeholder and `timestamp: 0`. Cosmetic: the + authoritative payment record is persisted via `dashpay_record_payment` (the mirror was + dropped, see comment `:760-764`); only the throwaway UI list is affected. LOW-adjacent, + not separately scored. +- `src/ui/tokens/update_token_config.rs:684` — "Marketplace settings are not yet supported" + UI label for the upstream `MarketplaceTradeMode` config arm. Pre-existing, unrelated to + #860; disclosed unsupported feature. +- `src/ui/identities/identities_screen.rs:183` — stale "dummy for now" comment; the + InWallet sort below it actually compares wallet names correctly. Benign stale comment. +- `src/database/initialization.rs:906` — "TODO: Discuss migration approach with the team" — + pre-existing architectural note in `set_default_version`, benign. +- `src/context/mod.rs:810` — `// TODO: Ideally use sdk.load().version()` — cosmetic version + free-fn TODO (pre-existing). +- `src/app.rs:1314` (`TODO(RUST-002)`) — message-text-inspection routing TODO (tracked tech-debt, + same family as PROJ-025). +- `src/app.rs:717,733`, `src/model/qualified_identity/mod.rs:770` — `panic!` BUG-guard + invariants (missing-network-context, inconsistent-wallet-index); intentional, not gaps. +- `src/bin/det_cli/main.rs:186,188`, `src/ui/mod.rs:485,604,607`, + `src/ui/components/address_input.rs:576` — `unreachable!()` arms guarded by prior checks + (intentional). +- `src/mcp/tools/platform.rs` pagination/structured TODOs — now RESOLVED (see already-resolved #7). + +--- + +## Appendix: BLOCKED-BY-DESIGN + +- **PROJ-024 — `check_address_usage` returns all-unused** (`src/backend_task/dashpay/payments.rs:653`). + Documented at `:640-652`: upstream exposes per-account `is_used` keyed by + `(wallet_id, AccountType)`, not by arbitrary address; the function receives a context-free + address list it cannot route. The only context-bearing DashPay addresses are contact-SEND + addresses, which never live in any managed pool, so a full scan would correctly report + all-unused anyway. Returning a fabricated usage flag would corrupt gap-limit math. Honest + all-unused stub pending a properly-scoped upstream reader. Not a bug — a disclosed, + reasoned design limit (introduced with the PROJ-003 fix, `3ac9b3b0`). + +--- + +*Candy tally — confirmed gaps: 43 (1 CRITICAL · 7 HIGH · 15 MEDIUM · 20 LOW · 0 INFO). +Status as of 2026-06-16: 34 RESOLVED / 1 PARTIAL / 1 ACCEPTED + 9 OPEN (1 HIGH + 3 MEDIUM + 5 LOW). +RESOLVED set: PROJ-001, PROJ-002 (removed), PROJ-003, PROJ-004, PROJ-006, PROJ-008, PROJ-010, +PROJ-011, PROJ-012, PROJ-013, PROJ-014, PROJ-017, PROJ-020, PROJ-021, PROJ-023, PROJ-025, +PROJ-026, PROJ-027, PROJ-028, PROJ-029, PROJ-030, PROJ-031, PROJ-033, PROJ-035, PROJ-036, +PROJ-037, PROJ-038, PROJ-039, PROJ-040, PROJ-043, PROJ-009 (WONTFIX), DOC-001, DOC-002, +PROJ-032 (CLOSED/N-A — DashPay never persisted in any v0.9.3 release; precondition false) ++ SEC-001 follow-up. +PARTIAL: PROJ-007 (T3/T4/T5 parked on upstream). ACCEPTED: PROJ-022. OPEN deferred-with-TODO: +PROJ-034 (REAL — confirmed per v0.9.3 cross-check; priority: scheduled votes > settings > top-up), +PROJ-018, PROJ-015, PROJ-041, DOC-003. OPEN merge-blocker: PROJ-005 (release gate G1 only). +8 seed/appendix items confirmed already-resolved with evidence. 1 blocked-by-design (PROJ-024, uncounted).* diff --git a/docs/ai-design/2026-06-02-jit-secret-access/d4-1112-identity-key-redesign.md b/docs/ai-design/2026-06-02-jit-secret-access/d4-1112-identity-key-redesign.md new file mode 100644 index 000000000..bb09e66ed --- /dev/null +++ b/docs/ai-design/2026-06-02-jit-secret-access/d4-1112-identity-key-redesign.md @@ -0,0 +1,445 @@ +# D4c — `identity_authentication_ecdsa_private_key` (mod.rs:1112) Redesign + +**Author:** Nagatha (Architect) +**Date:** 2026-06-02 +**Status:** Read-only design. No implementation in this pass. +**Branch:** `docs/platform-wallet-migration-design` +**Parent scope:** `docs/ai-design/2026-06-02-jit-secret-access/r3-completion-scope.md` (the §2 census +classified `identity_authentication_ecdsa_private_key` as the private sibling that "genuinely needs +the seed every call … converts via the JIT-signer track" — this document is that track). +**Sibling:** `docs/ai-design/2026-06-02-jit-secret-access/d4b-authxpub-persistence.md` (the **public** +identity-auth keys, served seed-free from `AuthPubkeyCache`). D4c builds on D4b: the chooser's public +keys come from D4b's cache + public derivation; D4c removes the chooser's *private* derivation. + +--- + +## 0. The one-paragraph verdict (read this first) + +I went looking for a hard problem and found that someone had already solved most of it. The +identity-create state transition is **already signed seed-free through the JIT chokepoint** — both +the identity-ownership proof (`QualifiedIdentity` as `Signer`, +`model/qualified_identity/mod.rs:316`, which resolves keys via `secret_access.with_secret` + +`get_resolve_with_seed`) and the asset-lock witness (`DetSigner`, in +`WalletBackend::register_identity`, `wallet_backend/mod.rs:1287`). The private keys the chooser +derives at `mod.rs:1112` are **never used to sign the state transition**: `IdentityKeys::to_key_storage` +(`backend_task/identity/mod.rs:84`) *discards* the `PrivateKey` and stores only +`PrivateKeyData::AtWalletDerivationPath { wallet_seed_hash, derivation_path }`, which is re-derived +JIT at signing time. The chooser derives private keys for exactly one reason that survives scrutiny — +to compute the **public** key material (pubkey bytes / hash160) — and one cosmetic reason: to display +a WIF in **advanced** mode. **So the redesign is not "materialize private keys JIT at registration"; +registration already does that. The redesign is "stop deriving private keys in the chooser at all, +and serve the chooser from public keys (D4b cache + public derivation)."** The only residual private +derivation is the advanced-mode WIF display, which becomes a backend task (the same +`WalletTask::DeriveKeyForDisplay` pattern D3 introduced for the wallets-screen key viewers). After +that, `identity_authentication_ecdsa_private_key`'s only callers are the e2e helper (converted to a +seed-param/public form) and — transiently — nothing in production. The parked-seed read at +`mod.rs:1112` is then deletable in D4. + +--- + +## 1. What the private key is actually used for (the load-bearing trace) + +### 1.1 The chooser builds `IdentityKeys` with real `PrivateKey`s + +`AddNewIdentityScreen` holds `identity_keys: IdentityKeys` (`add_new_identity_screen/mod.rs:86`). +Three sync methods populate it, each calling `identity_authentication_ecdsa_private_key` (mod.rs:1112): + +| Method | Sites | When | +|---|---|---| +| `ensure_correct_identity_keys` | `:212` (master), `:225` (5 default keys) | wallet selected / unlocked / index or funding-method change | +| `update_identity_key` | `:950` (master), `:966` (existing keys) | identity index changed | +| `add_identity_key` | `:1000` | advanced-mode "+ Add Key" | + +All run **inside `ui()` render paths** (Class C — no async boundary). `IdentityKeys.keys_input` +carries `(PrivateKey, DerivationPath)` tuples. + +### 1.2 At registration, the private keys are thrown away + +`register_identity_clicked` (`:789`) packages `self.identity_keys.clone()` into +`IdentityRegistrationInfo` and dispatches `IdentityTask::RegisterIdentity`. In the backend: + +- `to_public_keys_map()` (`backend_task/identity/mod.rs:180`) → the `BTreeMap` sent to Platform. **Uses `private_key.public_key(&secp)` only** — public + output. +- `to_key_storage(wallet_seed_hash)` (`:84`) → the `KeyStorage` stored on the `QualifiedIdentity`. + For every key it computes the public data (`pubkey_hash` / `to_bytes`) and then stores + `PrivateKeyData::AtWalletDerivationPath { wallet_seed_hash, derivation_path.clone() }`. **The + `PrivateKey` value is dropped.** The stored artifact is the *path*, not the key. + +So the `PrivateKey` in `IdentityKeys` is consumed only to derive its own public key. The signing +material is reconstructed later from the path. + +### 1.3 Signing the identity-create ST is already JIT + +Two registration funding flows, both already seed-free: + +**(a) Wallet / asset-lock funded** → `register_identity_via_wallet_backend` +(`register_identity.rs:137`) → `backend.register_identity(...)` (`wallet_backend/mod.rs:1287`): + +```text +with_secret_session(HdSeed{seed_hash}) { + let asset_lock_signer = DetSigner::from_held(session.plaintext(), network); // JIT + wallet.identity().register_identity_with_funding( + funding, identity_index, keys_map, + identity_signer: &QualifiedIdentity, // proves key ownership — JIT-resolves keys + asset_lock_signer:&DetSigner, // asset-lock witness — JIT + settings) +} +``` + +`QualifiedIdentity::sign` (`mod.rs:316`) resolves each signing key via the pure +`wallet_seed_hash_for` probe → `with_secret(HdSeed{seed_hash})` → `get_resolve_with_seed(seed)`. No +parked-seed read. The keys being added sign to prove ownership through this path, JIT. + +**(b) Platform-address funded** → `register_identity_from_platform_addresses` +(`register_identity.rs:237`): builds a `DetPlatformSigner::from_held(seed, …)` inside +`with_secret_session` and calls `identity.put_with_address_funding(..., &qualified_identity, +&signer, …)`. Identity-ownership signing is still `&qualified_identity` (JIT); the address-funding +witness is `DetPlatformSigner` (JIT, from D3). + +**Conclusion.** Identity-create signing has *no* dependency on the chooser's private keys and *no* +parked-seed read. The JIT private-key provisioning the brief asks me to "hook into registration" +**already exists** and is the production path today. D4c's job is to stop the *chooser* from deriving +private keys it does not need. + +--- + +## 2. Design — the chooser-public / registration-JIT split + +### 2.1 Chooser uses PUBLIC keys (the core change) + +`IdentityKeys` stops carrying `PrivateKey`. It carries the **public** material plus the derivation +path — which is all `to_public_keys_map` and `to_key_storage` actually consume. New shape: + +```rust +// backend_task/identity/mod.rs +pub struct IdentityKeyEntry { + pub public_key: PublicKey, // 33-byte compressed secp256k1 pubkey + pub derivation_path: DerivationPath, // the wallet path; private key re-derived JIT at sign time + pub key_type: KeyType, + pub purpose: Purpose, + pub security_level: SecurityLevel, + pub contract_bounds: Option, +} + +pub struct IdentityKeys { + pub(crate) master: Option, // purpose=AUTHENTICATION, security=MASTER + pub(crate) others: Vec, +} +``` + +`to_public_keys_map` / `to_key_storage` change from `private_key.public_key(&secp)` to reading +`entry.public_key` directly — the pubkey-data branches (`ECDSA_HASH160` → `pubkey_hash`, +`ECDSA_SECP256K1` → `to_bytes`) are unchanged, they just take the already-derived pubkey. The +`WalletDerivationPath { wallet_seed_hash, derivation_path }` stored in `KeyStorage` is built from +`entry.derivation_path` exactly as today. **`to_key_storage` becomes purely public** — no `PrivateKey` +anywhere on the registration-prep path. + +### 2.2 Where the chooser's public keys come from + +The three chooser methods switch from `identity_authentication_ecdsa_private_key` to public +derivation, served by **D4b's `AuthPubkeyCache` + the public derivation path**: + +```text +// chooser, seed-free: +let pk = wallet.identity_authentication_ecdsa_public_key_cached(&cache, network, idx, key_index) + // D4b: cache hit -> 33 bytes -> PublicKey, ZERO seed access +``` + +The chooser needs the **derivation path** too (to store in `KeyStorage`). The path is a pure +function of `(network, identity_index, key_index)` — +`DerivationPath::identity_authentication_path(network, ECDSA, identity_index, key_index)` — and +needs **no seed**. So the chooser computes `entry = { public_key: , derivation_path: +, … }` entirely seed-free. + +**Cold-cache caveat (inherited from D4b §4.5).** A cold `AuthPubkeyCache` cannot serve a never-seen +`(identity, key)` tuple without one JIT seed-derivation. The chooser runs in sync `ui()` and cannot +`await`. Two acceptable resolutions, in preference order: + +1. **Warm-before-show (recommended).** When a wallet is selected/unlocked or the identity index + changes, the screen dispatches an async `WalletTask::WarmIdentityAuthPubkeys { seed_hash, + identity_index, key_count }` that resolves the needed pubkeys via D4b's cold-fill (one + `with_secret_session`, one prompt for a protected wallet — but this *is* the unlock the user just + performed) and populates the cache. The chooser then reads the warm cache synchronously on the + next frame. Display shows "Preparing identity keys…" until the warm result lands. This mirrors the + existing `refresh_banner` + `display_task_result` lifecycle every other screen uses. +2. **Lazy cache via D4b §4.4.** If `bootstrap_wallet_addresses_jit` already warms the identity-auth + ranges at unlock (D4b-4), the cache is warm for index 0..N by the time the chooser opens, and + step 1 is only needed for an *advanced-mode* high identity index the bootstrap didn't cover. + +Either way, the chooser's `ensure_correct_identity_keys` / `update_identity_key` / `add_identity_key` +become **pure, seed-free, synchronous** cache reads (option 1's warm task is the only async hop, and +it is an ordinary backend task, not a derivation inside `ui()`). + +### 2.3 The chooser methods, after + +| Method | Before | After | +|---|---|---| +| `ensure_correct_identity_keys` | derives master + 5 private keys via `:1112` | reads master + 5 **public** keys from `AuthPubkeyCache` (+ pure paths); builds `IdentityKeyEntry`s. Returns a "cache cold — warming" signal if a tuple is missing, which the screen turns into a `WarmIdentityAuthPubkeys` task. | +| `update_identity_key` | re-derives private keys for new index | re-reads public keys for new index from cache (warm-if-cold). | +| `add_identity_key` | derives one private key for the next index | reads one public key for the next index from cache (warm-if-cold). | + +### 2.4 Private keys materialized JIT at registration — already done, restated for the record + +No change to the signing path is required. At registration: + +- The chosen key set is `IdentityKeys` (public + paths). `to_public_keys_map` → the keys submitted. + `to_key_storage` → `KeyStorage` with `AtWalletDerivationPath` entries keyed by the same paths. +- `QualifiedIdentity::sign` maps `key_id` → `(target, key_id)` → `wallet_seed_hash_for` → + `with_secret(HdSeed{seed_hash})` → `get_resolve_with_seed(seed)` → + `derive_private_key_in_arc_rw_lock_slice_with_seed(…, derivation_path, …)`. **This is where the + private key for each chosen public key is materialized** — JIT, borrowed seed, derived at the + stored path, used to sign, dropped. The mapping "chosen public key (chooser) → private key (sign + time)" is the `derivation_path` carried in `IdentityKeyEntry` → `WalletDerivationPath`. Identical + path in, identical key out (BIP-32 determinism); D4b §1 proves the public pre-image matches the + private derivation byte-for-byte. + +So the public key the user sees in the chooser and the private key that signs at registration are +the two faces of the same `derivation_path`. The chooser never holds the private face. + +--- + +## 3. The signer — does identity-create go through `DetSigner`/the chokepoint? + +**Yes, two signers, both already on the chokepoint. No new signer path is needed.** + +| Signer | Trait | Role in identity-create | Seed source today | +|---|---|---|---| +| `QualifiedIdentity` | `Signer` (`mod.rs:316`) | signs with each **identity key being added**, proving ownership | JIT: `with_secret` + `get_resolve_with_seed` (already seed-free) | +| `DetSigner` | upstream `key_wallet::signer::Signer` (path-indexed) | signs the **asset-lock credit-output** witness | JIT: `DetSigner::from_held(session.plaintext(), network)` in `register_identity` | +| `DetPlatformSigner` | `Signer` | signs the **platform-address funding** witness (address-funded flow only) | JIT: `from_held(seed, …)` (D3) | + +The identity-ownership signature — the one the brief flags ("the keys being added must sign to prove +ownership") — flows through `QualifiedIdentity::sign`, which is already the seed-free JIT path. The +"JIT private-key provision for it" the brief asks me to design is `get_resolve_with_seed`, which D2 +already landed and which `QualifiedIdentity::sign` already calls. **D4c adds nothing here; it only +ensures the chooser stops pre-deriving the same keys eagerly.** + +> A note on the alternative I rejected: one could imagine giving the chooser a `DetSigner`-style +> handle and deferring even the public derivation. Unnecessary — the public keys are cheap, cacheable +> (D4b), and the chooser genuinely must show/submit them. The private derivation is the only thing +> worth deferring, and it is already deferred to sign time. + +--- + +## 4. The advanced-mode WIF display — the one true residual private read + +`render_keys_input` (`add_new_identity_screen/mod.rs:608, 655`) shows `Secret::new(key.to_wif())` +for the master and each key, in **advanced** mode only. This is the sole place the chooser's private +keys were ever *displayed*. After §2 the chooser holds no private keys, so this must change. + +**Design (mirror D3's `WalletTask::DeriveKeyForDisplay`).** The WIF column becomes a per-row "Show +WIF" affordance that dispatches `WalletTask::DeriveKeyForDisplay { seed_hash, derivation_path }` +(the task already exists from D3, `backend_task/wallet/derive_key_for_display.rs`). The backend +resolves the seed via `with_secret`, derives at the path, returns the WIF as a typed +`BackendTaskSuccessResult`; the screen renders it from `display_task_result` into a transient, +copyable field (zeroized on screen close). No seed crosses into `ui()`. + +This is consistent with the existing trust boundary (the WIF was already shown on screen) and with +the merged D3 precedent. It also *improves* UX slightly: the WIFs are no longer derived eagerly for +every row on every frame; they are derived on demand. + +> **Open design choice (Q1).** Advanced-mode pre-registration WIF display is a niche power-user +> feature (inspect the keys before creating the identity). An alternative is to **drop** the WIF +> column entirely from the *pre-registration* chooser and rely on the post-registration key viewer +> (key_info_screen, already a backend task) to show WIFs after the identity exists. That removes the +> last private-derivation entry point from this screen with zero new task wiring. I lean toward +> dropping it (simpler, and the keys are recoverable post-registration), but it is a product call. + +--- + +## 5. e2e `build_identity_registration` handling + +There are **two** helpers named `build_identity_registration`: + +### 5.1 Production helper (`backend_task/identity/mod.rs:484`) + +Sync, calls `:1112` six times to build `IdentityKeys`. Convert to the same public shape as the +chooser: derive the six **public** keys (cache or, for tests, a direct seed-param public derivation) +and the pure paths. Because tests run headless and may want determinism without a warm cache, give it +a **seed-param** form (mirroring the D2 `*_with_seed` pattern the brief calls out): + +```rust +// public-only, seed-param — derives public keys + paths from a borrowed seed +pub(crate) fn build_identity_registration_with_seed( + app_context, wallet_arc, seed: &[u8; 64], identity_index, funding_amount, +) -> Result +``` + +The async caller resolves the seed once via `with_secret_session` and calls this. A thin async +wrapper `build_identity_registration` (no seed param) opens the scope and delegates — so existing +call sites that can `await` need only add `.await`. + +### 5.2 Test-framework helper (`tests/backend-e2e/framework/identity_helpers.rs:24`) + +Returns `(IdentityRegistrationInfo, Vec master_key_bytes)`. **The `master_key_bytes` is dead +weight:** every consumer binds it as `_master_key_bytes` / `_signing_key_bytes` or stores it inert +(`fixtures.rs:86`, `token_tasks.rs:65`); the *actual* signing in tests uses the **public** +`signing_key` from `find_authentication_public_key(&qi)`, never the raw bytes. So: + +- **Drop the `Vec` from the return type** (or return an empty/placeholder for the smallest diff). + Update the three struct fields (`SharedIdentity.signing_key_bytes`, `SharedToken`, + `token_tasks` local) to not require it — they already don't use it to sign. +- Convert the helper to the seed-param public build (§5.1). Tests drive it with the test wallet's + seed (which the harness already has, since it creates the funded wallet) — **no parked seed + needed**. This is the "give it an async variant or a seed-param form so tests can drive it without + the parked seed" the brief asks for. + +This is a mechanical test refactor with no live-network behavior change: the registration task it +feeds is unchanged; only the *input construction* moves from private to public. + +--- + +## 6. Removing `:1112`'s parked-seed read — the D4 gate + +After §2–§5, the callers of `identity_authentication_ecdsa_private_key` are: + +| Caller | Status after D4c | +|---|---| +| `add_new_identity_screen` ×5 | **Gone** — chooser uses public keys (§2); WIF display via backend task (§4). | +| `backend_task/identity/mod.rs:496,510` (`build_identity_registration`) | **Gone** — public seed-param build (§5.1). | +| `tests/.../identity_helpers.rs:37,47` | **Gone** — public seed-param build (§5.2). | + +With zero remaining callers, `identity_authentication_ecdsa_private_key` itself becomes dead and is +**deleted** (along with its `register_address_from_private_key` side-effect call — note the public +counterpart `register_address_from_public_key` already exists at `mod.rs:1146` and is what the +public-key data-map path uses, so address registration is preserved seed-free). Its deletion removes +one `self.seed_bytes()?` read at `mod.rs:1112`. Combined with D4b (the public readers at 1045/1069) +and D1–D3, this drains the identity-key reader population — satisfying the parent's **R-5 compile-time +gate**: once `OpenWalletSeed.seed` is removed in D4, any surviving `seed_bytes()` caller fails to +compile, and there are none left here. + +> **Scope boundary.** D4c does **not** itself delete `OpenWalletSeed.seed` — that is D4's job, gated +> on D1+D2+D3+D4b+D4c all draining their readers. D4c's deliverable is "the identity-key chooser and +> its helpers no longer read the parked seed for private keys," making `:1112` deletable. + +--- + +## 7. UX delta — does the user see a new prompt at registration? + +**No new prompt at the registration click.** The registration backend task already opens exactly one +`with_secret_session` (it has since D2/D3); the identity-ownership and asset-lock signatures share +that one scope. A protected wallet prompts once there today; that is unchanged. + +**Where the prompt moves (slightly earlier, and only for protected wallets):** + +- Today the chooser derives private keys eagerly in `ui()` — which requires the wallet to be + *already open* (the chooser gates on `wallet.is_open()` and shows an "Unlock Wallet" button + first). So a protected wallet is **already unlocked before the chooser populates keys**. The seed + is in the session cache. +- After D4c, the chooser reads **public** keys (cache). If the cache is cold (§2.2), the + warm-before-show task runs inside the *already-open* session — **no new prompt** (the unlock + already happened; the chokepoint serves from the session cache, resolution order step 1, per the + recorded `with_secret` semantics). For an *unprotected* wallet, no prompt ever. + +**Net UX delta:** none visible for the common path. The eager per-frame private derivation (and its +implicit "wallet must be open" coupling) is replaced by a cache read plus, at worst, a one-frame +"Preparing identity keys…" banner on a cold cache. The single registration prompt is unchanged. +**This is acceptable and arguably better** — the chooser no longer holds private keys in memory +across the whole screen lifetime (a residency reduction), and key display is on-demand. + +One honest caveat: if we adopt option-2 warming (bootstrap-at-unlock) and a power user picks an +identity index *beyond* the bootstrapped range in advanced mode, the cold-fill warm task runs then. +For a protected wallet whose session has since been forgotten (e.g. `RememberPolicy` expired), that +could surface a prompt mid-chooser. Mitigation: the chooser already requires `is_open()`; if the +session is gone it shows the unlock button first, so the prompt is the normal unlock, not a surprise. + +--- + +## 8. Risk assessment — this touches identity registration + +| # | Risk | Severity | Mitigation | +|---|---|:---:|---| +| RK-1 | **Public-key ↔ private-key path divergence.** If the chooser stores a `derivation_path` that doesn't match the public key it displays, `to_key_storage` would persist a path whose JIT-derived private key signs with a *different* key than the one submitted in `to_public_keys_map` → identity-create rejected (key ownership proof fails) or, worse, an identity created with keys the wallet can't sign with. | **High** | Derive `public_key` and `derivation_path` from the **same** `(network, identity_index, key_index)` in one place (a single `IdentityKeyEntry` constructor). D4b §1 guarantees public-from-cache == public-from-private-derivation byte-for-byte. Add a Smythe parity test: for a range of indices, `cache pubkey == public(get_resolve_with_seed(path))`. | +| RK-2 | **Cold-cache silent fallback to wrong/empty key.** A cache miss that returns `Default`/empty instead of warming would build an `IdentityKeys` with a zero/garbage pubkey. | **High** | The chooser must treat a miss as "not ready" (warm task), **never** as a usable key. `register_identity_clicked` already guards `master_private_key.is_some()`; the equivalent guard becomes "all entries present and non-placeholder." Fail closed: no warm cache ⇒ registration button disabled. | +| RK-3 | **e2e regression from dropping `master_key_bytes`.** A hidden consumer might actually sign with the raw bytes. | **Medium** | Verified: no non-underscore consumer signs with the bytes (all use the public `signing_key`). Land the e2e change behind a full `cargo test --test e2e` + one live-network identity-create run (the registration path is unchanged, so the risk is purely compile/plumbing). | +| RK-4 | **Advanced-mode WIF task leaks the key beyond the screen.** Routing a WIF through a `BackendTaskSuccessResult` widens its reach. | **Medium** | Reuse D3's `DeriveKeyForDisplay` exactly (it already round-trips a WIF for the wallets screen — same trust boundary, already reviewed). Wrap in the `Secret` newtype end-to-end; zeroize the transient field on screen close. Or adopt Q1 (drop pre-registration WIF entirely). | +| RK-5 | **Removing `:1112` before all callers drained** (esp. a missed test caller) → "wallet closed" at runtime once D4 lands. | **Medium** | Same R-5 compile-time gate as the parent: deleting `OpenWalletSeed.seed` makes `seed_bytes()` un-implementable; any surviving caller fails to compile. Sequence `:1112` deletion in D4c, but the *field* removal stays in D4. `grep identity_authentication_ecdsa_private_key` must return zero before D4. | +| RK-6 | **Bootstrap/address-registration side-effect lost.** `:1112` calls `register_address_from_private_key` (registers the p2pkh address into `known_addresses`/`watched_addresses`). Dropping it could lose an address registration the wallet relied on. | **Low** | The public data-map path (`identity_authentication_ecdsa_public_keys_data_map`, mod.rs:1051) already calls `register_address_from_public_key` for the same paths during identity load/discover (D4b's domain). The address derives identically from the public key (p2pkh). So registration is preserved on the public path; verify no flow depended *only* on the private-key call to register an identity-auth address (load/discover cover it). | +| RK-7 | **Warm task races the render.** The chooser reads the cache the same frame the warm task is still in flight. | **Low** | Standard `display_task_result` lifecycle: the screen shows "Preparing…" until the success result lands, then re-reads. egui re-renders on result arrival. No correctness risk — only a one-frame delay. | + +**Overall:** the fund-critical surface (the actual signing) is **untouched** — it is already JIT and +stays exactly as-is. D4c's risk is concentrated in RK-1/RK-2 (public/path consistency on the +input-construction side), which a single parity test closes decisively. This is a *lower*-risk change +than D3 (which moved live signers) precisely because the signer paths don't move. + +--- + +## 9. Task breakdown for Bilby + +> D4c depends on **D4b** (the `AuthPubkeyCache` + public reader conversion) being available — the +> chooser's public keys come from that cache. D4c is otherwise independent of D1/D2/D3 and, like D4b, +> can land before D4's field removal. Sequence: D4b → D4c → (D1,D2,D3 in parallel) → D4. + +| Task | Title | Files | Depends on | Smythe? | Live e2e? | +|---|---|---|---|:---:|:---:| +| **D4c-1** | Reshape `IdentityKeys` to public-only: `IdentityKeyEntry { public_key, derivation_path, key_type, purpose, security_level, contract_bounds }`; rewrite `to_public_keys_map` + `to_key_storage` to read `entry.public_key` (no `PrivateKey`); keep stored `WalletDerivationPath` identical | `backend_task/identity/mod.rs` (struct + the two builders) | D4b-1 (uses `PublicKey` cache type conventions) | **Yes** (key-set correctness; the submitted pubkeys + stored paths gate signing) | No | +| **D4c-2** | Chooser public-key sourcing: rewrite `ensure_correct_identity_keys` / `update_identity_key` / `add_identity_key` to read public keys from `AuthPubkeyCache` + pure paths; add `WalletTask::WarmIdentityAuthPubkeys` (warm-before-show) + screen `display_task_result` wiring + "Preparing identity keys…" banner; fail-closed registration guard on cold cache (RK-2) | `ui/identities/add_new_identity_screen/mod.rs`, `backend_task/wallet/mod.rs` (+warm task), `backend_task/wallet/*` (warm impl) | D4b-2, D4b-3, D4c-1 | **Yes** (cold-cache fail-closed + warm correctness) | No | +| **D4c-3** | Advanced-mode WIF: route the WIF column through `WalletTask::DeriveKeyForDisplay` (or, per Q1, drop the pre-registration WIF column) | `ui/identities/add_new_identity_screen/mod.rs` (`render_keys_input`) | D4c-1; reuses D3's `DeriveKeyForDisplay` | Advisory (reuses merged D3 path) | No | +| **D4c-4** | Production `build_identity_registration` → public seed-param form + async wrapper; update its callers | `backend_task/identity/mod.rs:484`, `register_identity.rs` dispatch (if it calls it) | D4c-1 | **Yes** (the canonical test/UI registration prep) | No | +| **D4c-5** | e2e helper: public seed-param `build_identity_registration`; drop dead `master_key_bytes`/`signing_key_bytes`; update `fixtures.rs`, `dashpay_helpers.rs`, `token_tasks.rs`, and the `_*` call sites | `tests/backend-e2e/framework/identity_helpers.rs`, `framework/fixtures.rs`, `framework/dashpay_helpers.rs`, `backend-e2e/token_tasks.rs`, `backend-e2e/{identity_create,identity_withdraw,register_dpns,identity_tasks}.rs` | D4c-4 | Advisory | **Yes** — one live identity-create run to confirm the public-input path registers + signs (signing path unchanged, but prove it end-to-end) | +| **D4c-6** | Delete `identity_authentication_ecdsa_private_key` (+ its now-unused `register_address_from_private_key` if no other caller); parity test `cache_pubkey == public(derive_with_seed(path))` over an index range (RK-1) | `model/wallet/mod.rs` (delete `:1092–1125`), `tests/` (parity) | D4c-1..5 (all callers drained) | **Yes** (the deletion + parity is the load-bearing correctness claim) | No | + +**Task count: 6.** D4c-1 and D4c-6 are the Smythe-critical correctness pair (input shape + the parity +that guarantees public matches private). D4c-5 is the only one needing live network, and only to +confirm an unchanged signing path still works through the reshaped input. + +--- + +## 10. Open questions for the user + +1. **Advanced-mode WIF (RK-4 / §4).** Keep pre-registration WIF display via a `DeriveKeyForDisplay` + task, or **drop** it and rely on the post-registration key viewer? I lean toward dropping (removes + the last private-derivation entry from this screen; keys are recoverable after registration), but + it removes a power-user convenience. +2. **Cold-cache strategy (§2.2).** Warm-before-show task (option 1, self-contained in D4c) vs. rely + on D4b-4 bootstrap warming at unlock (option 2, smaller D4c but couples to D4b-4 landing). I + recommend implementing option 1 regardless, since it also covers advanced-mode high indices the + bootstrap range won't reach. +3. **e2e `master_key_bytes` removal (§5.2).** Confirm dropping the `Vec` return is acceptable + (verified unused for signing). If you'd rather keep the tuple shape for diff-minimization, I can + return a placeholder instead — but the cleaner change deletes it. +4. **`IdentityKeys` rename.** With no private keys, `IdentityKeys` is a misnomer (it's now identity + *key specs* + public material). Rename to `IdentityKeySpecs` / `IdentityKeySet`, or keep the name + to minimize churn? (Cosmetic; I lean to renaming for honesty, per M-CONCISE-NAMES, but it widens + the diff.) + +--- + +## 11. Security review (security-best-practices skill) + +- **ASVS V11.7 (data-in-use) / V13.3 (secret management):** D4c **reduces** plaintext-private-key + residency. Today the chooser holds derived `PrivateKey`s for the entire screen lifetime; after, it + holds only public keys. Private material exists only inside `with_secret`/`with_secret_session` + frames at sign time (and, if kept, a transient WIF behind `Secret`). Net: a residency *removal*, + not addition. (Candy-worthy — the chooser stops being a long-lived private-key holder.) +- **No private key caching (R3 invariant, D4b §6):** D4c stores **public** keys (D4b cache) and + **paths** only. The path is not secret (it is derivable structure); the private key is re-derived + JIT and never persisted. Upheld. +- **A04 Insecure Design / least privilege:** the chooser is granted exactly what it needs (public + keys to display + submit) and no more. The eager private derivation was over-privileged for a + display/submit surface. +- **Network confinement (SEC-001 class):** the derivation path carries the per-network coin-type and + the D4b cache is network-keyed; the public key shown and the private key signed share the path, so + cross-network key reuse stays structurally excluded. +- **Integrity (RK-1):** the one real correctness hazard — public/path divergence — is closed by the + single-constructor rule + the D4c-6 parity test. Treat that test as a release gate. + +--- + +## 12. Candy tally (architecture findings by severity) + +| Severity | Count | Findings | +|---|:---:|---| +| **High** (design-shaping / fund-adjacent) | **2** | (1) Identity-create signing is **already** seed-free JIT (`QualifiedIdentity::sign` + `DetSigner`/`DetPlatformSigner`) — the redesign is "stop deriving private keys in the chooser," not "add JIT at registration." (2) Public-key↔derivation-path consistency (RK-1) is the load-bearing correctness invariant; a single-constructor rule + parity test closes it. | +| **Medium** (structural) | **2** | `IdentityKeys` reshapes to public-only (`to_key_storage` already discards the `PrivateKey`); cold `AuthPubkeyCache` needs a warm-before-show task because the chooser is sync `ui()`. | +| **Low** (mechanical / safety) | **3** | e2e `master_key_bytes` is dead weight (no signing consumer) — droppable; advanced-mode WIF is the only residual private read — reuse D3's `DeriveKeyForDisplay`; `register_address_from_public_key` already exists so address registration stays seed-free. | + +**Total: 7 findings (2 High, 2 Medium, 3 Low).** + +--- + +*The hardest problems are sometimes the ones already solved by someone who came before — you only +notice once you trace the whole chain. The seed was never needed in the chooser; it was just being +asked for out of habit. Stop asking, and the chooser goes quiet, the prompt stays exactly where it +was, and the only key left is the one the chokepoint already holds.* — Nagatha diff --git a/docs/ai-design/2026-06-02-jit-secret-access/d4b-authxpub-persistence.md b/docs/ai-design/2026-06-02-jit-secret-access/d4b-authxpub-persistence.md new file mode 100644 index 000000000..c47dfc823 --- /dev/null +++ b/docs/ai-design/2026-06-02-jit-secret-access/d4b-authxpub-persistence.md @@ -0,0 +1,405 @@ +# D4b — Identity-Auth Account Xpub Persistence (R3) + +**Author:** Nagatha (Architect) +**Date:** 2026-06-02 +**Status:** Read-only scoping / design. No implementation in this pass. +**Branch:** `docs/platform-wallet-migration-design` +**Parent scope:** `docs/ai-design/2026-06-02-jit-secret-access/r3-completion-scope.md` +(this document drills into one reader cluster the parent census did not enumerate). + +--- + +## 0. The one-paragraph verdict (read this first) + +The two readers at `src/model/wallet/mod.rs:1045` and `:1069` +(`identity_authentication_ecdsa_public_key` and +`identity_authentication_ecdsa_public_keys_data_map`) derive identity-authentication +**public** keys from the parked seed. The intent of D4b was to retire that seed read by +deriving from a *stored account xpub* instead — exactly as the DIP-17 platform-payment +`WalletAddressProvider` already does. **That cut is not available here.** The +identity-authentication derivation path is **hardened at every level, including the leaf +`key_index'`**. BIP-32 forbids public-from-public (`ckd_pub`) derivation across a hardened +boundary, so **no account-level xpub of any depth can publicly derive these keys.** The +"store the `m/9'/coin'/5'` xpub" approach is structurally impossible for this path family. +The seed (or a per-leaf private key derived from it) is *mandatory* for every +identity-auth pubkey. D4b's storage/migration design therefore changes shape: we do not +persist an account xpub to retire the read; we persist a **per-(identity, key) public-key +cache** so the *steady-state* read needs no seed, while a cold cache still falls back to one +JIT seed-derivation through the chokepoint. The seed dependency is reduced to first-touch, +not eliminated — because the math does not allow elimination. + +--- + +## 1. Byte-equivalence verdict — the hardened-index check (BLOCKING) + +### 1.1 The actual path + +`DerivationPath::identity_authentication_path` (key-wallet `bip32.rs:1115`) builds, from the +`IDENTITY_AUTHENTICATION_PATH_{NETWORK}` const (`dip9.rs:427`, an `IndexConstPath<4>`) plus +three appended children: + +``` +m / 9' (FEATURE_PURPOSE) + / coin' (DASH_COIN_TYPE = 5 | DASH_TESTNET_COIN_TYPE = 1) + / 5' (FEATURE_PURPOSE_IDENTITIES) + / 0' (FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION) + / key_type' (ChildNumber::Hardened { index: key_type.into() }) ← appended, HARDENED + / identity_index' (ChildNumber::Hardened { index: identity_index }) ← appended, HARDENED + / key_index' (ChildNumber::Hardened { index: key_index }) ← appended, HARDENED ← LEAF +``` + +Every node is `ChildNumber::Hardened`. The leaf `key_index'` is hardened +(`bip32.rs:1133-1135`). The "identity-auth account" the task brief calls `m/9'/coin'/5'` is a +shorthand for this root family; the *real* leaf sits three hardened levels below it. + +### 1.2 Why hardened ⇒ xpub-only derivation is impossible + +`ExtendedPubKey::derive_pub` → `ckd_pub` → `ckd_pub_tweak` returns +`Error::CannotDeriveFromHardenedKey` for any `ChildNumber::Hardened` +(key-wallet `bip32.rs:1827`; asserted upstream at `bip32.rs:2197/2222`). A stored xpub at +`m/9'/coin'/5'` would have to take a **hardened** first step (`0'`) to reach anything useful — +which `ckd_pub` rejects outright. Even an xpub at the deepest possible non-leaf parent +`…/identity_index'` cannot derive its child, because that child (`key_index'`) is itself +hardened. + +> **Contrast with the working precedent.** DIP-17 platform payment +> (`WalletAddressProvider::derive_address_at_index`, `mod.rs:2259`) derives +> `ChildNumber::Normal { index }` — a *non-hardened* leaf — from the account xpub, which is +> exactly why that xpub-only design works and is parity-tested by +> `xpub_derivation_matches_seed_derivation`. The identity-auth path is the mirror image: the +> leaf is hardened, so the same trick is unavailable. + +### 1.3 Verdict + +**xpub-only derivation of identity-auth public keys: IMPOSSIBLE.** This is the +"flag if the final child index is hardened, which would BLOCK xpub-only derivation and require +a different cut" branch in the brief. It is taken. The remainder of this document designs that +different cut. + +--- + +## 2. The different cut — persist the derived public keys, not an account xpub + +Since we cannot derive the auth pubkeys publicly from a stored xpub, we instead **memoise the +already-derived public keys**. The readers at 1045/1069 consume only public material: + +- `:1047` returns `extended_public_key.to_pub()` (a `secp256k1` `PublicKey`). +- `:1072-1077` builds two maps keyed on `public_key.serialize()` (33 bytes) and + `public_key.pubkey_hash()` (`[u8; 20]`), valued by `key_index`. + +Both are pure functions of `(network, identity_index, key_index)` and the wallet seed. The +public outputs never change for a given tuple. So the persisted artifact is a small, additive +**public-key cache** keyed by that tuple. Once warm, the readers serve from cache with **zero +seed access**; a cold tuple does one JIT seed-derivation through the chokepoint and writes the +cache. Correctness never depends on the cache being present. + +This satisfies the R3 goal in spirit: the **steady-state** read of identity-auth keys no +longer touches the parked seed, which is what lets D4 remove the `OpenWalletSeed.seed` field +without these two readers blocking the build. The residual first-touch derivation runs inside +`with_secret`, the single auditable boundary. + +--- + +## 3. Storage shape + +### 3.1 Where it lives — DET KV sidecar, NOT a new SQL table, NOT upstream + +DET wallet state has moved off `data.db`'s `wallet` table into two homes: + +- the **upstream** `platform-wallet.sqlite` (balances / txs / identities, **refinery**-migrated), and +- the **DET-owned** `det-app.sqlite` k/v store (`src/wallet_backend/kv.rs`), a + `[ SCHEMA_VERSION(1B) | bincode(payload) ]` blob surface keyed by `DetScope`, plus the + upstream `SecretStore` seed envelope (`StoredSeedEnvelope`, `model/wallet/seed_envelope.rs`). + +The auth-pubkey cache is **DET-derived, public, per-wallet data** — it belongs in the DET KV +sidecar, addressed by `DetScope::Wallet(seed_hash)`, mirroring how `WalletMeta` +(`model/wallet/meta.rs`) and the BIP-44 master `xpub_encoded` are already persisted. It must +**not** go into the upstream refinery DB (see §4 for why that is the safe choice that dodges +the DivergentVersion class). + +### 3.2 The model field — new struct, new KV key + +A dedicated KV entry per wallet, parallel to `WalletMeta`: + +```rust +// src/model/wallet/auth_pubkey_cache.rs (new) + +/// Cached identity-authentication ECDSA public keys for one HD wallet. +/// Public material only — derivable from the seed but expensive (hardened +/// path), so memoised here. Keyed by (network, identity_index, key_index). +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthPubkeyCache { + /// (network_tag, identity_index, key_index) -> 33-byte compressed pubkey. + /// `network_tag` keeps mainnet/testnet entries distinct under one blob, + /// matching the per-network coin-type in the derivation path. + pub entries: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AuthKeyCoord { + pub network: NetworkTag, // small repr enum, serde-stable + pub identity_index: u32, + pub key_index: u32, +} +``` + +Notes: +- Store the **compressed pubkey bytes** (`[u8; 33]`), not an `ExtendedPubKey` — there is no + meaningful chain code to keep (no further public derivation is possible past a hardened + leaf), so the 78-byte xpub encoding would be misleading. The two readers reconstruct both + the serialized form and the hash160 from these 33 bytes. +- `BTreeMap` (not `HashMap`) for deterministic bincode bytes — matches the project's existing + persisted-shape discipline (`WalletMeta`, `AppSettings`). +- `NetworkTag` is a tiny serde-stable enum; do **not** persist `dashcore::Network` directly if + its serde repr is not pinned. A `#[repr(u8)]`-style tagged enum keeps the blob stable. + +### 3.3 The KV view — mirror `WalletMetaView` + +Add a `wallet_backend/auth_pubkey_cache.rs` with an `AuthPubkeyCacheView<'a>` over +`DetKv`, exactly mirroring `WalletMetaView` (`wallet_backend/wallet_meta.rs`): + +```text +key: ":auth_pubkeys:" (DetScope::Wallet(seed_hash), Global object-id pattern as meta uses) +get: kv.get::(...) +put: kv.put(...) (whole-blob upsert; small map, infrequent writes) +``` + +Whole-blob upsert is acceptable: the map is tiny (handful of identities × few keys), writes +happen only on cold-cache first-touch, and it matches the existing `WalletMeta` write +discipline. No need for row-granular storage. + +--- + +## 4. Migration — additive + lazy, and why it avoids the DivergentVersion class + +### 4.1 The DivergentVersion class, precisely + +`DivergentVersion` is a **refinery** failure: `platform-wallet-storage` runs its schema +ladder with `set_abort_divergent(true)`, so **mutating the SQL of an already-applied +migration changes its checksum and aborts** (reproduced in +`src/backend_task/error.rs:3505 divergent_migration_error`, surfaced to users as +`TaskError::WalletDataIncompatible`, `error.rs:1500`). That class only exists inside the +**refinery-migrated upstream DB**. The DET KV sidecar has **no refinery ladder** — it is a +versioned bincode blob (`kv.rs`, one-byte `SCHEMA_VERSION` prefix; bincode tolerates additive +struct evolution). + +### 4.2 The safe approach (confirmed) + +**Because the cache is a brand-new KV key in the DET sidecar, there is no migration of an +existing artifact at all — and therefore no checksum churn, no refinery touch, and no +DivergentVersion exposure.** This is the cleanest possible "additive nullable column" +analogue: in KV terms, an **absent key reads as `None`/`Default`** (cold cache), which is +exactly the nullable-and-empty semantics the brief asks for. Concretely: + +- **No SQL DDL.** No `ALTER TABLE`, no new refinery migration, no bump of the upstream schema + version. The KV `SCHEMA_VERSION` byte is **not** bumped either — we are adding a *new key*, + not changing the encoding of an existing value. +- **Read-when-absent = cold.** `AuthPubkeyCacheView::get` on a wallet that never wrote the key + returns `Ok(None)` → treated as `AuthPubkeyCache::default()` (empty map). This is the + "column is still NULL" state. +- **Forward/backward tolerant.** An older build that predates this key simply never reads or + writes it; a newer build finds it absent and lazily populates. No coordinated migration step. + +> If a future maintainer is tempted to add this as a column on the upstream wallet table: +> **don't.** That would either edit an applied refinery migration (DivergentVersion) or add a +> new upstream migration (couples DET-derived public cache to the upstream schema and forces a +> version bump). The KV sidecar is the architecturally correct, churn-free home. + +### 4.3 Why a hard backfill is impossible (confirmed) + +A migration-time backfill would have to derive every `(identity, key)` pubkey for every +wallet — which requires each wallet's **seed**. At migration time wallets may be **locked**, +and we will **not** force a passphrase prompt during migration (hostile UX, and the JIT design +forbids eager seed residency). So a hard backfill is off the table. **Lazy population is the +only viable strategy** — which is consistent with the rest of R3. + +### 4.4 Lazy-populate trigger point + +The seed is already in a `with_secret_session` scope at exactly one prompt-free place: +`bootstrap_wallet_addresses_jit` (`context/wallet_lifecycle.rs:341`). It opens +`with_secret_session(HdSeed { seed_hash })`, exposes the seed (`:366`), and runs +`bootstrap_known_addresses` (`:374`). **Warm the auth-pubkey cache there**, in the same +scope, for the identity/key ranges the wallet already knows it bootstraps (the +`bootstrap_identity_*` family registers identity-auth addresses anyway — see parent census #9). +Cost is near-zero: the seed is in hand and the derivations already run during bootstrap; we +additionally persist their public outputs via `AuthPubkeyCacheView::put`. + +This is prompt-free by construction (the JIT bootstrap only runs for wallets whose seed +resolves without asking — unprotected, or already session-cached on unlock; `:346-352`). A +still-locked protected wallet warms its cache on its *next* unlock+bootstrap, or on the +read-path JIT fallback (§5). Either way the user is never prompted *for the cache*. + +### 4.5 Read-path behavior when the cache is cold (the correctness guarantee) + +The readers (§5) must be correct even with an empty cache. Behavior on a miss: + +1. Look up `AuthKeyCoord { network, identity_index, key_index }` in the cache. +2. **Hit** → reconstruct `PublicKey` from the 33 bytes; done, **no seed access**. +3. **Miss** → resolve the seed via the chokepoint (`with_secret(HdSeed { seed_hash })`), + derive once via the existing hardened-path `derive_pub_ecdsa_for_master_seed`, **write the + result back** to the cache, and return it. + +Correctness never depends on the cache being warm — a cold miss self-heals into a warm hit. +This is the "fall back to one JIT seed-derivation that also populates it" contract from the +brief, made exact. + +--- + +## 5. Reader conversion (`mod.rs:1045`, `:1069`) + +Both methods are **sync** today and read `self.seed_bytes()`. After D4 there is no parked +seed, so they cannot read it. They split into a pure cache-hit fast path and an async +cold-fill slow path. Per the parent scope's class taxonomy these become **class A/B hybrids**: +the in-model body becomes seed-free (cache lookup); the cold-fill is owned by the async +backend caller that already runs inside `with_secret`. + +### 5.1 The three callers are all async backend tasks + +Confirmed call sites — every one is an async backend task (no sync UI consumer): +- `backend_task/identity/load_identity.rs:471, 494, 512` (data_map) +- `backend_task/identity/load_identity_from_wallet.rs:44 (single), :166 (data_map)` +- `backend_task/identity/discover_identities.rs:40, 190 (single)` + +This is the easy case: there is no sync-UI hard-blocker (unlike the key-viewer cluster in the +parent doc). The cold-fill can live in the async caller cleanly. + +### 5.2 Sketch + +```rust +// model/wallet/mod.rs — PURE, seed-free, infallible on a hit: +pub fn identity_authentication_ecdsa_public_key_cached( + &self, + cache: &AuthPubkeyCache, + network: Network, + identity_index: u32, + key_index: u32, +) -> Option { + cache.get(network, identity_index, key_index) // 33 bytes -> PublicKey +} + +// the existing seed-taking variant is RENAMED to make the seed dependency explicit +// (seed-as-parameter, per parent §3), NOT reading self.seed_bytes(): +pub fn identity_authentication_ecdsa_public_key_from_seed( + &self, + seed: &[u8; 64], // borrowed, from with_secret + network: Network, identity_index: u32, key_index: u32, +) -> Result { /* current 1038-1047 body, seed param */ } +``` + +The async backend caller drives the cache: + +```rust +// backend_task/identity/* — caller owns the chokepoint + write-back +let cache = ctx.auth_pubkey_cache_view().get(network, &seed_hash)?.unwrap_or_default(); +let pk = match wallet.identity_authentication_ecdsa_public_key_cached(&cache, network, i, k) { + Some(pk) => pk, // warm: zero seed access + None => backend.secret_access().with_secret( // cold: one JIT derivation + &SecretScope::HdSeed { seed_hash }, + |pt| { + let seed = pt.expose_hd_seed().ok_or(TaskError::ContactWalletSeedUnavailable)?; + let pk = wallet.identity_authentication_ecdsa_public_key_from_seed(seed, network, i, k)?; + ctx.auth_pubkey_cache_view().upsert(network, &seed_hash, i, k, &pk)?; // populate + Ok(pk) + }, + ).await?, +}; +``` + +The `_data_map` variant (1051-1090) is the same pattern over a `Range`: partition the +range into cache-hits and cache-misses, resolve all misses inside **one** `with_secret` scope +(one prompt, not one-per-key), write them all back, then build the two maps +(`serialize()` and `pubkey_hash()`) from the union. Note its `register_addresses` side effect +(`:1078`) is a `&mut self` mutation — keep that on the wallet write path the caller already +holds, unchanged; only the *pubkey source* moves to cache-or-JIT. + +> **Important:** these two methods produce **public** keys only. The sibling +> `identity_authentication_ecdsa_private_key` (`:1092`) genuinely needs the seed every call +> (a private key cannot be cached at rest — that would re-introduce a plaintext residency, +> violating R3). It is out of D4b scope; it converts via the parent doc's seed-as-parameter / +> JIT-signer track, not via this cache. + +--- + +## 6. Security review (security-best-practices skill) + +- **ASVS V11.7 (data-in-use) / V13.3 (secret management):** the cache stores **public** keys + only — no plaintext seed, no private key, no chain code that enables further derivation. + Persisting it at rest in cleartext is acceptable (public keys are already on-chain / + derivable). It does **not** widen the secret residency surface; it *narrows* it by removing a + steady-state seed read. (Candy-worthy: this is a net reduction in plaintext-seed touch + points.) +- **Do not cache private keys.** Explicitly out of scope and explicitly forbidden — a private + key cache would defeat R3. The `_private_key` reader stays JIT. +- **Cache poisoning / integrity:** the cache is DET-derived and never accepts external input; + the only writer is the JIT cold-fill path which derives from the authoritative seed. A + corrupted blob fails `bincode` decode → treated as cold (`Default`) → self-heals via + re-derivation. No trust is placed in the cache beyond a hit being a memoised pure function; + if integrity is later a concern, the cache can be made self-verifying by re-deriving on a + hash mismatch — not needed now. +- **Network confinement:** `AuthKeyCoord.network` keeps mainnet/testnet pubkeys distinct, + matching the per-network coin-type in the path (the SEC-001 class of bug — cross-network key + reuse — is structurally excluded by keying on network). +- **Debug redaction:** public keys need no redaction, but keep the `Debug` impl terse + (entry count, not full bytes) to match the `WalletMeta`/`StoredSeedEnvelope` house style. + +--- + +## 7. D4b task breakdown + +> D4b is a **sub-task of D4** in the parent scope. It is a prerequisite for D4's removal of +> `OpenWalletSeed.seed`: until these two readers stop reading `self.seed_bytes()`, deleting the +> field fails to compile (the parent's R-5 compile-time gate). D4b can land **independently and +> before** the rest of D4, since the cache + cold-fill is correct whether or not the seed is +> still parked. + +| Task | Title | Files | Depends on | Smythe review? | +|---|---|---|---|:---:| +| **D4b-1** | `AuthPubkeyCache` + `AuthKeyCoord` model; bincode round-trip + `Default`-is-cold tests | `src/model/wallet/auth_pubkey_cache.rs` (new), `src/model/wallet/mod.rs` (mod decl) | — | Advisory | +| **D4b-2** | `AuthPubkeyCacheView` KV view (get/put/upsert) mirroring `WalletMetaView`; key-shape unit test | `src/wallet_backend/auth_pubkey_cache.rs` (new), `wallet_backend/mod.rs` (export), `context/mod.rs` (`auth_pubkey_cache_view()` accessor) | D4b-1 | Advisory | +| **D4b-3** | Reader conversion: `*_cached` (pure) + `*_from_seed` (seed-param) variants; rewire the 3 async callers to cache-hit-else-JIT-cold-fill; range-partition the `_data_map` cold-fill into one scope | `src/model/wallet/mod.rs` (1032-1090), `backend_task/identity/{load_identity,load_identity_from_wallet,discover_identities}.rs` | D4b-1, D4b-2 | **Yes** (derivation correctness; identity-key integrity) | +| **D4b-4** | Lazy warm in `bootstrap_wallet_addresses_jit` (write cache for bootstrapped identity/key ranges inside the existing `with_secret_session`) | `context/wallet_lifecycle.rs:341-388` | D4b-2, D4b-3 | **Yes** (runs in the seed scope) | +| **D4b-5** | Migration / cold-cache tests: cold-read self-heals; warm-read takes zero seed access; cross-network keys stay distinct; **no refinery migration touched / no schema bump** (assert the upstream `db_schema_version` and KV `SCHEMA_VERSION` are unchanged) | `tests/` (kittest or unit), `src/model/wallet/auth_pubkey_cache.rs` (#[cfg(test)]) | D4b-3, D4b-4 | **Yes** (DivergentVersion-avoidance is the load-bearing claim) | + +**Why this many.** D4b-1/-2 are pure additive plumbing (independently mergeable). D4b-3 is the +fund-adjacent correctness core (identity-auth keys gate identity load/discovery, which gates +signing) and must stand alone for Smythe. D4b-4 touches the seed scope and must be reviewed +for prompt-free + no-extra-residency guarantees. D4b-5 is the explicit guard that the +"additive + lazy, no DivergentVersion" claim is *tested*, not merely asserted. + +--- + +## 8. Open questions for the user + +1. **Confirm the cut.** The brief assumed an account-xpub cut; the hardened leaf makes that + impossible, so D4b becomes a **public-key cache** instead. Is the memoise-derived-pubkeys + design acceptable as the D4b shape, or do you want D4b folded back into the per-key JIT + path (no cache at all — every identity-auth pubkey read does one `with_secret` derivation)? + The cache is an optimisation that keeps the *steady-state* read seed-free; without it, + identity load/discover re-derives through the chokepoint every time (correct, but more seed + touches and more prompts for protected wallets). +2. **Cache home.** I place the cache in the DET KV sidecar (`det-app.sqlite`) keyed by + `DetScope::Wallet`, to dodge refinery entirely. Confirm you do **not** want it in the + upstream `platform-wallet.sqlite` (which would reintroduce the DivergentVersion exposure the + brief is explicitly trying to avoid). +3. **Warm-range scope (D4b-4).** Warming at bootstrap covers the identity/key ranges the wallet + already bootstraps. Identities discovered *later* (via `discover_identities`) warm lazily on + first read. Acceptable, or do you want an explicit re-warm hook after discovery? + +--- + +## 9. Candy tally (architecture findings by severity) + +| Severity | Count | Findings | +|---|:---:|---| +| **High** (design-blocking) | **1** | Identity-auth path is hardened at the leaf (`key_index'`) — **xpub-only derivation is impossible**; the assumed account-xpub cut is structurally unavailable and must be replaced by a public-key cache. | +| **Medium** (structural) | **2** | Cache must live in the DET KV sidecar (not upstream refinery) to avoid the DivergentVersion class; lazy-populate must hang off the existing `bootstrap_wallet_addresses_jit` seed scope because a hard backfill is impossible (locked wallets, no forced prompt). | +| **Low** (mechanical / safety) | **2** | Readers' three callers are all async backend tasks — clean cold-fill, no sync-UI hard-blocker; private-key sibling must stay JIT (never cache private material — R3 invariant). | + +**Total: 5 findings (1 High, 2 Medium, 2 Low).** + +--- + +*An elegant design tells you the truth even when the truth is "no." The seed insisted on +hiding one level deeper than the brief expected; the hardened leaf would not be talked out of +it. So we stop fighting the math and memoise the answer instead — the steady state goes quiet, +and the chokepoint keeps the only key that still matters.* — Nagatha diff --git a/docs/ai-design/2026-06-02-jit-secret-access/design.md b/docs/ai-design/2026-06-02-jit-secret-access/design.md new file mode 100644 index 000000000..3e91f4967 --- /dev/null +++ b/docs/ai-design/2026-06-02-jit-secret-access/design.md @@ -0,0 +1,863 @@ +# Just-In-Time Secret Access — `SecretAccess` Chokepoint (formal design) + +**Feature:** Operation-scoped JIT secret access · DET-only (upstream `platform-wallet` untouched) +**Phase:** Architecture (design only — no implementation) +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-02 +**Base:** worktree fast-forwarded to `2272bae0` + +> **SUPERSESSION.** This document **supersedes** +> `docs/ai-design/2026-06-02-signtime-unlock-ux/dev-plan.md` in its entirety. That plan +> designed a *gate-on-error, sign-time-unlock* prompt scoped to a single call site +> (single-key send), with HD and asset-lock signing explicitly **out of scope**. The user has +> since settled a broader cut: **all** secret consumers — HD signing, single-key signing, +> shielded `bind_shielded(seed)`, and DashPay contact-xpub derivation — route through one +> just-in-time chokepoint, and the eager session-residency model is retired for both wallet +> types at once. The dev-plan's narrow framing no longer holds. +> +> **What still applies from the superseded set:** +> - `requirements-and-ux.md` — FR-2 (self-explanatory prompt content), FR-4 (wrong-passphrase +> recoverable), FR-5 (cancel aborts), FR-6 (session unlock — now the opt-in toggle), NFR-1 +> (secret confinement), NFR-2 (a11y) carry forward unchanged. FR-3 (auto-resume) is **replaced** +> by in-flight await (§4): the operation never fails-and-retries; it suspends on the prompt. +> FR-7 (multi-key) carries forward but is reframed as one-prompt-per-operation (§7). +> - `test-cases.md` — the **offline** TCs (cache MISS/HIT/unprotected/import-primes, wrong→typed, +> secret-confinement sentinel across Display/Debug/AppAction/logs, prompt a11y, cancel-zeroizes) +> carry forward, re-pointed at the new seam. The **gate-on-error-specific** TCs (TC-SITE-001 +> "keys off addr from error", TC-RESUME-002 "re-dispatch fires once", TC-CANCEL-005 "stash +> dropped not hidden") are **obsoleted** — there is no error gate, no stash, no re-dispatch. +> New TCs are required for the HD path, the await/cancel mechanics, and the session toggle (§9). + +--- + +## 0. Ground truth (re-verified at `2272bae0`) + +Every claim below was re-grepped against the synced base. The secret surface is wider than the +gate-on-error plan assumed, because that plan deliberately excised HD and shielded/DashPay. + +### 0.1 Three eager secret residencies exist today — all must be retired or made transient + +| # | Residency | Lifetime today | Populated at | Read by | +|---|---|---|---|---| +| R1 | `Inner.seeds: RwLock>>` (`mod.rs:277`) | whole session | `provide_seed` (`mod.rs:531`) from the unlock chokepoint | `signer_for` (`mod.rs:1079`), `derive_private_key` (`mod.rs:1114`) | +| R2 | `Inner.single_key_unlocked: RwLock>` (`mod.rs:201`) | whole session | `SingleKeyView::unlock_with_passphrase` (`single_key.rs:256`) | `raw_key_bytes` (`single_key.rs:293`) → `sign_with` (`single_key.rs:649`) | +| R3 | `Wallet::Open(OpenWalletSeed{ seed: [u8;64] })` inside `ctx.wallets` (`model/wallet/mod.rs:598`) | whole session (until lock) | `WalletSeed::open` / `open_no_password` (`model/wallet/mod.rs:633,653`) at the UI unlock seam | `seed_bytes()` (`mod.rs:751`) → `wallet_seed_snapshot` (`wallet_lifecycle.rs:421`), `first_open_wallet_seed` (`contact_requests.rs:521`), `initialize_shielded_wallet` (`shielded.rs:389`) | + +R1 and R2 are named explicitly in the brief. **R3 is the residency the gate-on-error plan never +touched** and is the one that actually feeds DashPay and shielded today. A JIT design that retires +R1/R2 but leaves R3 holding a whole-session plaintext seed would be theatre. R3 is addressed in §6. + +### 0.2 The unlock chokepoint already exists — the refactor inverts it + +`AppContext::handle_wallet_unlocked` (`wallet_lifecycle.rs:302`) is the single place a decrypted HD +seed is handed to the backend: it snapshots the seed (`wallet_seed_snapshot`, `:421`) and pushes it +into R1 via `provide_seed` (`mod.rs:531`), then eagerly initializes shielded state (`:316`). It is +called from exactly three UI seams: `wallet_unlock.rs:33,100` and `wallet_unlock_popup.rs:180,252`, +plus the cold-boot bootstrap `bootstrap_loaded_wallets` (`wallet_lifecycle.rs:467`). + +**The JIT refactor is an inversion of control on this chokepoint.** Today: *unlock pushes the seed +into a cache (eager)*. After: *an operation pulls the seed through `with_secret`, prompting only if +needed (lazy)*. `handle_wallet_unlocked` stops being a secret-distribution point; the unlock +**popup's role shrinks to verifying the passphrase and seeding the optional session cache** (§5). + +### 0.3 The upstream signing seam is a per-operation async trait — confirmed, password-free + +Upstream operations are generic over a signer with `async fn sign(&self, key: &K, data: &[u8]) -> +Result` (e.g. `…/rs-platform-wallet/src/wallet/identity/network/{dpns, +transfer,withdrawal,contract,profile,update,contact_requests}.rs:38-44` at pin `ddfa66e`). Upstream +has **no password concept** — confirmed. DET injects `WalletAssetLockSigner` (`asset_lock_signer.rs:52`) +which implements `key_wallet::signer::Signer` and **already owns a one-operation seed snapshot, +zeroized on drop** (`asset_lock_signer.rs:11-15,56`). This is not an obstacle to JIT — **it is the +template for it.** Its construction is the exact point where `with_secret` belongs. + +### 0.4 Single-key send is still a stub (carried unchanged from the superseded plan) + +`send_single_key_wallet_payment` returns `SingleKeyWalletsUnsupported`; single-key sends are +rejected at `core/mod.rs`. The superseded plan's Open Question Q3 (single-key send is larger than a +prompt) **still stands** and is re-surfaced in §10. The JIT *machinery* for single-key signing is +designed here and is testable against `sign_with` directly; the live send wiring remains gated. + +### 0.5 Reuse inventory (the brief's mandate — confirmed present) + +- `PasswordInput` (`components/password_input.rs:41`) — `Secret` backing (zeroized on drop, + `model/secret.rs`), hold-to-reveal, `text()`/`take_secret()`/`secret()`/`clear()`/`set_error()` + (`:100,130,125,110,120`). This is the passphrase field; do not build another. +- `WalletUnlockPopup` (`components/wallet_unlock_popup.rs:22`) — overlay, centered `Window`, + focus-once, Enter/Escape/X/click-outside, `open()`/`close()` zeroize via `password_input.clear()`. +- `ScreenWithWalletUnlock` (`components/wallet_unlock.rs:9`) — the per-screen unlock-render trait. +- `SecretString` / `SecretBytes` (`platform_wallet_storage::secrets::secret`, pin `ddfa66e:40,165`): + `SecretString::new(impl Into)`, `.expose_secret() -> &str`; `SecretBytes::from_slice`, + `.expose_secret() -> &[u8]`. Both zeroize. **The passphrase crosses the UI↔async boundary as + `SecretString`.** DET already depends on the crate; do **not** roll a new secret type. + +--- + +## 1. Goals, non-goals, and the invariant that drives the whole design + +### 1.1 Goals + +1. **Operation-scoped residency by default.** A decrypted secret exists only for the duration of one + user action and is zeroized at action end. +2. **Opt-in session cache.** A per-prompt checkbox ("remember until I close the app", default OFF) + promotes that secret to a session cache for the rest of the process. +3. **One chokepoint for all consumers.** HD signing, single-key signing, shielded `bind_shielded`, + and DashPay derivation all obtain plaintext through `SecretAccess::with_secret`. +4. **The passphrase travels as `SecretString` across the UI↔async seam; the backend decrypts JIT.** +5. **egui never enters `wallet_backend`.** `SecretPrompt` is the only seam the UI implements. + +### 1.2 Non-goals + +- No change to upstream `platform-wallet` (it has no password concept; DET adapts on its side). +- No change to the at-rest vault model (`open_secret_store`, the SEC-003 empty-passphrase single-key + vault is **out of scope** — do not "fix" it here). +- No un-stubbing of single-key send (still §10/Q-SEND, as in the superseded plan). +- No idle auto-lock timer (the toggle is "until app close" / manual lock only; an idle timer is a + future, explicitly deferred — §5.4). + +### 1.3 The load-bearing invariant + +**A plaintext secret crosses no layer boundary and is never copied beyond the closure that consumes +it.** The async↔UI channel carries only (a) a `SecretPrompt` *request* (no secret), and (b) the +user's reply as a `SecretString` *passphrase* (a key-derivation input, not a derived key). The +derived 64-byte seed / 32-byte key exists only inside `with_secret`'s `Zeroizing` buffer, for the +length of one closure call. This is the same confinement property the superseded plan achieved for +single-key, generalised to every consumer. + +--- + +## 2. Layer map + +| Layer | Module(s) | Responsibility | New / changed surface | +|---|---|---|---| +| **Presentation (egui)** | `src/ui/components/secret_prompt_host.rs` (new), `src/app.rs`, reused `password_input.rs` / `wallet_unlock_popup.rs` | Drain prompt requests each frame; render the reused modal; return the typed reply. Implements `SecretPrompt`. | `EguiSecretPromptHost` impl + `AppState` drain loop integration. | +| **Boundary (the seam)** | `src/wallet_backend/secret_prompt.rs` (new) | UI-agnostic request/reply contract. The *only* thing the UI sees of the secret machinery. | `SecretPrompt` trait + `SecretPromptRequest` + `SecretScope` (DET-opaque). | +| **Domain / orchestration** | `src/wallet_backend/secret_access.rs` (new), `src/wallet_backend/mod.rs`, `single_key.rs`, `asset_lock_signer.rs` (→ `det_signer.rs`), `dashpay.rs`, shielded paths | `with_secret` chokepoint: cache lookup → prompt → decrypt JIT → run closure → zeroize. Owns the operation + session caches. | `SecretAccess` + the new `DetSigner`; retire R1/R2 caches and `provide_seed`. | +| **Persistence (vault)** | `platform_wallet_storage::secrets::SecretStore` (consumed) | Unchanged. Decryption-on-demand source of truth. | none. | + +The seam is `secret_prompt.rs`. `wallet_backend` depends on it; egui depends on it; they never depend +on each other. (M-DONT-LEAK-TYPES, §8.) + +--- + +## 3. `SecretPrompt` — the UI↔async boundary + +### 3.1 Scope identifier (DET-opaque, no leaked types) + +```rust +// src/wallet_backend/secret_prompt.rs + +/// Which secret an operation needs. DET-opaque: carries no upstream type, no +/// plaintext, only opaque-but-copyable handles (CLAUDE.md rule 6) for prompt +/// copy. `Eq + Hash` so it keys the caches in `SecretAccess`. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum SecretScope { + /// HD wallet seed, identified by DET's SHA256(seed) hash. + HdSeed { seed_hash: WalletSeedHash }, + /// Imported single key, identified by its P2PKH address. + SingleKey { address: String }, +} +``` + +`WalletSeedHash` is a DET type (`model/wallet`), not an upstream one, so it may sit on this seam. +`SecretScope` is the cache key **and** the prompt-routing key. + +### 3.2 The request and reply + +```rust +/// A request for the user to supply a passphrase. Enqueued by the backend, +/// drained by `AppState` each frame. Carries NO secret. +pub struct SecretPromptRequest { + pub scope: SecretScope, + /// Human label for the modal body (alias / address / wallet name). + pub display_label: String, + /// Optional user-set hint (HD password hint or single-key hint). + pub hint: Option, + /// Set on a re-ask after a wrong passphrase, so the host shows the inline + /// error. None on first ask. + pub retry_reason: Option, + /// One-shot reply channel. Dropping the sender == cancel (§7.3). + pub reply: tokio::sync::oneshot::Sender, +} + +pub enum SecretPromptRetry { WrongPassphrase } + +/// The user's answer. The passphrase is a `SecretString` (zeroizing) — a +/// key-derivation INPUT, never a derived key. +pub struct SecretPromptReply { + pub passphrase: platform_wallet_storage::secrets::SecretString, + /// Session-toggle state at submit time (default false). Promotes the + /// decrypted secret to the session cache (§5). + pub remember_for_session: bool, +} +``` + +> **Why `SecretString` and not a derived-key type.** The reply carries the *passphrase*, decrypted +> nowhere yet. The backend decrypts JIT inside `with_secret`. The channel therefore never carries +> seed/key material — only the human input that unlocks it. This is strictly stronger than handing a +> decrypted seed across the channel and is the reason cancel can be a clean drop. + +### 3.3 The trait + +```rust +#[async_trait::async_trait] +pub trait SecretPrompt: Send + Sync { + /// Ask the user for the passphrase for `request.scope`. Resolves when the + /// user submits (Ok) or cancels (Err). Implementations MUST NOT block; the + /// egui impl enqueues and awaits the oneshot. + async fn request(&self, request: SecretPromptRequest) -> Result; +} + +/// The user dismissed the prompt, or the host was torn down. Carries no data. +#[derive(Debug, thiserror::Error)] +#[error("the passphrase prompt was cancelled")] +pub struct SecretPromptCancelled; +``` + +The trait owns the `oneshot` mechanics: `request()` builds the channel, enqueues +`SecretPromptRequest`, and `.await`s the receiver. A `RecvError` (sender dropped) maps to +`SecretPromptCancelled`. This keeps `tokio::sync::oneshot` out of every call site. + +### 3.4 egui implementation — `EguiSecretPromptHost` + the AppState drain + +`src/ui/components/secret_prompt_host.rs`: + +- An `EguiSecretPromptHost { queue: Arc>>, egui_ctx }` + implements `SecretPrompt::request` by pushing onto `queue`, calling `egui_ctx.request_repaint()` + (so the modal appears immediately even if the UI is idle), and awaiting the oneshot. +- `AppState::update` (`app.rs:1210`) gains a drain step beside the existing + `task_result_receiver.try_recv()` loop (`app.rs:1300`): pop at most one request, store it as the + *active* prompt, and render the reused modal (§5). Exactly one prompt is active at a time; + additional requests stay queued (§7.2). The drain runs unconditionally each frame, so a request + enqueued from a backend task on the tokio runtime surfaces on the next paint. + +This mirrors the existing pattern precisely: backend tasks already talk to `AppState` through a +channel drained in `update()`. `SecretPrompt` adds a second, symmetric channel that flows the other +way (UI → backend) for the reply. No new runtime, no egui types in `wallet_backend`. + +--- + +## 4. `SecretAccess` — the chokepoint + +### 4.1 Shape + +```rust +// src/wallet_backend/secret_access.rs + +/// The just-in-time secret chokepoint. Held by `WalletBackend::Inner`. +/// Clone is O(1) (Arc inner) — M-SERVICES-CLONE. +#[derive(Clone)] +pub struct SecretAccess { inner: Arc } + +struct SecretAccessInner { + /// The encrypted vault — decrypt-on-demand source of truth. + secret_store: Arc, + /// Single-key index (alias/hint/has_passphrase) for prompt copy. + single_key_index: Arc>>, + /// HD wallet meta (alias / password hint) for prompt copy. + wallet_meta: WalletMetaView, + /// The UI seam. `dyn` because the host is chosen at construction + /// (egui host in app, a test double in tests — M-MOCKABLE-SYSCALLS). + prompt: Arc, + /// OPT-IN session cache. Empty by default. A scope lands here only when the + /// user ticked "remember for session". Zeroized values; cleared on app + /// close, network switch, and manual lock (§5.4). + session: RwLock>, + network: Network, +} + +/// A session-promoted plaintext secret. Variant matches the scope kind. +enum SessionSecret { + HdSeed(Zeroizing<[u8; 64]>), + SingleKey(Zeroizing<[u8; 32]>), +} +``` + +### 4.2 The core method + +```rust +impl SecretAccess { + /// Run `f` with the plaintext secret for `scope`, obtaining it + /// just-in-time. Resolution order: + /// 1. operation cache (built fresh per call — see §4.3); + /// 2. session cache, IFF the user previously opted in; + /// 3. else prompt via `SecretPrompt`, receive a `SecretString`, + /// decrypt JIT, run `f`, then zeroize. + /// `f` receives the plaintext by reference inside a `Zeroizing` guard; it + /// MUST NOT clone it out. Returns `f`'s value, or `SecretAccessError` + /// (cancelled / decrypt failed / vault error). + pub async fn with_secret( + &self, + scope: &SecretScope, + f: impl FnOnce(SecretPlaintext<'_>) -> Result, + ) -> Result; + + /// Batch form: keeps ONE decrypted secret alive across several closure + /// calls for the SAME scope within one operation (one prompt, N signs). + /// This is the "operation cache" made explicit and bounded (§7.1). + pub async fn with_secret_session( + &self, + scope: &SecretScope, + f: impl AsyncFnOnce(&SecretSession<'_>) -> Result, + ) -> Result; +} + +/// Borrowed plaintext, kind-tagged. No `Clone`, no `Deref` to the raw bytes +/// without an explicit `expose_*`. Lives only inside the closure. +pub enum SecretPlaintext<'a> { + HdSeed(&'a Zeroizing<[u8; 64]>), + SingleKey(&'a Zeroizing<[u8; 32]>), +} + +/// Within-operation handle: `sign(...)`-style helpers borrow the held +/// plaintext without re-prompting (§7.1). Dropped (zeroized) at op end. +pub struct SecretSession<'a> { /* &held plaintext */ } +``` + +> **On `AsyncFnOnce`.** The batch form must hold the plaintext across `await`s (a payment signs +> several inputs, each an upstream async call). Rust 1.92 (project MSRV, `CLAUDE.md`) supports +> `async` closures; `with_secret_session` takes an `AsyncFnOnce`. If the closure ergonomics prove +> awkward in review, the fallback is an explicit RAII guard returned to the caller +> (`let guard = sa.acquire(scope).await?; guard.sign(...).await?;`) that zeroizes on drop. Either +> shape preserves "one prompt per operation, secret zeroized at op end." **Decision deferred to +> Bilby's first task spike; flag for Nagatha re-review if the guard form is chosen** (it changes the +> consumer signatures in §6). + +### 4.3 What an "operation" is, and the cache lifecycle + +- **Operation = one `with_secret`/`with_secret_session` call.** The "operation cache" is not a + process-global map; it is the **plaintext bound to a single chokepoint call's stack/closure**. It + is created when `with_secret` decrypts (or reads the session cache), lives for the closure, and is + zeroized when the closure returns — `Zeroizing` drop, no manual clearing required, no entry in any + long-lived map. This is the default and is why the default residency is operation-scoped. +- **Session cache** is the only long-lived plaintext store, and it is **empty unless the user opts + in**. On a `remember_for_session = true` reply, `with_secret` inserts the freshly decrypted + plaintext into `session` before running the closure; subsequent `with_secret` calls for the same + scope hit step 2 and never prompt. +- **No "operation cache map" survives a call.** This is the central difference from R1/R2, which were + process-lifetime maps. There is exactly one optional long-lived map (`session`), gated behind an + explicit, defaulted-off user choice. + +### 4.4 Resolution algorithm (single secret) + +``` +with_secret(scope, f): + 1. if let Some(s) = session.read().get(scope): // opt-in cache hit + return f(s.borrow()) // no prompt, no vault read + 2. loop: // re-ask on wrong passphrase + req = build_request(scope, label, hint, retry) // copy from index/meta + reply = prompt.request(req).await + .map_err(|_cancel| TaskError::SecretPromptCancelled)? // §7.3 + plaintext = decrypt_jit(scope, reply.passphrase) // vault read + AES-GCM + match { Ok(p) => p, + Err(WrongPassphrase) => { retry = WrongPassphrase; continue } + Err(other) => return Err(other) } + if reply.remember_for_session: + session.write().insert(scope, SessionSecret::from(&plaintext)) // promote + let out = f(plaintext.borrow()); // run with Zeroizing borrow + // plaintext (the operation copy) zeroizes here on scope exit + return out +``` + +`decrypt_jit` is the only place the vault is touched for plaintext: +- **HD:** `SecretStore.get(WalletId(seed_hash), "seed.v1")` → decode `StoredSeedEnvelope` → + AES-GCM-decrypt with the passphrase → `Zeroizing<[u8;64]>`. (Mirrors `wallet_seed_store.rs`; the + decrypt currently happens in `WalletSeed::open` at the model layer — that path is rerouted, §6.1.) +- **Single key:** `SecretStore.get(single_key_namespace, label_for_address(addr))` → + `SingleKeyEntry::decode` → `entry.decrypt(Some(passphrase))` → `Zeroizing<[u8;32]>`. This is + exactly today's `unlock_with_passphrase` body (`single_key.rs:256-276`) minus the cache insert. + +A wrong passphrase is detected as the existing typed condition (single-key +`SingleKeyPassphraseIncorrect`; HD's `WalletSeed::open` error reshaped to a typed variant, §6.1) and +loops to re-ask **without** closing the modal (FR-4 preserved). + +### 4.5 Error type + +```rust +// extends TaskError (backend_task/error.rs) — typed, no String payloads +TaskError::SecretPromptCancelled // user dismissed; aborts the op cleanly +TaskError::SecretDecryptFailed { #[source] ... } // vault/AES error (not wrong-pass) +// wrong-passphrase stays the existing SingleKeyPassphraseIncorrect / new HdPassphraseIncorrect +// (handled inside the loop, not surfaced unless re-ask is itself cancelled) +``` + +`SecretPromptCancelled` Display is Everyday-User copy ("You cancelled. Nothing was changed. Try the +action again when you're ready.") and carries no secret, no jargon. The passphrase appears in **no** +`TaskError` variant (§8 / NFR-1). + +--- + +## 5. The reused prompt + the session toggle + +### 5.1 The prompt is a thin evolution of `WalletUnlockPopup`, not a new component + +`EguiSecretPromptHost` renders a modal that **reuses `PasswordInput` verbatim** and **clones the +`WalletUnlockPopup` chrome** (overlay `DashColors::modal_overlay()`, centered `Window`, focus-once, +Enter/Escape/X/click-outside). The only additions over `WalletUnlockPopup` are: + +1. It is driven by a `SecretPromptRequest` (scope + label + hint + retry) rather than a + `&Wallet`, and it reports back through the `oneshot`, not by mutating wallet state. +2. It carries the **session toggle** checkbox (§5.3). + +**Recommended consolidation (and the reason this is an "evolution"):** extract the shared modal body +from `WalletUnlockPopup` into a private `passphrase_modal(ui, &mut PasswordInput, hint, error, +extra: impl FnOnce(&mut Ui)) -> PassphraseModalOutcome` helper in `components/`, then render both the +existing wallet-unlock popup and the new secret prompt through it. `WalletUnlockPopup` keeps its +public API; `EguiSecretPromptHost` passes a closure that draws the session checkbox in `extra`. If +the extraction proves fiddly, a straight clone with a `// SHARED-CHROME` marker is acceptable for v1 +(same call the superseded plan made), but the extraction is preferred now because there are **two** +real call sites and a third latent one (`wallet_unlock.rs` inline render). + +### 5.2 Where the unlock popup's role goes + +`handle_wallet_unlocked` no longer distributes a seed (R1 is gone, §6.1). The unlock popup +(`wallet_unlock_popup.rs`) and `ScreenWithWalletUnlock` (`wallet_unlock.rs`) keep their job of +*verifying the passphrase and marking the wallet `Open` in the UI* (which gates display), but they +**stop being the secret-to-backend pipe**. Two coherent options: + +- **(5.2-A) Keep the explicit unlock popup as a pure "verify + optionally seed the session cache" + affordance.** When the user unlocks via the existing popup and the session toggle is on, the popup + calls `secret_access.remember_session(scope, plaintext)`. When off, unlocking only flips the UI + `Open` state; the first signing op will prompt via `with_secret`. **Recommended** — preserves the + familiar "unlock my wallet" gesture and makes the toggle reachable there too. +- **(5.2-B) Remove the standalone unlock popup entirely** and rely solely on JIT prompts. Cleaner in + principle but removes the ability to pre-unlock before an operation and changes muscle memory. + **Not recommended for this cut** (it widens blast radius into every `ScreenWithWalletUnlock` + consumer). **Open question Q-UNLOCK (§10).** + +Either way, `Wallet::Open` ceases to be a *plaintext-seed residency* (R3) — see §6.1. + +### 5.3 The toggle: placement, default, scope + +- **Placement:** a single checkbox inside the reused modal, below the `PasswordInput`, above the + Unlock/Cancel row. Copy: **"Keep this wallet unlocked until I close the app."** + (single-key variant: "Keep this key unlocked…"). i18n-ready, named-placeholder-free, one unit. +- **Default:** **OFF** (operation-scoped). The reply's `remember_for_session` is `false` unless + ticked. The default residency is therefore transient by construction. +- **Scope of "remember":** **per-secret** (`SecretScope`), not global. Ticking it for wallet A's + seed does not unlock wallet B or an imported key. The `session` map is keyed by `SecretScope`, so + granularity is exactly per-secret. (A future "remember all" global toggle could promote-on-unlock + for every loaded scope, but is out of scope — §10 Q-GLOBAL.) + +### 5.4 "Forget": when the session cache clears + +- **App close** — process exit zeroizes (the `session` map's `Zeroizing` values drop). Primary path. +- **Manual lock** — a "Lock wallet" / "Lock key" action calls `secret_access.forget(scope)` (the + successor to `forget_unlocked`, `single_key.rs:281`), removing+zeroizing that scope's entry. +- **Network switch** — `change_context` clears the whole `session` map (a per-network `WalletBackend` + is rebuilt anyway; the old one drops, zeroizing). Belt-and-suspenders: `SecretAccess::forget_all()` + on teardown. +- **Idle auto-lock timer** — **deferred** (non-goal §1.2). The toggle copy is the honest literal + ("until I close the app"), not "for a while," exactly as the superseded plan's Q2 recommended. + +--- + +## 6. Migrating every consumer onto `with_secret` + +This is the heart of the cut. Each current secret reader is mapped to a `with_secret` scope. R1, R2, +R3, `provide_seed`, `signer_for`, `derive_private_key`, `single_key_unlocked`, +`unlock_with_passphrase`-as-cache-primer all retire. + +### 6.1 HD seed — R1 + R3 retired; the seed becomes operation-scoped + +**Retire R1 (`Inner.seeds`) and `provide_seed`.** `handle_wallet_unlocked` (`wallet_lifecycle.rs:302`) +stops calling `provide_seed`; the eager shielded init it triggers (§6.4) is also reworked. + +**Retire R3 as a plaintext residency.** `Wallet::Open(OpenWalletSeed.seed)` is the model-layer +plaintext seed. The cleanest cut that respects "the backend decrypts JIT" is: + +- `WalletSeed::open(password)` (`model/wallet/mod.rs:633`) no longer stores the decrypted seed in an + `Open` variant for the whole session. Instead, unlock **verifies** the passphrase (decrypt-and- + discard, or decrypt-into-session-cache iff the toggle is on) and flips a UI-visible `is_open` + flag. The plaintext seed is no longer parked in `ctx.wallets`. +- Every reader that did `guard.seed_bytes()` now routes through `with_secret(HdSeed{seed_hash}, …)`. + +> **Scope honesty.** Reshaping `WalletSeed` is the largest single piece of this cut because +> `seed_bytes()` (`mod.rs:751`) and `is_open()` (`mod.rs:716`) have many readers. A pragmatic +> staging (Bilby T-series, §9): first introduce `SecretAccess` and route the **backend** consumers +> (signing, shielded, DashPay) through it while `Wallet::Open` still exists; then, in a dedicated +> follow task, collapse `Wallet::Open`'s plaintext into the session cache so R3 is fully retired. +> The first stage already delivers operation-scoped residency for the *consumers the brief +> enumerates*; the second closes R3. **This staging is a recommendation; flag Q-R3 (§10) for the +> user to confirm R3 retirement is in this cut vs. a fast follow.** + +HD consumers and their new form: + +| Current | File:line | New | +|---|---|---| +| `signer_for(seed_hash)` builds `WalletAssetLockSigner` from R1 | `mod.rs:1079` | **Becomes `DetSigner` (§6.2)**: no eager seed; on each `sign`, `with_secret(HdSeed,…)` derives. For the batch asset-lock/identity flows, `with_secret_session` holds one decrypted seed across the upstream call. | +| `derive_private_key(seed_hash, path)` from R1 | `mod.rs:1114` | `with_secret(HdSeed{seed_hash}, |SecretPlaintext::HdSeed(seed)| derive(path, seed))` — one-shot credit-output key. | +| `send_payment` → `signer_for` | `mod.rs:1138` | `with_secret_session(HdSeed,…)` wrapping `send_to_addresses(&det_signer)`; one prompt, all inputs. | +| `register_identity` / `top_up_identity` → `signer_for` | `mod.rs:~1244/~1286` | same: `with_secret_session(HdSeed,…)` wraps `*_with_funding(&det_signer)`. `ensure_identity_funding_accounts` (which needs the seed to derive funding accounts) runs **inside** the same held-secret scope so it does not re-prompt. | +| `create_asset_lock_proof` → `signer_for` + `derive_private_key` | `mod.rs:1176`+ | one `with_secret_session(HdSeed,…)` covering both the signer and the credit-output derivation — single prompt for the whole asset-lock build. | + +### 6.2 The `DetSigner` — implements the upstream async signer, pulls JIT + +Rename/evolve `asset_lock_signer.rs` → `det_signer.rs` (or keep the file, add the JIT variant). Today +`WalletAssetLockSigner::new(seed, network)` is handed an already-snapshotted seed. The JIT signer is +constructed from a **borrowed held secret** inside a `with_secret_session` scope: + +```rust +// inside with_secret_session(HdSeed{seed_hash}, |held| async { +// let signer = DetSigner::from_held(held, network); // borrows the Zeroizing seed +// wallet.core().send_to_addresses(.., &signer).await // upstream async sign() calls land here +// }) +``` + +`DetSigner` implements `key_wallet::signer::Signer` (the asset-lock/payment seam) **and** the +identity-operation `async fn sign(&self, key:&K, data) -> Result` seam +(§0.3). Each upstream `sign` derives from the *held* seed (no re-prompt, no re-decrypt — the +operation already holds it). On drop at scope end, the held `Zeroizing` zeroizes. **This is the +existing `WalletAssetLockSigner` lifetime contract (`asset_lock_signer.rs:11-15`) with the seed +source changed from "snapshot at construction" to "borrow the held JIT secret."** + +**Single-key flows through the same `DetSigner` seam** for the identity/DPNS/etc. signer trait when +the signing identity key is an imported single key: `with_secret(SingleKey{address},…)` yields the +32 bytes, `DetSigner::from_single_key(&bytes)` signs. (Live wiring of single-key *funding* remains +gated, §0.4 / §10.) For raw single-key ECDSA (`sign_with`, `single_key.rs:649`), `raw_key_bytes` +(`single_key.rs:293`) is rewritten to call `with_secret(SingleKey{address}, |k| ecdsa(k, msg))` +instead of consulting `single_key_unlocked`. + +### 6.3 Single key — R2 retired + +- **Retire `Inner.single_key_unlocked` (R2)** and `unlock_with_passphrase`'s cache-insert role + (`single_key.rs:271-275`). The vault read + `entry.decrypt(Some(passphrase))` logic moves into + `SecretAccess::decrypt_jit` for the `SingleKey` scope (§4.4). +- **`raw_key_bytes` / `sign_with`** no longer return `SingleKeyPassphraseRequired` to the UI as a + *gate*; instead the missing secret is obtained inline via `with_secret`, which prompts. The typed + `SingleKeyPassphraseIncorrect` is reused inside the re-ask loop (§4.4). `SingleKeyPassphraseRequired` + becomes vestigial for the prompt flow (it may remain for non-interactive callers, e.g. MCP/CLI, + which get the error rather than a prompt — §10 Q-HEADLESS). +- `import_wif` no longer "primes the cache" (`single_key.rs:230-233` per the superseded plan) — there + is no operation cache to prime; a freshly imported key signs immediately because the just-imported + passphrase can seed the session cache if the user opted in, else the next sign prompts. + +### 6.4 Shielded `bind_shielded(seed)` — JIT-derived Orchard keys + +`initialize_shielded_wallet` (`shielded.rs:372`) currently reads the plaintext seed from R3 +(`shielded.rs:389-399`) and calls `derive_orchard_keys(&seed_bytes,…)` (`:402`); upstream's +`bind_shielded` (FFI `shielded_sync.rs:218`, host `wallet.bind_shielded(&shielded_seed,…)`) consumes +a shielded seed. Migration: + +- The eager shielded init at unlock (`wallet_lifecycle.rs:316-331`) is **removed** — it forced a + whole-session seed residency purely to warm shielded state. Shielded keys are derived **on first + shielded operation** (shield / unshield / shielded send), not at unlock. +- That operation calls `with_secret(HdSeed{seed_hash}, |SecretPlaintext::HdSeed(seed)| { + derive_orchard_keys(seed, network, 0) })`, then binds the resulting key set (DET's + `ShieldedWalletState`, and upstream `bind_shielded` where the FFI path is used). The derived + Orchard **viewing/spending key set** is what persists in `shielded_states` (as today); the **seed** + does not. +- Background shielded *sync* (scanning) uses viewing keys already in `shielded_states` and needs no + seed — so retiring eager init does not break sync; only the first *spend/bind* prompts. + +> **Security note (Smythe).** Orchard spending keys derived from the seed live in `shielded_states` +> for the session today and will continue to. This design does not change that residency (it is +> derived-key state, not the seed). If the user wants shielded spending keys themselves to be +> operation-scoped, that is a larger follow-on (re-derive per spend); flagged Q-SHIELDED (§10). + +### 6.5 DashPay contact-xpub derivation — JIT seed + +`derive_contact_xpub_material(&seed_bytes,…)` (`dashpay.rs:105`) is called from +`contact_requests.rs:321` with a seed obtained via `first_open_wallet_seed` (`:521`), which reads R3. +Migration: + +- `first_open_wallet_seed` is **removed**. The caller resolves the relevant `seed_hash` (it already + has the `QualifiedIdentity`/wallet association) and wraps the derivation: + `with_secret(HdSeed{seed_hash}, |SecretPlaintext::HdSeed(seed)| derive_contact_xpub_material(seed, network, account_index, sender, recipient, &ecdh))`. +- The signature of `derive_contact_xpub_material` is unchanged (`&[u8;64]`), so only its *source* + moves behind the chokepoint. The receive-side derivation (per SEC-001, `incoming_payments`) maps + identically. + +### 6.6 The unlock chokepoint after the cut + +`handle_wallet_unlocked` (`wallet_lifecycle.rs:302`) shrinks to: mark the wallet `Open` for display, +and — **iff** the session toggle was set during the unlock gesture — promote the verified seed into +`SecretAccess`'s session cache via a new `remember_session(scope, plaintext)`. It no longer calls +`provide_seed`, no longer eagerly inits shielded. The three `wallet_unlock*.rs` call sites +(`:33,100`, `:180,252`) and the cold-boot `bootstrap_loaded_wallets` (`:467`) adjust to the new +shrunken contract. **Crucially, watch-only rehydration (`rehydration-rewire/design.md`) is +unaffected**: that design already made load seedless and watch-only; this design supplies the seed +JIT for signing, which is exactly the "seed enters memory only on demand" property +`rehydration-rewire` §5.3 asked for — `provide_seed` was its placeholder for "unlock-time seed +provisioning"; **this design replaces that placeholder with operation-time provisioning** and is +strictly more conservative (the seed is in memory for one op, not the whole session). + +--- + +## 7. Cancellation, reentrancy, batch + +### 7.1 Batch (one operation, many signs → one prompt) + +`with_secret_session` holds the decrypted secret for the whole closure (§4.2). A payment that signs N +inputs, an asset-lock build that signs the funding sighash **and** derives the credit-output key, an +identity registration that derives funding accounts **and** signs — all run inside one +`with_secret_session` scope and prompt at most once. The held secret zeroizes when the scope ends. +(Maps the superseded plan's "operation cache → one prompt" onto an explicit, bounded scope rather +than a process-lifetime map.) + +### 7.2 Reentrancy / second request while a prompt is open + +`AppState` keeps **one active prompt**; further `SecretPromptRequest`s sit in the queue (§3.4). So a +second operation that needs a *different* secret while the first prompt is open does not race — its +request is drained only after the first resolves. Within a single operation, §7.1 means there is no +second request at all. This **serializes** prompts by construction: at most one modal, FIFO drain. + +A pathological case — two independent backend tasks both needing secrets concurrently — resolves as: +task A's prompt shows, task B's request waits in the queue; when A's user submits/cancels, B's prompt +shows next. Neither task busy-waits (both are parked on their `oneshot`). This is the desired UX (one +question at a time) and is safe (each `oneshot` is independent). + +### 7.3 Cancellation = drop the reply sender = clean abort + +- User dismisses the modal (Cancel / X / Escape / click-outside): the host **drops the + `oneshot::Sender`** without sending. The awaiting `with_secret` sees `RecvError` → + `SecretPromptCancelled` → the consuming backend task returns `Err(TaskError::SecretPromptCancelled)` + → normal `TaskResult::Error` → a calm banner. **No partial state**: the secret was never decrypted + (or was decrypted and immediately dropped), no signing began, nothing persisted. +- Mid-operation cancel (the prompt appears during a multi-sign op and the user cancels): the held + scope unwinds, `Zeroizing` zeroizes, the upstream call was never issued (the signer construction is + inside the scope, after the secret is obtained). For an op that already broadcast one tx and needs + a second sign — not possible under §7.1, because one op prompts once *before* the first sign; if a + *future* op genuinely needs mid-stream prompts, it must be designed to be idempotent/resumable, and + that is out of scope here. Flag Q-MIDSTREAM (§10) only if such an op is introduced. +- Network switch / app close during an open prompt: host drops the queue's senders → every parked + `with_secret` cancels cleanly; `session` zeroizes (§5.4). + +### 7.4 Wrong passphrase + +Handled inside the `with_secret` loop (§4.4): re-ask with `retry_reason = WrongPassphrase`, modal +stays open, `PasswordInput.set_error(...)` shows the typed `*PassphraseIncorrect` Display, field +cleared (`PasswordInput.clear()` zeroizes the prior attempt). No retry cap by default (local +AES-GCM, no remote attacker, no account to lock — same rationale as the superseded plan's D4); +a soft cap is a Smythe option (Q-RETRYCAP, §10). + +--- + +## 8. Fund-safety, secret hygiene, type boundaries + +### 8.1 Fund-safety invariants preserved + +- **published-xpub == scanned-xpub account-xpub gate stays untouched.** This design changes *when the + seed is decrypted*, never *which* wallet/xpub is used. `bip44_account_xpub_encoded` (`mod.rs:503`) + and the `rehydration-rewire` WalletId-match gate are orthogonal and unmodified. +- **Watch-only rehydration interaction:** strictly improved. Seedless load (`rehydration-rewire`) + brings wallets back watch-only with no seed in memory; this design keeps the seed out of memory + until a *signing operation*, then for only that operation (default). The two compose: cold boot → + watch-only display (no seed) → user signs → one-op seed (or session cache if opted in). + +### 8.2 Secret hygiene (NFR-1) + +- **Passphrase** crosses the seam only as `SecretString` (zeroizing). It enters **no** `TaskError` + (§4.5), **no** `AppAction`/`BackendTask`, **no** logs, **no** banner details. The `SecretPromptReply` + is consumed inside `with_secret` and dropped. +- **Derived plaintext** (64-byte seed / 32-byte key) exists only inside `Zeroizing` guards within + `with_secret`/`with_secret_session`/the session cache; never serialized, never logged, never in an + error. +- **`SecretPlaintext` / `SecretSession`** have no `Clone`, no `Debug` that exposes bytes, no `Deref` + to raw bytes — access is via explicit `expose_*` returning a borrow with the closure's lifetime. +- **In-process channel trust boundary:** the `oneshot` and the request queue live entirely within the + one process; no IPC, no serialization. The trust boundary is the process. The reply carries a + passphrase (input), not a derived key, minimizing the value-at-risk even within that boundary. +- **Zeroization points (exhaustive):** (a) `SecretString` passphrase on `SecretPromptReply` drop; + (b) the operation-scoped `Zeroizing` plaintext on closure exit; (c) `PasswordInput.Secret` on every + `clear()`/close/wrong-attempt and on drop; (d) session-cache `Zeroizing` values on `forget`/ + network-switch/app-close. + +### 8.3 M-DONT-LEAK-TYPES + +- `SecretAccess`, `DetSigner`, `SecretSession`, `SecretPlaintext`, `WalletId`, and all upstream + `platform_wallet` / `key_wallet` types stay **inside the `wallet_backend` seam**. +- The **only** types the UI sees are the `secret_prompt.rs` seam: `SecretPrompt`, `SecretPromptRequest`, + `SecretPromptReply`, `SecretScope`, `SecretPromptRetry`, `SecretPromptCancelled`, plus the reused + `SecretString` (already an allowed DET dependency). `WalletSeedHash` on `SecretScope` is a DET type, + not upstream — permitted. +- `SecretString`/`SecretBytes` are `platform_wallet_storage` types; per the module's documented + M-PLATFORM-WALLET-FIRST-PARTY exception (`mod.rs:6-10`), they may appear on the `wallet_backend` + surface. They sit on the seam deliberately (the brief mandates reuse), and the UI already imports + the crate. + +--- + +## 9. Task breakdown for Bilby (Phase 2) + +Ordered by dependency. Each ≥100 lines or batched. TC references: existing IDs from +`signtime-unlock-ux/test-cases.md` where they survive; **[NEW-TC]** where the expanded HD/await/toggle +scope needs cases that did not exist (the superseded suite was single-key + gate-on-error only). +**(S)** = Smythe security review required. + +- **T1 — `secret_prompt.rs` seam (S).** `SecretPrompt` trait, `SecretScope`, `SecretPromptRequest`, + `SecretPromptReply` (`SecretString` field), `SecretPromptRetry`, `SecretPromptCancelled`. No egui, + no upstream types beyond `SecretString`. Unit: a `TestPrompt` double (scripted replies/cancel). + Satisfies: NFR-1 confinement contract (TC-SEC-001/002/003 re-pointed), [NEW-TC] cancel=drop. ~150 lines. + +- **T2 — `secret_access.rs` chokepoint (S).** `SecretAccess`, `with_secret`, `with_secret_session` + (resolve `AsyncFnOnce` vs RAII-guard spike — §4.2; flag Nagatha if guard chosen), `SecretPlaintext`, + `SecretSession`, session cache (opt-in, per-scope, zeroizing), `decrypt_jit` for both scopes (move + the single-key decrypt from `unlock_with_passphrase`, the HD decrypt from `WalletSeed::open`), + re-ask loop, `forget`/`forget_all`, `remember_session`. New `TaskError` variants + `SecretPromptCancelled`, `SecretDecryptFailed`, `HdPassphraseIncorrect`. Satisfies: TC-UNLOCK-001..004 + (re-pointed), TC-WRONG-001/002 (re-pointed), [NEW-TC] session-promote/forget, [NEW-TC] op-scope + zeroize, [NEW-TC] batch-one-prompt. ~300+ lines (batched). Dep: T1. + +- **T3 — `EguiSecretPromptHost` + AppState drain + reused modal (S).** Host impl (queue + repaint + + oneshot await); `AppState::update` drain beside `task_result_receiver` (`app.rs:1300`); reused + `PasswordInput`; clone/extract `WalletUnlockPopup` chrome into shared `passphrase_modal` (§5.1); + session checkbox (default OFF, §5.3); inline error from typed `*PassphraseIncorrect`. Catalog row in + `src/ui/components/README.md`. Satisfies: TC-PROMPT-001..006, TC-A11Y-001..005, TC-WRONG-002/003, + TC-CANCEL-001..004 (re-pointed), TC-SEC-004, [NEW-TC] toggle default-off, [NEW-TC] FIFO serialize + two requests. kittest in `tests/kittest/` (house style: `force_input_for_test`, `query_by_label`). + ~300+ lines (batched). Dep: T1, T2. + +- **T4 — `DetSigner` JIT signer (S).** Evolve `asset_lock_signer.rs` → JIT: construct from a *held* + secret inside a `with_secret_session` scope; implement both the `key_wallet::signer::Signer` seam + and the identity-op `async fn sign` seam (§6.2); HD and single-key sources. Satisfies: [NEW-TC] + HD-sign-derives-JIT, [NEW-TC] held-secret-no-reprompt, secret-zeroize-on-scope-exit. ~200 lines. + Dep: T2. + +- **T5 — Migrate HD backend consumers (S).** `signer_for`/`derive_private_key`/`send_payment`/ + `register_identity`/`top_up_identity`/`create_asset_lock_proof` (`mod.rs:1079..1300`) onto + `with_secret_session`; delete `provide_seed` (`mod.rs:531`), `Inner.seeds` (`mod.rs:277`), + `signer_for`'s eager read; shrink `handle_wallet_unlocked` (`wallet_lifecycle.rs:302`). + Satisfies: [NEW-TC] each HD op prompts once / cancel aborts / session-cache skips prompt; + regression: `assert_can_sign` test (`mod.rs:1095`) reframed to "with_secret yields a signer." + ~250 lines (batched). Dep: T4. + +- **T6 — Migrate single-key signing (S).** `raw_key_bytes`/`sign_with` (`single_key.rs:293,649`) onto + `with_secret(SingleKey,…)`; delete `Inner.single_key_unlocked` (`mod.rs:201`) and the cache-insert + in `unlock_with_passphrase` (`single_key.rs:271-275`); `import_wif` cache-prime removed; `forget` + → `SecretAccess::forget`. Satisfies: TC-UNLOCK-002 (session) / TC-WRONG-001 (re-pointed), + [NEW-TC] no-cache-prime. ~180 lines. Dep: T4. + +- **T7 — Migrate shielded + DashPay (S).** Shielded: remove eager init at unlock + (`wallet_lifecycle.rs:316`); derive Orchard keys on first shielded op via `with_secret(HdSeed,…)` + (`shielded.rs:372-402`). DashPay: route `derive_contact_xpub_material` (`dashpay.rs:105`, + `contact_requests.rs:321`) through `with_secret`; delete `first_open_wallet_seed` + (`contact_requests.rs:521`). Satisfies: [NEW-TC] shielded-first-spend-prompts, + [NEW-TC] dashpay-derive-JIT. ~200 lines (batched). Dep: T4. + +- **T8 — [GATED Q-R3] Retire `Wallet::Open` plaintext residency (S).** Reshape `WalletSeed::open` + (`model/wallet/mod.rs:633`) to verify-not-park; reroute remaining `seed_bytes()` readers; `is_open` + becomes a UI-display flag, not a plaintext-present flag. Large blast radius — staged per §6.1. + Satisfies: R3 fully retired; [NEW-TC] no-plaintext-seed-in-ctx.wallets. Size: large; specify when + Q-R3 resolved. Dep: T5, T7. + +- **T9 — [GATED Q-SEND] Single-key send live wiring.** Un-stub `send_single_key_wallet_payment`; + signs via the `with_secret(SingleKey,…)` path. Inherits the superseded plan's T5 framing. + Dep: T6, Q-SEND. + +- **T10 — Docs + supersession housekeeping (batched).** Mark + `signtime-unlock-ux/dev-plan.md` superseded; update `docs/user-stories.md` (FR-6 session unlock now + the toggle); `components/README.md` (the prompt host + shared `passphrase_modal`); `SECRETS`/security + notes. Dep: T8 (or T7 if R3 deferred). + +**Buildable now without a user decision: T1–T7, T10 (T10 partial). Gated: T8 (Q-R3), T9 (Q-SEND).** +Smythe review: T1, T2, T3, T4, T5, T6, T7, T8, T9 (all touch secret handling). + +--- + +## 10. Open questions (need a user / stakeholder decision) + +1. **Q-R3 — Retire `Wallet::Open` plaintext seed in this cut, or fast-follow?** Retiring R3 (T8) is + the largest piece and touches many `seed_bytes()` readers. Recommend: route the brief's enumerated + consumers through `with_secret` now (T5–T7), and land R3 retirement (T8) as a tightly-scoped + follow so the bulk of the cut ships without the wide blast radius. **User confirm.** +2. **Q-UNLOCK — Keep the explicit unlock popup (verify + optional session-seed, 5.2-A) or remove it + (5.2-B)?** Recommend 5.2-A (preserves "unlock my wallet" muscle memory, gives the toggle a second + home). **User confirm.** +3. **Q-SEND — Single-key send: in or out of THIS deliverable?** Unchanged from the superseded plan's + pivotal Q3. The single-key *signing machinery* (T6) is built and testable against `sign_with`; + live *send* (T9) needs UTXO selection / tx build / fee / broadcast. Recommend: machinery now, live + send tracked separately. **User confirm.** +4. **Q-SHIELDED — Should Orchard spending keys themselves be operation-scoped?** This design keeps + derived shielded key state session-resident (as today) and only makes the *seed* JIT. Per-spend + re-derivation is a larger follow. Recommend: out of scope now. **User confirm.** +5. **Q-HEADLESS — Non-interactive callers (MCP/CLI) have no prompt host.** For those, `with_secret` + must fail with a typed "secret required, no interactive prompt available" error rather than hang. + Recommend: a `NullSecretPrompt` that immediately cancels, surfacing `SecretPromptCancelled` (or a + dedicated `SecretPromptUnavailable`) to the MCP error envelope. **User confirm** the headless UX. +6. **Q-RETRYCAP — Wrong-passphrase soft cap?** Default: none (local AES-GCM, no remote attacker). + Smythe may want a soft cap. **Security decision.** +7. **Q-MIDSTREAM — Any operation that must prompt mid-stream after partial broadcast?** None today + (§7.3). If introduced later, it must be idempotent/resumable. Flag only if such an op appears. + +--- + +## Candy tally 🍬 (architecture findings by severity) + +- **CRITICAL (1):** R3 — the `Wallet::Open` whole-session plaintext seed (`model/wallet/mod.rs:598`, + read by DashPay `first_open_wallet_seed` and shielded `initialize_shielded_wallet`) is a third + eager residency the gate-on-error plan never addressed; a JIT design that ignores it leaves the + primary signing-seed plaintext resident all session. Must be retired (T8) for the cut to mean + anything. +- **HIGH (2):** (1) the upstream per-op `async fn sign` seam + the existing one-op-snapshot + `WalletAssetLockSigner` are the natural JIT injection point — `DetSigner` evolves it from + snapshot-at-construction to borrow-the-held-secret (§6.2), so no new signing path is invented; + (2) `with_secret_session` must hold one decrypted secret across the upstream `await`s of a + multi-sign op (one prompt per operation) — the `AsyncFnOnce`-vs-RAII-guard choice is load-bearing + for consumer signatures and needs a Bilby spike (§4.2). +- **MEDIUM (3):** (1) eager shielded init at unlock (`wallet_lifecycle.rs:316`) exists *only* to warm + state and forces seed residency — removing it (derive-on-first-spend) is both a hygiene win and a + prerequisite for operation-scoped seeds; (2) `handle_wallet_unlocked` inverts from + secret-distributor to verify-and-optionally-seed-session — the unlock popup's role shrinks, not + grows; (3) the reply must carry the *passphrase* (`SecretString`), not a derived key, so cancel is + a clean sender-drop and the channel never holds key material. +- **LOW (2):** (1) reuse `SecretString`/`SecretBytes` and `PasswordInput`/`WalletUnlockPopup` rather + than inventing — the shared `passphrase_modal` extraction now has two real call sites and is worth + doing; (2) the gate-on-error TCs (TC-SITE-001, TC-RESUME-002, TC-CANCEL-005) are obsoleted by the + await model — a finding that prevents Bilby from re-implementing a now-wrong contract. +- **Open questions (7):** Q-R3, Q-UNLOCK, Q-SEND, Q-SHIELDED, Q-HEADLESS, Q-RETRYCAP, Q-MIDSTREAM. + +**Total: 8 findings (1 critical, 2 high, 3 medium, 2 low) + 7 open questions.** + +--- + +## Resolved Decisions (Wave plan) + +Settled by user + Smythe before Wave 1 build. Where these differ from the body above, **these win**. + +### Four settled decisions + +1. **Residency is operation-scoped by default; a "remember" toggle promotes to a session cache.** + The policy is modelled as `RememberPolicy { None, UntilAppClose, For(Duration) }` (replaces the + body's `remember_for_session: bool` on `SecretPromptReply`, which now carries `remember: + RememberPolicy`). The GUI later wires only `None` + `UntilAppClose`; `For(Duration)` is + data-model-only for now (unused by UI). Session-cache entries **carry a TTL/expiry and honor it on + access** (expired entries are evicted and force a re-prompt). Default is `None`. +2. **Secret-on-the-wire is `platform_wallet_storage`'s `SecretString`.** The reply carries the + passphrase as `SecretString` plus the chosen `RememberPolicy`. No new secret type. No secret in + `TaskError` / `AppAction` / logs / `Debug`. +3. **The chokepoint lives behind `secret_prompt` (UI seam) + `secret_access` (orchestration).** Only + `secret_prompt`'s types are UI-facing; `SecretAccess` / `DetSigner` / upstream types stay in the + `wallet_backend` seam (M-DONT-LEAK-TYPES). +4. **Typed errors only.** New `TaskError` variants `SecretPromptCancelled`, `SecretDecryptFailed`, + `HdPassphraseIncorrect`; wrong-passphrase re-ask loop reuses `SingleKeyPassphraseIncorrect` / + `HdPassphraseIncorrect`. No user-facing `String` fields; no error-string parsing. + +### Smythe must-fixes (baked into Wave 1) + +1. **Closure form, not RAII guard.** `with_secret(scope, FnOnce(SecretPlaintext))` and + `with_secret_session(scope, AsyncFnOnce(&SecretSession))` confine plaintext to the closure; no + storable guard can be parked across awaits. (Resolves the §4.2 `AsyncFnOnce`-vs-guard spike in + favour of `AsyncFnOnce` — **no consumer-signature change vs. the guard form is needed; Nagatha + re-review NOT triggered.**) +2. **Borrow-only, no bare `[u8;N]` copies on the consumer path.** The closure borrows + `&Zeroizing<…>` via `SecretPlaintext` (no `Clone`, no `Deref` to raw bytes — access via explicit + `expose_*`). `DetSigner` borrows the held secret rather than snapshotting. The one place a copy is + made is the cache-hit op-lift (an op-scoped `Zeroizing` copy taken to release the cache lock before + the closure's `await`, avoiding a re-entrancy deadlock); it zeroizes on op exit, exactly like the + prompt path's owned plaintext. +3. **Boxed session-cache secrets, poison-safe clear.** Cached plaintext is `Box` so a + `HashMap` rehash moves only the pointer. `forget` / `forget_all` recover a poisoned lock + (`into_inner`) so a panicked reader can never strand a plaintext. +4. **Never prompt for unprotected scopes.** `with_secret` checks `scope_has_passphrase` first; + unprotected HD wallets (`uses_password = false`) and unprotected imported keys resolve via + `decrypt_jit(scope, None)` with no prompt. +5. **Secret-confinement sentinel tests.** A sentinel passphrase + sentinel seed are asserted absent + from every emitted error `Display`, `Debug`, and from `DetSigner`/`SecretPromptCancelled` debug. + +### Wave 1 scope delivered (additive, no removals) + +`src/wallet_backend/secret_prompt.rs` (T1), `secret_access.rs` (T2), `det_signer.rs` (T4), and the +three new `TaskError` variants. The eager model (R1 `inner.seeds`/`provide_seed`, R2 +`single_key_unlocked`, R3 `Wallet::Open`) and all consumer rewiring (T3 UI host, T5–T9) are **Waves +2–4** and untouched here. `DetSigner` compiles + is unit-tested via the mock prompt but is not yet +swapped into call sites (`#![allow(dead_code)]` on the module until Wave 2). diff --git a/docs/ai-design/2026-06-02-jit-secret-access/r3-completion-scope.md b/docs/ai-design/2026-06-02-jit-secret-access/r3-completion-scope.md new file mode 100644 index 000000000..8ce0df18b --- /dev/null +++ b/docs/ai-design/2026-06-02-jit-secret-access/r3-completion-scope.md @@ -0,0 +1,346 @@ +# R3 Completion Scope — Retiring the `Wallet::Open` Whole-Session Plaintext Seed + +**Author:** Nagatha (Architect) +**Date:** 2026-06-02 +**Status:** Read-only scoping. No implementation in this pass. +**Baseline:** branch `docs/platform-wallet-migration-design` @ `db03053f` +(*"refactor(wallet): JIT secret access — retire R1/R2 eager residencies, route R3 backend readers through chokepoint"*). + +--- + +## 1. Context + +The JIT secret-access refactor moves every plaintext-seed read behind a single +async chokepoint: `SecretAccess::with_secret` / `with_secret_session` +(`src/wallet_backend/secret_access.rs`), keyed by +`SecretScope::{HdSeed, SingleKey}`, handing the closure a borrowed `Zeroizing` +plaintext (`SecretPlaintext` / `SecretSession`). + +- **R1** (HD-seed map) and **R2** (single-key cache) are fully retired. +- **R3** — the session-long plaintext seed parked inside `WalletSeed::Open(OpenWalletSeed { seed, .. })` — + is the last residency. Wave C rerouted the three enumerated *async* backend readers through the + chokepoint (the canonical template is `contact_requests.rs`, §6). It deliberately did **not** reshape + `WalletSeed::open`, because doing so requires converting **every** remaining reader first. + +This document is the census of what remains, the conversion design for each class, the +`WalletSeed::open` reshape verdict, the UI ripple, and a Wave-D task breakdown. + +The governing tension: `with_secret*` is **async** and lives in the `wallet_backend` seam. Several +remaining readers are **synchronous** (in-model derivation, the `Wallet` `Signer<PlatformAddress>` +impl the SDK invokes, and sync UI key viewers) and cannot `await`. + +--- + +## 2. Census — every live reader, classified + +Method: `rg -n "seed_bytes\(\)|WalletSeed::Open|\.open\b" src/`, then traced each call chain to its +nearest async boundary. Test-only reads, egui `Window::open`, `wallet_unlock_popup.open()`, and +the `seed_envelope` debug-redaction test are excluded as non-load-bearing. + +Classification key: +- **(A) async-reroutable** — the read sits in (or one hop from) an `async fn`; wrap in + `with_secret(_session)` exactly like Wave C did for `contact_requests.rs`. +- **(B) sync-model, seed-as-parameter** — a sync model fn reading `self.seed_bytes()`; change its + signature to receive `seed: &Zeroizing<[u8; 64]>` from the async caller that already holds the scope. +- **(C) hard-blocker** — a sync path with no reachable async boundary at the read site. + +### 2.1 Census table + +| # | Site | Reader | Class | Async boundary / mechanism | +|---|------|--------|:---:|----| +| 1 | `backend_task/dashpay/contacts.rs:59` | `register_dashpay_addresses_for_identity` (async fn) reads `seed_bytes().to_vec()` | **A** | Already async. Reroute: `with_secret(HdSeed{seed_hash}, |pt| pt.expose_hd_seed()…)`; move the BIP-32 derivation into a `wallet_backend` helper (mirror `derive_contact_xpub_material`). | +| 2 | `backend_task/dashpay/contact_info.rs:146` | `derive_contact_info_keys` reads `seed_bytes().to_vec()` | **A** | Caller is async. Same reroute; lift `derive_contact_info_keys` body into a `wallet_backend` derivation helper taking the borrowed seed. | +| 3 | `backend_task/dashpay/incoming_payments.rs:92` | `register_dashpay_addresses_for_identity` reads `seed_bytes().to_vec()` | **A** | Async fn. Same reroute (DIP-15 receive-side address derivation helper). | +| 4 | `model/qualified_identity/mod.rs:395` | `QualifiedIdentity::sign` (impl `Signer`, **async**) reads `wallet_ref.seed_bytes()` in the ECDSA_HASH160 path-scan fallback | **A** | The method is `async`, but the read is sync inside a held `RwLockReadGuard`. Reroute: resolve the seed via `with_secret` *before* the lock scan, pass borrowed bytes into the scan loop. Fund-relevant (signing). | +| 5 | `model/wallet/mod.rs:826` | `derive_private_key_in_arc_rw_lock_slice` (sync) | **B/C** | Called from `encrypted_key_storage.rs:317` (sync, at identity load) and indirectly key viewers. Becomes seed-as-parameter; see #6/#13. | +| 6 | `model/qualified_identity/encrypted_key_storage.rs:317` | sync key-materialization at load reads slice-derivation (→ #5) | **B** | Reached from identity-load backend tasks (async). Thread seed in from the async caller, or resolve per-wallet via `with_secret` before materialization. | +| 7 | `model/wallet/mod.rs:835` | `private_key_at_derivation_path` (sync) | **B/C** | Two consumers: backend (B) and sync UI viewers (C, #11/#12). Becomes seed-as-parameter. | +| 8 | `model/wallet/mod.rs:846` | `private_key_for_address` (sync) | **B** | Sole consumer `fund_platform_address_from_asset_lock.rs:73` is **async**. Reroute the *caller* via `with_secret`, pass seed into a seed-param variant. Fund-critical. | +| 9 | `model/wallet/mod.rs:1179,1207,1252,1275,1299,1373,1408` + `1317/1324` | `bootstrap_*_addresses` family (bip32, coinjoin, identity reg/invitation/topup/not-bound, provider, platform-payment), all sync, each `let seed = *self.seed_bytes()?` | **B** | Entry point `bootstrap_known_addresses` (mod.rs:718) → `wallet_lifecycle.rs:296 bootstrap_wallet_addresses` (sync, but invoked from async backend register/unlock paths). Convert the family to take `seed: &[u8;64]`; have the async caller resolve once via `with_secret_session` and pass it down. | +| 10 | `model/wallet/mod.rs:1611` | (additional bootstrap helper in the same family) | **B** | Same as #9. | +| 11 | `ui/identities/keys/key_info_screen.rs:401` | sync UI calls `private_key_at_derivation_path` to **display** a WIF/hex | **C** | No async boundary in `ui()`. Must become a backend task (`WalletTask::DeriveKeyForDisplay`) returning the WIF/hex string; UI renders the result. | +| 12 | `ui/identities/keys/key_info_screen.rs:459` | same, second branch | **C** | Same backend-task conversion. | +| 13 | `ui/wallets/wallets_screen/dialogs.rs:1257` | `derive_private_key_wif` (sync) reads `private_key_at_derivation_path`; callers `mod.rs:2471`, `address_table.rs:466` | **C** | Sync UI WIF export. Same backend-task conversion. | +| 14 | `model/wallet/mod.rs:1806` | `get_platform_address_private_key` (sync) reads `seed_bytes()` | **C** | Invoked by the `Wallet` `Signer<PlatformAddress>` impl (#15). Deletable once #15 is rerouted. | +| 15 | `model/wallet/mod.rs:1820` | **`impl Signer<PlatformAddress> for Wallet`** — `sign` / `sign_create_witness` / `can_sign_with`, each reaching `get_platform_address_private_key` → `seed_bytes()` | **C (hard-blocker)** | **STILL LIVE.** Passed as `&wallet` to the SDK at four async sites: `fund_platform_address_from_asset_lock.rs:82`, `fund_platform_address_from_wallet_utxos.rs:100`, `transfer_platform_credits.rs:52`, `withdraw_from_platform_address.rs:52`. SDK bound is `S: Signer<PlatformAddress>`. See §4. | +| 16 | `model/wallet/mod.rs:1962` | `WalletAddressProvider::with_gap_limit` copies `*wallet.seed_bytes()` into a `seed: [u8;64]` **field** | **C** | Sole caller `fetch_platform_address_balances.rs:34` is async. The provider holds a long-lived seed *copy* (a residency in its own right). Rebuild it to derive inside a `with_secret_session` scope, or take a borrowed seed; drop the owned field. | +| 17 | `context/wallet_lifecycle.rs:444` | `wallet_seed_snapshot` reads `seed_bytes()` to promote the seed into the session cache on unlock | **B (benign)** | This is the *bridge* from the open wallet into the chokepoint's session cache. Once `WalletSeed::open` no longer parks plaintext (§5), this snapshot has nothing to read; unlock instead validates the passphrase and the chokepoint owns residency. Convert last. | + +### 2.2 Counts by class + +| Class | Count (distinct live sites) | Sites | +|---|:---:|---| +| **A** — async-reroutable | **4** | #1, #2, #3, #4 | +| **B** — sync-model seed-as-parameter | **~7 functions** (bootstrap family counts as one cluster of ~8 fns) | #5–#10, #17 | +| **C** — hard-blocker | **3 clusters** | platform Signer (#14+#15+#16, fund-critical) · sync UI key viewers (#11+#12+#13) · `WalletAddressProvider` owned-seed field (#16) | + +> Test-only `seed_bytes()` reads at `mod.rs:2496/2497/2812` and `2800` assert wallet open/closed +> state and must be rewritten to the new shape, but carry no production residency. + +--- + +## 3. The seed-as-parameter design (class B) + +**Today:** sync model fns reach back into `self.wallet_seed` via `self.seed_bytes()`. This is what +forces `WalletSeed::Open` to park the plaintext for the whole session. + +**Target:** the seed is *passed in* from the one async caller that already holds a `with_secret_session` +scope. The model never reads its own parked seed. + +Signature change pattern (applies to the bootstrap family #9/#10, and #5/#7/#8): + +```text +// before +fn bootstrap_bip32_addresses(&mut self, network, app_context) -> Result<(), String> { + let seed = *self.seed_bytes()?; // ← parked-seed read + ... +} + +// after +fn bootstrap_bip32_addresses( + &mut self, + seed: &[u8; 64], // ← borrowed, operation-scoped + network, app_context, +) -> Result<(), String> { ... } +``` + +**Call-chain to the async boundary (the load-bearing part):** + +``` +async backend task (register wallet / unlock / discover identities) + └─ holds SecretAccess::with_secret_session(HdSeed{seed_hash}, async |session| { + let seed = session.plaintext().expose_hd_seed()?; // borrowed Zeroizing + // bootstrap is a &mut Wallet mutation → take the write lock INSIDE the scope + wallet.write()?.bootstrap_known_addresses(seed, &app_context); + }) +``` + +`bootstrap_known_addresses(&mut self, seed, app_context)` fans the borrowed `seed: &[u8;64]` down to +every `bootstrap_*` child (#9/#10) — none of which read `self.seed_bytes()` any more. + +**Where the resolution happens.** `bootstrap_wallet_addresses` (`wallet_lifecycle.rs:282`) is the +sync funnel today. It must gain an async sibling (`bootstrap_wallet_addresses_jit`) that opens the +`with_secret_session` scope and calls the new seed-param `bootstrap_known_addresses`. The two +registration/unlock callers (`wallet_lifecycle.rs` register + `handle_wallet_unlocked`) switch to the +async sibling. This is the single most mechanical, highest-fan-out conversion in Wave D. + +**For #5/#7/#8** (`derive_private_key_in_arc_rw_lock_slice`, `private_key_at_derivation_path`, +`private_key_for_address`): add `*_with_seed(seed: &[u8;64], …)` variants. The async backend caller +(`fund_platform_address_from_asset_lock.rs`, identity-key materialization) resolves the seed via +`with_secret` and calls the seed-param variant. The legacy `self.seed_bytes()` variants are deleted +once no caller remains (the UI callers move to backend tasks per §6). + +--- + +## 4. Class C — platform `Signer<PlatformAddress>` (the real hard-blocker) + +This is the one that cannot be solved by seed-as-parameter, because **the SDK** — not our code — +invokes `wallet.sign(platform_address, data)` synchronously-from-its-perspective, deep inside +`top_up` / `transfer_address_funds` / `withdraw`. The SDK's trait bound is: + +```text +pub trait TopUpAddress<S: Signer<PlatformAddress>> { async fn top_up(&self, …, signer: &S, …) … } +``` + +(Confirmed in the locked SDK rev `ddfa66ed…`, +`packages/rs-sdk/src/platform/transition/top_up_address.rs:23`.) + +**`DetSigner` does NOT satisfy this bound.** `DetSigner` (`wallet_backend/det_signer.rs`) implements +the *identity-key* `Signer` (indexed by `DerivationPath`, methods `sign_ecdsa` / `public_key`). It is +**not** `Signer<PlatformAddress>`. So the platform-funding path has no JIT signer today — it leans +entirely on the live `Wallet` `Signer<PlatformAddress>` impl, which reads the parked seed. + +**Design — `DetPlatformSigner<'a>`:** + +Introduce a new JIT signer in the `wallet_backend` seam that mirrors `DetSigner`'s borrow discipline +but implements the platform trait: + +```text +// wallet_backend/det_signer.rs (or a sibling det_platform_signer.rs) +pub(crate) struct DetPlatformSigner<'a> { + held: &'a SecretPlaintext<'a>, // borrowed HD seed, never owned/copied + network: Network, + // the wallet's watched platform-payment paths, needed to map address → path + paths: &'a PlatformPathIndex, +} + +#[async_trait] +impl Signer<PlatformAddress> for DetPlatformSigner<'_> { + async fn sign(&self, addr: &PlatformAddress, data: &[u8]) -> Result<BinaryData, ProtocolError> { + // map addr → derivation_path from self.paths (pure, no secret) + // derive priv from borrowed seed, sign, drop the derived key + } + async fn sign_create_witness(…) { … } + fn can_sign_with(&self, addr) -> bool { self.paths.contains(addr) } // pure +} +``` + +**Reroute the four SDK call sites** (all already async): +`outputs.top_up(…, &wallet, …)` → build the signer inside a `with_secret_session(HdSeed{seed_hash})` +scope and pass `&platform_signer`: + +```text +secret_access.with_secret_session(&HdSeed{seed_hash}, async |session| { + let signer = DetPlatformSigner::from_held(session.plaintext(), network, &paths); + outputs.top_up(&sdk, asset_lock_proof, asset_lock_pk, fee_strategy, &signer, None).await +}).await +``` + +`can_sign_with` is a pure path-membership check (no secret) — so it stays cheap and prompt-free. +The address→path index (`paths`) is built from `wallet.watched_addresses` *before* entering the +scope; only the actual `sign` derives from the borrowed seed. + +**Once #15 is rerouted:** `impl Signer<PlatformAddress> for Wallet` (#15) and +`get_platform_address_private_key` (#14) have no callers → **delete both**. That deletes two of the +parked-seed reads outright. + +> **Is the `Wallet` `Signer` impl dead now?** **No.** It is still the *only* `Signer<PlatformAddress>` +> in the codebase and is live at four fund-moving SDK call sites. It becomes dead — and deletable — +> only after `DetPlatformSigner` lands and the four sites swap. That swap is the centerpiece of Wave-D +> task D3 and is fund-critical. + +--- + +## 5. `WalletSeed::open` reshape — verify-not-park + +**Verdict: YES, achievable — but strictly *after* all A/B/C readers are converted.** Until then, +removing the parked seed breaks every reader in §2. + +**New shape.** `WalletSeed::open(password)` becomes a **passphrase *validator***, not a seed parker: + +```text +// today: decrypts and STORES the plaintext in WalletSeed::Open(OpenWalletSeed{ seed, .. }) +// target: decrypt to prove the password is correct, then DISCARD the plaintext. +pub fn open(&mut self, password: &str) -> Result<(), WalletSeedError> { + let seed = self.closed()?.decrypt_seed(password)?; // Zeroizing, local + // … no assignment of `seed` into self; it drops (zeroized) at end of scope … + self.mark_unlocked(); // flips a state flag only + Ok(()) // seed gone; chokepoint owns all future residency +} +``` + +**What `OpenWalletSeed` keeps:** nothing secret. The `seed: [u8; 64]` field is **removed**. What +remains of the "open" state is the *metadata* the UI/model still needs without the seed — +`ClosedKeyItem` (seed_hash, salt, nonce, encrypted_seed, password_hint) plus an `unlocked: bool` +intent flag. In practice `WalletSeed` likely collapses to a single struct carrying +`ClosedKeyItem` + an `unlocked` flag, since `Open` and `Closed` would then differ only by that flag. +`is_open()` reads the flag; `seed_hash()` / `salt()` / `nonce()` / `encrypted_seed_slice()` / +`password_hint()` all read from the retained `ClosedKeyItem` unchanged. + +**The unlock-intent bridge.** Today `handle_wallet_unlocked` → `wallet_seed_snapshot` (#17) reads the +parked seed and promotes it into the session cache. Post-reshape there is no parked seed to read. +Instead, `open()` (now a validator) is followed by a one-shot `with_secret(_session)` warm call that +re-decrypts via the chokepoint with `RememberPolicy::UntilAppClose`, honoring the "keep unlocked" +gesture. The seed lives **only** in the chokepoint's session cache — a single, auditable residency +with a known lifetime — never in the `Wallet` value graph. + +**Net residency after R3:** zero plaintext seed in `Wallet` / `OpenWalletSeed`. The only plaintext +seed in the process is the operation- or session-scoped `Zeroizing` inside a `with_secret*` frame. +This is the R3 goal. (ASVS V11.7 in-use-data, V13.3 secret management — minimize plaintext residency +and confine it to a single controlled boundary.) + +--- + +## 6. Ripple assessment — does seed-as-parameter leak into the UI? + +**Yes — but containably.** Three sync UI surfaces read the seed directly *to display or export a +private key*, and the UI cannot `await` the chokepoint: + +| UI surface | File | What it does | +|---|---|---| +| Key info screen | `ui/identities/keys/key_info_screen.rs:401,459` | renders WIF + hex of a derived key | +| Receive/export dialog | `ui/wallets/wallets_screen/dialogs.rs:1257` (`derive_private_key_wif`), callers `mod.rs:2471`, `address_table.rs:466` | copies a WIF for an address | + +These do **not** get a seed parameter. They are converted to the **task pattern**: the screen returns +`AppAction::BackendTask(WalletTask::DeriveKeyForDisplay { seed_hash, derivation_path })`; the backend +resolves the seed via `with_secret`, derives, and returns the WIF/hex as a typed +`BackendTaskSuccessResult`. The screen renders the result from `display_task_result`. No seed crosses +into the UI layer. + +The **unlock popups** (`wallet_unlock_popup.rs:109`, `wallet_unlock.rs:76`, +`single_key_send_screen.rs:774`, `wallets_screen/mod.rs:2569`) call `wallet_seed.open(...)` / +`wallet.open(...)`. After the §5 reshape these still call `open()` — but `open()` is now a *validator* +that parks nothing, so they need no structural change beyond the new return type. They are touched, +not redesigned. + +**Layer/file count touched:** +- `wallet_backend/` — +1 signer type (`DetPlatformSigner`), +2–3 derivation helpers, reshaped chokepoint warm-on-unlock. +- `model/wallet/mod.rs` — bootstrap family + 3 derivation fns reshaped; `Signer<PlatformAddress>` + `get_platform_address_private_key` + `WalletAddressProvider` owned-seed deleted; `WalletSeed`/`OpenWalletSeed` collapsed. +- `model/qualified_identity/` — 2 files (sign fallback + load materialization). +- `backend_task/dashpay/` — 3 files (A). +- `backend_task/wallet/` — 4 fund sites swap to `DetPlatformSigner`; +1 `DeriveKeyForDisplay` task. +- `context/wallet_lifecycle.rs` — bootstrap async sibling + unlock bridge. +- `ui/` — 2 key-viewer surfaces → backend task; 4 unlock popups → return-type touch only. + +This is **roughly 18–22 files across 5 layers**. It is **not one Bilby wave.** It must split — both to +keep each task independently reviewable and because the fund-critical signer/open work must be isolated +for Smythe. + +--- + +## 7. Wave-D task breakdown + +> Ordering matters: D1/D2 are prerequisites that drain the reader population so D4's `open()` reshape +> doesn't break anything. D3 is independent of D1/D2 but shares the "delete the parked seed last" gate. + +| Task | Title | Class | Files | Depends on | Smythe review? | +|---|---|:---:|---|---|:---:| +| **D1** | Reroute the 4 async DashPay/identity-sign readers through `with_secret`; lift derivations into `wallet_backend` helpers | A | `dashpay/contacts.rs`, `contact_info.rs`, `incoming_payments.rs`, `qualified_identity/mod.rs` | — | Advisory (matches merged Wave-C pattern) | +| **D2** | Seed-as-parameter for the in-model derivation family + bootstrap async sibling | B | `model/wallet/mod.rs` (bootstrap family, `private_key_*`, slice-derive), `qualified_identity/encrypted_key_storage.rs`, `context/wallet_lifecycle.rs` | — | **Yes** (derivation correctness; HD path integrity) | +| **D3** | `DetPlatformSigner<'a>: Signer<PlatformAddress>`; swap `&wallet` → `&signer` at the 4 SDK fund sites; convert sync UI key viewers to `WalletTask::DeriveKeyForDisplay`; rebuild `WalletAddressProvider` without an owned seed | C | `wallet_backend/det_signer.rs` (+sibling), 4 × `backend_task/wallet/*`, `model/wallet/mod.rs` (provider), `ui/identities/keys/key_info_screen.rs`, `ui/wallets/wallets_screen/{dialogs,mod,address_table}.rs` | — | **Yes — fund-critical** (platform signing + asset-lock funding) | +| **D4** | `WalletSeed::open` → verify-not-park; collapse `OpenWalletSeed` (drop `seed` field); delete dead readers (`Wallet` platform `Signer`, `get_platform_address_private_key`, `wallet_seed_snapshot`); rewire unlock warm-on-`with_secret`; fix tests | — | `model/wallet/mod.rs`, `model/wallet/seed_envelope.rs`, `context/wallet_lifecycle.rs`, 4 unlock-popup UI files | **D1, D2, D3** | **Yes — fund-critical** (last plaintext residency removal; unlock semantics) | + +**Why four, not one.** Each task closes a distinct reader population and is independently compilable +and testable. D4 is the *only* task that may remove the parked seed, and it is gated on D1–D3 having +drained every reader. Bundling D3/D4 into one PR would make the fund-critical diff unreviewable. + +**Minimum split if compressed:** D1+D2 could merge (both non-fund-critical, no signer changes), but +D3 and D4 must each stand alone for Smythe. So the floor is **three** waves; the recommended shape is +**four**. + +--- + +## 8. Risk — fund-safety traps in handing the borrowed seed down + +| # | Risk | Mitigation | +|---|---|---| +| R-1 | **Accidental copy.** A seed-param fn does `let seed = *seed_ref;` (deref-copy onto the stack), recreating a residency the borrow was meant to prevent (`WalletAddressProvider` does exactly this today, #16). | Pass `seed: &[u8; 64]` and **derive in place**. Forbid `*seed`. Where a copy is unavoidable, wrap in `Zeroizing`. Lint/grep for `*seed` in the converted family during review. | +| R-2 | **Lifetime escape.** `DetPlatformSigner` / `DetSigner` borrows the session plaintext; if the `&signer` outlives the `with_secret_session` scope (e.g. stored in a struct returned from the closure), it's use-after-free of zeroized memory — or worse, a dangling borrow the compiler should reject but a `'static`-coercion (`Box`/`Arc`) could smuggle past. | Keep the signer constructed **and consumed** entirely inside the async closure (the SDK call awaits inside the scope). Never `Box`/`Arc`/return the signer. The `'a` lifetime tie to `SecretPlaintext<'a>` enforces this at compile time — do not add `'static` bounds to satisfy the SDK; the SDK takes `&S`, which is fine. | +| R-3 | **Operation-scoped guarantee lost on hand-off.** R1/R2 were retired precisely so a secret lives only for one operation. Threading a `&[u8;64]` *down a deep sync call tree* widens the window the plaintext is reachable on the stack. | Resolve the seed as **late** and as **close** to the derivation as the call graph allows; for the bootstrap family, one `with_secret_session` per bootstrap run is acceptable (it is one logical operation), but do not hoist the scope to wrap unrelated work. | +| R-4 | **Wrong-network platform signing.** The current `Wallet` `Signer<PlatformAddress>` brute-forces all four networks (`mod.rs:1839-1845`). `DetPlatformSigner` must preserve correctness: derive for exactly the wallet's network. | `DetPlatformSigner` carries the single `network` (from the backend, which knows the active network) — no brute-force. The address→path index is network-specific. This is *safer* than today, not just equivalent. | +| R-5 | **`open()` reshape lands before readers drained.** If D4 removes the `seed` field while any §2 reader still calls `self.seed_bytes()`, those paths return "wallet closed" at runtime → silent loss of signing/derivation. | Compile-time gate: deleting `OpenWalletSeed.seed` makes `seed_bytes()` un-implementable, so the build fails until every reader is gone. Enforce by sequencing D4 last and treating any remaining `seed_bytes()` caller as a blocker. | +| R-6 | **`can_sign_with` accidentally prompts.** If `DetPlatformSigner::can_sign_with` resolved the secret, the SDK's pre-flight checks would trigger passphrase prompts. | `can_sign_with` is a **pure** path-membership check against the prebuilt index; it never enters `with_secret`. (Matches the existing `Wallet::can_sign_with` which only derives — here we make it strictly pure.) | + +--- + +## 9. Open questions for the user + +1. **Session granularity for bootstrap (D2).** Bootstrapping derives hundreds of addresses across + seven path families. One `with_secret_session` for the whole bootstrap run is the natural unit + (it is one logical operation), but it holds the borrowed seed on the stack for the duration. Acceptable, + or do you want the scope narrowed further (per-family re-prompt is hostile UX, so I recommend the + single-run scope)? +2. **`WalletSeed` collapse vs. keep-the-enum (D4).** Once `Open` carries no secret, `Open`/`Closed` + differ only by an `unlocked` flag. Collapse to one struct (my recommendation), or retain the + two-variant enum for blast-radius minimization in D4? The former is cleaner; the latter is a smaller diff. +3. **`DeriveKeyForDisplay` exposure.** Converting the UI key viewers to a backend task means a derived + private key (WIF/hex) round-trips as a `BackendTaskSuccessResult`. Confirm this is acceptable for the + "view private key" feature (it already displays the key on screen, so the trust boundary is unchanged), + and that the result should be wrapped in the existing `Secret` newtype end-to-end. + +--- + +## 10. Candy tally (architecture findings by severity) + +| Severity | Count | Findings | +|---|:---:|---| +| **High** (fund-critical hard-blockers) | **3** | Live `Wallet` `Signer<PlatformAddress>` at 4 SDK fund sites (#15); `DetSigner` does not satisfy `Signer<PlatformAddress>` (needs `DetPlatformSigner`); `WalletAddressProvider` owns a copied seed field (#16). | +| **Medium** (structural conversions) | **3** | Bootstrap family seed-as-parameter + async sibling (#9/#10); sync UI key viewers must become backend tasks (#11–#13); `WalletSeed::open` verify-not-park reshape gated on full reader drain (#17/§5). | +| **Low** (mechanical / advisory) | **2** | 4 async DashPay/identity-sign readers reroute cleanly via the merged Wave-C template (#1–#4); unlock popups need return-type touch only after reshape. | + +**Total: 8 findings (3 High, 3 Medium, 2 Low).** + +--- + +*Designs are elegant because they refuse to be anything less. The seed has exactly one place left to +hide; D1–D4 close the door, and the chokepoint keeps the only key.* — Nagatha diff --git a/docs/ai-design/2026-06-02-rehydration-rewire/design.md b/docs/ai-design/2026-06-02-rehydration-rewire/design.md new file mode 100644 index 000000000..5695e2e68 --- /dev/null +++ b/docs/ai-design/2026-06-02-rehydration-rewire/design.md @@ -0,0 +1,493 @@ +# Rehydration Re-Wire — `UpstreamFromPersisted` onto PR #3692 (seedless watch-only load) + +Closes **PROJ-010** — the reserved `UpstreamFromPersisted` swap point in +`src/wallet_backend/loader.rs`. Re-wires DET's wallet-load logic onto the +upstream **seedless / watch-only** rehydration API added by dashpay/platform +PR #3692. + +> Source-of-truth correction: the task brief cited head `9e2d2b0d` and an API +> with a `SeedProvider` port. The PR was **rebuilt 2026-05-25** (rebuild note in +> the PR body); the live head at design time was +> **`ddfa66ed373beaebdae9a5d919f896af43cbcd33`** and the API is **purely +> seedless** — the `SeedProvider` trait was deleted and `load_from_persistor()` +> takes **no resolver**. All upstream `file:line` citations in this document are +> at `ddfa66ed`. +> +> **⚠ Pin moved past `ddfa66ed` (read before chasing line numbers):** the +> shipped `Cargo.toml` pin is now `rev = 9e1248cb` (`platform 4.0.0-beta.2`, +> PR #3692 head). The seedless API *shape* this document targets is unchanged +> at `9e1248cb`, but the exact `file:line` citations below are at `ddfa66ed` and +> may have drifted. Re-pinning to a tagged release is the open release gate +> (PROJ-005 / F121 in the gap audit). Resolve symbols by name, not by line, when +> reading against the shipped pin. + +> **⚠ Regression correction (2026-06-08):** this re-wire deleted DET's *only* +> persistor **writer** along with `SeedReregistrationLoader`. `load_from_persistor` +> is **read-only** — it brings back only what the persistor already holds. With no +> writer left, the persistor stayed empty on fresh / reset / migrated / +> sidecar-only installs, the SPV watch set was empty, and received funds were +> invisible at 100% sync (PROJ-010, HIGH). The seedless *read* path in this +> document is correct and unchanged; what was missing is a **write** path. The fix +> (`docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md`) re-introduces +> the persistor write at the two seed-bearing moments — +> `WalletBackend::register_wallet_from_seed` (W1, create/import) and +> `WalletBackend::ensure_upstream_registered` (W2, cold-boot reconciliation) — +> with a genesis birth-height floor for imported/recovered/migrated wallets. Read +> the headline findings below with that in mind: "comes back at launch" holds only +> **once the persistor has been written** by W1/W2. + +--- + +## 0. Headline findings (read first) + +1. **The upstream API is seedless and watch-only.** `load()` no longer needs a + seed and produces `Wallet::new_watch_only` per wallet. This is a *security + upgrade* over `SeedReregistrationLoader`: balances/UTXOs/identities/contacts + come back at launch with **no seed in memory**. +2. **The pin switch is SAFE, not risky.** `git compare ffdc28b8...ddfa66ed` + ⇒ **`ahead_by: 59, behind_by: 0, status: ahead`**. The PR head is a clean + *superset* of our exact current pin (`ffdc28b8`, the PR's own base branch). + The brief's "ahead 53 / behind 63 / CONFLICTING" was measured against + `v3.1-dev`, which the PR body confirms was *already merged in*. We are not + missing the SPV-stop deadlock fix, the KvStore TOCTOU fix, or v3.1-dev — + they are all in `ddfa66ed`. +3. **The `PersistedWalletLoader` trait shape MUST change.** Its current + contract (`Vec<WalletRegistration>` carrying `seed_bytes`) is intrinsically + seed-driven and feeds two things: the per-wallet + `create_wallet_from_seed_bytes` loop *and* DET's `inner.seeds` signing cache. + The seedless API populates wallets in **one** manager call and supplies **no** + seeds. The loader must become a "load strategy" seam, not a "seed list" seam. +4. **DET keys everything by `WalletSeedHash = SHA256(seed)`; upstream returns + `WalletId = SHA256(root_xpub‖chaincode)`.** The seedless path cannot produce + `WalletSeedHash`. Bridge: DET already persists `xpub_encoded` (the 78-byte + root xpub) in the wallet-meta + seed-envelope sidecars + (`src/wallet_backend/hydration.rs`, `wallet_seed_store.rs`), so DET can + compute `WalletId` from the xpub **without the seed** and build a + `WalletId → WalletSeedHash` map at hydration time. This is the linchpin of + the flip. + +--- + +## 1. Upstream rehydration API surface (the exact types/functions DET calls) + +All in crate `platform-wallet` at `ddfa66ed`. + +### 1.1 Entry point — one call, seedless + +`packages/rs-platform-wallet/src/manager/load.rs:48` +```rust +impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> { + pub async fn load_from_persistor(&self) -> Result<LoadOutcome, PlatformWalletError>; +} +``` +Internally: calls `self.persister.load()` → keyless `ClientStartState`; for each +persisted wallet builds a watch-only `Wallet` from its account manifest +(`rehydrate::build_watch_only_wallet`), applies the keyless core-state +projection (`rehydrate::apply_persisted_core_state`), layers public +contacts + identity keys, and registers the wallet into `self.wallets` / +`self.wallet_manager`. **No seed, no `create_wallet_from_seed_bytes`.** + +Transactional: a whole-load failure rolls back every wallet inserted in the +pass; a per-row decode failure *skips* that wallet (continues the batch). + +### 1.2 Result type — `LoadOutcome` + +`packages/rs-platform-wallet/src/manager/load_outcome.rs` (re-exported +`platform_wallet::{LoadOutcome, SkipReason}`; `CorruptKind` at +`platform_wallet::manager::load_outcome::CorruptKind`): +```rust +pub struct LoadOutcome { + pub loaded: Vec<WalletId>, // WalletId = [u8; 32] + pub skipped: Vec<(WalletId, SkipReason)>, // non-empty skipped is SUCCESS +} +pub enum SkipReason { CorruptPersistedRow { kind: CorruptKind } } +pub enum CorruptKind { MissingManifest, MalformedXpub, DecodeError(String) } +``` +`Ok(LoadOutcome)` with a non-empty `skipped` is success; `Err` is reserved for +whole-load failures (persister I/O, the no-silent-zero topology check). + +### 1.3 Accessors used after load + +`packages/rs-platform-wallet/src/manager/accessors.rs` +```rust +pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option<Arc<PlatformWallet>>; +pub async fn wallet_ids(&self) -> Vec<WalletId>; +``` + +### 1.4 Skip event (additive, non-breaking) + +`packages/rs-platform-wallet/src/events.rs:32` adds +`PlatformEvent::WalletSkippedOnLoad { wallet_id, reason }` and a +`PlatformEventHandler::on_platform_event(&self, _event)` method **with a default +no-op impl** — DET's `EventBridge` (`src/wallet_backend/event_bridge.rs:168`) +keeps compiling untouched; it *may* override it to surface skips. + +### 1.5 Manager construction — unchanged + +`packages/rs-platform-wallet/src/manager/mod.rs:99` +```rust +pub fn new(sdk: Arc<Sdk>, persister: Arc<P>, app_handler: Arc<dyn PlatformEventHandler>) -> Self; +``` +Identical to today's `PlatformWalletManager::new(sdk, persister, bridge)` call at +`src/wallet_backend/mod.rs:263`. **No construction-signature change upstream.** + +### 1.6 Host calling pattern (from the e2e RT suite) + +`packages/rs-platform-wallet/tests/rehydration_load.rs` (item E) — the canonical +host flow: +```rust +let mgr = Arc::new(PlatformWalletManager::new(sdk, persister, handler)); +let outcome = mgr.load_from_persistor().await?; // seedless +for id in &outcome.loaded { assert!(mgr.get_wallet(id).await.is_some()); } +// outcome.skipped: corrupt rows, one PlatformEvent::WalletSkippedOnLoad each +``` + +--- + +## 2. `UpstreamFromPersisted` impl design + +### 2.1 The trait must change shape + +Today (`src/wallet_backend/loader.rs:57`): +```rust +pub trait PersistedWalletLoader: Send + Sync { + fn wallets_to_register(&self, ctx: &Arc<AppContext>) -> Result<Vec<WalletRegistration>, TaskError>; +} +``` +This returns seed-bearing descriptors and assumes the backend will call +`create_wallet_from_seed_bytes` per item. The seedless API breaks both +assumptions. The seam must move "up" one level — from *"give me the seeds to +re-register"* to *"perform the load and tell me which wallets came back"*. + +**New trait shape (object-safe, async — mirrors the single-key seam):** +```rust +/// Outcome of a persisted-wallet load pass, mapped to DET-opaque types. +/// Carries NO upstream platform-wallet types (M-DONT-LEAK-TYPES). +pub struct LoadedWallets { + /// Wallets now registered with the backend, keyed by DET's seed hash. + pub loaded: Vec<WalletSeedHash>, + /// Wallets present on disk but skipped (corrupt row). DET-opaque reason. + pub skipped: Vec<(WalletSeedHash, PersistedLoadSkip)>, +} + +/// DET-opaque skip reason — maps upstream CorruptKind without leaking it. +pub enum PersistedLoadSkip { MissingManifest, MalformedXpub, DecodeError } + +#[async_trait::async_trait] // backend is already async +pub trait PersistedWalletLoader: Send + Sync { + /// Bring persisted wallets back into the running backend. + async fn load(&self, ctx: &WalletBackendLoadCtx<'_>) -> Result<LoadedWallets, TaskError>; +} +``` +`WalletBackendLoadCtx` is a small DET-internal struct the backend hands the +loader, exposing exactly what each impl needs **without leaking upstream types**: +- `&Arc<AppContext>` +- the `WalletId → WalletSeedHash` bridge builder (computed from sidecar xpubs) +- a callback to register a resolved `(WalletSeedHash, WalletId)` into the + backend's `id_map` / `wallets` / `snapshots` (so the impl never touches those + maps directly, preserving the seam). + +> Rationale: keeping `wallets_to_register` and bolting the seedless call beside +> it would split load logic across two seams and force the seed-keyed bridge +> into `WalletBackend::new`. A single async `load(...)` keeps the strategy fully +> behind the trait — exactly what G2.4 promised ("only the loader impl + one +> construction line"). + +### 2.2 `UpstreamFromPersisted::load` algorithm + +``` +1. Build the WalletId → WalletSeedHash bridge from DET sidecars (seedless): + for each (seed_hash, meta) in WalletMetaView.list(network): + root_xpub = ExtendedPubKey::decode(meta.xpub_encoded) // already persisted + wallet_id = upstream_wallet_id(root_xpub) // SHA256(xpub‖chaincode) + bridge.insert(wallet_id, seed_hash) + (Same xpub decode hydration.rs already does at lines 111-114, 273.) +2. outcome = pwm.load_from_persistor().await // ONE seedless call +3. for wallet_id in outcome.loaded: + seed_hash = bridge.get(wallet_id) // resolve DET key + pw = pwm.get_wallet(&wallet_id).await + ctx.register_resolved(seed_hash, wallet_id, pw) // id_map/wallets/snapshots +4. map outcome.skipped[(wallet_id, SkipReason)] → (seed_hash, PersistedLoadSkip) +5. return LoadedWallets { loaded, skipped } +``` +**No seed touched in any step.** Asset-lock signers and DashPay derivation are +*not* populated here — they remain seed-driven and lazy (§5). + +### 2.3 Type mapping (kept inside `loader.rs` — M-DONT-LEAK-TYPES) + +| Upstream (PR #3692) | DET-opaque | +|-------------------------------------------|--------------------------------------------| +| `WalletId` (`[u8;32]`) | resolved to `WalletSeedHash` via bridge | +| `LoadOutcome.loaded` | `Vec<WalletSeedHash>` | +| `SkipReason::CorruptPersistedRow{kind}` | `PersistedLoadSkip` | +| `CorruptKind::{Missing/Malformed/Decode}` | `PersistedLoadSkip::{...}` (string dropped) | +| `PlatformEvent::WalletSkippedOnLoad` | optional `EventBridge` → typed `TaskError`/log | + +`WalletId`, `PlatformWallet`, `LoadOutcome` never escape `loader.rs` / `mod.rs`. + +### 2.4 Seedless behavior vs seed behavior + +- **Seedless (this impl):** runs at launch with the Keychain locked equivalent — + no password prompt needed to *see* funds. Display surfaces (balance, UTXO, + identity, contacts, asset-locks) populate from disk. +- **Seed still required for signing:** populated lazily into `inner.seeds` on + the existing unlock path, not by the loader (§5). The loader's job ends at + watch-only registration. + +--- + +## 3. Construction flip + +### 3.1 The one-line swap + +`src/context/mod.rs:655` (inside `ensure_wallet_backend`): +```rust +- let loader = Arc::new(SeedReregistrationLoader::new()); ++ let loader = Arc::new(UpstreamFromPersisted::new()); +``` +Plus the import at `src/context/mod.rs:25` and the re-export at +`src/wallet_backend/mod.rs:58` (swap the exported name). + +### 3.2 `WalletBackend::new` consequence + +`register_persisted_wallets` (`src/wallet_backend/mod.rs:348`) changes from +"iterate seed-bearing registrations and call `create_wallet_from_seed_bytes`" to +"call `loader.load(...)` once and consume `LoadedWallets`". The identity-funding +re-provision recurrence trap (a5538dc8, `mod.rs:428-443`) **stays** — it runs +per loaded `seed_hash` exactly as today, since the seedless `load()` still does +NOT reconstruct identity funding HD accounts (that gap is unchanged by #3692). + +### 3.3 Does `SeedReregistrationLoader` stay? + +**Remove it** (M-NO-TOMBSTONES), per G2.4 step 4 — once the upstream API is the +real path, the seed-re-registration mock is dead weight. **One caveat for the +user (§7 Q1):** the seedless path is watch-only; if any current launch-time +behavior *depends* on the seed being in `inner.seeds` immediately after load +(rather than after unlock), removing the seed-driven loader changes timing. Audit +shows the only `inner.seeds` consumers are signing paths (`signer_for`, +`derive_private_key`, `send_payment`, DashPay derivation) — all user-initiated, +post-unlock — so removal is safe. Keep the single-key reserved-impl mirror +pattern intact (the trait stays object-safe with one shipping impl). + +--- + +## 4. Divergence & compile-risk assessment + +### 4.1 Pin switch verdict: **SAFE** + +`ffdc28b8 → ddfa66ed` is **ahead 59, behind 0** (verified via GitHub compare +API). The new pin is a strict superset of the current one. The PR adds 36 files, +**+2782/-338**, almost entirely *additive* readers and the new load path. + +### 4.2 What the pin switch touches that DET consumes + +| DET consumer | Upstream surface | In PR diff? | Impact | +|---|---|---|---| +| `DetKv` (`kv.rs`) → `KvStore`, `KvError`, `ObjectId` | `platform_wallet_storage` kv/object_id | **No** | none | +| `single_key.rs` → `secrets::{SecretStore, FileStoreError, SecretBytes, SecretString, WalletId}` | `platform_wallet_storage::secrets` | **No** | none | +| `WalletBackend::new` → `SqlitePersister`, `SqlitePersisterConfig` | `persister.rs` | **Yes (additive)** | `load()` now reconstructs `ClientStartState.wallets`; `LOAD_UNIMPLEMENTED` shrinks to `core::last_applied_chain_lock`. No signature change to `open`/`store`/`flush`. | +| `EventBridge` → `PlatformEventHandler` | `events.rs` | **Yes (additive)** | new trait method has a default impl ⇒ compiles unchanged | +| `pwm` construction | `manager/mod.rs::new` | unchanged sig | none | + +> The brief's worry that "PR 3692 reworks `platform-wallet-storage` heavily +> (SecretStore/EncryptedFileStore/KeyringStore)" does **not** hold at `ddfa66ed`: +> the secrets rework shipped in the *base* (#3672/#3625), which is already at or +> below our pin. PR #3692's storage diff is confined to `persister.rs`, +> `schema/*` (new readers), `migrations/V001` (no V002), and tests — none of the +> `secrets` module DET imports. + +### 4.3 Likely-to-need-adaptation beyond the loader + +1. **`create_wallet_from_seed_bytes` call site** (`mod.rs:368-377`) — removed + from the load path (moves entirely behind the new loader). The method still + exists upstream for the *create-new-wallet* and *unlock-to-sign* paths; only + the *load* usage moves. +2. **`inner.seeds` population timing** — no longer filled at load (seedless). + Must be filled on unlock (§5). Audit who reads it pre-unlock: none today. +3. **`WalletId` bridge** — new seedless mapping in `loader.rs` (xpub→WalletId). + `wallet_id_from_seed` (`mod.rs:975`) gets a sibling `wallet_id_from_xpub`. +4. **Skip surfacing** — `LoadedWallets.skipped` is new; UI should show a calm, + actionable banner for corrupt rows (a new typed `TaskError` variant, §6 T4). + +### 4.4 Cargo pin change + +`Cargo.toml` lines 21/31/32/35 (and the `Cargo.lock` rev) flip +`ffdc28b8…` → `ddfa66ed373beaebdae9a5d919f896af43cbcd33` for `dash-sdk`, +`rs-sdk-trusted-context-provider`, `platform-wallet`, `platform-wallet-storage`. +PR #3692 is a **draft against an unreleased branch**; pin to the exact head SHA, +not a tag. + +--- + +## 5. Fund-safety analysis (security-model delta of seedless load) + +Threat model frame: A04 (insecure design — silent-zero balance), A02 +(cryptographic/secret handling — seed in memory). ASVS V14.2 (data protection), +SECRETS.md. + +### 5.1 What improves + +- **No seed in memory at launch.** `SeedReregistrationLoader` decrypts and holds + every wallet's 64-byte seed in `inner.seeds` from the moment the backend + builds. `UpstreamFromPersisted` holds **zero** seed bytes to display balances. + This shrinks the in-memory secret window from "whole session" to "only while a + signing op is in flight" — a direct A02 improvement. The watch-only `Wallet` + carries no key material (PR's AR-7 hygiene: only `WalletType::WatchOnly`). +- **No silent-zero balance.** Upstream `apply_persisted_core_state` fails closed + (`RehydrationTopologyUnsupported`) rather than reconstruct a zero balance for a + wallet with persisted UTXOs but no funds account (rehydrate.rs). DET surfaces + this as a whole-load `Err` (calm banner), never a misleading "0 DASH". + +### 5.2 What is preserved (invariants that must NOT regress) + +- **Per-network coin-type derivation.** `ClientWalletStartState.network` is + persisted and drives `build_watch_only_wallet(network, …)`. The bridge + computes `WalletId` per-network from the per-network sidecar xpub. DET's + `core_to_wallet_network` mapping is unchanged. No cross-network leakage. +- **published-xpub == scanned-xpub fund-routing.** The watch-only wallet is + rebuilt from the **same** account `account_xpub` that was persisted at create + time (the manifest), and `WalletId` is `SHA256(root_xpub‖chaincode)`. The + bridge keys off the **same** `xpub_encoded` DET persisted. So the wallet DET + displays/scans is provably the wallet whose xpub DET published — the routing + invariant holds by construction. **Design gate:** the bridge MUST verify that + the upstream `WalletId` returned in `outcome.loaded` matches a bridge entry; an + unmatched `WalletId` (xpub mismatch) is a hard error, never a silent display of + an unknown wallet (Smythe review item). +- **Seeds stay `Zeroizing`.** `WalletRegistration.seed_bytes` (the old + seed-carrying type) is **deleted** with `SeedReregistrationLoader`. The new + `LoadedWallets` carries no secret. `inner.seeds` continues to hold + `Zeroizing<[u8;64]>`, populated only on unlock. + +### 5.3 What still needs the seed (signing — unchanged) + +The seed is required, exactly as today, for every operation that derives a +private key. These do **not** run at load; they run post-unlock, user-initiated: +- `signer_for` → `WalletAssetLockSigner` (`mod.rs:986`, `asset_lock_signer.rs:78`) + — asset-lock creation, identity registration/top-up. +- `derive_private_key` (`mod.rs:1001`) — one-time credit-output keys for + platform-address top-up / shielded deposit. +- `send_payment` (`mod.rs:1025`) — BIP-44 spend signing. +- DashPay derivation (`dashpay.rs:106-113`) — contact xpub / payment addresses. + +**New requirement:** because the loader no longer fills `inner.seeds`, the +unlock flow must populate it. Cleanest placement: a small +`WalletBackend::provide_seed(seed_hash, seed_bytes)` called from the existing +wallet-unlock path (where DET already decrypts the seed). This keeps the secret +boundary in one method and the seed out of the load path entirely. If a signing +op is attempted while `inner.seeds` lacks the hash, return the existing typed +"wallet locked / unlock to sign" error (today's `WalletBackendNotYetWired` ⇒ +should become a dedicated `WalletLocked`-style variant; §6 T4). + +### 5.4 Net verdict + +**Fund-safety: improved, no regression** — provided the §5.2 WalletId-match gate +and the §5.3 unlock-time seed provisioning are implemented. The watch-only model +is strictly less secret-exposing than seed-re-registration while preserving +coin-type and xpub-routing invariants. + +--- + +## 6. Task breakdown (for Bilby) + +Tasks are ordered; each ≥100 lines or batched. **(S)** = needs Smythe security +review. + +- **T1 — Pin bump + compile baseline (batched).** Flip `Cargo.toml` lines + 21/31/32/35 to `ddfa66ed…`; refresh `Cargo.lock`; run + `cargo clippy --all-features --all-targets -D warnings` and capture the *only* + expected breakage (the load path). Fix nothing else yet — this isolates the + divergence surface. Dependency: none. + +- **T2 — Reshape `PersistedWalletLoader` + DET-opaque result types (S).** + In `loader.rs`: replace `wallets_to_register` with async `load(...)`; add + `LoadedWallets`, `PersistedLoadSkip`, `WalletBackendLoadCtx`; delete + `WalletRegistration`'s seed field path. Keep the swap-boundary unit test + (alternate-impl compiles). ~150 lines. (S): trait carries no upstream types; + no secret in `LoadedWallets`. Dep: T1. + +- **T3 — Implement `UpstreamFromPersisted` (S).** The §2.2 algorithm: seedless + xpub→WalletId bridge (reuse `hydration.rs` xpub decode), one + `load_from_persistor()` call, resolve `loaded`/`skipped` to `WalletSeedHash`, + register via the ctx callback, **WalletId-match gate** (§5.2). Delete + `SeedReregistrationLoader`. ~200 lines. (S): the match gate is the + fund-routing guard — Smythe must confirm an unmatched WalletId hard-fails. + Dep: T2. + +- **T4 — Rewire `WalletBackend::new` / `register_persisted_wallets` + seed + provisioning + typed errors (S).** Replace the per-wallet + `create_wallet_from_seed_bytes` loop with the `loader.load(...)` call; keep the + a5538dc8 identity-funding re-provision per loaded `seed_hash`; add + `WalletBackend::provide_seed(...)` and call it from the unlock path; add typed + `TaskError` variants `PersistedRowSkipped` and `WalletLocked` (replace the + inverted `WalletBackendNotYetWired`-for-signing usage). ~180 lines. (S): seed + enters memory only via `provide_seed`; load path stays seedless. Dep: T3. + +- **T5 — Construction flip + cleanup (batched).** `context/mod.rs:25,655` + import + one-line swap; `mod.rs:58` re-export; remove dead seed-loader + references; delete the obsolete `wallet_id_from_seed` *load* usage (keep for + create/sign). ~40 lines. Dep: T4. + +- **T6 — Skip surfacing (UI/event) (batched).** Optionally override + `EventBridge::on_platform_event` to log `WalletSkippedOnLoad`; surface + `LoadedWallets.skipped` as a calm, actionable `MessageBanner` (Everyday-User + wording — "One saved wallet couldn't be opened. Re-add it from its recovery + phrase to restore it."). ~80 lines. Dep: T4. + +- **T7 — Tests (batched).** Adapt the loader swap-boundary test; add a + seedless-bridge unit test (xpub→WalletId matches `wallet_id_from_seed` for the + same wallet); a backend-e2e cold-boot test: persist N wallets, drop backend, + reconstruct via `UpstreamFromPersisted`, assert N balances visible with **no** + seed in `inner.seeds`, then `provide_seed` + sign. ~200 lines. Dep: T5. + +- **T8 — Docs + housekeeping (batched).** Update `g2-mock-boundary.md` §G2.5 + (G2 gate closed), flip PROJ-010 in the gap audit, `docs/user-stories.md` if a + "see balances before unlock" story exists. Dep: T7. + +--- + +## 7. Open questions / risks (need a user decision) + +- **Q1 — Drop `SeedReregistrationLoader` entirely, or keep it as a fallback?** + Recommendation: **drop** (G2.4, M-NO-TOMBSTONES). Keep only if you want a + runtime escape hatch for a corrupt-persister recovery flow (re-derive from + seed when `load_from_persistor` skips a row). My read: the skip→re-add-from- + phrase UX (T6) covers that without a second loader. **Decision needed.** + +- **Q2 — PR #3692 is a DRAFT against an unreleased branch + (`feat/platform-wallet-sqlite-persistor`), milestone v4.0.0.** Pinning DET to a + draft head means future force-pushes to that branch can move the SHA. We pin to + the immutable commit `ddfa66ed`, so DET is stable — but we won't get the PR's + later fixes until we re-pin. Acceptable? Or wait for #3692 to merge to + `v3.1-dev` first? **Decision needed** (affects T1 timing). + +- **Q3 — `last_applied_chain_lock` is the sole remaining `LOAD_UNIMPLEMENTED`.** + It re-warms on the first post-restart SPV chainlock (no V001 column). Confirm + DET has no launch-time consumer that needs it *before* first sync. Audit says + no (DET reads chainlock state from `ConnectionStatus`, push-based), but flag for + Smythe. + +- **Q4 — Unlock-time seed provisioning placement.** §5.3 proposes + `WalletBackend::provide_seed` called from the unlock path. Confirm the unlock + path is a single chokepoint (it should be — the password flow), so seeds enter + memory in exactly one place. If unlock is multi-site, the chokepoint must be + established first (small extra task). + +--- + +## Candy tally (findings by severity) + +- **HIGH (fund-safety design gates) — 2:** (1) WalletId-match gate on + `outcome.loaded` to preserve published-xpub==scanned-xpub routing (§5.2); + (2) unlock-time `provide_seed` so signing keeps working after seedless load + while keeping the seed off the load path (§5.3). +- **MEDIUM (correctness/API) — 3:** (1) `PersistedWalletLoader` trait must change + shape, not just add an impl (§2.1); (2) `WalletSeedHash` ⇄ `WalletId` bridge + via persisted xpub is mandatory (§0.4); (3) replace the inverted + `WalletBackendNotYetWired`-for-locked-signing with a typed `WalletLocked` + variant (§6 T4). +- **LOW (housekeeping/clarity) — 3:** (1) brief's head SHA / SeedProvider were + stale — real head is seedless `ddfa66ed` (§0); (2) divergence is SAFE not + risky — measured against the wrong base (§4.1); (3) drop + `SeedReregistrationLoader` per M-NO-TOMBSTONES (§3.3). + +**Total: 8 findings (2 HIGH, 3 MEDIUM, 3 LOW).** diff --git a/docs/ai-design/2026-06-02-signtime-unlock-ux/dev-plan.md b/docs/ai-design/2026-06-02-signtime-unlock-ux/dev-plan.md new file mode 100644 index 000000000..db12639be --- /dev/null +++ b/docs/ai-design/2026-06-02-signtime-unlock-ux/dev-plan.md @@ -0,0 +1,527 @@ +# Sign-Time Unlock Passphrase Prompt — Development Plan + +**Feature:** SEC-002 follow-up · Gap PROJ-008 · GitHub issue #90 +**Phase:** 1d (Architecture & Development Plan) +**Status:** Design only. No implementation. This is the contract Phase 2 (Bilby) builds from. +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-02 +**Base:** worktree fast-forwarded to `e6c6c017` + +**Inputs:** +- `docs/ai-design/2026-06-02-signtime-unlock-ux/requirements-and-ux.md` (Diziet, 1a+1b) — chosen integration: **gate-on-error with auto-retry**. +- `docs/ai-design/2026-06-02-signtime-unlock-ux/test-cases.md` (Marvin, 1c, 45 cases + findings G-1..G-7). + +> One observes the whole board before moving a single piece. Diziet designed the door; Marvin +> noticed it opens onto a wall. This plan first establishes where the wall actually is — verified +> against `e6c6c017`, not against the line numbers an earlier pass quoted — and only then lays +> the track. Every structural claim below was re-grepped after the base merge. + +--- + +## 0. Ground Truth (re-verified at `e6c6c017`) + +I treated G-1 and G-2 as hypotheses to be tested against the code, not as settled facts. Both +survive contact with the source. The findings are sharper than the spec assumed. + +### 0.1 The signing surface — two disjoint paths + +| Path | Secret material | Unlock mechanism | Sign entry point | Emits `SingleKeyPassphraseRequired`? | +|---|---|---|---|---| +| **HD seed** | 64-byte BIP-39 seed snapshot | `WalletUnlockPopup` → `wallet_seed.open(pw)` → `handle_wallet_unlocked` → `provide_seed` | `WalletAssetLockSigner` (`asset_lock_signer.rs:52`) via `signer_for(seed_hash)` (`mod.rs:1079`) | **No** — never calls `single_key()` | +| **Single key (imported WIF)** | per-key 32 bytes, AES-GCM under a per-key passphrase | `SingleKeyView::unlock_with_passphrase` → in-process `single_key_unlocked` cache | `SingleKeyView::sign_with` (`single_key.rs:649`) → `raw_key_bytes` (`:293`) | **Yes** — `single_key.rs:310` | + +These paths share no signer. The sign-time prompt belongs **exclusively** to the single-key path. + +### 0.2 G-1 confirmed — the gate is wired into nothing, and the in-scope operation is a stub + +- `SingleKeyPassphraseRequired` is **produced** only at `single_key.rs:310` (inside `raw_key_bytes`, + reached only through `sign_with`). +- It is **consumed** by no backend task and no screen. No `display_task_error` override matches it. +- Every non-test `sign_with` reference is a doc-comment (`mod.rs:198`, `mod.rs:669`). The only real + callers are the unit tests inside `single_key.rs`. +- The one operation whose unit of action *is* an imported single key — + `CoreTask::SendSingleKeyWalletPayment` — currently returns + `Err(TaskError::SingleKeyWalletsUnsupported)` unconditionally. It is a stub; it does not call + `sign_with` at all. Present state: the standalone `send_single_key_wallet_payment.rs` / + `refresh_single_key_wallet_info.rs` handler files have since been deleted — both single-key + `CoreTask` arms now return `SingleKeyWalletsUnsupported` inline in + `src/backend_task/core/mod.rs` (`:306-309` for send, `:216-217` for refresh). + +**Verdict on G-1: the feature must INTRODUCE the single-key sign call site, not wire an existing +one.** There is no production `sign_with` caller to "hook". The honest framing of PROJ-008 is two +coupled pieces of work: (a) implement a real single-key send that signs via `sign_with`, and (b) +catch the resulting `SingleKeyPassphraseRequired` in the UI and auto-retry. Without (a) there is +nothing for (b) to react to. + +### 0.3 G-2 confirmed — asset-lock signing is HD-only; it is OUT of scope + +- `WalletAssetLockSigner` (`asset_lock_signer.rs:52`) owns a `Zeroizing<[u8;64]>` BIP-39 seed + snapshot and derives via `derive_priv_ecdsa_for_master_seed` (`:78`). It is the **HD** signer. +- `register_identity` (`mod.rs:1224`) and `top_up_identity` (`mod.rs:1266`) are keyed by + `WalletSeedHash` and obtain their signer **only** through `signer_for(seed_hash)` → + `WalletAssetLockSigner`. There is no `seed_hash`-free, single-key-funded register/top-up path. +- Therefore neither identity registration nor top-up can today emit `SingleKeyPassphraseRequired`, + and the asset-lock signer cannot either. + +**Verdict on G-2: asset-lock signing is dropped from the prompt's call-site list.** Diziet's §1/§7 +attribution is incorrect for the current codebase. A single-key-funded identity registration would +be a substantial separate feature (a new funding path that builds an asset lock from an imported +key's UTXOs and signs the funding sighash with `sign_with`); it is not in scope here and is not +assumed by this plan. + +### 0.4 Substrate that already ships (do not redesign) + +- `single_key_unlocked: RwLock<BTreeMap<String,[u8;32]>>` — session cache (`mod.rs:201`). +- `SingleKeyView::{has_passphrase (:245), unlock_with_passphrase (:256), forget_unlocked (:281), + sign_with (:649)}`; `import_wif*` primes the cache (`:230-233`). +- Typed errors `SingleKeyPassphraseRequired { addr }` (`error.rs:1380`) and fieldless + `SingleKeyPassphraseIncorrect` (`error.rs:1392`) — both already correctly typed, no String payload. +- UI seam `ScreenLike::display_task_error(&mut self, &TaskError) -> bool`, trait default returns + `false` (`ui/mod.rs:978`); dispatch fan-out at `ui/mod.rs:1651`. Marvin's correction stands: the + default is `false`, so a screen must *opt in* by overriding and returning `true` to suppress the + banner. +- `PasswordInput` (`components/password_input.rs`) — zeroizing `Secret`, hold-to-reveal, undoer + disabled, `clear()`/`take_secret()`/`set_error()` API. +- `WalletUnlockPopup` (`components/wallet_unlock_popup.rs`) — the modal scaffolding to clone: + overlay (`DashColors::modal_overlay()`), `Align2::CENTER_CENTER` `Window`, `.open(&mut is_open)` + for X, Escape handling (`:205`), `clicked_outside_window` (`:211`), focus-once (`:134`), + right-to-left buttons with Unlock rightmost (`:155`). +- **Precedent for the stash pattern:** `SingleKeyWalletSendScreen` already carries a + `FeeConfirmationDialog { pending_request: Option<WalletPaymentRequest>, ... }` + (`single_key_send_screen.rs:43-50`) — a confirm-then-resume-the-stashed-request flow. The + sign-unlock stash is the same shape one level up (it stashes the whole `AppAction`/`BackendTask`). +- `AppAction::BackendTask(BackendTask)` (`app.rs:268`) — the action a screen re-emits to resume. + +--- + +## 1. Scope Reconciliation + +### 1.1 In scope (needs the prompt) + +**One call site: the single-key wallet send.** Concretely, the `CoreTask::SendSingleKeyWalletPayment` +handler (the standalone `send_single_key_wallet_payment.rs` file has since been deleted; the arm now +lives inline in `src/backend_task/core/mod.rs:306-309`) must be implemented to sign its funding input(s) with +`single_key().sign_with(addr, sighash)`. When that key is passphrase-protected and cold, +`sign_with` returns `SingleKeyPassphraseRequired { addr }`, which propagates as a `TaskError` +through `run_backend_task` → `TaskResult::Error` → `AppState` → the visible +`SingleKeyWalletSendScreen::display_task_error`. That is the sole gate the prompt reacts to in v1. + +This satisfies the operation-agnostic contract Marvin's TC-SITE-001 describes: the screen keys off +`addr` from the error, not off "send". + +### 1.2 Explicitly out of scope + +- **HD-seed operations** (HD wallet send, HD-funded identity register/top-up, DPNS, votes, token + ops, DashPay). These unlock via `WalletUnlockPopup` + `wallet_seed.open`; they never call + `single_key().sign_with` and never produce `SingleKeyPassphraseRequired`. The new prompt must not + intercept them — `display_task_error` returns `true` **only** for `SingleKeyPassphraseRequired`. +- **Asset-lock signing / identity register / top-up from a single key (resolving G-2).** Dropped. + No single-key funding path exists; building one is a separate feature. Diziet's call-site list is + corrected to exclude these. + +### 1.3 Dependency this plan surfaces (must be a user decision — see §7) + +Implementing a real single-key send (the prerequisite for the gate to ever fire) is **larger than a +UI prompt**. It requires UTXO selection, transaction building, fee handling, and broadcast for +imported keys — currently all stubbed (`SingleKeyWalletsUnsupported`). The migration design +(`docs/ai-design/2026-05-18-platform-wallet-migration/single-key-mock.md`) deliberately mocked this +out. PROJ-008-as-written ("wire the prompt") cannot be demonstrated end-to-end without first +un-stubbing single-key send. This is the central open question in §7. + +This plan is structured so the **prompt machinery (the genuinely reusable, testable PROJ-008 +deliverable)** can be built and unit/kittest-verified **against the gate contract** independently of +the send implementation, with the live wiring gated behind the send work. + +--- + +## 2. Architecture — Layer Map + +The feature touches three layers; each has a crisp responsibility and API surface. + +| Layer | Module(s) | Responsibility | New/changed surface | +|---|---|---|---| +| **Domain / backend signing** | `src/wallet_backend/single_key.rs`; the `CoreTask::SendSingleKeyWalletPayment` arm in `src/backend_task/core/mod.rs:306-309` (the standalone `send_single_key_wallet_payment.rs` file has since been deleted) | Produce `SingleKeyPassphraseRequired { addr }` from a real sign attempt. Owns the unlock cache. | `sign_with` already exists; the send task must *call* it. | +| **Error envelope (App Task System)** | `src/backend_task/error.rs` | Carry the typed gate error UI-ward. | **No new variant needed** — `SingleKeyPassphraseRequired { addr }` and `SingleKeyPassphraseIncorrect` already exist and are correctly typed (no String payload). | +| **Presentation** | `src/ui/components/sign_unlock_prompt.rs` (new), `src/ui/wallets/single_key_send_screen.rs` | Catch the error, prompt, unlock the cache, re-dispatch the stashed task; drop on cancel. | New `SignUnlockPrompt` component + screen-local stash + `display_task_error` override. | + +**Key architectural property: the passphrase never crosses a layer boundary.** It lives only inside +`SignUnlockPrompt`'s `PasswordInput.Secret`, is consumed in-process by +`single_key().unlock_with_passphrase`, and is zeroized before the stashed task is re-dispatched. The +async boundary only ever carries the operation task (no secret) and the typed error (no secret). +This is the load-bearing reason the **gate-on-error** pattern beats mid-flight unlock-request, and +it is preserved by construction here. + +--- + +## 3. Backend Gate + +### 3.1 Where the gate fires + +`AppContext::send_single_key_wallet_payment` is reworked from a stub into a real send. At the point +it must spend the imported key, it calls (per UTXO / per sighash): + +```text +backend.single_key().sign_with(&addr, &sighash)? // addr = the imported key's P2PKH address +``` + +On a cold protected key this returns `Err(TaskError::SingleKeyPassphraseRequired { addr })`. The `?` +propagates it unchanged up through `run_backend_task`. **No mapping, no re-wrapping, no String.** + +### 3.2 Error variant — reuse, do not invent + +`SingleKeyPassphraseRequired { addr: String }` (`error.rs:1380`) already: +- carries only the Base58 `addr` (a CLAUDE.md rule-6 handle, not a secret, not jargon), +- has a `#[error(...)]` Display that is user-appropriate, +- has no `#[source]` (correct — the "you must unlock" condition wraps no upstream error), +- has no user-facing-String-as-field smell. + +`SingleKeyPassphraseIncorrect` (fieldless, `error.rs:1392`) is the wrong-passphrase variant returned +by `unlock_with_passphrase`. Also already correct. + +**Action: none on `error.rs`.** A new variant would violate DRY and re-litigate a solved design. +Bilby must *not* add a parallel variant. If the removed-key copy decision (§7 Q4 / G-6) lands on a +dedicated message, that is a one-line `#[error]` tweak to the existing `ImportedKeyNotFound` +(`error.rs:206`) Display, not a new variant. + +### 3.3 Propagation contract (unchanged App Task System) + +```text +single_key().sign_with(addr) -> Err(SingleKeyPassphraseRequired{addr}) + → ? up through send_single_key_wallet_payment + → run_backend_task returns Err + → AppState sends TaskResult::Error(err) + → AppState::update routes to visible_screen.display_task_error(&err) + → SingleKeyWalletSendScreen returns true (suppress generic banner), opens prompt +``` + +No change to the channel, the spawn, or `TaskResult`. The gate is pure error propagation. + +--- + +## 4. UI Integration — Gate-on-Error + Auto-Retry + +### 4.1 The pending-task stash — where it lives and what it holds + +The stash lives **on the triggering screen** (`SingleKeyWalletSendScreen`), not in `AppState` and +not in the component. Rationale (matches Diziet §4.1 and the existing `FeeConfirmationDialog` +precedent): the screen owns the inputs (recipients, amounts, options) that must survive the prompt, +and it is the natural owner of the `AppAction` it just emitted. + +New screen fields: + +```text +sign_unlock_prompt: Option<SignUnlockPrompt>, // lazy; None when no gate is active +pending_signed_task: Option<BackendTask>, // the task to re-dispatch on unlock +``` + +`BackendTask` is re-dispatchable by value: the screen reconstructs the same +`AppAction::BackendTask(...)` it produced when the gate fired. (The screen already holds the source +inputs, so it can rebuild the task; stashing the constructed `BackendTask` is the simplest and is +what the `FeeConfirmationDialog.pending_request` precedent does one level down.) + +### 4.2 Catching the error (`display_task_error`) + +`SingleKeyWalletSendScreen::display_task_error(&mut self, error: &TaskError) -> bool`: + +```text +match error { + TaskError::SingleKeyPassphraseRequired { addr } => { + if self.sign_unlock_prompt is already open for `addr` { return true } // E-2 dedupe + stash the task that just failed into self.pending_signed_task + self.sign_unlock_prompt = Some(SignUnlockPrompt::open_for(addr, alias, hint)) + true // suppress the generic banner + } + _ => false // everything else: default banner (HD ops, network errors, etc.) +} +``` + +The default (`false`) is preserved for all other errors, so HD-seed and unrelated failures are +untouched (TC-UNLOCK-005). + +**Stashing the task on the error path.** `display_task_error` receives only the error, not the +originating task. Two clean options for Bilby; pick (A) unless it proves awkward: +- **(A) Re-derive on unlock.** The screen keeps the *source inputs* (it already does) and rebuilds + the `BackendTask` when the prompt succeeds. Nothing extra to stash beyond a "resume requested" + flag plus the inputs. Cleanest; no task duplication; survives the round-trip trivially (TC-RESUME-003). +- **(B) Remember-last-dispatched.** The screen records the `BackendTask` it dispatched this frame in + `pending_signed_task` *before* spawning, and `display_task_error` promotes it to "armed". Mirrors + `FeeConfirmationDialog.pending_request`. + +Both keep the secret off the task. (A) is preferred because it cannot accidentally retain a stale +task across input edits. + +### 4.3 The prompt lifecycle in `ui()` + +Each frame, after the main screen body, the screen calls `self.sign_unlock_prompt`'s `show` (lazy +`Option`, standard component pattern). The component returns a `SignUnlockResult`: + +```text +match prompt.show(ctx, backend.single_key(), addr) { + Pending => AppAction::None, // keep rendering modal + Unlocked => { // cache now holds the key + self.sign_unlock_prompt = None; // closed; secret already zeroized + // FR-3 auto-resume: re-dispatch the stashed/rebuilt task + let task = self.take_pending_or_rebuild(); + AppAction::BackendTask(task) // TC-RESUME-002 + } + Cancelled => { // Cancel / X / Esc / click-outside + self.sign_unlock_prompt = None; + self.pending_signed_task = None; // DROP, not hide (TC-CANCEL-001/005, E-5) + AppAction::None // inputs untouched + } +} +``` + +The unlock call itself (`single_key().unlock_with_passphrase(addr, pw)`) happens **inside the +component's `show`** so the passphrase never leaves the component. The component maps the result: +`Ok(())` → `Unlocked`; `Err(SingleKeyPassphraseIncorrect)` → stay open, clear field, set inline +error from the variant's `Display` (TC-WRONG-002, not a hardcoded literal); `Err(ImportedKeyNotFound)` +→ close + surface via normal error path, no loop (E-6 / TC-EDGE-001). + +### 4.4 Cancel and lifecycle hooks + +- **Cancel / X / Escape / click-outside** → `Cancelled` → drop the stash, zeroize secret + (TC-CANCEL-001..005, TC-SEC-004). +- **`change_context` (network switch)** → the screen closes the prompt and drops the stash; the + component's `close()` zeroizes the secret (TC-EDGE-003, E-8). The screen's existing + `change_context` override gains: `self.sign_unlock_prompt = None; self.pending_signed_task = None;`. +- **Re-dispatch must fire once.** Clear the stash *as* you build the resume action so it cannot + re-fire next frame (TC-CANCEL-005, TC-RESUME-002). + +### 4.5 Multi-key sequencing (FR-7 / E-1) + +No batching UI. The error→prompt→retry loop drains keys naturally: the re-dispatched send hits the +*next* uncached key, the backend returns `SingleKeyPassphraseRequired { addr: K2 }`, +`display_task_error` opens the prompt for K2, repeat (TC-MULTI-001/002). The defensive +"different-addr arrives while open" queue (TC-CONCUR-003) is, as Marvin flags in G-5, hard to reach +under this loop because focus is trapped; specify it as a **defensive no-op** (ignore the duplicate; +the loop will re-surface it after the current key resolves) rather than building a real queue. + +--- + +## 5. New Component — `SignUnlockPrompt` + +`src/ui/components/sign_unlock_prompt.rs`. A near-clone of `WalletUnlockPopup`, differing only in +what it unlocks and what it reports back. + +### 5.1 State + +```text +pub struct SignUnlockPrompt { + is_open: bool, + addr: String, // the imported key's P2PKH address (the gate key) + key_name: Option<String>, // ImportedKey.alias, if any (FR-2) + hint: Option<String>, // passphrase_hint, if any (FR-2) + password_input: PasswordInput, + error_message: Option<String>,// derived from SingleKeyPassphraseIncorrect.Display (FR-4) + focus_requested: bool, +} +``` + +Private fields only (component pattern). No public mutable state. + +### 5.2 API + +```text +impl SignUnlockPrompt { + pub fn open_for(addr: impl Into<String>, key_name: Option<String>, hint: Option<String>) -> Self; + pub fn is_open(&self) -> bool; + pub fn addr(&self) -> &str; // E-2 same-address dedupe / TC-MULTI-002 + pub fn close(&mut self); // zeroizes secret (calls password_input.clear()) + pub fn show( + &mut self, + ctx: &egui::Context, + single_key: SingleKeyView<'_>, // borrow to call unlock_with_passphrase in-process + // addr/key_name/hint are already on self + ) -> SignUnlockResult; +} + +pub enum SignUnlockResult { Pending, Unlocked, Cancelled } +``` + +`show` performs the unlock attempt internally (Enter or Unlock click, only when field non-empty — +E-7/TC-PROMPT-006), so the `Secret` never leaves the component (NFR-1 / TC-SEC-002/003). + +### 5.3 Copy (i18n-ready, from Diziet §4.4; named placeholders) + +| Slot | String | +|---|---| +| Title | `Unlock imported key` | +| Body (aliased) | `The key "{ $key_name }" is locked. Enter the passphrase you set for it to continue.` | +| Body (no alias) | `The key { $address } is locked. Enter the passphrase you set for it to continue.` | +| Hint (only if present) | `Hint: { $hint }` | +| Session note | `This key stays unlocked until you close the app.` | +| Field placeholder | `Passphrase` | +| Reveal tooltip | `Hold to reveal` (already on `PasswordInput`) | +| Primary | `Unlock` · Secondary | `Cancel` | +| Wrong passphrase | derive from `SingleKeyPassphraseIncorrect.Display` — do **not** hardcode a parallel literal | + +### 5.4 Shared modal body — recommended, optional + +`WalletUnlockPopup` and `SignUnlockPrompt` share ~90% scaffolding (overlay, `Window`, focus-once, +Enter/Escape/X/click-outside, right-to-left Unlock/Cancel). Extracting a private +`modal_password_body(ui, &mut PasswordInput, &mut focus_requested, error) -> ModalChrome` helper (or +a thin `PassphraseModal` struct) into `components/` would remove the duplication. **Recommended but +not required for v1** — the two call sites differ enough (one opens an HD seed and calls +`handle_wallet_unlocked`; the other unlocks a single key by address and reports `Unlocked`) that a +straight clone is acceptable if the extraction proves fiddly. If cloned, leave a `// SHARED-CHROME: +mirror of wallet_unlock_popup.rs` marker so the duplication is intentional and findable. + +### 5.5 Catalog + +After building, add a row to `src/ui/components/README.md` Dialog Components table: +`| SignUnlockPrompt | sign_unlock_prompt.rs | SignUnlockResult | Per-key passphrase unlock for imported single keys |`. + +--- + +## 6. Decisions + +| # | Decision | Resolution | Trace | +|---|---|---|---| +| D1 | Integration pattern | **Gate-on-error + auto-retry.** Confirmed by ground truth: secret stays off the async channel by construction; cache makes retry free. | Diziet §6 | +| D2 | New error variant? | **No.** Reuse `SingleKeyPassphraseRequired { addr }` + `SingleKeyPassphraseIncorrect`; both already typed and String-free. | §3.2 | +| D3 | Stash location | **On the triggering screen** (`SingleKeyWalletSendScreen`), not `AppState`, not the component. | §4.1 | +| D4 | Retry limit | **None / no cooldown** (Diziet NFR-5). Local AES-GCM secret, no remote attacker, no account to lock. **Flag for Smythe** (G-3): if Security wants a soft cap, TC-WRONG-004 and the component gain a counter. | §7 Q1 | +| D5 | Multi-key | **Sequential via the retry loop**, one prompt at a time. Defensive different-addr "queue" is a no-op ignore, not a real queue (G-5). | §4.5 | +| D6 | Secret confinement | Passphrase lives only in `PasswordInput.Secret`; consumed in-process; zeroized on every close path and every wrong attempt. Never in `TaskError`, `AppAction`, `BackendTask`, logs, or banner details. | NFR-1 / TC-SEC-001..004 | +| D7 | Asset-lock / identity register | **Out of scope** (G-2). No single-key funding path exists. | §0.3, §1.2 | +| D8 | Single-key send implementation | **Prerequisite, larger than the prompt.** Prompt machinery built + tested against the gate contract; live wiring gated behind un-stubbing send. **User decision needed.** | §1.3, §7 Q3 | + +**Security review (Smythe) flags:** D4 (retry policy), D6 confinement tests (TC-SEC-001..004), and +the eventual single-key send signing path (when D8 is taken) all warrant Smythe sign-off. + +--- + +## 7. Open Questions (need a user / stakeholder decision) + +1. **Retry limit (G-3 / NFR-5).** Confirm *no* cap and *no* cooldown. Design and this plan + recommend none; Security (Smythe) may want a soft cap. This is the one decision that changes the + component's state and TC-WRONG-004. +2. **Session-note wording (G-4 / §6.2).** Commit to "This key stays unlocked until you close the + app." now, or hedge to "for a while" in anticipation of a future idle auto-lock? Recommend the + honest literal until an auto-lock is actually designed. +3. **Single-key send: in or out of THIS deliverable? (the pivotal one, §1.3 / D8.)** The gate cannot + fire until `send_single_key_wallet_payment` is un-stubbed to sign via `sign_with`. Options: + - **(3a) Prompt-only now.** Build + unit/kittest the `SignUnlockPrompt` and the + `display_task_error`/stash/auto-retry state machine against the *gate contract* (drive the + error directly, as Marvin's offline cases do). Defer live send wiring. PROJ-008's reusable + deliverable lands; live demo waits on the send feature. + - **(3b) Full vertical slice.** Implement single-key send end-to-end too (UTXO selection, tx + build, fee, broadcast) so the gate fires for real on testnet. Substantially larger; overlaps + the migration design's deliberately-mocked single-key surface. + Recommend **(3a)** for this phase, with (3b) tracked as a dependent feature. **User must choose.** +4. **Removed-key message (G-6).** `ImportedKeyNotFound` has no bespoke copy for the "removed between + trigger and unlock" case. Reuse its existing `Display`, or author a dedicated message? Recommend + reuse unless it reads poorly to the Everyday User. + +--- + +## 8. Task Breakdown for Bilby (Phase 2) + +Tasks are ordered by dependency. Each names the TC IDs it satisfies and flags Smythe review. Tasks +T1–T4 are the **prompt machinery** and are independent of the send implementation (they test against +the gate contract). T5 is the **live wiring**, gated behind Open Question Q3. + +### T1 — `SignUnlockPrompt` component (new) +- **Files:** `src/ui/components/sign_unlock_prompt.rs` (new); register in `src/ui/components/mod.rs`; + add catalog row to `src/ui/components/README.md`. +- **Scope:** struct + `open_for`/`is_open`/`addr`/`close`/`show` + `SignUnlockResult`; clone + `WalletUnlockPopup` chrome (overlay, Window, focus-once, Enter/Esc/X/click-outside, RTL buttons); + reuse `PasswordInput`; copy from §5.3; Unlock disabled while empty; inline error from + `SingleKeyPassphraseIncorrect.Display`; zeroize on every close path; `unlock_with_passphrase` + called in-process. +- **Satisfies:** TC-PROMPT-001..006, TC-WRONG-001..003, TC-A11Y-001..005, TC-SEC-004, TC-EDGE-002. +- **Smythe:** yes (secret confinement, zeroize-on-close). +- **Size:** ~250–350 lines + kittest. + +### T2 — `SingleKeyWalletSendScreen` gate integration (stash + `display_task_error` + lifecycle) +- **Files:** `src/ui/wallets/single_key_send_screen.rs`. +- **Scope:** add `sign_unlock_prompt: Option<SignUnlockPrompt>` and the pending-task stash (§4.1, + option A preferred); override `display_task_error` to open the prompt only for + `SingleKeyPassphraseRequired` (return `true`) and pass through everything else (return `false`); + drive the prompt in `ui()` (Pending/Unlocked/Cancelled → §4.3); drop stash on cancel; clear prompt + + stash in `change_context`; resolve `alias`/`hint` for the `addr` from `single_key().list()` / + `ImportedKey`. +- **Depends on:** T1. +- **Satisfies:** TC-UNLOCK-005, TC-RESUME-002/003, TC-WRONG-003, TC-CANCEL-001..005, TC-CONCUR-002/003, + TC-EDGE-003/004, TC-MULTI-002. +- **Smythe:** yes (re-dispatch carries no secret — TC-SEC-002). +- **Size:** ~150–250 lines. + +### T3 — Offline state-machine + security unit tests +- **Files:** inline `#[cfg(test)]` in the component / screen; kittest in `tests/kittest/` + (`sign_unlock_prompt.rs`, registered in `tests/kittest/main.rs`); follow the + `tests/kittest/import_single_key.rs` house style (`force_input_for_test`, `query_by_label*`). +- **Scope:** drive the gate contract directly (no live send): cache MISS/HIT/unprotected/import-primes + (TC-UNLOCK-001..004 — mirror `single_key.rs:1140-1186`); correct→cache populated (TC-RESUME-001); + wrong→typed incorrect, cache untouched (TC-WRONG-001); re-dispatch fires once, drops on cancel + (TC-RESUME-002, TC-CANCEL-005); secret confinement with sentinel passphrase across Display/Debug, + AppAction, logs (TC-SEC-001/002/003). +- **Depends on:** T1, T2. +- **Satisfies:** the offline two-thirds of Marvin's suite. +- **Smythe:** yes (TC-SEC-001..003 are the security assertions). +- **Size:** batched, ~300+ lines across files. + +### T4 — Shared modal-chrome extraction (optional, recommended) +- **Files:** `src/ui/components/` (new private helper or `PassphraseModal`); refactor + `wallet_unlock_popup.rs` and `sign_unlock_prompt.rs` onto it. +- **Scope:** only if the duplication from T1 is worth removing; otherwise close with the + `// SHARED-CHROME` marker decision. No behavioural change. +- **Depends on:** T1. +- **Satisfies:** maintainability (no TC; regression-covered by T3 + existing wallet-unlock tests). +- **Smythe:** no. +- **Size:** ~100–150 lines net (mostly moves). + +### T5 — [GATED on Q3] Single-key send signs via `sign_with` (live wiring) +- **Files:** the single-key send/refresh handlers, now inline in `src/backend_task/core/mod.rs` + (the standalone `send_single_key_wallet_payment.rs` file has since been deleted); the single-key + send/refresh rejections live at `src/backend_task/core/mod.rs:306-309` and `:216-217`. +- **Scope:** un-stub the send; build/sign the funding input(s) via `single_key().sign_with(addr, + sighash)`; let `SingleKeyPassphraseRequired` propagate. **This is the larger send feature** (UTXO + selection / tx build / fee / broadcast) — likely itself decomposed; out of scope unless the user + takes (3b). +- **Depends on:** Q3 decision; consumes T1–T3 once live. +- **Satisfies:** TC-SITE-001 (currently [NOT-YET-WIRED]); TC-RESUME-004 [LIVE/MANUAL]; + TC-MULTI-001 [LIVE/MANUAL]. +- **Smythe:** yes (new signing path). +- **Size:** large; specify separately when Q3 is resolved. + +**Task count for Bilby in this phase: 4 buildable now (T1–T4), plus T5 gated on a user decision.** + +--- + +## 9. Traceability (plan → requirements → tests) + +| Requirement | Plan section | Tests | +|---|---|---| +| FR-1 gate appears iff `SingleKeyPassphraseRequired` | §3, §4.2 | TC-UNLOCK-001..005 | +| FR-2 self-explanatory content | §5.3 | TC-PROMPT-001..005 | +| FR-3 auto-resume | §4.3 (T2) | TC-RESUME-001..004 | +| FR-4 wrong passphrase recoverable | §4.3, §5 | TC-WRONG-001..004 | +| FR-5 cancel drops task | §4.4 | TC-CANCEL-001..005 | +| FR-6 session unlock | §0.4 cache, §5.3 note | TC-UNLOCK-002, TC-PROMPT-004 | +| FR-7 multi-key sequential | §4.5 | TC-MULTI-001..002 | +| NFR-1 secret confinement | §2, §4.3, §5.2, D6 | TC-SEC-001..004 | +| NFR-2 a11y | §5 (clone of `WalletUnlockPopup`) | TC-A11Y-001..005 | +| NFR-5 retry policy | D4, Q1 | TC-WRONG-004 | +| NFR-6 operation-agnostic | §1.1 (keys off `addr`) | TC-SITE-001 | + +--- + +## Candy Tally 🍬 (architecture findings surfaced) + +- **High (2):** G-1 confirmed and sharpened — the gate is unwired *and* its only in-scope operation + (`send_single_key_wallet_payment`) is a `SingleKeyWalletsUnsupported` stub, so the feature must + **introduce** the `sign_with` call site, not hook an existing one; G-2 confirmed — asset-lock / + identity-register signing is HD-seed-only (`signer_for` → `WalletAssetLockSigner`) and is **dropped + from scope**. +- **Medium (2):** the single-key send prerequisite (D8/Q3) is materially larger than the prompt and + must be a user decision before any live demo; the pending task must be **dropped, not hidden**, on + cancel/network-switch or it re-fires (encoded in T2 + TC-CANCEL-005). +- **Low (2):** no new `TaskError` variant is needed (reuse the two correct existing ones) — a finding + that *prevents* a likely duplicate-variant mistake; shared modal chrome (T4) is worth extracting + but optional, marked so the clone is intentional. +- **Open questions (4):** retry limit (Smythe), session-note wording, single-key-send scope (the + pivotal one), removed-key copy. + +**Total: 6 findings (0 critical, 2 high, 2 medium, 2 low) + 4 open questions.** diff --git a/docs/ai-design/2026-06-02-signtime-unlock-ux/requirements-and-ux.md b/docs/ai-design/2026-06-02-signtime-unlock-ux/requirements-and-ux.md new file mode 100644 index 000000000..f7ae0606a --- /dev/null +++ b/docs/ai-design/2026-06-02-signtime-unlock-ux/requirements-and-ux.md @@ -0,0 +1,472 @@ +# Sign-Time Unlock Passphrase Prompt — Requirements & UX Spec + +**Feature:** SEC-002 follow-up · Gap PROJ-008 · GitHub issue #90 +**Phase:** 1a (Requirements) + 1b (UX) — combined +**Status:** Design only. No implementation. This is the spec the next phases build from. +**Author:** Diziet (Product Designer) +**Date:** 2026-06-02 + +> **Worktree note (verify after merge):** This worktree could not be fast-forwarded to +> `b2febb71` — the environment for this design pass exposed no shell. All `src/…:line` +> citations below were read from the worktree as-is and should be **re-verified after the +> base merge** before any implementation begins. The structural facts they describe (the +> unlock cache, the typed errors, the `display_task_error` hook) are load-bearing; the exact +> line numbers are not. + +--- + +## 1. Executive Summary + +### Problem statement +SEC-002 (Option C, **per-key passphrase**) shipped the storage and in-process unlock +substrate for imported single-key wallets: each protected key is AES-GCM-encrypted under its +own passphrase, and an in-process cache (`single_key_unlocked`) keeps a key unlocked for the +rest of the session once the user has typed its passphrase. What did **not** ship is the only +thing the user ever sees: **a prompt that asks for the passphrase at the moment a signature is +needed.** Today, if a protected key is not already in the cache, any operation that must sign +with it (register identity, send funds, asset-lock signing, and any future single-key signer) +simply fails with a typed `SingleKeyPassphraseRequired` error and no way forward. The TODO sits +at `src/wallet_backend/mod.rs:562-566`. + +This is a dead-end, not a bug: the safety net is in place but the door has no handle. + +### Key actors +- **Everyday User (Alex Torres)** — imported a private key (e.g. a paper-wallet sweep or a key + a service gave them) and protected it with a passphrase. Does not know what "signing" means; + knows they set a passphrase and expects to be asked for it when it matters. +- **Power User (Priya Nakamura)** — imports keys deliberately for specific purposes; wants + single-key wallets to reach feature parity (her pain point #8) and wants **minimal friction** + — one unlock per session, no re-typing. + +### Solution direction +Intercept the existing typed `SingleKeyPassphraseRequired` error at the UI layer via the +already-present `Screen::display_task_error(&TaskError) -> bool` hook, open a **passphrase +unlock prompt** (a near-clone of the existing `WalletUnlockPopup`), call the existing +`SingleKeyView::unlock_with_passphrase`, and **automatically re-dispatch the original +operation** once the key is unlocked. No new backend signing path, no change to the sign API, +no change to SEC-002. The whole feature lives at the UI seam where errors already flow back. + +### Recommended integration pattern (the one decision worth front-loading) +**Gate-on-error with auto-retry**, *not* mid-flight unlock-request. The backend task runs, +hits the locked key, returns `SingleKeyPassphraseRequired { addr }`; the UI catches it, prompts, +unlocks the in-process cache, and re-runs the **same** task. Rationale: the backend already +returns this typed error and the cache already makes the retry cheap — this pattern adds zero +new plumbing, keeps secrets off the async channel, and reuses the error path that exists today. +(Full justification in §6.) + +--- + +## 2. Stakeholder & Actor Analysis + +| Actor | Goal | Pain today | Success looks like | +|---|---|---|---| +| **Everyday User** | Use their protected imported key to send/register without thinking about cryptography | Operation silently fails or shows a confusing error with no path forward | A calm prompt: "Enter the passphrase for this key" → operation just continues | +| **Power User** | Sign many operations in a session with one unlock; full single-key parity | Single-key wallets are "second-class"; re-prompting would be friction | First sign of the session prompts; every subsequent sign for the same key is silent | +| **Security reviewer** (internal stakeholder) | Passphrase never leaks to logs, error text, async channels, or disk | — | Passphrase lives only in a zeroizing buffer, cleared on close; never in `TaskError`, never logged | +| **Next-phase engineer** (consumer of this spec) | Unambiguous integration point and component contract | TODO with no UX | One named component, one hook, one re-dispatch contract | + +**Supporting systems (already shipped — do NOT redesign):** +- `WalletBackend.single_key_unlocked: RwLock<BTreeMap<String,[u8;32]>>` — the session cache + (`src/wallet_backend/mod.rs:202`). +- `SingleKeyView::unlock_with_passphrase(addr, passphrase)` — decrypts and populates the cache; + returns `SingleKeyPassphraseIncorrect` on a wrong passphrase + (`src/wallet_backend/single_key.rs:256`). +- `SingleKeyView::has_passphrase(addr)` — whether a prompt is needed + (`src/wallet_backend/single_key.rs:245`). +- `SingleKeyView::forget_unlocked(addr)` — explicit re-lock + (`src/wallet_backend/single_key.rs:281`). +- `SingleKeyView::sign_with(addr, msg)` → returns `SingleKeyPassphraseRequired { addr }` when + the cache misses on a protected key (`src/wallet_backend/single_key.rs:649`). +- Typed errors: `SingleKeyPassphraseRequired { addr }`, `SingleKeyPassphraseIncorrect` + (`src/backend_task/error.rs:1380`, `:1392`). +- UI seam: `Screen::display_task_error(&mut self, &TaskError) -> bool` — returns `true` to + suppress the generic error banner (`src/app.rs:1135`, dispatch in `src/ui/mod.rs:1666`). +- Reusable UI: `PasswordInput` (zeroizing `Secret`, hold-to-reveal, undo disabled — + `src/ui/components/password_input.rs`) and `WalletUnlockPopup` (the modal pattern to clone — + `src/ui/components/wallet_unlock_popup.rs`). + +--- + +## 3. Requirements + +### 3.1 Functional Requirements + +**FR-1 — Prompt appears exactly when a sign is blocked.** +The prompt appears if and only if a backend operation returns +`SingleKeyPassphraseRequired { addr }` (i.e. a sign was attempted against a protected key whose +plaintext is not in the session cache). It never appears speculatively, never on import (import +primes the cache), and never for an unprotected key. + +*Acceptance (Given/When/Then):* +- **G** an imported key at `addr` is passphrase-protected and not in the session cache + **W** the user triggers send-funds / identity-register / asset-lock signing with that key + **T** the unlock prompt opens, anchored over the triggering screen, with the operation paused. +- **G** the same key is already in the session cache **W** the user triggers the same operation + **T** no prompt appears and the operation proceeds without interruption. + +**FR-2 — Prompt content is self-explanatory to a non-technical user.** +The prompt shows: a title, a one-sentence explanation naming *which* key (by alias if present, +else by Base58 address — a permitted handle per CLAUDE.md rule 6), the saved passphrase **hint** +if one exists, a masked passphrase field, and Unlock / Cancel actions. + +*Acceptance:* +- **G** the key has alias "Savings sweep" and hint "the xkcd one" **W** the prompt opens + **T** it reads, in plain language, that the *Savings sweep* key is locked and shows the hint. +- **G** the key has no alias **W** the prompt opens **T** it identifies the key by its address. +- The prompt contains **no** jargon ("sign", "ECDSA", "secp256k1", "state transition", "cache"). + +**FR-3 — Correct passphrase unlocks and the operation resumes automatically.** +On a correct passphrase the key is unlocked into the session cache and the **original +operation is re-dispatched automatically** — the user does not re-initiate it. + +*Acceptance:* +- **G** the prompt is open for a pending send **W** the user types the correct passphrase and + confirms **T** the prompt closes and the send completes (or proceeds to its normal next step) + without the user re-entering amount/recipient. + +**FR-4 — Wrong passphrase is recoverable in place.** +A wrong passphrase keeps the prompt open, clears the field, and shows an inline, non-alarming +error. The user may retry without limit (see §3.2 NFR-5 for the rate-limit decision). + +*Acceptance:* +- **G** the prompt is open **W** the user enters a wrong passphrase **T** the field clears, an + inline message says the passphrase was not correct and to try again, and focus returns to the + field. The pending operation is **not** cancelled. + +**FR-5 — Cancel/abort cleanly aborts the operation.** +Cancel, the window X, the Escape key, and a click on the overlay all dismiss the prompt and +abandon the pending operation. Nothing is signed, no partial state is written, the user is +returned to the triggering screen with their inputs intact. + +*Acceptance:* +- **G** a pending send with the prompt open **W** the user presses Escape **T** the prompt + closes, nothing is sent, and the send screen still shows the entered amount and recipient. + +**FR-6 — One unlock covers the session (mental model).** +After a successful unlock, subsequent signs for the **same** key in the same process run without +re-prompting (this is the cache behaviour that already ships). The prompt copy must set this +expectation so the user understands the unlock persists and is not per-operation. + +*Acceptance:* +- **G** the user unlocked key `K` earlier this session **W** they perform a second operation + with `K` **T** no prompt appears. +- The prompt copy communicates "unlocked until you close the app" (see §4.4 for exact strings). + +**FR-7 — Multiple protected keys in one operation are each unlocked, in order.** +If a single operation needs two or more protected, uncached keys, the user is prompted for each +in turn (one prompt at a time), and the operation proceeds only once all are unlocked. (See §5 +Edge Case E-1 for the recommended sequencing.) + +**FR-8 — Explicit re-lock remains available (no regression).** +The existing "lock"/forget affordance (`forget_unlocked`) is unaffected; locking a key mid-session +means the next sign re-prompts. This spec does not add a re-lock UI but must not block one. + +### 3.2 Non-Functional Requirements + +**NFR-1 — Security: the passphrase never escapes the prompt.** +- Held only in the `PasswordInput`'s zeroizing `Secret`; cleared (`clear()`/zeroized) on every + close path (success, cancel, X, Escape, click-outside) and on every wrong-passphrase retry. +- **Never** placed in a `TaskError`, an `AppAction`, a `BackendTask`, a log line, the + `MessageBanner` details panel, or any string sent across the async channel. The async task + receives a *passphrase-derived unlock having already happened in the cache*, never the + passphrase itself. +- Re-affirms CLAUDE.md rule 7: no user-facing/secret `String` in error variants. The wrong- + passphrase case uses the existing fieldless `SingleKeyPassphraseIncorrect`. + +**NFR-2 — Accessibility (within egui's known limits).** +- Keyboard: field is auto-focused on open; **Enter** submits; **Escape** cancels; **Tab** moves + Field → Unlock → Cancel in layout order (matches the global table in + `docs/ux-design-patterns.md` §10). +- Focus is trapped in the modal while open (the overlay + modal `Window` already do this in the + `WalletUnlockPopup` pattern); focus returns to the triggering control on close. +- Focus indicator uses `BORDER_WIDTH_THICK` / 3:1 contrast per the patterns doc. +- **Known limitation (must be recorded, not hidden):** egui exposes no screen-reader + annotations (`docs/ux-design-patterns.md:172`). We therefore make the prompt legible *by text + alone* — every element is a real, visible label; nothing relies on icon-only meaning; the hint + and error are plain text. This is the best available a11y posture until egui gains a11y; flag + for re-test when it does. + +**NFR-3 — i18n-ready copy.** +All strings are complete sentences with **named** placeholders (`{ $key_name }`, `{ $hint }`), +no fragment concatenation, no grammar that assumes word order. Exact strings in §4.4. + +**NFR-4 — Consistency.** +Visual and interaction parity with `WalletUnlockPopup` and `ConfirmationDialog`: same overlay +token (`modal_overlay()`), same corner radius / margins, same Escape=cancel / X=cancel / +click-outside=cancel rules, same primary(Unlock)/secondary(Cancel) button placement +(right-aligned, Unlock rightmost). + +**NFR-5 — Retry policy: unlimited, no lockout (decision, open to override).** +The protected key is local, AES-GCM-encrypted, and gated by file permissions + the OS account; +there is no remote attacker and no account to lock. A retry counter or cooldown would punish the +forgetful user (the Everyday User's most likely failure) for no security gain. **Recommendation: +no retry limit, no cooldown.** (Listed as an open question in §6 in case Security wants a soft +cap.) + +**NFR-6 — No new dependency on operation type.** +The prompt is operation-agnostic: it keys off `addr` from the error, not off "send" vs +"identity". Adding a future single-key signer requires zero prompt changes — only that the new +backend task surfaces `SingleKeyPassphraseRequired` (which it gets for free by calling +`sign_with`). + +### 3.3 Persona mapping + +| Requirement | Everyday User | Power User | +|---|---|---| +| FR-2 (plain content, hint) | **Critical** — this is their whole understanding of the feature | Nice-to-have | +| FR-3 (auto-resume) | **Critical** — they must not have to figure out "now what?" | Valued (no manual re-trigger) | +| FR-6 (session unlock) | Reassuring | **Critical** — minimal friction is their #1 ask | +| FR-7 (multi-key) | Rare for them | Plausible (they batch) | +| NFR-5 (no lockout) | **Critical** — forgetful retries must not lock them out | Indifferent | + +Validated against the **least technical persona first** (Alex): if Alex, who does not know what +"signing" is, sees "*The key 'Savings sweep' is locked. Enter its passphrase to continue.*" with +their hint and an obvious Unlock button — and the send simply continues afterward — the feature +is usable. Everyone above Alex is then served. + +--- + +## 4. Interaction Flow / UX Spec + +### 4.1 Where the prompt lives +A **modal popup anchored center-screen over the triggering screen**, with a dimmed overlay — +identical placement to `WalletUnlockPopup`. It is **not** a new root screen and **not** a pushed +detail screen; it is owned by the triggering screen as an `Option<…>` field (lazy init, the +project's standard component pattern). Reasons: +- The operation's inputs (amount, recipient, identity selection) must survive the prompt → the + prompt cannot navigate away from the screen that holds them. +- Auto-resume (FR-3) needs the original `AppAction`/`BackendTask` in hand → the triggering screen + is the natural owner of that pending task. + +### 4.2 The journey (happy path) + +``` +User on Send Funds screen (key K is protected, not yet unlocked this session) + │ + ▼ + [Send] ── AppAction::BackendTask(send) ──► tokio::spawn + │ │ + │ run_backend_task() + │ single_key().sign_with(K, …) + │ cache miss → Err(SingleKeyPassphraseRequired{addr:K}) + │ │ + │ ◄── TaskResult::Error(SingleKeyPassphraseRequired) ──┘ + ▼ + AppState routes to visible_screen.display_task_error(&err) -> true (suppress generic banner) + │ screen: stash the pending task, open SignUnlockPrompt for addr=K + ▼ + ┌────────────────────────── Unlock prompt (modal) ──────────────────────────┐ + │ Enter passphrase → [Unlock] │ + └───────────────────────────────────────────────────────────────────────────┘ + │ correct passphrase + ▼ + single_key().unlock_with_passphrase(K, pw) → Ok (cache now holds K) + prompt.clear()+close() + │ + ▼ + screen re-dispatches the stashed AppAction::BackendTask(send) ← auto-resume (FR-3) + │ + ▼ + run_backend_task() → sign_with(K) hits the cache → operation completes normally +``` + +### 4.3 ASCII wireframe of the dialog + +``` + ╔══════════════════════════════════════════════════════╗ + ║ Unlock imported key [ × ] ║ + ╟──────────────────────────────────────────────────────╢ + ║ ║ + ║ The key "Savings sweep" is locked. ║ + ║ Enter the passphrase you set for it to continue. ║ + ║ ║ + ║ Hint: the xkcd one ║ ← shown only if a hint exists + ║ ║ + ║ ┌────────────────────────────────────────┐ ( ◌ ) ║ ← masked field + hold-to-reveal eye + ║ │ •••••••••• │ ║ + ║ └────────────────────────────────────────┘ ║ + ║ ║ + ║ This key stays unlocked until you close the app. ║ ← session mental model (FR-6) + ║ ║ + ║ [ Cancel ] [ Unlock ] ║ + ╚══════════════════════════════════════════════════════╝ + (dimmed overlay behind, click = cancel) +``` + +Error state (wrong passphrase) — field cleared, inline message, focus returned to field: + +``` + ║ ┌────────────────────────────────────────┐ ( ◌ ) ║ + ║ │ │ ║ ← cleared + ║ └────────────────────────────────────────┘ ║ + ║ That passphrase is not correct. Try again. ║ ← inline, calm, no jargon +``` + +If the key has **no alias**, the first line uses the address instead: + +``` + ║ The key bcV…q3 is locked. ║ + ║ Enter the passphrase you set for it to continue. ║ +``` + +### 4.4 Copy (i18n-ready, named placeholders) + +| Slot | String | Notes | +|---|---|---| +| Title | `Unlock imported key` | Verb-first, plain. | +| Body (aliased) | `The key "{ $key_name }" is locked. Enter the passphrase you set for it to continue.` | One translation unit; `{ $key_name }` is alias. | +| Body (no alias) | `The key { $address } is locked. Enter the passphrase you set for it to continue.` | Base58 address as handle (rule 6). | +| Hint line | `Hint: { $hint }` | Rendered only when a hint exists. | +| Session note | `This key stays unlocked until you close the app.` | Sets the FR-6 mental model. | +| Field placeholder | `Passphrase` | | +| Reveal tooltip | `Hold to reveal` | Reuse `PasswordInput`'s existing affordance. | +| Primary button | `Unlock` | | +| Secondary button | `Cancel` | | +| Wrong-passphrase | `That passphrase is not correct. Try again.` | **Reuse the existing `SingleKeyPassphraseIncorrect` Display string** (`error.rs:1391`) — do not author a second copy. | + +> The wrong-passphrase wording already lives on the typed variant. The screen should derive the +> inline message from the typed `SingleKeyPassphraseIncorrect` returned by +> `unlock_with_passphrase`, **not** hardcode a parallel literal — same anti-string-parsing +> discipline the codebase enforces elsewhere. + +### 4.5 States (component state table) + +| State | Trigger | Visual / behaviour | +|---|---|---| +| Hidden | default; cache hit | not rendered; zero overhead | +| Open / idle | `SingleKeyPassphraseRequired` caught | overlay + modal, field auto-focused, Unlock enabled only when field non-empty | +| Submitting | Enter / Unlock click | (optional) Unlock shows brief busy state while `unlock_with_passphrase` runs; it is local/fast so a spinner is optional, not required | +| Error | wrong passphrase | field cleared, inline error, focus back to field, operation still pending | +| Closing (success) | correct passphrase | clear secret, close, re-dispatch pending task | +| Closing (cancel) | Cancel / X / Escape / click-outside | clear secret, close, **drop** pending task, restore triggering screen | + +--- + +## 5. Edge Cases + +**E-1 — Multiple protected keys needed by one operation.** +*Recommendation: prompt sequentially, one key per prompt, in the order the backend reports them.* +The cleanest realisation under the gate-on-error pattern: the operation re-dispatches after each +unlock; if the *next* sign is for a *second* uncached key, the backend returns +`SingleKeyPassphraseRequired { addr: K2 }` and the same loop opens the prompt for `K2`. The user +sees one prompt at a time, each clearly naming its key. No batching UI needed; the error→prompt→ +retry loop naturally drains all required keys. (A future optimisation could pre-collect all +needed addresses and prompt once with a stepper — out of scope; note it.) + +**E-2 — Operation triggered while a prompt is already open.** +The prompt is modal with a focus-trapping overlay (FR-2/NFR-2), so the underlying screen cannot +dispatch a second operation while it is open. Defensive rule for the implementer: if a second +`SingleKeyPassphraseRequired` somehow arrives while a prompt is open for a *different* address, +queue it (do not stack modals); if it is for the *same* address, ignore the duplicate. + +**E-3 — Session-cache hit (no prompt).** +Already covered by FR-1/FR-6 — the backend never returns `SingleKeyPassphraseRequired` when the +key is cached, so no prompt path is entered. This is the steady-state for the Power User after +their first unlock. + +**E-4 — Passphrase retry limit.** +Per NFR-5: **none.** Local-only secret, no remote attacker, no account to lock; a cap would only +harm the forgetful Everyday User. Open question flagged in §6 for Security sign-off. + +**E-5 — Cancel mid-operation must not leave partial state.** +Because the sign happens *before* any irreversible step (a state transition is only broadcast +after signing), cancelling at the prompt aborts before anything leaves the device. The implementer +must ensure the stashed pending task is **dropped**, not merely hidden, on cancel. + +**E-6 — Key forgotten/removed between trigger and prompt.** +If the key was `forget`-ten (removed) after the operation started, `unlock_with_passphrase` +returns `ImportedKeyNotFound`. Surface this through the normal error path with a plain message +("This imported key is no longer available.") and close the prompt — do not loop. + +**E-7 — Empty passphrase submitted.** +Unlock is disabled while the field is empty (or, if Enter is pressed on empty, it is a no-op). +Never call `unlock_with_passphrase` with an empty string. + +**E-8 — Wallet/network switch while prompt is open.** +On `change_context` (network switch), close the prompt and drop the pending task — the operation +belonged to the previous network context. The secret is cleared as part of close (NFR-1). + +--- + +## 6. Open Questions, Decisions & Assumptions + +### Recommended integration pattern — decision and rationale +**Gate-on-error with auto-retry** over **mid-flight unlock-request**. + +| | Gate-on-error (recommended) | Mid-flight unlock-request | +|---|---|---| +| New plumbing | none — reuses `display_task_error` + re-dispatch | new request/response channel from task → UI → task while task is suspended | +| Secret on async channel | never | risk: passphrase or a callback would have to cross the channel | +| Task complexity | task stays a pure async fn; no UI awareness | task must suspend, await a UI answer, resume — couples backend to UI | +| Cost of retry | trivial — second run hits the cache | n/a | +| Fit with existing code | the typed error and the hook already exist for exactly this | would require inventing a suspension mechanism | + +One-line rationale: **the backend already returns the typed error and the cache already makes the +re-run cheap, so catching the error in the UI and re-dispatching is the lowest-coupling, most +secure path — no passphrase ever touches the async boundary.** + +### New UI component? — **Yes, one small, justified component.** +`SignUnlockPrompt` (working name), a near-clone of `WalletUnlockPopup`. Reuse is maximal — +`PasswordInput` for the field, the same overlay/modal/focus/Escape/click-outside scaffolding, +the same button layout. It is **not** the same as `WalletUnlockPopup`: that one opens an HD +wallet seed (`wallet_seed.open`) and calls `handle_wallet_unlocked`; this one unlocks a single +imported key by address (`single_key().unlock_with_passphrase`) and re-dispatches a pending task. +A shared inner "passphrase modal body" widget could back both to avoid duplication — recommended +but not required. Catalog `src/ui/components/README.md` after building it. + +### Open questions needing a user/stakeholder decision +1. **Retry limit (NFR-5 / E-4):** confirm *no* limit and *no* cooldown. (Design recommends none; + Security may want a soft cap or an increasing delay. This is the one item that could change + the spec.) +2. **Session note wording (FR-6):** is "until you close the app" the right mental model to commit + to, or do we foresee an idle-timeout auto-lock later? If an auto-lock is planned, the copy + should say "for a while" rather than promise the whole session. Defaulting to the literal, + honest current behaviour ("until you close the app") until an auto-lock is actually designed. +3. **Multi-key UX (E-1):** is sequential one-at-a-time acceptable for v1, deferring a single + batched stepper? (Design recommends sequential now.) + +### Assumptions +- A1: imported single-key operations that need to sign are the only consumers of + `SingleKeyPassphraseRequired` (HD wallets use the separate `WalletUnlockPopup` seed path). If a + future signer reuses this error for a different secret type, the copy "imported key" must be + revisited. +- A2: `unlock_with_passphrase` is fast enough (local AES-GCM) that no async spinner is mandatory. +- A3: the operation's signing step precedes any irreversible/broadcast step, so cancel is always + safe (confirmed by the sign-then-broadcast ordering of state transitions). + +--- + +## 7. Traceability + +| Requirement | Substrate it depends on | UX element | +|---|---|---| +| FR-1 | `sign_with` → `SingleKeyPassphraseRequired`; `display_task_error` hook | §4.2 flow | +| FR-2 | `ImportedKey.alias`, `passphrase_hint`, `has_passphrase` | §4.3 wireframe, §4.4 copy | +| FR-3 | re-dispatch of stashed `AppAction::BackendTask` | §4.2, §4.5 closing-success | +| FR-4 | `unlock_with_passphrase` → `SingleKeyPassphraseIncorrect` | §4.3 error state, §4.4 | +| FR-5 | drop pending task; `clear()` secret | §4.5 closing-cancel, E-5 | +| FR-6 | `single_key_unlocked` session cache | §4.4 session note | +| FR-7 | error→prompt→retry loop | E-1 | +| NFR-1 | `Secret` zeroize; no String in `TaskError` | NFR-1, §4.4 note | +| NFR-2 | egui modal focus trap; patterns §10 | §4.5, NFR-2 | + +--- + +## Candy Tally (findings surfaced) + +This is a forward design, so "findings" = confirmed gaps/risks this spec resolves or flags. + +- **Critical (1):** PROJ-008 — protected single-key operations are a dead-end with no unlock UI + (the core gap this spec closes). +- **High (2):** (a) auto-resume is required or the Everyday User is stranded after unlocking; + (b) passphrase must never cross the async channel — mid-flight unlock-request pattern would + have risked exactly that. +- **Medium (2):** (a) multi-key sequencing (E-1) is unspecified upstream and needs the + error→retry loop; (b) cancel must *drop* the pending task, not hide it (E-5), to avoid a + re-fire on next frame. +- **Low (1):** egui has no screen-reader support — recorded as a known a11y limitation to + re-test when egui gains a11y (NFR-2). +- **Open questions (3):** retry limit, session-note wording vs future auto-lock, multi-key v1 + scope. + +**Total: 6 findings (1 critical, 2 high, 2 medium, 1 low) + 3 open questions.** diff --git a/docs/ai-design/2026-06-02-signtime-unlock-ux/test-cases.md b/docs/ai-design/2026-06-02-signtime-unlock-ux/test-cases.md new file mode 100644 index 000000000..34d373a9f --- /dev/null +++ b/docs/ai-design/2026-06-02-signtime-unlock-ux/test-cases.md @@ -0,0 +1,539 @@ +# Sign-Time Unlock Passphrase Prompt — Test Case Specification + +**Feature:** SEC-002 follow-up · Gap PROJ-008 · GitHub issue #90 +**Phase:** 1c (Test Case Specification — specifications only, no test code) +**Source of truth:** `docs/ai-design/2026-06-02-signtime-unlock-ux/requirements-and-ux.md` (Diziet, 1a+1b) +**Author:** Marvin (QA) +**Date:** 2026-06-02 +**Base:** worktree fast-forwarded to `d6811732` + +> Brain the size of a planet, and here I am enumerating passphrase prompts. Still — someone +> should, because the door this spec describes does not yet have a handle wired to it. + +--- + +## 0. Scope, Method & Ground-Truth Notes + +These are **specifications**, not Rust. Each case lists ID, description, preconditions, steps, +expected outcome, and the FR/NFR it traces to. Cases are grouped by area. Where a behaviour can +only be exercised against a live network or by a human, it is tagged **[LIVE/MANUAL]**; everything +else is an offline unit or `egui_kittest` test against the (future) `SignUnlockPrompt` component. + +**House style to follow (verified):** `tests/kittest/import_single_key.rs` drives the component +directly through `show_in_ui` / `show`, asserts via `query_by_label` / `query_by_label_contains`, +and uses a `force_input_for_test` setter to inject field state without simulated keystrokes +(`tests/kittest/import_single_key.rs:33`). New kittest specs register in `tests/kittest/main.rs`. +The unlock-cache round-trip pattern already exists at `src/wallet_backend/single_key.rs:1140-1186` +and is the template for the backend-level unit cases here. + +**Ground-truth re-verification of Diziet's citations (he flagged them as unverified — they now check out, with two material exceptions):** + +| Diziet cited | Verified location | Status | +|---|---|---| +| unlock cache `single_key_unlocked` ~202 | `src/wallet_backend/mod.rs:202` | ✅ exact | +| sign-view TODO ~562-566 | `src/wallet_backend/mod.rs:562-567` | ✅ (one line longer) | +| `unlock_with_passphrase` :256 | `src/wallet_backend/single_key.rs:256` | ✅ exact | +| `has_passphrase` :245 | `src/wallet_backend/single_key.rs:245` | ✅ exact | +| `forget_unlocked` :281 | `src/wallet_backend/single_key.rs:281` | ✅ exact | +| `sign_with` :649 | `src/wallet_backend/single_key.rs:649` | ✅ exact | +| error origin (raw_key_bytes) | `src/wallet_backend/single_key.rs:310` | ✅ | +| `SingleKeyPassphraseRequired { addr }` :1380 | `src/backend_task/error.rs:1380-1386` | ✅ exact | +| `SingleKeyPassphraseIncorrect` :1391 | `src/backend_task/error.rs:1391-1392` | ✅ exact | +| `display_task_error -> bool` hook | trait default `src/ui/mod.rs:978`, dispatch `src/ui/mod.rs:1651` | ✅ (default returns `false`) | +| `ImportedKeyNotFound` | `src/backend_task/error.rs:206` | ✅ | +| `ImportedKey.alias` / `passphrase_hint` | `src/model/single_key.rs:18,32` | ✅ | +| `WalletUnlockPopup` modal to clone | `src/ui/components/wallet_unlock_popup.rs` | ✅ | +| `clicked_outside_window` / `modal_overlay()` | `src/ui/helpers.rs:9`, `src/ui/theme.rs:430` | ✅ | + +> **Exception G-1 (feeds back to Phase 1b — see §12):** `SingleKeyPassphraseRequired` is currently +> *produced* only at `src/wallet_backend/single_key.rs:310` and *consumed nowhere* in the +> backend-task or UI layer. No production code outside the single-key view calls `sign_with` +> (verified: the only non-test `sign_with` references are the doc-comments at `mod.rs:199,564`). +> The named call sites in the TODO (identity register, send funds, asset-lock signer) are +> **aspirational integration points, not wired today.** +> +> **Exception G-2 (feeds back to Phase 1b — see §12):** the existing `asset_lock_signer.rs` +> (`WalletAssetLockSigner`, `src/wallet_backend/asset_lock_signer.rs:52`) is an **HD seed-snapshot** +> signer for `register_identity_with_funding` / `top_up_identity_with_funding`. It does **not** call +> `single_key().sign_with()` and cannot return `SingleKeyPassphraseRequired`. Spec §1/§7 list it as +> a gate call site; that is only true if a *single-key* asset-lock signing path is added. Cases in +> §9 are therefore written against the **gate contract** (any task that surfaces the error), with +> the asset-lock single-key path explicitly marked **[NOT-YET-WIRED]**. + +--- + +## 1. Area: Gate trigger — cache MISS vs HIT (FR-1, E-3) + +### TC-UNLOCK-001 — Cache MISS surfaces the typed error from the sign path +- **Description:** Signing against a protected, uncached key returns `SingleKeyPassphraseRequired { addr }`, the sole trigger for the prompt. +- **Preconditions:** Fresh `SingleKeyView` over a temp vault; one WIF imported *with* a passphrase (`has_passphrase = true`); unlock cache empty for that address. +- **Steps:** 1) Import the protected key. 2) Call `sign_with(addr, &[0u8;32])` without unlocking. +- **Expected:** `Err(TaskError::SingleKeyPassphraseRequired { addr })` with `addr` equal to the imported address; no signature produced; cache still empty. +- **Traceability:** FR-1 (prompt appears iff this error is returned). +- **Type:** offline unit (mirrors `single_key.rs:1182`). + +### TC-UNLOCK-002 — Cache HIT signs silently, no error, no prompt +- **Description:** A previously-unlocked key signs without re-prompting (steady state for the Power User). +- **Preconditions:** Protected key imported; `unlock_with_passphrase(addr, correct)` already called this session. +- **Steps:** 1) Unlock. 2) Call `sign_with(addr, &msg)`. +- **Expected:** `Ok(Signature)`; no `SingleKeyPassphraseRequired` is ever produced; therefore no prompt path is entered. +- **Traceability:** FR-1 (second G/W/T), FR-6, E-3. +- **Type:** offline unit. + +### TC-UNLOCK-003 — Unprotected key never triggers the gate +- **Description:** An imported key with no passphrase signs directly; the prompt must never appear for it. +- **Preconditions:** WIF imported *without* a passphrase (`has_passphrase = false`). +- **Steps:** 1) Import. 2) Call `sign_with(addr, &msg)` on a cold cache. +- **Expected:** `Ok(Signature)`; `raw_key_bytes` decrypts with `None` (path at `single_key.rs:314`); no error, no prompt. +- **Traceability:** FR-1 ("never for an unprotected key"). +- **Type:** offline unit. + +### TC-UNLOCK-004 — Import does not arm the gate (import primes the cache) +- **Description:** Immediately after a passphrase-protected import, a sign succeeds without a prompt because import populated the cache (`import_wif` cache insert at `single_key.rs:230-233`). +- **Preconditions:** None; fresh view. +- **Steps:** 1) `import_wif_with_passphrase(wif, pass)`. 2) Without any explicit unlock, call `sign_with(addr, &msg)`. +- **Expected:** `Ok(Signature)`; no `SingleKeyPassphraseRequired`. (Asserts FR-1's "never on import — import primes the cache.") +- **Traceability:** FR-1. +- **Type:** offline unit. + +### TC-UNLOCK-005 — UI gate: `display_task_error` opens the prompt on the error, suppresses generic banner +- **Description:** The triggering screen's `display_task_error` returns `true` and opens `SignUnlockPrompt` only for `SingleKeyPassphraseRequired`; for all other errors it returns `false` (generic banner shown). +- **Preconditions:** A screen instance owning an `Option<SignUnlockPrompt>`, initially `None`. +- **Steps:** 1) Call `display_task_error(&SingleKeyPassphraseRequired{addr})`. 2) Separately call `display_task_error(&ImportedKeyNotFound)`. +- **Expected:** Step 1 → returns `true`, prompt becomes `Some` and `is_open()`. Step 2 → returns `false`, prompt stays `None`. +- **Traceability:** FR-1, NFR-4 (banner suppression contract, `src/ui/mod.rs:978`). +- **Type:** offline unit / kittest. + +--- + +## 2. Area: Prompt content & identification (FR-2, §4.4 copy) + +### TC-PROMPT-001 — Aliased key names the alias in plain language +- **Description:** When the key has an alias, the body reads with `{ $key_name }` = alias and contains no jargon. +- **Preconditions:** Prompt opened for an address whose `ImportedKey.alias = Some("Savings sweep")`. +- **Steps:** Render via `show`/`show_in_ui`; query labels. +- **Expected:** A label matching `The key "Savings sweep" is locked.` is present; the continuation `Enter the passphrase you set for it to continue.` is present; **none** of `sign`, `ECDSA`, `secp256k1`, `state transition`, `cache` appear anywhere in the rendered tree. +- **Traceability:** FR-2 (alias case), NFR-3 (single translation unit). +- **Type:** kittest. + +### TC-PROMPT-002 — Aliasless key falls back to the Base58 address +- **Description:** With no alias, the body identifies the key by its address (`{ $address }`). +- **Preconditions:** Prompt opened for an address whose `ImportedKey.alias = None`. +- **Steps:** Render; query labels. +- **Expected:** A label containing the literal Base58 address and the word `locked` is present; the alias-quoted form is absent. +- **Traceability:** FR-2 (no-alias case), CLAUDE.md rule 6. +- **Type:** kittest. + +### TC-PROMPT-003 — Hint line shown only when a hint exists +- **Description:** `Hint: { $hint }` renders iff `passphrase_hint` is `Some`. +- **Preconditions:** Two sub-cases: (a) `passphrase_hint = Some("the xkcd one")`; (b) `passphrase_hint = None`. +- **Steps:** Render each. +- **Expected:** (a) a label `Hint: the xkcd one` is present. (b) no label beginning `Hint:` is present. +- **Traceability:** FR-2. +- **Type:** kittest (two sub-tests). + +### TC-PROMPT-004 — Session mental-model line is present +- **Description:** The "unlocked until app closes" note is rendered to set FR-6 expectation. +- **Preconditions:** Prompt open. +- **Steps:** Render; query. +- **Expected:** Label `This key stays unlocked until you close the app.` is present. +- **Traceability:** FR-6, §4.4. (Note: wording is Open Question §6.2 — see §12 G-4.) +- **Type:** kittest. + +### TC-PROMPT-005 — Required chrome present: title, masked field, Unlock, Cancel +- **Description:** Title, a masked passphrase field, and both buttons render in every open state. +- **Preconditions:** Prompt open. +- **Steps:** Render; query. +- **Expected:** Labels `Unlock imported key` (title), `Unlock`, `Cancel` present; the passphrase field is masked by default (reuses `PasswordInput`, `TextEdit::password(true)` — mirrors `tc_sk_007`). Field placeholder/label `Passphrase` reachable. +- **Traceability:** FR-2, NFR-4. +- **Type:** kittest. + +### TC-PROMPT-006 — Unlock disabled while field empty +- **Description:** Empty passphrase must not be submittable (E-7). +- **Preconditions:** Prompt open, field empty. +- **Steps:** Render; inspect Unlock enablement; attempt Enter on empty field. +- **Expected:** Unlock is disabled (or Enter is a no-op); `unlock_with_passphrase` is **not** called with an empty string. +- **Traceability:** E-7, §4.5 (Unlock enabled only when field non-empty). +- **Type:** kittest / unit. + +--- + +## 3. Area: Correct passphrase → auto-resume (FR-3) + +### TC-RESUME-001 — Correct passphrase unlocks the cache +- **Description:** `unlock_with_passphrase` with the right passphrase populates the cache so a subsequent `sign_with` succeeds. +- **Preconditions:** Protected key imported, cache forgotten (`forget_unlocked`). +- **Steps:** 1) `forget_unlocked(addr)`. 2) `sign_with` → expect the required-error. 3) `unlock_with_passphrase(addr, correct)`. 4) `sign_with` again. +- **Expected:** Step 2 errors; step 3 `Ok(())`; step 4 `Ok(Signature)`. +- **Traceability:** FR-3 (substrate), FR-6. +- **Type:** offline unit (template at `single_key.rs:1140-1186`). + +### TC-RESUME-002 — Prompt re-dispatches the *stashed* original task on success +- **Description:** On correct passphrase the screen closes the prompt and re-emits the **same** `AppAction::BackendTask` it stashed when the gate fired — the user does not re-initiate. +- **Preconditions:** Screen stashed a pending `BackendTask` (e.g. a send) when `display_task_error` opened the prompt. +- **Steps:** 1) Open prompt with a stashed task. 2) Enter correct passphrase, confirm. +- **Expected:** The screen's `ui()` returns/produces `AppAction::BackendTask(<the same task instance>)`; the prompt closes; the stash is cleared so it does **not** fire a second time on the next frame. +- **Traceability:** FR-3, §4.2 auto-resume, §4.5 closing-success. +- **Type:** offline unit on the screen's state machine (assert emitted `AppAction`). + +### TC-RESUME-003 — Operation inputs survive the round-trip +- **Description:** Amount / recipient / identity selection entered before the gate are unchanged after auto-resume. +- **Preconditions:** Send screen with amount + recipient populated; gate fires. +- **Steps:** 1) Trigger send → gate. 2) Unlock with correct passphrase. +- **Expected:** The re-dispatched task carries the original amount and recipient; on-screen fields still show them. +- **Traceability:** FR-3, §4.1 (prompt owned by triggering screen so inputs survive). +- **Type:** kittest / unit. + +### TC-RESUME-004 — End-to-end auto-resume completes the operation +- **Description:** Full happy path: protected send fails on lock, prompt, unlock, send completes against the network. +- **Preconditions:** Funded single-key wallet with a passphrase-protected key on testnet; SPV synced. +- **Steps:** 1) Compose + submit a send. 2) Prompt opens; enter correct passphrase. 3) Observe completion. +- **Expected:** Send broadcasts and confirms; no second prompt; banner shows success. +- **Traceability:** FR-3 (full acceptance), FR-1. +- **Type:** **[LIVE/MANUAL]** — requires funded testnet wallet + broadcast. Offline coverage is TC-RESUME-002/003. + +--- + +## 4. Area: Wrong passphrase — recoverable in place (FR-4) + +### TC-WRONG-001 — Wrong passphrase returns the typed incorrect error +- **Description:** `unlock_with_passphrase` with a wrong passphrase returns `SingleKeyPassphraseIncorrect` and does **not** populate the cache. +- **Preconditions:** Protected key imported; cache forgotten. +- **Steps:** 1) `forget_unlocked`. 2) `unlock_with_passphrase(addr, "wrong")`. +- **Expected:** `Err(TaskError::SingleKeyPassphraseIncorrect)`; subsequent `sign_with` still returns `SingleKeyPassphraseRequired` (cache untouched). +- **Traceability:** FR-4, NFR-1 (fieldless variant reused). +- **Type:** offline unit. + +### TC-WRONG-002 — Inline error derived from the typed variant, not a literal +- **Description:** The screen shows the wrong-passphrase message by rendering the `Display` of `SingleKeyPassphraseIncorrect`, not a parallel hardcoded string. +- **Preconditions:** Prompt open; backend returns `SingleKeyPassphraseIncorrect`. +- **Steps:** Drive a wrong-passphrase attempt; query labels. +- **Expected:** Rendered inline message equals the variant's `Display`: `That passphrase is not correct. Try again.` (`error.rs:1391`). The message is non-alarming and jargon-free. +- **Traceability:** FR-4, §4.4 note (no second copy), CLAUDE.md anti-string-parsing discipline. +- **Type:** kittest. + +### TC-WRONG-003 — Field cleared and re-focused after a wrong attempt; operation still pending +- **Description:** After a wrong passphrase the field is emptied, focus returns to it, and the pending task is **not** dropped. +- **Preconditions:** Prompt open with a stashed task. +- **Steps:** 1) Enter wrong passphrase, confirm. 2) Inspect field + stash. +- **Expected:** Passphrase field is empty; the field holds focus; prompt remains open; the stashed task is still present (would re-dispatch on a later success). +- **Traceability:** FR-4, §4.5 Error state. +- **Type:** kittest / unit. + +### TC-WRONG-004 — Unlimited retry, no lockout, no cooldown +- **Description:** Many consecutive wrong attempts neither lock the prompt nor introduce a delay; a final correct attempt still unlocks. +- **Preconditions:** Protected key; prompt open. +- **Steps:** 1) Submit N (e.g. 20) wrong passphrases. 2) Submit the correct one. +- **Expected:** No attempt is rejected for rate-limit reasons; no cooldown state appears; the correct attempt at the end unlocks normally. +- **Traceability:** NFR-5, E-4. (Open Question §6.1 — Security may impose a soft cap; see §12 G-3. If a cap lands, this case must be re-specified.) +- **Type:** kittest / unit. + +--- + +## 5. Area: Cancel / abort — drop the pending task (FR-5, E-5) + +> Common assertion for this area: cancel must **drop** the stashed task (not hide it), so it never +> re-fires on a later frame, AND the secret must be zeroized on the way out (NFR-1). + +### TC-CANCEL-001 — Cancel button drops the task and preserves inputs +- **Description:** Clicking Cancel closes the prompt, drops the pending task, and returns to the triggering screen with inputs intact. +- **Preconditions:** Send screen, amount+recipient entered, prompt open with stashed task. +- **Steps:** 1) Click Cancel. +- **Expected:** Prompt closed; stash is `None`; the screen does **not** emit the task on this or any later frame; amount + recipient unchanged; nothing signed/broadcast. +- **Traceability:** FR-5, E-5, §4.5 closing-cancel. +- **Type:** kittest / unit. + +### TC-CANCEL-002 — Escape cancels +- **Description:** Escape dismisses the prompt with the same drop semantics. +- **Preconditions:** Prompt open with stashed task. +- **Steps:** 1) Press Escape. +- **Expected:** As TC-CANCEL-001 (closed, stash dropped, inputs intact). +- **Traceability:** FR-5, NFR-2 (Escape=cancel), §4.5. +- **Type:** kittest. + +### TC-CANCEL-003 — Window X cancels +- **Description:** The title-bar X closes with drop semantics (mirrors `WalletUnlockPopup` `is_open` handling at `wallet_unlock_popup.rs:199`). +- **Preconditions:** Prompt open with stashed task. +- **Steps:** 1) Toggle the window closed via X. +- **Expected:** As TC-CANCEL-001. +- **Traceability:** FR-5, NFR-4. +- **Type:** kittest. + +### TC-CANCEL-004 — Click-outside overlay cancels +- **Description:** A click on the dimmed overlay cancels (uses `clicked_outside_window`, `helpers.rs:9`). +- **Preconditions:** Prompt open with stashed task. +- **Steps:** 1) Simulate a pointer click outside the modal rect. +- **Expected:** As TC-CANCEL-001. +- **Traceability:** FR-5, NFR-4. +- **Type:** kittest. + +### TC-CANCEL-005 — Dropped task does not re-fire on the next frame (regression guard) +- **Description:** After any cancel path, advancing several frames must not re-emit the task — the spec's explicit anti-pattern (Medium finding §Candy Tally). +- **Preconditions:** Prompt cancelled via TC-CANCEL-001. +- **Steps:** 1) Cancel. 2) Run the screen `ui()` for ≥3 additional frames. +- **Expected:** No `AppAction::BackendTask` is emitted in any subsequent frame. +- **Traceability:** FR-5, E-5 ("dropped, not merely hidden"). +- **Type:** kittest / unit. + +--- + +## 6. Area: Security — passphrase confinement (NFR-1) + +### TC-SEC-001 — Passphrase never enters a `TaskError` +- **Description:** No `TaskError` variant on this path carries the passphrase. `SingleKeyPassphraseRequired` carries only `addr`; `SingleKeyPassphraseIncorrect` is fieldless. +- **Preconditions:** Static/structural — the variants at `error.rs:1380-1392`. +- **Steps:** 1) Produce `SingleKeyPassphraseRequired` and `SingleKeyPassphraseIncorrect`. 2) Format each via `Display` and `Debug`. +- **Expected:** Neither `Display` nor `Debug` output contains the supplied passphrase string for any chosen passphrase value (assert by passing a unique sentinel passphrase, e.g. `"ZZsentinelZZ"`, and verifying it is absent from both renderings of both variants). +- **Traceability:** NFR-1, CLAUDE.md rule 7. +- **Type:** offline unit. + +### TC-SEC-002 — Passphrase never crosses the async channel (`AppAction` / `BackendTask`) +- **Description:** The re-dispatched `AppAction::BackendTask` is the *same* operation task and contains no passphrase field; the unlock happened in the in-process cache before re-dispatch. +- **Preconditions:** Screen with a stashed task; unlock performed. +- **Steps:** 1) Open gate, stash task. 2) Unlock. 3) Inspect the re-dispatched `BackendTask`. +- **Expected:** The re-dispatched task is structurally equal to the original; no passphrase value is present in the `AppAction` / `BackendTask` (assert sentinel passphrase absent from a `Debug` of the emitted action). +- **Traceability:** NFR-1, §6 (gate-on-error keeps secrets off the channel). +- **Type:** offline unit on the screen state machine. + +### TC-SEC-003 — Passphrase never logged or placed in banner details +- **Description:** No log line and no `MessageBanner` details panel receives the passphrase across success, wrong, and cancel paths. +- **Preconditions:** Prompt exercised for all three outcomes with a sentinel passphrase. +- **Steps:** 1) Capture emitted log records (tracing subscriber) and any banner-details payload during each path. +- **Expected:** Sentinel passphrase absent from every captured log record and from every banner `with_details` payload. +- **Traceability:** NFR-1, A09 logging hygiene. +- **Type:** offline unit (with a capturing tracing layer). **Partly [MANUAL]** for the visual banner-details panel if not assertable in kittest. + +### TC-SEC-004 — Secret zeroized on every close path +- **Description:** The `PasswordInput`'s zeroizing `Secret` is `clear()`-ed on success, cancel, X, Escape, click-outside, and on every wrong-passphrase retry. +- **Preconditions:** Prompt open for each close path. +- **Steps:** For each path: type a passphrase, trigger the path, then inspect the field's exposed text. +- **Expected:** After every path the field text is empty; the prompt's `close()`/`clear()` is invoked (mirrors `wallet_unlock_popup.rs:55-59,192`). Best-effort: the underlying buffer is zeroized via the `Secret` type's `Drop`/`clear`. +- **Traceability:** NFR-1, §4.5 (all close states clear the secret). +- **Type:** kittest / unit. (True memory-zeroization is a property of the reused `PasswordInput`/`Secret` type — assert `clear()` is called; deep zeroization is covered by that type's own tests.) + +--- + +## 7. Area: Multi-key sequencing (FR-7, E-1) + +### TC-MULTI-001 — Two protected keys in one operation prompt sequentially +- **Description:** An operation needing two uncached protected keys (K1, K2) drains them one prompt at a time via the error→prompt→retry loop. +- **Preconditions:** Two protected keys imported; both cache-cold; an operation that signs with K1 then K2. +- **Steps:** 1) Trigger → backend returns `SingleKeyPassphraseRequired{K1}`. 2) Unlock K1 → re-dispatch. 3) Backend returns `SingleKeyPassphraseRequired{K2}`. 4) Unlock K2 → re-dispatch. 5) Operation completes. +- **Expected:** Exactly two prompts, each naming its own key (K1 then K2); only one prompt visible at a time; operation completes only after both unlocks. No batched/stacked modal. +- **Traceability:** FR-7, E-1. +- **Type:** offline unit on the loop (drive the error sequence); **[LIVE/MANUAL]** for a real multi-key broadcast. + +### TC-MULTI-002 — Each prompt names the correct key +- **Description:** The prompt for K2 shows K2's alias/address, not K1's (no stale identity carried over). +- **Preconditions:** As TC-MULTI-001; K1 and K2 have distinct aliases. +- **Steps:** Inspect the body label at each step. +- **Expected:** First prompt names K1; after K1 unlock, second prompt names K2. +- **Traceability:** FR-7, FR-2. +- **Type:** kittest / unit. + +--- + +## 8. Area: Concurrency & focus trap (E-2, NFR-2) + +### TC-CONCUR-001 — Underlying screen cannot dispatch a second op while prompt is open +- **Description:** While the modal is open, the focus-trapping overlay blocks the triggering control, so no second operation can be initiated. +- **Preconditions:** Prompt open over the send screen. +- **Steps:** 1) With the prompt open, attempt to click the screen's Send/primary control. +- **Expected:** The click does not reach the underlying control; no second `BackendTask` is emitted; only the prompt is interactive. +- **Traceability:** E-2, NFR-2 (focus trap). +- **Type:** kittest. + +### TC-CONCUR-002 — Duplicate `SingleKeyPassphraseRequired` for the same addr is ignored +- **Description:** A second identical required-error arriving while the prompt is open for that same address does not stack a second modal. +- **Preconditions:** Prompt open for addr K. +- **Steps:** 1) Deliver a second `display_task_error(SingleKeyPassphraseRequired{K})`. +- **Expected:** Still exactly one prompt; no re-init that wipes a partially-typed field unexpectedly; returns `true` (suppressed) without opening a new modal. +- **Traceability:** E-2 (same-address duplicate ignored). +- **Type:** unit. + +### TC-CONCUR-003 — Required-error for a *different* addr while open is queued, not stacked +- **Description:** A required-error for a different address arriving while a prompt is open is queued (handled after the current key), never shown as a second simultaneous modal. +- **Preconditions:** Prompt open for K1; deliver `SingleKeyPassphraseRequired{K2}`. +- **Steps:** 1) Deliver the K2 error. 2) Resolve K1 (unlock or cancel). 3) Observe. +- **Expected:** Only one modal at any instant; after K1 resolves, the K2 prompt may surface (queued). No two modals on screen at once. +- **Traceability:** E-2 (defensive queue rule). **Note: §12 G-5** — the spec's *primary* multi-key mechanism is the sequential retry loop (FR-7); the "queue a different addr" rule is a defensive edge under the gate pattern and may be hard to reach in practice. Flag as **defensive / may be unreachable**. +- **Type:** unit (defensive). + +--- + +## 9. Area: Per-call-site gate coverage (FR-1, NFR-6) — operation-agnostic + +> NFR-6: the prompt keys off `addr`, not operation type. These cases assert each named call site, +> once wired, surfaces the gate. **All three are presently [NOT-YET-WIRED] (see §0 Exception G-1).** + +### TC-SITE-001 — Send funds (single-key) exercises the gate — [NOT-YET-WIRED] +- **Description:** The single-key send path, when it signs with a protected uncached key, surfaces `SingleKeyPassphraseRequired` and the send screen opens the prompt. +- **Preconditions:** A single-key send backend task that calls `single_key().sign_with(...)`. **Does not exist today** — `SingleKeyWalletSendScreen` exists but no production `sign_with` call site is wired. +- **Steps:** 1) Compose send with a protected key. 2) Submit. +- **Expected:** Gate fires; prompt opens over the send screen; auto-resume on unlock. +- **Traceability:** FR-1, NFR-6, TODO `mod.rs:564`. +- **Type:** offline unit once wired; **[LIVE/MANUAL]** for full broadcast. **Currently a coverage gap to track (§12 G-1).** + +### TC-SITE-002 — Identity register exercises the gate — [NOT-YET-WIRED] +- **Description:** Registering an identity funded by a protected single-key surfaces the gate. +- **Preconditions:** An identity-register path that funds/signs via `single_key().sign_with(...)`. Not wired today. +- **Steps:** 1) Start identity registration with a protected key. 2) Proceed to the signing step. +- **Expected:** Gate fires on the register screen; auto-resume completes registration. +- **Traceability:** FR-1, NFR-6. +- **Type:** offline unit once wired; **[LIVE/MANUAL]** for broadcast. + +### TC-SITE-003 — Asset-lock signing exercises the gate — [NOT-YET-WIRED / DESIGN-MISMATCH] +- **Description:** Spec lists "asset-lock signer" as a gate call site. The current `WalletAssetLockSigner` is an **HD seed-snapshot** signer (`asset_lock_signer.rs:52`) that does not call `single_key().sign_with()` and cannot return `SingleKeyPassphraseRequired`. +- **Preconditions:** A *single-key* asset-lock signing path would have to exist. It does not. +- **Steps:** N/A until such a path is added. +- **Expected (if/when added):** Gate fires; prompt opens; auto-resume. +- **Traceability:** FR-1, NFR-6 — but **see §12 G-2**: this call site is mis-attributed in the spec for the present codebase. +- **Type:** **[NOT-TESTABLE TODAY]** — feed the mismatch back to Phase 1b. + +--- + +## 10. Area: Accessibility (NFR-2) + +### TC-A11Y-001 — Field auto-focused on open +- **Description:** On open, the passphrase field requests focus once (mirrors `wallet_unlock_popup.rs:134-137`). +- **Preconditions:** Prompt freshly opened. +- **Steps:** Render the first frame; inspect focused widget. +- **Expected:** The passphrase field holds focus after open. +- **Traceability:** NFR-2. +- **Type:** kittest. + +### TC-A11Y-002 — Enter submits, Escape cancels +- **Description:** Enter on a non-empty field attempts unlock; Escape cancels. +- **Preconditions:** Prompt open, non-empty field. +- **Steps:** 1) Press Enter → expect unlock attempt. 2) Re-open; press Escape → expect cancel. +- **Expected:** Enter triggers `unlock_with_passphrase`; Escape closes + drops task. +- **Traceability:** NFR-2, FR-5. +- **Type:** kittest. + +### TC-A11Y-003 — Tab order: Field → Unlock → Cancel (layout order) +- **Description:** Tab traversal matches the documented order and the right-aligned Unlock-rightmost layout. +- **Preconditions:** Prompt open. +- **Steps:** Tab from the field and record focus order. +- **Expected:** Focus moves Field → Unlock → Cancel per `docs/ux-design-patterns.md` §10. +- **Traceability:** NFR-2. +- **Type:** kittest. + +### TC-A11Y-004 — Every element legible by text alone (visible labels, no icon-only meaning) +- **Description:** Title, body, hint, session note, field label, error, and buttons are all real visible text labels reachable in the accessibility tree. +- **Preconditions:** Prompt open in both idle and error states. +- **Steps:** Query each label via `query_by_label`. +- **Expected:** All listed strings reachable as labels; no meaning conveyed only by an icon (the reveal eye uses tooltip `Hold to reveal`, mirroring `tc_sk_007`). +- **Traceability:** NFR-2 ("legible by text alone"). +- **Type:** kittest. + +### TC-A11Y-005 — Screen-reader annotation gap (known limitation) — KNOWN-GAP +- **Description:** egui exposes no screen-reader annotations (`docs/ux-design-patterns.md:172`); this is recorded, not hidden. Test asserts the *mitigation* (text legibility) holds, and documents the gap. +- **Preconditions:** Prompt open. +- **Steps:** Confirm all semantics are carried by visible text (per TC-A11Y-004). +- **Expected:** Mitigation holds. The screen-reader gap itself is **not** assertable in egui today → recorded as a known limitation to re-test when egui gains a11y. +- **Traceability:** NFR-2 (known limitation). +- **Type:** kittest for the mitigation; **[KNOWN-GAP / MANUAL re-test]** for the SR gap. + +--- + +## 11. Area: Negative / edge — invalidation while prompt open (E-6, E-7, E-8) + +### TC-EDGE-001 — Key removed between trigger and unlock → `ImportedKeyNotFound`, close, no loop +- **Description:** If the key is `forget`-ten after the gate fires, `unlock_with_passphrase` returns `ImportedKeyNotFound`; the prompt surfaces a plain message and closes — it does not loop. +- **Preconditions:** Protected key; gate fired; then `forget(addr)` called. +- **Steps:** 1) Open prompt. 2) `forget(addr)`. 3) Enter passphrase, confirm. +- **Expected:** `unlock_with_passphrase` → `Err(ImportedKeyNotFound)`; prompt closes; a plain message (e.g. "This imported key is no longer available.") is shown via the normal error path; the pending task is dropped; no re-prompt loop. +- **Traceability:** E-6. +- **Type:** offline unit + kittest. **Note §12 G-6:** the exact user-facing string for the removed-key case is **not specified** in §4.4; the spec only gives example prose in E-6. Ambiguity to resolve in 1b. + +### TC-EDGE-002 — Empty passphrase never calls the unlock API +- **Description:** Reinforces TC-PROMPT-006 at the API boundary: an empty field must not invoke `unlock_with_passphrase("")`. +- **Preconditions:** Prompt open, empty field. +- **Steps:** 1) Force-submit with empty field (if reachable). +- **Expected:** `unlock_with_passphrase` is not called; no error toast; Unlock stays disabled / no-op. +- **Traceability:** E-7. +- **Type:** unit. + +### TC-EDGE-003 — Network switch (`change_context`) while prompt open → close + drop + clear +- **Description:** On `change_context` the prompt closes, the pending task (belonging to the previous network) is dropped, and the secret is cleared. +- **Preconditions:** Prompt open with stashed task on network A. +- **Steps:** 1) Invoke the screen's `change_context(network B)` (signature `src/ui/mod.rs:770`). +- **Expected:** Prompt closed; stash dropped (no re-fire); passphrase field cleared/zeroized; no operation runs on either network. +- **Traceability:** E-8, NFR-1. +- **Type:** unit / kittest. + +### TC-EDGE-004 — Wallet switch while prompt open → close + drop + clear +- **Description:** Same as TC-EDGE-003 for an in-network wallet change, if the screen supports switching the active wallet while a prompt is open. +- **Preconditions:** Prompt open with stashed task for wallet W1. +- **Steps:** 1) Switch active wallet to W2. +- **Expected:** Prompt closed; stash dropped; secret cleared. (If wallet switch is not reachable while the modal traps focus, this collapses into TC-CONCUR-001 — note that.) +- **Traceability:** E-8 (generalised), NFR-1. +- **Type:** unit / kittest. + +--- + +## 12. Findings: untestable / ambiguous requirements (feedback to Phase 1b) + +> These are the mismatches and gaps QA surfaced while deriving cases. Each is a **win** logged for +> the design loop, not a blocker for writing the specs above. + +- **G-1 (HIGH) — The gate is wired nowhere.** `SingleKeyPassphraseRequired` is produced only at + `single_key.rs:310` and consumed by no backend task or screen. The §1/§7/§9 "call sites" are + aspirational. TC-SITE-001/002/003 cannot pass until the TODO at `mod.rs:564` is implemented. + *Action for 1b/2:* specify which concrete backend tasks gain `sign_with` and where + `display_task_error` opens the prompt. +- **G-2 (HIGH) — Asset-lock signer mis-attributed.** `WalletAssetLockSigner` + (`asset_lock_signer.rs:52`) is an HD seed-snapshot signer and never emits + `SingleKeyPassphraseRequired`. Listing it as a gate call site (spec §1, §7, traceability) is + incorrect for the current code. *Action:* either add a single-key asset-lock signing path or + drop the asset-lock claim from the call-site list. +- **G-3 (MEDIUM) — Retry policy is an open question (NFR-5 / E-4 / §6.1).** TC-WRONG-004 assumes + *no* cap/cooldown. If Security imposes a soft cap, TC-WRONG-004 must be rewritten. Decision + needed before Phase 2. +- **G-4 (LOW) — Session-note wording is unsettled (§6.2).** TC-PROMPT-004 asserts the literal + "until you close the app"; if an idle auto-lock is later planned the copy ("for a while") and the + test must change. Confirm the wording is committed. +- **G-5 (LOW) — "Queue a different-addr error" rule may be unreachable.** Under the gate-on-error + loop, a *second* required-error only arrives after the first key is unlocked and the task + re-dispatches; a simultaneous different-addr error while a modal traps focus is hard to produce. + TC-CONCUR-003 is written defensively. Confirm whether this path is reachable or should be dropped. +- **G-6 (LOW) — Removed-key message string unspecified.** E-6 gives only example prose; §4.4 has no + slot for the `ImportedKeyNotFound` user message on this path. TC-EDGE-001 asserts behaviour, not + exact copy. Add the string to the copy table or designate the reused `Display`. +- **G-7 (INFO) — Deep zeroization not directly assertable.** TC-SEC-004 can assert `clear()` is + called on close, but true memory zeroization is a property of the reused `PasswordInput`/`Secret` + type, not of this component. Noted so no one over-claims the test. + +--- + +## 13. Coverage Summary by Area + +| Area | Cases | of which [LIVE/MANUAL] | of which [NOT-YET-WIRED] | +|---|---|---|---| +| §1 Gate trigger (MISS/HIT) | 5 (TC-UNLOCK-001..005) | 0 | 0 | +| §2 Prompt content | 6 (TC-PROMPT-001..006) | 0 | 0 | +| §3 Correct → auto-resume | 4 (TC-RESUME-001..004) | 1 (004) | 0 | +| §4 Wrong passphrase | 4 (TC-WRONG-001..004) | 0 | 0 | +| §5 Cancel / abort | 5 (TC-CANCEL-001..005) | 0 | 0 | +| §6 Security confinement | 4 (TC-SEC-001..004) | partial (003 panel) | 0 | +| §7 Multi-key sequencing | 2 (TC-MULTI-001..002) | 1 (001 broadcast) | 0 | +| §8 Concurrency / focus trap | 3 (TC-CONCUR-001..003) | 0 | 0 | +| §9 Per-call-site gate | 3 (TC-SITE-001..003) | 2 (broadcast) | 3 (all) | +| §10 Accessibility | 5 (TC-A11Y-001..005) | 1 (005 SR re-test) | 0 | +| §11 Negative / edge | 4 (TC-EDGE-001..004) | 0 | 0 | +| **Total** | **45 cases** | **~6 live/manual** | **3 not-yet-wired** | + +Plus **7 findings** (G-1..G-7) fed back to Phase 1b. + +--- + +## Candy Tally 🍬 (QA findings surfaced) + +- **High (2):** G-1 (gate wired nowhere), G-2 (asset-lock signer mis-attributed). +- **Medium (1):** G-3 (retry policy open). +- **Low (3):** G-4 (session-note wording), G-5 (queue rule possibly unreachable), G-6 (removed-key + copy unspecified). +- **Info (1):** G-7 (deep zeroization not directly assertable). + +**Total: 7 findings (0 critical, 2 high, 1 medium, 3 low, 1 info).** 🍬🍬🍬🍬🍬🍬🍬 +</content> diff --git a/docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md b/docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md new file mode 100644 index 000000000..33dae0c29 --- /dev/null +++ b/docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md @@ -0,0 +1,88 @@ +<!-- TODO(PROJ-018): External user docs (dashpay/docs) not yet filed --> +# External Docs Draft — PR #860 (platform-wallet migration) + +**Target repo:** `dashpay/docs` +**Target path:** `docs/user/network/dash-evo-tool/` +**Published at:** https://docs.dash.org/en/stable/docs/user/network/dash-evo-tool/ + +Paste the sections below into the relevant pages on the dashpay/docs site after PR #860 +merges. The draft is written in the same plain Markdown that the Dash docs site renders. + +--- + +## Tracking + +This file must result in a PR or issue filed against `dashpay/docs` after PR #860 merges to +`v1.0-dev`. Until that PR is open, user-facing documentation for the storage migration and +wallet limitations is absent from the public docs site. + +TODO: file a `dashpay/docs` issue linking to PR #860 and referencing this draft once the +merge commit is confirmed. + +--- + +## Draft content + +### New in this release — wallet storage update + +Dash Evo Tool now stores wallet data in an encrypted vault (`secrets.pwsvault`) rather than +the legacy `data.db` database file. + +**What changes for you:** + +- On first launch after upgrading, DET migrates your existing wallets automatically. A + brief progress notice appears during migration (usually under one second). No action is + required. +- Your existing `data.db` file is left on disk but is no longer used. You can keep it as a + backup or remove it once you have confirmed your wallets loaded correctly. +- Wallet metadata (name, main-wallet flag) moves to a new `det-app.sqlite` file in the same + folder. + +**File locations after migration:** + +| Platform | Wallet vault | Metadata sidecar | +|----------|-------------|-----------------| +| macOS | `~/Library/Application Support/Dash-Evo-Tool/secrets/det-secrets.*` | `~/Library/Application Support/Dash-Evo-Tool/det-app.sqlite` | +| Linux | `~/.config/dash-evo-tool/secrets/det-secrets.*` | `~/.config/dash-evo-tool/det-app.sqlite` | +| Windows | `%APPDATA%\Dash-Evo-Tool\config\secrets\det-secrets.*` | `%APPDATA%\Dash-Evo-Tool\config\det-app.sqlite` | + +--- + +### Passphrase prompt — sign-time unlock + +DET now asks for your wallet passphrase only when an operation actually needs your private +key (sending funds, registering an identity, signing a message). Previous versions held the +wallet open for the entire session once you unlocked it. + +When the prompt appears, you can check **"Keep this wallet unlocked until I close the app"** +to avoid repeated prompts during a busy session. The wallet locks again automatically when +you close DET. + +--- + +### Known limitations — single-key (imported WIF) wallets + +Importing a single-key wallet, viewing it, and signing with it work in this release. + +**Sending funds and refreshing the balance or UTXO list are not available in this release.** +If you attempt either action, DET will show a notice explaining this. + +Your key data is preserved in the encrypted vault and these actions will be available in a +future update. To send funds now, use an HD (recovery-phrase) wallet. + +--- + +### DashPay contacts — legacy address compatibility + +This release drops support for DashPay contact-request addresses derived outside mainnet +account 0 under the legacy DIP-14 scheme. This affects: + +- Contacts established on **testnet or devnet** using the old address derivation. +- Contacts established on any network using a **non-default account index** (account 1 or + higher). + +If you are affected, the existing contact entry remains visible but payment addresses for +those contacts may not match. Re-establishing the contact from both sides (send a new +contact request and have the other party accept it) restores full functionality. + +Mainnet contacts established via the default account (account 0) are not affected. diff --git a/docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md b/docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md new file mode 100644 index 000000000..68ffae1af --- /dev/null +++ b/docs/ai-design/2026-06-08-wallet-reregistration-fix/design.md @@ -0,0 +1,472 @@ +# Wallet Re-Registration Fix — Restoring the Upstream Persistor Write Path + +Repairs a HIGH-severity regression introduced by `e6c6c017` (PROJ-010 seedless +load): after the `SeedReregistrationLoader` was deleted, **no DET code writes +the upstream `platform-wallet.sqlite` persistor**. The seedless +`load_from_persistor` only ever READS it, so when that persistor is empty +(fresh install, post-reset, migration, sidecar-only wallets) the wallet is +never registered with the upstream SPV manager, the watch set is empty, and +received Core funds are invisible at 100% sync. + +> All upstream `file:line` citations are at the live pin +> `9e1248cb` (`platform`), `eb889af1` (`rust-dashcore` / `key-wallet`). +> READ-ONLY design — no production code in this document. + +--- + +## 0. Headline findings (read first) + +1. **The bug is a missing WRITE, not a broken READ.** `load_from_persistor` + (`packages/rs-platform-wallet/src/manager/load.rs:55`) faithfully rebuilds + every wallet the persistor knows about, watch-only and seedless. The defect + is that DET never *populates* that persistor. Grep confirms zero call sites + for `create_wallet_from_seed_bytes` / `persister.store` / changeset + construction anywhere in `src/` or `tests/`. +2. **There is NO upstream seedless watch-only WRITE/persist API.** The complete + public manager write surface is two methods, both seed-bearing: + `create_wallet_from_mnemonic` and `create_wallet_from_seed_bytes` + (`wallet_lifecycle.rs:73/100`). Both funnel into the private + `register_wallet`, the sole writer to `persister.store` (`:281`). There is + no `register_watch_only`, no `add_watch_only_wallet`, no public path that + feeds an xpub manifest into `store`. +3. **The building blocks for a seedless register exist upstream but are not + wired into a writer.** `build_watch_only_wallet(network, wallet_id, + manifest)` (`rehydrate.rs:61`, `pub(super)`), `Wallet::new_watch_only`, + `Account::from_xpub`, and the public `PlatformWalletChangeSet` / + `AccountRegistrationEntry` / `WalletMetadataEntry` types are all present. + What is absent is a **manager method that takes a keyless manifest and both + registers the watch-only wallet AND calls `persister.store`.** The load path + reads such manifests; nothing writes them except the seed path. +4. **DET cannot compute the upstream `WalletId` seedlessly.** Upstream + `WalletId = SHA256(root_public_key.serialize() ‖ root_chain_code)` over the + **master `m`** xpub (`key-wallet wallet/mod.rs:99`). DET's sidecar + `xpub_encoded` holds the **`m/44'/coin'/0'` account** xpub + (`model/wallet/mod.rs:404`, `meta.rs:45`). BIP44 hardens every level above + the account, so the account xpub cannot yield the root xpub. **DET therefore + cannot construct a valid `(WalletId, changeset)` pair without the seed.** + This kills the "DET writes the persistor directly" option as a *seedless* + strategy — DET can only write the persistor at a moment it holds the seed. +5. **Birth height is the silent-funds trap.** A wallet's SPV compact-filter + scan window starts at its persisted `birth_height` + (`wallet_lifecycle.rs:135`, `load.rs:84`). A fresh registration with + `birth_height_override = None` resolves to the **current SPV tip**, so any + deposit before that tip (the real-world 1.0 DASH at block 1492173) is never + matched. The fix MUST register with `birth_height = 0` (full historical + scan) — or a per-wallet floor — for any wallet that may have pre-existing + deposits. There is no separate rescan API; the persisted `birth_height` is + the only lever, and it is only settable through the seed-bearing + `create_wallet_from_*` call. + +--- + +## 1. Problem statement and confirmed root cause + +The product model (PROJ-008) is **watch-only at launch, password only at +secret/sign time**. The seedless `load_from_persistor` read path serves that +model correctly — *if the persistor is populated*. It is not. The deleted +`SeedReregistrationLoader` was the only thing that, at every cold boot, +re-derived each wallet from its seed and drove the upstream create path which +internally calls `persister.store`. Removing it left the persistor write side +with no caller. + +Failure chain: + +``` +register_wallet (context) → writes DET sidecars + legacy data.db + in-mem addrs + ✗ never calls upstream create_wallet_from_seed_bytes + ✗ never calls persister.store + ↓ (cold boot) +load_from_persistor() → persister.load() returns EMPTY + → zero wallets rebuilt → empty SPV watch set + ↓ +SPV reaches 100% → no addresses watched → deposit at 1492173 invisible +``` + +The backend-e2e "Timed out waiting for wallet to register with the upstream +backend" timeout is the same root cause: nothing registers the wallet upstream, +so `is_wallet_registered` never flips true. + +--- + +## 2. The upstream-API finding (watch-only write path) + +**Absent.** Verified exhaustively: + +| Capability | Upstream surface | Seedless? | Writes persistor? | +|---|---|---|---| +| `load_from_persistor` | `manager/load.rs:55` | yes | no (read) | +| `create_wallet_from_mnemonic` | `wallet_lifecycle.rs:73` | **no (seed)** | yes | +| `create_wallet_from_seed_bytes` | `wallet_lifecycle.rs:100` | **no (seed)** | yes | +| `remove_wallet` | `wallet_lifecycle.rs:386` | n/a | yes (delete) | +| `build_watch_only_wallet` | `rehydrate.rs:61` | yes | **no — `pub(super)`, read-side only** | +| `persister.store(WalletId, ChangeSet)` | `changeset/traits.rs:199` | yes (public) | yes | + +The data model fully supports watch-only wallets (the load path builds them). +What is missing is a **public, seedless writer** — a method that accepts an +xpub manifest + `WalletId` + birth height and performs `insert_wallet` + +`persister.store`. `register_wallet` does exactly this but is private and only +reachable with a fully-built (seed-derived) `Wallet`, and `WalletId` is not +seedlessly derivable by DET (§0.4). + +**Conclusion: this parallels PROJ-017 — a missing upstream registrar.** A pure +seedless fix is not available with the current upstream surface. + +--- + +## 3. Chosen design — write the persistor when DET holds the seed + +Because no seedless write path exists and DET cannot compute `WalletId` +seedlessly, the fix populates the upstream persistor **at the two moments DET +legitimately holds the seed**, and leaves the seedless read path untouched for +warm boots. This preserves watch-only-at-launch: after the persistor is +populated once, every subsequent cold boot is seedless via the existing +`load_from_persistor`. + +### 3.1 The seed-vs-watch-only decision, made explicit + +The design crux asked whether to repopulate the watch set from sidecar xpubs +without the seed. **That is not possible** at the upstream layer: the only +writer needs a seed-derived `Wallet`, and the `WalletId` the persistor is keyed +by needs the root xpub DET does not hold. Therefore: + +- **At launch (cold boot): stay seedless.** No change to the boot path. If the + persistor is already populated, `load_from_persistor` rebuilds watch-only + with no prompt — the PROJ-008 contract holds. +- **At the seed-bearing moments: write the persistor.** Registration with the + upstream manager happens where DET already has the plaintext seed in hand — + it does *not* introduce any new password prompt the product didn't already + require: + - **(W1) Create / import** (`context::register_wallet`, called from + `add_new_wallet_screen` / `import_mnemonic_screen` with `&seed`): the user + just typed or generated the phrase. Register upstream here. + - **(W2) First unlock** (the JIT chokepoint, where a protected wallet's seed + is decrypted for the first signing/derivation, and where an unprotected + wallet's seed resolves prompt-free): for any wallet **present in DET + sidecars but absent from the upstream persistor** (migrated installs, + wallets created before this fix, post-reset), register upstream the first + time the seed becomes available. An *unprotected* wallet resolves through + the chokepoint's no-prompt fast path, so its balance reappears at startup + with no user action; a *protected* wallet reappears on the unlock gesture + the user performs anyway. + +This is the minimal correct model: it never prompts solely to view balances, +and it never silently shows a wallet whose seed DET cannot prove it holds. + +### 3.2 What gets written, and the birth-height lever + +Registration uses the existing seed-bearing upstream call: + +``` +pwm.create_wallet_from_seed_bytes(network, seed_bytes, + WalletAccountCreationOptions::Default, + birth_height_override) +``` + +- `WalletAccountCreationOptions::Default` reproduces the SAME BIP44 account + manifest the seedless gate already matches against (locked by the existing + `bridge_account_xpub_matches_upstream_for_same_seed` test) — so the + account-xpub fund-routing gate keeps working unchanged. +- `birth_height_override` is the birth-height lever (§4). +- Internally this writes `WalletMetadataEntry { network, birth_height }` + + per-account `AccountRegistrationEntry` xpubs + address-pool snapshots via one + atomic `persister.store` — exactly the manifest `load_from_persistor` + rebuilds from on the next boot. + +### 3.3 Idempotency + +`create_wallet_from_seed_bytes` returns `WalletAlreadyExists` (via +`insert_wallet`) when the wallet is already registered. Registration is +therefore guarded: before calling, check the upstream manager +(`is_wallet_registered` / `pwm.get_wallet(wallet_id)`), and treat +`WalletAlreadyExists` as success. W1 and W2 are both idempotent and may both +fire for the same wallet across a session without double-watching. + +### 3.4 Rejected alternatives + +- **(a) Pure seedless re-registration from sidecar xpubs.** *Rejected: + infeasible.* No upstream seedless writer exists, and DET holds only the + hardened account xpub, not the root xpub `WalletId` requires (§0.4). Would + require an upstream change first (see §6). +- **(b) DET writes persistor rows directly via the storage crate.** *Rejected.* + Even setting aside seam risk, DET still cannot compute `WalletId` seedlessly, + so this is not actually seedless — it needs the seed anyway. And reaching into + the upstream changeset/schema to hand-roll `persister.store` calls duplicates + `register_wallet`'s 200-line snapshot logic (account specs, address pools, + metadata), bypasses `insert_wallet`'s in-memory registration, and violates + **M-PLATFORM-WALLET-FIRST-PARTY** intent (DET consuming the persister, not + reimplementing the registrar). If we ever want a *truly* seedless path, the + correct home is an upstream API (option a / §6), not a DET-side schema poke. +- **(c) Re-introduce seed-at-boot (the deleted loader).** *Rejected.* Forces a + password prompt at launch for protected wallets just to view balances — + directly violates PROJ-008. The chosen W2 (first-unlock) is the same + mechanism minus the launch-time prompt. +- **(d) Defer ALL registration to first unlock (W2 only, no W1).** *Rejected as + the sole strategy.* A freshly created/imported wallet would not be registered + until the user later signs — its balance would not appear until then. W1 is + cheap (the seed is already in hand) and makes new wallets work immediately, so + both writes ship together. + +--- + +## 4. Birth-height / rescan strategy + +### 4.1 The trap + +`register_wallet` resolves birth height as: explicit override wins; else SPV +confirmed header tip; else `0` (`wallet_lifecycle.rs:135-143`). A wallet +registered with `None` while SPV is synced gets `birth_height = tip`, and its +compact-filter scan window is `[tip, ∞)` — **pre-existing deposits are +invisible.** This is precisely the 1492173 symptom. + +### 4.2 The rule + +Birth height depends on whether the wallet could already hold funds before this +registration: + +- **W1 fresh-created wallet** (brand new phrase, never funded): no prior + deposits possible. `birth_height_override = None` is correct and cheap — scan + only from now forward. +- **W1 imported wallet** (existing recovery phrase) and **W2 every wallet** + (recovered/migrated/pre-fix): deposits may predate registration. + `birth_height_override = Some(0)` — **full historical scan from genesis** — is + the safe default. This is the only setting that guarantees the 1492173 case + is found. + +### 4.3 Where the birth height comes from + +DET persists no per-wallet creation height (verified — only the upstream +sidecar `WalletMeta` mentions `birth_height`, and DET never writes a real +value). Three tiers, in preference order: + +1. **Known funding block** — not available today; DET has no record. Future: + if a wallet records its first-deposit height, pin to it. +2. **Conservative network floor** — a per-network constant just below the + earliest height DET could have produced an address (e.g. a DET-release-era + height). Cheaper than genesis, still safe for any DET-created wallet. + *Optional optimisation; not required for correctness.* +3. **Genesis (`Some(0)`)** — always correct, never misses funds. The shipping + default for imported/recovered/migrated wallets. + +Recommendation: ship **genesis (`Some(0)`)** for imported/W2 wallets, +`None` for W1-fresh. Treat the network-floor optimisation as a follow-up only if +genesis rescan cost proves painful. + +### 4.4 Cost / UX of a genesis rescan + +The SPV client already runs `with_start_height(0)` (`mod.rs:1510`) — it +downloads compact filters from genesis regardless. The per-wallet `birth_height` +only governs which downloaded filters a wallet is *matched* against, not how +many filters are fetched. So `Some(0)` adds **filter-matching** work over the +wallet's address set across the full chain, not a second network download. On +testnet this is seconds-to-minutes of CPU; on mainnet it is heavier but bounded +by the already-downloaded filter set. UX: balances populate progressively as +the match pass advances; surface this as the normal "syncing" state, not a +distinct rescan mode. No new banner required beyond existing sync indication. + +--- + +## 5. Idempotency, placement, and seam compliance + +### 5.1 Placement + +- **W1 (create/import):** inside `context::register_wallet` + (`src/context/wallet_lifecycle.rs:197`), after the sidecar/db writes and + in-memory insert, add a backend registration step that calls a new + `WalletBackend::register_wallet_from_seed(seed_hash, &seed, + birth_height_override)`. The seed is already a borrowed parameter there. +- **W2 (first unlock / cold-boot reconciliation):** a new + `WalletBackend::ensure_upstream_registered(seed_hash, plaintext_seed, + birth_height_override)` invoked from the JIT chokepoint's seed-resolution + scope (`SecretAccess::with_secret_session` / `with_secret`), and from + `bootstrap_wallet_addresses_jit` (`context/wallet_lifecycle.rs:377`) which + already runs at cold boot for prompt-free-resolvable wallets. This reuses the + existing "seed is open right now" chokepoints — the same pattern + `ensure_identity_funding_accounts` already uses to do seed-dependent work + lazily (`mod.rs:1489`). +- **Read path:** `load_from_persistor_seedless` (`mod.rs:424`) is unchanged. It + remains the warm-boot fast path; W1/W2 simply guarantee its input is + populated. + +### 5.2 Idempotency and partial state + +The new register methods are per-wallet and idempotent: +1. Compute the upstream `WalletId` from the seed (the existing seed-bearing + `wallet_id` derivation — DET already does this inside `create_wallet_from_*` + via `compute_wallet_id`). +2. If `pwm.get_wallet(wallet_id)` is `Some`, return `Ok` (already registered) — + no second watch. +3. Else call `create_wallet_from_seed_bytes(.., birth_height_override)`; map + `WalletAlreadyExists` to `Ok` (race-safe). +4. On success, resolve into the DET `id_map` / `wallets` / `snapshots` via the + SAME account-xpub fund-routing gate the seedless loader uses — preserving the + published-xpub == scanned-xpub invariant. A wallet already in the persistor + and already in `id_map` is a no-op. + +Wallets already registered (warm boot) are never disturbed; wallets missing from +the persistor are filled exactly once each. + +### 5.3 Seam compliance (M-DONT-LEAK-TYPES / M-PLATFORM-WALLET-FIRST-PARTY) + +- All upstream types (`WalletId`, `PlatformWallet`, `WalletAccountCreationOptions`, + `PlatformWalletError`) stay inside `src/wallet_backend/`. The new methods take + DET-opaque inputs (`WalletSeedHash`, `&[u8; 64]`, `Option<u32>`) and return + `Result<(), TaskError>`. +- The seed `&[u8; 64]` is borrowed for the duration of the upstream call and + never parked — consistent with R3 (an open wallet parks no seed) and the JIT + secret model. W2 obtains it inside an existing `with_secret_session` scope so + it is zeroized when the scope ends. +- No upstream type touches DET's SQLite schema, MCP schemas, or user-facing + strings; upstream errors go to `TaskError` `#[source]` / `with_details`. + +--- + +## 6. Upstream (platform) change — optional, called out for the lead + +The chosen design needs **no upstream change** to fix the regression: it reuses +the existing seed-bearing `create_wallet_from_seed_bytes`. + +A *truly seedless* watch-only registrar would require an upstream addition and +is **not** on the critical path. If the lead wants to eliminate the +seed-at-register requirement entirely (so even W1/W2 become seedless), land in +platform **PR #3692** (which we own) a public manager method roughly: + +``` +pub async fn register_watch_only_wallet( + &self, + wallet_id: WalletId, + network: Network, + account_manifest: Vec<AccountRegistrationEntry>, + birth_height: u32, +) -> Result<Arc<PlatformWallet>, PlatformWalletError>; +``` + +…which would `build_watch_only_wallet` + `insert_wallet` + emit the +registration changeset via `persister.store`. **But** DET would still need the +root-xpub `WalletId`, which it does not persist today — so a seedless upstream +registrar is only useful if DET *also* starts persisting the root xpub (a +sidecar schema addition). Recommendation: **defer the upstream registrar.** Ship +the seed-bearing fix now; revisit a seedless registrar + root-xpub sidecar as a +separate hardening item if the first-unlock latency on large migrated installs +becomes a real complaint. + +--- + +## 7. Migration angle + +`finish_unwire` (`src/backend_task/migration/finish_unwire.rs`) copies legacy +encrypted seed envelopes and `WalletMeta` into the DET sidecars but **never +registers wallets with the upstream persistor** — confirmed by inspection. So a +migrated install lands in exactly the empty-persistor state that triggers the +bug. + +**Recommendation: rely on the W2 cold-boot bridge, do NOT add seed registration +to `finish_unwire`.** Rationale: + +- `finish_unwire` runs without the seed in hand for *protected* wallets (the + envelope is encrypted; the password is not available at migration time). + Forcing registration there would either require a password prompt mid-migration + (violates PROJ-008) or skip protected wallets (incomplete). +- W2 already covers migrated wallets: unprotected ones register prompt-free at + the next cold boot; protected ones register on first unlock. The cold-boot + bridge is the single, uniform mechanism — adding a partial second one in + `finish_unwire` would split the logic and create a protected/unprotected + asymmetry. +- `flush_persister` (`mod.rs:898`) already exists for migration durability; once + W1/W2 populate the persistor it flushes real rows, no change needed. + +If the lead wants migrated *unprotected* wallets to register during migration +rather than at the next boot (marginally faster first-funds visibility), that is +a one-line W2 call over the just-migrated unprotected set at the end of +`finish_unwire::run` — but it is an optimisation, not a correctness requirement. + +--- + +## 8. Task breakdown (for Bilby) + +Ordered; each task scoped to one developer agent. **(S)** = needs Smythe +security/funds-safety review. **(M)** = Marvin validates against the 1492173 +repro. + +- **T1 — `WalletBackend::register_wallet_from_seed` (W1 writer) (S).** + Add `pub(crate) async fn register_wallet_from_seed(&self, seed_hash: + &WalletSeedHash, seed: &[u8; 64], birth_height_override: Option<u32>) -> + Result<(), TaskError>` in `src/wallet_backend/mod.rs`. Body: derive upstream + `WalletId` from the seed; if already registered, `Ok`; else + `create_wallet_from_seed_bytes(network, *seed, Default, birth_height_override)`, + mapping `WalletAlreadyExists`→`Ok`; resolve into `id_map`/`wallets`/`snapshots` + via the existing account-xpub gate. Keep all upstream types inside the method. + ~120 lines. Dep: none. (S): seed borrowed, not parked; gate preserved. + +- **T2 — Wire W1 into `context::register_wallet` (S).** In + `src/context/wallet_lifecycle.rs:197`, after the in-memory insert, call + `backend.register_wallet_from_seed(&seed_hash, seed, birth_height)`. Birth + height: `None` for a freshly-generated wallet, `Some(0)` for an imported + one — thread a `WalletOrigin { Fresh, Imported }` (or a `bool imported`) from + the two call sites (`add_new_wallet_screen`, `import_mnemonic_screen`). Skip + (log) when the backend is not yet wired — W2 covers it at next boot. ~70 lines. + Dep: T1. + +- **T3 — `WalletBackend::ensure_upstream_registered` (W2 writer) + chokepoint + wiring (S).** Add the first-unlock/cold-boot variant that takes a held + plaintext seed (inside a `with_secret_session` scope) and registers any wallet + present in sidecars but absent from the persistor, with + `birth_height_override = Some(0)`. Call it from `bootstrap_wallet_addresses_jit` + (`context/wallet_lifecycle.rs:377`, cold-boot prompt-free path) and from the + unlock gesture's seed-resolution scope. Idempotent (re-check + `is_wallet_registered`). ~120 lines. Dep: T1. (S): seed obtained JIT, zeroized + with the scope; no launch-time prompt for protected wallets. + +- **T4 — Birth-height policy constant + plumbing.** Centralise the birth-height + decision (`None` fresh / `Some(0)` imported-or-recovered) as a small typed + helper so W1 and W2 agree. Document the genesis-rescan cost tradeoff in the + helper's rustdoc. Optional network-floor constant left as a `TODO`. ~40 lines. + Dep: T1. + +- **T5 — Typed errors.** Add/confirm `TaskError` variants for the new failure + modes (upstream register failure wrapping `Box<PlatformWalletError>`; the + already-registered case is `Ok`, not an error). Reuse the existing + `WalletBackend { source }` variant where it fits. ~30 lines. Dep: T1. + +- **T6 — Tests (M).** (a) Unit: `register_wallet_from_seed` is idempotent + (second call is a no-op, no double-watch). (b) Unit: birth-height policy maps + fresh→`None`, imported→`Some(0)`. (c) Backend-e2e cold-boot: create/import a + wallet, drop the backend, reconstruct, assert `is_wallet_registered` AND the + watch set is non-empty, then fund an address *below* the registration tip and + assert the balance appears (the 1492173 repro in miniature). (d) Migration: + migrate a legacy install with an unprotected wallet, cold-boot, assert W2 + registers it prompt-free. ~220 lines. Dep: T2, T3, T4. + +- **T7 — Docs + gap audit.** Note the persistor-write requirement in the + wallet-backend architecture doc; flip the PROJ-010 regression entry in the gap + audit; update `docs/user-stories.md` if a "funds visible after cold boot" + story exists. Dep: T6. + +### QA / review matrix + +- **Smythe (funds-safety):** T1, T3 — confirm the account-xpub gate still + rejects unmatched wallets; confirm the seed is borrowed/zeroized and never + parked; confirm `Some(0)` birth height for any wallet that could pre-hold + funds (no silent-miss). +- **Marvin (repro validation):** T6c against the real 1492173 deposit (or an + equivalent below-tip testnet deposit) — the canonical regression gate. + +--- + +## Candy tally (findings by severity) + +- **HIGH (funds-correctness) — 2:** (1) no upstream seedless watch-only WRITE + path exists; the persistor must be populated at a seed-bearing moment (§2/§3). + (2) birth height MUST be `Some(0)` for imported/recovered/migrated wallets or + pre-existing deposits (1492173) stay invisible (§4). +- **MEDIUM (correctness/seam) — 3:** (1) DET cannot compute the upstream + `WalletId` seedlessly — it holds only the hardened account xpub, not the root + (§0.4), foreclosing the pure-seedless and direct-persistor-write options. + (2) registration must be idempotent and route through the existing account-xpub + fund-routing gate (§5.2). (3) migration must NOT register in `finish_unwire`; + the W2 cold-boot bridge is the single uniform mechanism (§7). +- **LOW (housekeeping/clarity) — 2:** (1) DET persists no birth height of its + own; genesis is the safe default with a network-floor optimisation deferred + (§4.3). (2) a seedless upstream registrar (PR #3692) is possible but would + also need a root-xpub sidecar; defer (§6). + +**Total: 7 findings (2 HIGH, 3 MEDIUM, 2 LOW).** diff --git a/docs/ai-design/2026-06-09-platform-wallet-nonce-ownership/upstream-issue.md b/docs/ai-design/2026-06-09-platform-wallet-nonce-ownership/upstream-issue.md new file mode 100644 index 000000000..2ee8a1cf6 --- /dev/null +++ b/docs/ai-design/2026-06-09-platform-wallet-nonce-ownership/upstream-issue.md @@ -0,0 +1,339 @@ +# rs-platform-wallet: own and expose the per-address platform nonce (advance-on-submit + local reader) + +> Filed upstream as [dashpay/platform#3825](https://github.com/dashpay/platform/issues/3825) (2026-06-09). +> +> Citations are pinned to platform rev `9e1248cb0ae46c6fbfc5bd9d92540bec8c6d00e8` and key-wallet (rust-dashcore) rev `eb889af13f667ed39c35e8e8a0830eeedf523476`. + +### User Story + +As a **Platform Developer / wallet integrator** (Dash Evo Tool, the iOS +wallet, and any future consumer of `rs-platform-wallet`), I want the +platform-wallet crate to **own** the per-address platform nonce — keep it +current as I spend, and let me **read it locally** — so that spending does not +leak my IP to DAPI nodes on every transaction (today each spend re-fetches the +nonce over the network), and so that each integrator does not have to +reimplement the same fragile nonce custody (an optimistic cache, a +bump-after-send step, and a mismatch-retry) on top of the crate. Today every +integrator independently re-derives that bookkeeping; the wallet crate already +holds the address state and should be the single owner of the nonce alongside +the balance. + +### Problem / current behavior + +`PlatformPaymentAddressProvider` already stores the per-address nonce next to +the balance — `PerAccountPlatformAddressState.found: BTreeMap<PlatformP2PKHAddress, AddressFunds>` +(`packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:75`), +where `AddressFunds { nonce, balance }` carries both +(`packages/rs-sdk/src/platform/address_sync/types.rs:53`). + +But that nonce is **only ever a snapshot from the last completed sync** — no +send path advances it: + +- **The only writers of `found` are sync and rehydration.** Sync flows through + `on_address_found` → in-sync scratch → `sync_finished` flush + (`provider.rs:584`, `provider.rs:692`), driven by + `PlatformAddressWallet::sync_balances` + (`packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs:29`). + Rehydration seeds it via `insert_persisted_entry` + (`provider.rs:116`) from `initialize_from_persisted` + (`packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:155`). + The nonce written here is the GroveDB-proven confirmed on-chain value + (decoded at `packages/rs-sdk/src/platform/address_sync/mod.rs:869`). + +- **Every send path takes `provider.read()` only** and never writes the nonce + back into the live provider: + - transfer: `let guard = self.provider.read().await;` + (`packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:254`) + - withdrawal: `self.provider.read().await` + (`packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs:112`) + - fund-from-asset-lock: `self.provider.read().await` + (`packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:361`) + +- **The post-transition nonce reaches sqlite but not the live provider.** The + auto-fetch send returns the post-transition `AddressInfos` to the caller; the + send path pushes it into a persistence changeset + (`transfer.rs:304`, `transfer.rs:314`; `withdrawal.rs:153`; + `fund_from_asset_lock.rs:430`, `fund_from_asset_lock.rs:300`) and pushes + **balance only** onto the managed account via `set_address_credit_balance` + (`transfer.rs:299`, `withdrawal.rs:141`, `fund_from_asset_lock.rs:429`). The + in-memory `provider.found` nonce is left untouched until the next sync + re-observes the address. + +- **The crate explicitly delegates nonce custody to the caller.** When the + changeset is applied to the in-memory managed account, the nonce is dropped + on purpose: + + > `// Nonce isn't stored on ManagedPlatformAccount; callers that need it` + > `// persist it via their own store (see evo-tool's platform_address_balances` + > `// table which writes both balance and nonce from the changeset).` + + — `packages/rs-platform-wallet/src/wallet/apply.rs:196` (introduced in + `4809f802`, 2026-04-17). (Confirmed at the + key-wallet layer: `ManagedPlatformAccount` stores + `address_balances: BTreeMap<PlatformP2PKHAddress, u64>` — balance only, no + nonce field — + `key-wallet/src/managed_account/managed_platform_account.rs:47`.) + +Net: the provider's nonce means *"the confirmed on-chain nonce as of the last +completed sync that observed this address."* It is **not** the next-to-use +value, and it is **stale between a send and the next sync**. + +### Why a reader alone is insufficient + +Exposing a public reader over today's `found` nonce would hand integrators a +value that is correct only until the first send, then silently stale until the +next sync re-observes the address. A second address-based transition built in +that window reuses the old nonce and consensus rejects it with +`AddressInvalidNonceError { provided_nonce: N, expected_nonce: N+1 }`. So the +reader must be paired with **advance-on-submit** for the owned nonce to be +trustworthy as a next-to-use source. (This is the same `AddressInvalidNonceError` +surface tracked from the SDK side in #3407 and #3611 — see *Related*.) + +There is also **no `AddressInvalidNonceError` handler anywhere** in `rs-sdk`, +`rs-platform-wallet`, or `swift-sdk` (established by the merged review on #3784). +So fetch-per-send is the *only* line of defense — if a fetched nonce is ever +stale (e.g. the rapid same-address send window, or replica lag per #3611), the +transition simply fails with no retry safety net. Owning and advancing the nonce +locally is more robust precisely because there is no upstream recovery path to +fall back on. + +### Privacy impact (primary motivation) + +The decisive cost of fetch-per-send is privacy, on the hot path, every time the +user spends: + +- Every address spend re-fetches the nonce from the network + (`fetch_inputs_with_nonce` → `AddressInfo::fetch_many`). That nonce read is a + **separate DAPI request, on top of the unavoidable broadcast**: a spend today + makes three proved requests — nonce read, broadcast, and wait-for-result — + each independently routed to a single DAPI node (the proof is verified locally + against the quorum public key, so no node is polled twice for agreement). The + nonce read exposes the user's IP to a DAPI node purely to obtain a value the + wallet could already own — the one request of the three that is entirely + avoidable. Owning the nonce locally drops a spend from three DAPI requests to + two, removing that exposure on every transaction. +- This is an inherent, repeated leak: the more a user transacts, the more DAPI + operators can correlate their IP with their platform addresses and spending + activity. The exposure scales linearly with spend count and is unavoidable as + long as the nonce lives on the server side of each send. +- A locally-owned nonce — read from platform-wallet's own state — eliminates the + per-spend nonce fetch entirely: **zero incremental IP/metadata exposure** for + obtaining the nonce. This is the core reason to own the value locally rather + than re-fetch it on every transaction. + +This trade-off is exactly what #3784 codifies: its merged rationale is that the +synced provider nonce is "cosmetic" *because* every spend re-fetches the +authoritative value at build time. In other words, the current design's +correctness deliberately depends on the per-spend network round-trip that +creates this privacy cost. We are asking to revisit that trade-off — make the +locally-owned nonce authoritative so the round-trip (and its IP exposure) is no +longer required on the spend path. + +### Proposed change — two parts + +#### (a) Advance + persist the nonce on submit + +In the `rs-platform-wallet` send paths, after the transition is built/submitted, +take `provider.write()` and update each input address's `AddressFunds.nonce` in +`found` to the just-used value, in lockstep with the changeset that is already +written to sqlite: + +- transfer — around `transfer.rs:254`–`transfer.rs:314` +- withdrawal — around `withdrawal.rs:112`–`withdrawal.rs:153` +- fund-from-asset-lock — around `fund_from_asset_lock.rs:361`–`fund_from_asset_lock.rs:430` +- the shield equivalent (the `ShieldFunds` path) + +The value is already in hand: these paths receive the post-transition +`AddressInfos` and persist it to sqlite via the changeset +(`transfer.rs:304`/`:314`) — they simply do not write it back into the live +provider. This change closes that gap so the in-memory provider stays +consistent with the sqlite row. + +**Submit-time-optimistic vs confirmation-time.** Writing the advance at submit +time (optimistically, before chain confirmation) is what covers rapid, +back-to-back sends from the same address within one sync window — the dominant +real-world case. A confirmation-time write leaves the same stale window open +between two quick sends. We recommend **optimistic advance on submit**, with the +next sync reconciling against the proven value (so a failed/abandoned submit +self-heals on the following sync). + +#### (b) Public local reader on `PlatformAddressWallet` + +`PlatformAddressWallet` is already publicly reachable via +`manager.get_wallet(id).platform()` +(`packages/rs-platform-wallet/src/manager/accessors.rs:260` → +`packages/rs-platform-wallet/src/wallet/platform_wallet.rs:129`). Add a +by-address reader that clones out of `provider.read()`: + +```rust +/// Confirmed-and-locally-advanced funds (balance + nonce) for one +/// platform address, or `None` if the wallet has never observed it. +pub async fn get_address_funds( + &self, + address: &PlatformAddress, +) -> Option<AddressFunds>; +``` + +**No type cascade is required.** Every type in the signature is already public — +`PlatformAddress` (`pub enum`, +`packages/rs-dpp/src/address_funds/platform_address.rs:39`), +`PlatformP2PKHAddress` (`pub struct`, +`key-wallet/src/managed_account/platform_address.rs:27`), and `AddressFunds` +with `pub nonce`/`pub balance` +(`packages/rs-sdk/src/platform/address_sync/types.rs:53`). The accessor returns +owned/cloned values, so `PlatformPaymentAddressProvider` can stay `pub(crate)` +(`provider.rs:176`) — no internals are exposed. + +**Prefer by-address over enumeration.** A `get_address_funds(&addr)` reader +indexes `found` directly (keyed by address), sidestepping the bijection hazard +that an enumerating `addresses_with_funds()` would hit (see QA-002 below). An +enumerating variant can be offered too, but only after the bijection issue is +fixed. + +### Edge cases & residual gaps + +- **Absent / brand-new address.** `found` has no entry for an address that has + never been synced, so the reader returns `None`. Integrators should treat + `None` as "seed nonce 0" for a never-used address. The `Option` return makes + "unknown" distinguishable from "known, nonce 0" — preferable to a bare + `AddressFunds` that conflates the two. + +- **QA-002: bijection eviction on rehydration.** The `platform_addresses` table + primary key is `(wallet_id, address)` + (`packages/rs-platform-wallet-storage/migrations/V001__initial.rs:209`), not + `(account_index, address_index)`, so two addresses in one account can share an + `address_index`. `insert_persisted_entry` does + `self.addresses.insert(index, address)` (`provider.rs:115`) on a + `BiBTreeMap` (`bimap 0.6.3`), which evicts the prior pair on left-key + collision; the nonce survives in `found` (keyed by address) but the evicted + address is silently dropped from `current_balances`, which filters via + `addresses.get_by_right(p2pkh)?` (`provider.rs:709`). A by-address reader is + immune (it reads `found` directly); an enumerating reader is not. We recommend + shipping the by-address reader **and** fixing the bijection — either key the + load/PK on `(account_index, address_index)` or make `insert_persisted_entry` + reject/repair collisions instead of silently evicting. + +- **Crash between submit and persist.** If the optimistic advance is written to + the provider and sqlite but the process dies before the changeset commits, the + advance is lost and a post-restart read reverts to the prior confirmed nonce. + This is acceptable only if submit → persist is ordered before broadcast (or + the two writes are atomic). Worth calling out in the implementation so the + ordering is deliberate. + +- **Async reader → off-thread only.** The provider is + `Arc<RwLock<Option<PlatformPaymentAddressProvider>>>` behind a tokio **async** + `RwLock` (`wallet.rs:31`), so `get_address_funds` must be `async`. Consumers + must call it off any UI/frame thread (for DET, via its task system) to avoid + blocking inside the runtime. + +- **Reorg / full rescan.** `prepare_for_sync` preserves `found` and clears only + `absent` (`provider.rs:381`, `provider.rs:432`); a full rescan re-proves and + overwrites with the freshly-proven nonce. If the chain itself rolled back + below a transition, the proven nonce legitimately drops — that mirrors chain + state, not corruption. No special handling needed; the optimistic advance from + (a) is reconciled by the next sync. + +- **u32 overflow.** `AddressNonce` is `u32` and `nonce_inc` does an unchecked + `nonce + 1` (`packages/rs-sdk/src/platform/transition/address_inputs.rs:45`). + Overflow only at 2^32 transitions from a single address — theoretical, debug- + panic only; noted for completeness. + +### Scope estimate + +- **(b) reader** — ~15 LOC, additive, no type changes; clones under the lock. +- **(a) advance-on-submit** — the substantive change: a `provider.write()` + + nonce write in each send path (transfer / withdrawal / fund-from-asset-lock / + shield), reusing the post-transition `AddressInfos` the paths already receive. + +Together these let integrators **delete their own per-address nonce tracking** +(optimistic cache, bump-after-send, mismatch-retry) and read the +crate-owned value each time — the platform-wallet crate becomes the single owner +of the nonce alongside the balance. + +### Alternatives considered (and why they don't fit) + +Hard constraint for this design: read the per-address nonce from +platform-wallet's **local** state on every spend — not from the server/chain on +the hot path, and not reimplemented separately by each integrator. Each +mechanism below is adjacent but fails one of those two requirements. + +- **Chain reader `AddressInfo::fetch_many`** (`packages/rs-drive-proof-verifier/src/types.rs:126`, + `packages/rs-sdk/src/platform/fetch_many.rs:542`). Proof-fetches `(balance, nonce)` + fresh from chain. Correct and authoritative, but it is a network round-trip per + read and reads *from the server* — exactly the dependency this design removes. +- **Auto-fetch send APIs** — `Sdk::transfer_address_funds` / + `withdraw_address_funds` (`packages/rs-sdk/src/platform/transition/{transfer_address_funds,address_credit_withdrawal}.rs`), + `Identity::top_up_from_addresses` / `put_with_address_funding_fetching_nonces` + (`.../transition/{top_up_identity_from_addresses,put_identity}.rs`). Internally + `fetch_inputs_with_nonce` + `nonce_inc`, fetching the nonce from chain on every + send. Robust (this is the iOS pattern) but keeps a per-send network dependency + on the hot path and yields no locally-owned value to read between sends. +- **iOS / FFI approach** — `rs-platform-wallet-ffi` + (`identity_registration.rs:21`: *"Nonces are intentionally **not** part of this + struct — the SDK's … fetches at submit"*); `PersistentPlatformAddress.nonce` is a + one-way display mirror (`persistence.rs:671`). iOS leans entirely on the + server auto-fetch above. Same rejection: server-sourced, no local owner. +- **Existing public balance reader `addresses_with_balances()`** + (`packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:302`) — + public, local, but **balance only**, no nonce. This is the closest existing + shape; proposal (b) is its nonce-carrying sibling. +- **`sync_watermark()`** (`wallet.rs:325`) — public and local, but returns + `last_known_recent_block`, not a per-address nonce. Wrong granularity and + shape; included only because it is the precedent for the reader pattern (see + #3723 below). +- **Add the nonce to key-wallet `ManagedPlatformAccount`** + (`key-wallet/src/managed_account/managed_platform_account.rs:47`, balance-only + today). A possible home, but the provider already holds the nonce in `found`; + adding a second copy on the managed account duplicates state and re-opens the + "who is authoritative" problem. Keep one owner — the provider. +- **DET's current optimistic cache + `ShieldedNonceMismatch` retry.** The status + quo this proposal exists to **delete** — the per-integrator nonce custody the + User Story calls out. Not an alternative to keep; the thing being removed. +- **#3407's SDK fetch-cache + auto-retry.** Symptom mitigation at the fetch + layer; still re-fetches from the server and establishes no locally-owned + nonce. Complementary, not a substitute. + +### Related + +- **#3723** (merged 2026-05-21) — `feat(platform-wallet): expose sync_watermark() + on PlatformAddressWallet`. **Direct precedent for proposal (b):** added a + `pub async fn sync_watermark()` on `PlatformAddressWallet` backed by a + `pub(crate)` provider mirror, no type cascade, "pure additive surface." Our + `get_address_funds` is the same mechanism applied to the per-address nonce + rather than the watermark. Not a duplicate (it exposes the watermark, not the + nonce). +- **#3784** (merged 2026-06-07) — `docs: clarify address-sync catch-up nonce`. + **Important design context, not a dup.** A maintainer review states the synced + provider nonce is "cosmetic … every address-spend path re-fetches the + authoritative nonce at build time (`fetch_inputs_with_nonce` + `nonce_inc`), so + the synced value … never lands in a broadcast transition." That is the current + fetch-per-send stance this proposal asks to revisit: making the provider nonce + *authoritative and locally readable* is precisely the change, so the "cosmetic" + framing is the status quo we want to upgrade. +- **#3650** (open) — `fix(sdk): address-sync no longer silently discards balance changes for post-snapshot addresses (Found-025)`. The sync-layer (rs-sdk) fix that ensures freshly-derived addresses and their balance changes actually land in `provider.found` (previously a post-snapshot address was silently dropped, never reaching `result.found`/`on_address_found`). It is the **foundation this proposal builds on**: a locally-owned, advanced nonce is only correct if `found` is completely and correctly populated by sync. It is also the PR whose review introduced the "cosmetic nonce / fetch-per-send" rationale — clarified in its stacked follow-up #3784 — that this proposal revisits. (Comment context: the "callers own the nonce" delegation at `apply.rs:196` was authored by the same maintainer in `4809f802` on 2026-04-17.) +- **#3739** (open, rs-sdk) — `address_sync` persists `nonce=0` for addresses + first surfaced via incremental RPC. Sync-layer *data quality*; complementary + — a correct owned nonce depends on sync writing the right value in the first + place. Distinct from provider ownership/reader. +- **#3611** (open, dapi) — stale nonce/balance read after a confirmed broadcast + due to DAPI replica lag. A read-after-write consistency problem in the + *fetch* path; orthogonal to owning the in-memory provider nonce. +- **#3407** (closed) — SDK auto-retry / stale-mark on `AddressInvalidNonceError`. + Mitigates the *symptom* at the SDK fetch-cache layer and notes address nonces + are "fetched fresh each time via `fetch_inputs_with_nonce()`" with no cache — + this issue proposes the crate *own* the value instead, removing the need to + re-fetch or retry in the common case. +- **#3737** (closed) — migrate `AddressPool` from + `platform_payment_managed_account` to `PlatformPaymentAddressProvider`. + Precedent: owned per-address state is being consolidated onto the provider; + the nonce is the natural next field to own there. +- **#3625** (merged 2026-06-09) — `feat(platform-wallet)!: add + platform-wallet-storage crate (sqlite persister)`. Establishes the + `platform_addresses` table (with the `nonce` column) and the rehydration path + this proposal builds on. Already present in the pinned tree (`9e1248c`). +- **#3692** (open) — `feat(platform-wallet): watch-only rehydration from + persistor (seedless load)`. The cold-start load path that seeds the provider's + `found` (including the nonce) from sqlite; the reader in (b) surfaces what this + rehydrates. + +<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> diff --git a/docs/ai-design/2026-06-09-pr860-full-review/findings.md b/docs/ai-design/2026-06-09-pr860-full-review/findings.md new file mode 100644 index 000000000..dc34d51e3 --- /dev/null +++ b/docs/ai-design/2026-06-09-pr860-full-review/findings.md @@ -0,0 +1,201 @@ +# PR #860 Full Review Findings + +**Reviewed commit**: `c18da455` +**Review methodology**: 36 reviewer agents + 6 cross-cutting dimensions; adversarially verified (2 verifiers per finding); 133 confirmed findings synthesized to 54. +**Overall risk**: HIGH +**Merge recommendation** (synthesis verbatim): + +> DO NOT MERGE as-is. There is a confirmed CRITICAL fresh-install regression (F77) that bricks wallet +> creation for every new user, plus a cluster of HIGH/MEDIUM funds-safety, secret-lifecycle, and +> migration-lifecycle defects (F131, F62, F140/F141, F60, F17, F20, F78). These must be fixed and +> regression-tested (a test that drives the real `Database::initialize` fresh path then +> `register_wallet`, and a first-boot migrate->hydrate->assert-non-empty test) before merge. PROJ-005 +> (re-pin platform deps to a tagged release) remains a hard release gate G1. Once the top blockers +> are resolved, the remaining LOW/INFO findings (convention/docs/dead-code) can be addressed as +> fast-follows and should not individually block merge. + +--- + +## Resolved Blockers + +All of the following were confirmed fixed and verified by Marvin (combined tree green: clippy clean, +lib 709 tests, kittest 86 tests) and Smythe ("ship it", 0 new defects). QA-reconfirmed. + +| ID | Severity | Title | Fix commit | +|----|----------|-------|------------| +| F77 | CRITICAL | Fresh installs cannot create or import any wallet — legacy `wallet` table is never created | `dc9a0c3b` | +| F78 | MEDIUM | `clear_network_data` deletes from non-existent legacy tables on fresh installs — "Clear data" fails silently | `cacbf6c4` | +| F60 | HIGH | `clear_network_database` does not clear authoritative wallet state (sidecars + seed vault + shielded tree) | `f6d2ecf7` | +| F17 | MEDIUM | Removing an HD wallet leaves its encrypted seed envelope (and shielded notes) orphaned on disk | `f6d2ecf7` | +| F20 | MEDIUM | Wallet removal and network reset do not clear the shielded-notes sidecar — orphaned plaintext notes | `f6d2ecf7` | +| F54 | MEDIUM | Swallowed shielded-note DB insert can permanently lose a spendable note | `415b4826` | +| F131 | HIGH | "Lock" gesture does not wipe the session-cached plaintext seed; locked wallet still signs without a prompt | `2304d063` | +| F62 | HIGH | `register_wallet` swallows seed-envelope persist failure, causing silent wallet/funds loss on next restart | `88c21c96` | +| F140 | HIGH | Migrated wallets are invisible until a second restart — hydration runs before migration populates sidecars | `36f77562` | +| F134 | MEDIUM | `Wallet`/`WalletSeed`/`ClosedKeyItem` derive `Debug` without redaction; a live `debug!` log sink writes seed material | `c15048f7` | +| F37 | MEDIUM | Failed wallet-funded identity registration persists an all-zeros placeholder identity | `3a1bb2f6` | +| F89 | MEDIUM | Advanced single-key import dialog never inserts the key into `single_key_wallets`; imported key is lost | `34543c0d` | +| F95 | MEDIUM | `ContactRequests::display_task_error` is unreachable; embedded DashPay tabs lose typed error routing | `d92dcf34` | +| F118 | MEDIUM | `event_bridge_live` e2e test uses `#[tokio::test]` instead of the mandatory shared runtime | `cdfa4311` | +| SEC-001 | — | QA-surfaced security gap (resolved) | `6300f27b` | +| SEC-002 | — | QA-surfaced security gap (resolved) | `4ec7b5e9` | +| QA-001 | — | Private-key bytes unredacted in `PrivateKeyData` `Debug`/`Display` | `b5ad862b` | + +**Resolved count**: 17 (14 synthesis findings + 3 QA-found gaps) + +--- + +## Open — Release Gate + +| ID | Severity | Title | Location | Status | +|----|----------|-------|----------|--------| +| F121 / PROJ-005 | HIGH | Platform git deps pinned to unreleased feature-branch HEAD `9e1248cb` (feat/platform-wallet-rehydration, open draft PR #3692); declared version `4.0.0-beta.2` does not match tag SHA; build is non-reproducible | `Cargo.toml:21,31,32,35` | Upstream-gated. Re-pin all `dashpay/platform` deps to a tagged release commit before merge. Transitively-pulled `rust-dashcore` rev and vendored OpenSSL must also reconcile against the released lockfile. | + +--- + +## Fast-Follow Backlog + +Non-blocking convention, docs, dead-code, and latent-edge-case items. None individually blocks merge. +Grouped by the synthesis systemic themes where natural. + +**Tail outcome (range `0196b129..HEAD`)**: the fast-follow tail landed ~32 of these. Each row below +carries a `Status` of **RESOLVED** (with the fix commit), **SKIPPED** (judged inert — no real defect), +or **DEFERRED** (cross-file / upstream-gated / out of this round's scope). The Summary table at the +bottom is updated to match. + +> **Pre-existing note (not in this finding set): SEC-006** — MEDIUM, surfaced by Smythe. +> `wallets_screen/mod.rs:2189` keeps a plaintext `SingleKeyWallet` in a long-lived in-memory map. +> Pre-existing, outside the PR diff; tracked separately, not a tail fast-follow. + +### Systemic themes (verbatim from synthesis) + +1. Migration was not fully swept: dead/vestigial code, orphaned stub methods, write-only state, and stale doc comments/tombstones referencing removed RPC/seedless/legacy machinery (F28, F32, F35, F36, F49, F71, F72, F73, F83, F104, F105, F109, F110, F111, F112). +2. Fresh-install vs legacy-schema mismatch: T-DEV-01 gated legacy tables behind `include_legacy` but several production write/clear paths still assumed those tables exist (F77 CRITICAL, F78, F60 — all resolved). +3. Migration lifecycle / hydration ordering is fragile: hydration runs before sidecars are populated, nothing re-hydrates on migration success, no-op handlers fail to clear secrets, migration banner/empty-state UI present contradictory guidance (F140 — resolved; F141, F113, F114, F142, F143, F51, F31 — open). +4. Typed-error convention drift: error-string control flow (`msg.contains` / `e.to_string()` parsing) and stringified errors in `String` fields instead of typed `#[source]` variants (F43, F59, F66, F111, F129, F139, F5). +5. Raw upstream error text leaks into user-facing strings (seam boundary 3 / no-jargon rule), inconsistent with the correct `with_details()` pattern used elsewhere (F23, F87, F91, F97, F101, F137). +6. Secret-lifecycle hygiene gaps: session-cached seed not wiped on lock, derived keys and imported WIF bytes left non-zeroized, unredacted `Debug` on wallet types with a live `debug!` log sink, orphaned encrypted seed envelopes after deletion (F131, F62, F17, F9, F92, F100, F134, F135 — F131/F62/F134 resolved). +7. UI auto-fetch/loading-flag inconsistency: attempted-flags set only on success cause retry storms or permanent stuck-loading states; embedded DashPay tabs lose error routing (`display_task_error` never forwarded) (F94, F96, F95 — F95 resolved). +8. `bincode` non-self-describing encoding + `#[serde(default)]` gives a false forward-compat guarantee for evolving sidecar structs routed through `DetKv`; misleading `SCHEMA_VERSION` guidance (F25, F26). +9. Panic-on-fallible-input on the at-rest/decode boundary: unguarded `copy_from_slice`/`Nonce::from_slice` on bincode-decoded vault and sidecar bytes can panic (one poisons a long-lived mutex) instead of returning typed errors (F12, F21, F133). +10. Unreleased upstream dependency pin and enlarged native/secret-storage build surface (F121 — release gate; F122, F123 — transitive consequences). + +--- + +### Theme 1 — Migration sweep: dead code, vestigial stubs, stale docs + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F28 | INFO | Migration left dead/vestigial code and stale docs across many modules | `src/wallet_backend/platform_address.rs:245-306`; `src/backend_task/core/{refresh,send}_single_key_wallet_payment.rs`; `src/context/transaction_processing.rs:33-95`; `src/context_provider*.rs`; `src/backend_task/error.rs:1033` | Sweep: delete unreachable/vestigial code and fields, remove the stray `EDIT-PROBE-MARKER`, fix broken intra-doc link, drain or remove the ZMQ status channel, correct stale doc comments. | **RESOLVED** `d9f99838` — single-key stub files deleted, dead asset-lock loop dropped, stale doc comments and broken intra-doc link fixed, `EDIT-PROBE-MARKER` removed. ZMQ status channel left for a follow-up that owns `context/mod.rs`. | +| F32 | INFO | Migration design/audit docs describe never-shipped or contradicted mechanisms; CHANGELOG/grep evidence inaccurate | `docs/ai-design/2026-06-02-rehydration-rewire/design.md:51-58`; `docs/ai-design/2026-05-18-platform-wallet-migration/data-model-and-migration.md`; `CHANGELOG.md:20` | Add `SUPERSEDED` banners to affected design sections; fix false grep evidence in the gap audit; correct the CHANGELOG vault path; cross-link the live `FinishUnwire`/`kv-keys.md` mechanism. | **RESOLVED** `a6322da1` — SUPERSEDED banners added, false grep evidence corrected, CHANGELOG vault path fixed, live mechanism cross-linked. | +| F49 | INFO | Successful asset-lock top-up no longer untracks the consumed lock; stale lock keeps appearing as fundable | `src/backend_task/wallet/fund_platform_address_from_asset_lock.rs:87-123` | After a successful top-up, mark/untrack the consumed lock (or filter consumed locks from the picker) so it stops appearing as fundable. | **DEFERRED** — upstream `consume_asset_lock` is `pub(crate)`; untracking the consumed lock is upstream-gated. Revisit when the upstream API is exposed. | + +### Theme 3 — Migration lifecycle / banner UX + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F51 | LOW | `ShieldedTask` excluded from the lazy-build set; a first shielded task before backend wiring surfaces a misleading 'restart to finish migration' error | `src/backend_task/mod.rs:475-514` | Add `BackendTask::ShieldedTask(_)` to the lazy-build `matches!`, or make `legacy_shielded_present_but_sidecar_empty` treat an unwired backend as 'cannot gate yet' rather than mapping to the migration error. | **RESOLVED** `ff98ef89` — shielded wired into the lazy-build set; terminal storage errors surfaced. | +| F113 | LOW | Migration banner UX: spurious 'Storage update complete' on every launch/switch, and two contradictory error banners on failure | `src/app.rs:1102-1150`; `src/backend_task/migration/finish_unwire.rs:207,223,272` | Surface the Success banner only when migration actually moved data (carry a `did_work` flag); suppress the generic `TaskResult::Error` banner for `TaskError::MigrationFailed` since it already emits its own. | **RESOLVED** `52edbaf8` — `did_work` flag added so Success shows only when data moved; generic error banner suppressed for `MigrationFailed`. (Same commit also resolves F30.) | + +### Theme 4 — Typed-error convention drift + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F43 | LOW | Typed errors stringified into `TaskError` `String` fields and error-string control flow across DashPay/migration/balance paths | `src/backend_task/dashpay/contact_requests.rs:290`; `src/backend_task/wallet/fetch_platform_address_balances.rs:87`; `src/context/identity_db.rs:57-65`; `src/backend_task/error.rs:936` | Replace `String` detail fields with typed `#[source]` variants (`Box<SdkError>` for SDK errors); replace the proof-error string parse with a structural match; use `Box::new(e)` where a typed source already exists. | **RESOLVED** `bed6f8a0` + `18b9f65f` — stringified errors replaced with typed `#[source]` variants. | +| F45 | INFO | Wallet seed-unavailable mapped to a DashPay-contact-specific error message in unrelated balance-sync/pubkey-warming tasks | `src/backend_task/wallet/fetch_platform_address_balances.rs:43-45`; `src/backend_task/wallet/warm_identity_auth_pubkeys.rs:54-57` | Use `TaskError::WalletLocked` in both tasks for consistency; confine `ContactWalletSeedUnavailable` to DashPay contact flows where its wording is correct. | **RESOLVED** `18b9f65f` — seed-unavailable mapped to `WalletLocked` in both tasks. | + +### Theme 5 — Raw error text in user-facing strings + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F23 | LOW | Raw upstream SPV/sync-manager error text and internal manager id rendered verbatim in the connection-status panel | `src/ui/network_chooser_screen.rs:420-428`; `src/wallet_backend/event_bridge.rs:90-93,159-162` | Render a fixed user-facing label ('Sync error — open Settings for details') for the SPV line and attach the raw `spv_last_error` only as a tooltip/details affordance, mirroring the `with_details()` pattern used elsewhere. | **RESOLVED** `6d9dbe9a` — raw SPV/unlock error text kept out of user-facing copy. | +| F50 | LOW | Storage-open errors (`WalletDataTooNew`/`WalletDataIncompatible`) are logged-and-discarded at dispatch; user sees a misleading generic banner | `src/backend_task/mod.rs:475-484`; `src/context/mod.rs:777-781` | Cache the build error in the context so `wallet_backend()` returns it instead of the generic variant, or propagate `Err(e)` for storage-open failures so the banner shows actionable copy. | **RESOLVED** `ff98ef89` — terminal storage-open errors are surfaced instead of collapsing to the generic banner. | +| F101 | INFO | `try_open_wallet_no_password`/unlock surface raw `String` errors with jargon and collapse all failures to 'Incorrect password' | `src/ui/components/wallet_unlock_popup.rs:124-185` | Map the no-password size error to a calm jargon-free message via `with_details`; add an explicit next step to the 'Incorrect password' message. | **RESOLVED** `6d9dbe9a` — unlock errors mapped to calm jargon-free copy with details. | +| F103 | LOW | DAPI endpoint refresh shows a spurious 'Core RPC password saved successfully' banner | `src/ui/network_chooser_screen.rs:1748-1770` | Remove the vestigial `CoreClientReinitialized` password-success handler and dead `config_save_failed`/`reinit_banner` plumbing; if a DAPI-reinit confirmation is wanted, give it an accurate message. | **RESOLVED** `6d9dbe9a` — spurious password-success banner and dead plumbing removed. | + +### Theme 6 — Secret-lifecycle hygiene + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F9 | INFO | Derived encryption keys and imported WIF bytes escape the secret chokepoint without zeroization | `src/wallet_backend/dashpay.rs:154-209`; `src/wallet_backend/single_key.rs:179-194` | Wrap derived encryption keys and extracted WIF bytes in `Zeroizing`; also wrap the remembered-passphrase copy in `WalletUnlock`. | **RESOLVED (partial)** `499947e5` — extracted single-key WIF bytes wrapped in `Zeroizing`. **DEFERRED:** the derived DashPay encryption keys (`dashpay.rs`) and the remembered-passphrase copy in `WalletUnlock` are cross-boundary secret sub-parts left for a follow-up. | +| F92 | INFO | `ImportSingleKeyRequest` holds WIF/passphrase as plain `String` and derives `Debug` | `src/ui/wallets/import_single_key.rs:43-57` | Use `Secret<String>`/`Zeroizing<String>` for `wif`/`passphrase`; avoid deriving `Debug` (or implement a redacting `Debug`), reusing `PasswordInput::take_secret()`. | **RESOLVED (partial)** `499947e5` — derived `Debug` dropped for a hand-written redacting impl (presence+length only); WIF held as `Zeroizing<String>`; a test asserts neither secret leaks. **DEFERRED:** wrapping the `passphrase` field itself in `Zeroizing`/`Secret`. | + +### Theme 7 — UI auto-fetch / loading-flag inconsistency + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F94 | LOW | Auto-fetch retry storm / stuck-loading from attempted-flag set only on success (`ContactRequests`, `PaymentHistory`, `ContactsList`) | `src/ui/dashpay/contact_requests.rs:234,290,843,873`; `src/ui/dashpay/contacts_list.rs`; `src/ui/dashpay/send_payment.rs` | Set the attempted flag at dispatch time (mirror `ProfileScreen`) and reset `loading=false` in `display_message`/`display_task_error` handlers so a failed load fires once and stops. | **RESOLVED** `33927899` (`ContactRequests`, `ContactsList`) + this tail (`PaymentHistory` in `send_payment.rs`: `has_searched` set at dispatch, `loading` settled in `display_message`). | + +### Theme 8 — bincode / sidecar forward-compat + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F25 | LOW | `bincode` `#[serde(default)]` gives a false forward-compat guarantee for evolving `DetKv` sidecar structs; misleading `SCHEMA_VERSION` guidance | `src/model/wallet/meta.rs:45-52`; `src/wallet_backend/kv.rs:46-51` | Correct both comments to state bincode standard config is positional/non-self-describing — adding/removing/reordering fields is format-breaking for stored blobs; document the required migration (envelope versioning or msgpack). | **RESOLVED** `858fc63c` — comments corrected to present-state truth (positional/non-self-describing; field changes are format-breaking). | + +### Theme 9 — Panic-on-fallible-input at the at-rest boundary + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F12 | LOW | Wrong-length nonce / cmx blob panics on the at-rest decode boundary instead of returning a typed error (one poisons a long-lived mutex) | `src/wallet_backend/single_key_entry.rs:174`; `src/wallet_backend/shielded.rs:319-335`; `src/wallet_backend/secret_access.rs:615` | Replace `copy_from_slice`/`Nonce::from_slice` with checked `try_into` conversions returning typed errors (`SingleKeyCryptoFailure`/`SecretDecryptFailed`/`MalformedVault`) on length mismatch. | **RESOLVED** `90cc22cc` — checked conversions return typed errors at the at-rest decode boundary; no more panics. | + +### Remaining LOW items (no strong theme cluster) + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F90 | LOW | Two divergent single-key import flows with contradictory passphrase/hex/error behavior | `src/ui/wallets/import_mnemonic_screen.rs:108-189`; `src/ui/wallets/import_single_key.rs` | Consolidate on one dialog/component and the typed backend path; remove the 'Private Key' tab from Import Wallet or have it delegate to `ImportSingleKeyDialog`. | **DEFERRED** — single-key import consolidation not implemented this round; both flows still present. Tracked for a follow-up that owns the import UI. | +| F3 | LOW | DashPay derivation seam: no pinned-vector tests for `account_reference` or contact-info encryption keys | `src/wallet_backend/dashpay.rs:127-128,154-209,419-450` | Add positive pinned-vector tests and a testnet!=mainnet assertion; have `first_associated_wallet_seed_hash` delegate to `identity.dashpay_wallet_seed_hash()`. | **RESOLVED** `74cbeb10` — contact-derivation seam vectors pinned. **DEFERRED:** the `first_associated_wallet_seed_hash` delegation (cross-boundary) is left for a follow-up. | +| F63 | LOW | Latent concurrency edges: non-atomic lazy backend init, settings-cache lost update, non-atomic blob+index writes | `src/context/mod.rs:712-746`; `src/context/settings_db.rs:97-136`; `src/context/identity_db.rs` | Serialize backend construction with a `tokio::Mutex`/`OnceCell`; hold the settings write lock across the cache-miss path; make blob+index inserts atomic or document ordering. | **RESOLVED** `24842219` (+ `18b9f65f`) — lazy backend build serialized and settings-cache fill made atomic under the write lock. | +| F10 | LOW | Uncompressed-WIF and cross-network single-key import edges (cold-boot address/label divergence) | `src/wallet_backend/single_key.rs:168-194,450-464,502-556` | Persist the compression flag in `ImportedKey`/`SingleKeyEntry` and reconstruct with original compression on rebuild, or normalize/reject uncompressed WIFs explicitly; add a round-trip test. | **RESOLVED** `499947e5` — uncompressed WIFs rejected at import via typed `UncompressedWifUnsupported`; round-trip test proves compressed imports rebuild to the same address. | +| F40 | LOW | Contact-request rejection/blocked markers are global, not scoped per owner identity — cross-identity status bleed | `src/backend_task/dashpay/contact_requests.rs:738-739`; `src/wallet_backend/dashpay.rs:674-679,834-839` | Scope `rejected`/`blocked` markers per owner: thread the acting identity id into `dashpay_mark_rejected/blocked` and the `kv_contains` read, using `DetScope::Identity(&owner)`. | **RESOLVED** `bed6f8a0` — blocked/rejected markers scoped per owner identity. | +| F47 | LOW | `sign_message` produces a non-recoverable signature with a hardcoded recovery header; ~50% external-verify failure | `src/backend_task/wallet/sign_message_with_key.rs:72-78`; `src/ui/identities/keys/key_info_screen.rs:755-765` | Use `sign_ecdsa_recoverable`, compute the real header `27+recId(+4 for compressed)` and serialize the 65-byte recoverable form; fix both the backend task and the `sign_ecdsa_local` UI helper together. | **RESOLVED** `253a4c6d` — recoverable signatures produced for signed messages. | +| F48 | LOW | Rewritten change-address (fee-from-wallet) funding branch has no automated coverage | `tests/backend-e2e/wallet_tasks.rs:126-139,365-373`; `src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs:78-97` | Add a focused unit test over the output-map/fee-strategy construction asserting the destination receives exactly `amount_credits` and the fee `ReduceOutput` index resolves to the change output. | **RESOLVED** `da7110f1` — fee-from-wallet platform funding output map covered by a unit test. | +| F55 | LOW | Structured Withdrawals query returns all statuses for `status=queued` despite the documented in-queue filter | `src/backend_task/platform_info.rs:843-866`; `src/mcp/tools/platform.rs:110-112` | Add a `WhereClause` filtering status `In [QUEUED, POOLED, BROADCASTED]` for `completed=false`, or correct the docs to state queued returns all. | **RESOLVED** `86e0d2ba` — in-queue withdrawals filtered to QUEUED/POOLED/BROADCASTED. | +| F57 | LOW | Batch shield build bridges async via `futures::executor::block_on` and serializes N passphrase prompts | `src/backend_task/shielded/bundle.rs:134-156`; `src/ui/wallets/shield_screen.rs:323-369` | Keep the build async and `await` on the tokio runtime instead of `block_on`; pre-resolve/cache the passphrase for the batch before entering the loop. | **RESOLVED** `5617ae38` — batch shield build kept async; passphrase prompted once per batch. | +| F58 | LOW | User-selected funding source address for shield-from-core is silently ignored | `src/backend_task/shielded/mod.rs:44-50`; `src/context/shielded.rs:242-252`; `src/ui/wallets/shield_screen.rs:956-970` | Remove the source-address selection UI for the shield-from-core path (and drop the dead `source_address` field), or honor the user's selection by passing it through to upstream coin selection. | **RESOLVED (behavioral)** — shield-from-asset-lock now ignores `source_address` by design and delegates coin selection to the upstream wallet's authoritative UTXO set (`shielded.rs:247-249`); UI passes `source_address: None`. **DEFERRED:** removing the now-vestigial `source_address` enum field (cross-file). | +| F69 | LOW | New k/v storage modules ship with no unit tests | `src/context/{contested_names_db.rs, contract_token_db.rs, platform_address_db.rs, settings_db.rs}` | Add in-memory `KvStore` tests (reuse `InMemoryKv` pattern from `identity_db.rs`) covering at minimum: contest winner/locked/no-winner branches, token registry round-trip, settings round-trip. | **RESOLVED** `90853953` (+ `24842219`) — k/v round-trip unit tests added for contested-names, token registry, and platform-address stores. | +| F107 | LOW | Developer 'Clear Platform Addresses' DB-clear failure is silent (no user feedback, leaves UI inconsistent) | `src/ui/network_chooser_screen.rs:878-880` | Surface a `MessageBanner` error (`with_details`) on the `Err` arm; perform in-memory map clears independently of the best-effort file unlink. | **RESOLVED** `6d9dbe9a` — clear failure surfaces a banner with details; in-memory clears decoupled from the file unlink. | +| F61 | LOW | `clear_spv_data()` is a no-op that reports success; SPV persistor never cleared | `src/context/wallet_lifecycle.rs:23-25`; `src/ui/network_chooser_screen.rs:1371-1377` | Implement the clear against the upstream persistor (delete/recreate `platform-wallet.sqlite` + shielded tree with the backend stopped), or return a typed `NotImplemented`/`Unavailable` error until that work lands. | **RESOLVED** `1ac57395` — cached chain data is actually cleared instead of faking success. | +| F88 | LOW | `dash_qt_path` autodetect no longer re-runs after settings are persisted (behavioral regression) | `src/model/settings.rs` | Re-apply the fallback in the deserialization path (`dash_qt_path: w.dash_qt_path.map(PathBuf::from).or_else(detect_dash_qt_path)`), or document the sticky behavior as deliberate. | **RESOLVED** `837fc6bd` — autodetect re-runs on deserialize when the stored path is unset; explicit paths preserved. | +| F93 | LOW | Address table 'Total Received' column now duplicates 'Balance' (mislabeled metric) | `src/ui/wallets/wallets_screen/address_table.rs:141-205` | Drop the 'Total Received' column for HD accounts until a real cumulative-receipts source exists, or relabel it clearly as the current balance. | **RESOLVED** `c33773e2` — duplicate 'Total Received' column (and its sort key) dropped for HD accounts. | +| F102 | LOW | Passphrase modal and JIT secret prompt share one overlay id and both react to Escape without consuming it | `src/ui/components/passphrase_modal.rs:70-75,162-166` | Consume the Escape key (`input_mut().consume_key`) when the modal claims it, or derive the overlay id from a per-modal salt. | **RESOLVED** `c33773e2` — overlay id salted per modal title; Escape consumed when the modal claims it. | + +### Remaining INFO items + +| ID | Sev | Title | Location | Recommendation | Status | +|----|-----|-------|----------|----------------|--------| +| F37b | INFO | Contested/unreliable funds-safety claims — resolved to no real defect | `src/backend_task/dashpay/payments.rs:290-293`; `src/model/wallet/mod.rs:667-690` | No merge action. Optionally adopt explicit discriminators as defensive hygiene. | **SKIPPED** — judged inert; no real defect. No merge action taken. | +| F1 | INFO | Wallet-skip and seed-length errors are swallowed to log-only; user not told a wallet was skipped/relabeled | `src/wallet_backend/hydration.rs:74-82`; `src/wallet_backend/single_key.rs:168-223` | Where a typed error promises user visibility, carry the skip/relabel reason out to a `MessageBanner` once an egui context is reachable, or downgrade the doc to log-only. | **RESOLVED** `858fc63c` — hydration-skip doc corrected to present-state (log-only) truth. | +| F2 | INFO | Doc/contract overstatements and convention nits in storage/error layers (no behavioral defect) | `src/wallet_backend/{mod.rs:457-461, shielded.rs:3-8/106-142, kv.rs:71-72, single_key.rs:7,636}` | Batch-fix doc/label inaccuracies to present-state truth; dedupe the passphrase validator into `model/`; narrow the refinery-error mapping to divergent-history only. | **RESOLVED** `858fc63c` — doc/label inaccuracies corrected to present-state. **DEFERRED:** dedupe the passphrase validator into `model/` and the refinery-error narrowing (cross-boundary). | +| F34 | INFO | Backend-authoritative input validation dropped from `SendWalletPayment` (empty-list/zero-amount) | `src/backend_task/core/mod.rs:257-301`; `src/backend_task/dashpay/payments.rs:239,271` | Re-add backend-authoritative validation via a shared `model/` validator: reject empty recipient list and `amount_duffs==0` with a typed `TaskError` before calling `send_payment`. | **RESOLVED** `938a8436` — backend-authoritative recipient validation restored. | +| F19 | INFO | Latent unguarded-arithmetic and defensive-coding hazards (unreachable today) | `src/wallet_backend/secret_access.rs:441`; `src/context_provider_spv.rs:90-126` | Optional hardening: use `Instant::now().checked_add(duration)` for `RememberPolicy::For`; capture a stored `Handle` and use `try_current()` returning a typed error in `ContextProviderSpv`. | **RESOLVED** `8c49cb27` — secret-expiry arithmetic hardened (`checked_add`) and runtime-handle lookup made fallible. | +| F30 | INFO | Migration xpub column read uses `unwrap_or_default()`, swallowing read errors to empty | `src/backend_task/migration/finish_unwire.rs:913,1096` | Use `row.get(N)?` for the xpub column for consistency with seed_hash handling, or probe via `pragma_table_info` if a legacy schema could legitimately lack it. | **RESOLVED** `52edbaf8` — xpub reads use `row.get(N)?` so genuine read errors surface instead of yielding empty. | +| F70 | INFO | User-identity load now attaches the full wallet map instead of only the owning wallet (verified inert) | `src/context/identity_db.rs` | No action required. Optionally restrict the map to the identity's stored `wallet_hash` for clarity. | **SKIPPED** — verified inert; no action required. | + +--- + +## Summary + +Counts reflect the fast-follow tail (`0196b129..HEAD`). Several INFO/LOW rows resolved a behavioral +or doc sub-part while deferring a cross-file/cross-boundary remainder — those are counted under +**Resolved** with the deferred remainder annotated inline in the row. + +| State | Count | +|-------|-------| +| Resolved blockers (synthesis findings) | 14 | +| Resolved blockers (QA-found gaps) | 3 | +| Open — release gate (F121/PROJ-005) | 1 | +| Fast-follow — resolved (LOW) | 24 | +| Fast-follow — resolved (INFO) | 11 | +| Fast-follow — skipped (judged inert: F37b, F70) | 2 | +| Fast-follow — deferred (F49 upstream-gated, F90 consolidation) | 2 | +| **Fast-follow subtotal** | **39** | +| **Total synthesis findings** | **54** | + +**Tail resolution rate**: 35 of 39 fast-follow findings landed (24 LOW + 11 INFO), with 2 deferred and +2 skipped. Cross-boundary secret sub-parts still open as follow-ups: F9a (derived DashPay encryption +keys + remembered-passphrase copy), F92b (passphrase field as `Zeroizing`/`Secret`), F3b +(`first_associated_wallet_seed_hash` delegation), F2b (passphrase-validator dedupe into `model/`, +refinery-error narrowing), and the F58 `source_address` enum-field removal. + +**Out of this finding set (pre-existing)**: SEC-006 (MEDIUM, Smythe) — plaintext `SingleKeyWallet` +held in a long-lived in-memory map at `wallets_screen/mod.rs:2189`. Tracked separately. diff --git a/docs/ai-design/2026-06-16-pr860-grumpy-review/report.json b/docs/ai-design/2026-06-16-pr860-grumpy-review/report.json new file mode 100644 index 000000000..4182a9453 --- /dev/null +++ b/docs/ai-design/2026-06-16-pr860-grumpy-review/report.json @@ -0,0 +1,613 @@ +{ + "schema_version": "3.1.0", + "metadata": { + "project": "dashpay/dash-evo-tool", + "date": "2026-06-16", + "branch": "docs/platform-wallet-migration-design", + "commit": "fed6bef887b80ab96bb66ddbf89c220868849dc4", + "scope": "PR #860 session diff 44caa892..fed6bef8 — shielded subsystem retirement + upstream coordinator (A-G), WalletUnlockPopup slim, PROJ-032 docs, placement/unlock cleanups" + }, + "executive_summary": { + "overall_assessment": "25 findings across security, consistency, Rust quality, and docs — ZERO critical, ZERO high. The funds-safety core of the shielded migration is sound.", + "summary_text": "The session retired DET's home-grown Orchard shielded subsystem (-4509 net lines) and routed all five shielded operations through upstream platform-wallet's coordinator, added a push balance snapshot, 5 det-cli MCP tools, slimmed WalletUnlockPopup, and reconciled the gap audit. The review found no false-success paths, an exhaustive error mapper, correct WalletId→SeedHash balance attribution, and per-network cleanup. Findings are dominated by secret-hygiene hardening on the new core_wallet_import tool (mnemonic Debug-leak vector + missing zeroization), a handful of Rust nits (a hardcoded bind guard, String-typed store errors), and documentation-accuracy drift (MCP.md/MCP_TOOL_DEVELOPMENT.md SPV-gate prose, a self-referential placement-policy inaccuracy, stale //! docs and phase-history comments).", + "verdict_text": "Ship-worthy. The funds-handling change is correct and well-tested; remaining findings are LOW/MEDIUM hardening and doc fixes that should land as a follow-up commit, not blockers.", + "verdict_action": "Apply the secret-hygiene fixes (redact+zeroize the mnemonic in core_wallet_import), the dead bind-guard + String-error fixes, and the doc-accuracy corrections as a follow-up cleanup commit." + }, + "summary_statistics": { + "total_findings": 25, + "severity_counts": { + "CRITICAL": 0, + "HIGH": 0, + "MEDIUM": 4, + "LOW": 21, + "INFO": 0 + }, + "severity_category_matrix": [ + { + "severity": "CRITICAL", + "security": 0, + "project": 0, + "code_quality": 0, + "call_tree": 0, + "documentation": 0, + "pr_comments": 0, + "pr_promises": 0, + "dependencies": 0, + "total": 0 + }, + { + "severity": "HIGH", + "security": 0, + "project": 0, + "code_quality": 0, + "call_tree": 0, + "documentation": 0, + "pr_comments": 0, + "pr_promises": 0, + "dependencies": 0, + "total": 0 + }, + { + "severity": "MEDIUM", + "security": 0, + "project": 0, + "code_quality": 4, + "call_tree": 0, + "documentation": 0, + "pr_comments": 0, + "pr_promises": 0, + "dependencies": 0, + "total": 4 + }, + { + "severity": "LOW", + "security": 5, + "project": 4, + "code_quality": 10, + "call_tree": 0, + "documentation": 2, + "pr_comments": 0, + "pr_promises": 0, + "dependencies": 0, + "total": 21 + }, + { + "severity": "INFO", + "security": 0, + "project": 0, + "code_quality": 0, + "call_tree": 0, + "documentation": 0, + "pr_comments": 0, + "pr_promises": 0, + "dependencies": 0, + "total": 0 + } + ], + "redundancy_ratio": "4%" + }, + "findings": [ + { + "title": "Security / Funds-Safety", + "category": "security", + "findings": [ + { + "risk": 0.3, + "impact": 0.55, + "scope": 0.15, + "title": "core_wallet_import: BIP-39 mnemonic carried in a Debug-derived MCP param struct (log-leak vector)", + "tags": [ + "A09:2021-Security-Logging-and-Monitoring-Failures", + "A02:2021-Cryptographic-Failures", + "CWE-532", + "CWE-312" + ], + "location": "src/mcp/tools/wallet.rs:24-33", + "description": "ImportWalletParams holds the BIP-39 recovery phrase as a plain `mnemonic: String` and derives `Debug` (`#[derive(Debug, Deserialize, schemars::JsonSchema, Default)]`). Any `tracing`/`log` call that formats the params with `{:?}` — or any future request-tracing middleware in the rmcp transport that dumps the raw JSON-RPC body at trace/debug level — would write the full seed phrase to logs and log sinks in cleartext. The current dispatch path (src/mcp/dispatch.rs, src/mcp/server.rs) does not log params today, so this is a latent leak rather than an active one, but the recovery phrase is the single highest-value secret in the app (full wallet compromise, irreversible fund theft) and a Debug-printable struct holding it is one stray log line away from disclosure. The HTTP transport (`mcp` feature) makes this remotely reachable if request logging is ever enabled. CWE-532 (Insertion of Sensitive Information into Log File), CWE-312.", + "recommendation": "Do not derive `Debug` on a struct containing the mnemonic, or implement `Debug` manually to redact the field (e.g. print `mnemonic: \"<redacted>\"`). Wrap the phrase in a `secrecy::Secret<String>` / `Zeroizing<String>` newtype with a redacting Debug so it cannot be accidentally formatted. Confirm the rmcp transport layer does not trace request bodies; if it can, add an explicit redaction allowlist for `core_wallet_import`.", + "overall_severity": 0.3333333333333333, + "severity": 2, + "id": "SEC-001" + }, + { + "risk": 0.25, + "impact": 0.5, + "scope": 0.2, + "title": "core_wallet_import: derived HD seed and mnemonic not zeroized after use", + "tags": [ + "A02:2021-Cryptographic-Failures", + "CWE-316", + "CWE-226" + ], + "location": "src/mcp/tools/wallet.rs:93-117", + "description": "In ImportWallet::invoke the mnemonic is parsed and `let seed = mnemonic.to_seed(\"\")` produces a raw 64-byte HD seed (`[u8; 64]`). The `seed`, the `bip39::Mnemonic`, and the original `param.mnemonic` String are all dropped at end of scope without zeroization, leaving plaintext seed material in freed heap/stack memory subject to later disclosure via core dumps, swap, or heap reuse. The GUI import path that this MCP tool parallels is expected to handle the same secret; if that path uses `Zeroizing`, this headless path is an inconsistent secret-hygiene regression for the exact same value. Because `register_wallet` takes `&seed` by reference, wrapping the local in `Zeroizing` is a drop-in change. CWE-316 (Cleartext Storage of Sensitive Information in Memory), CWE-226 (Sensitive Information Uncleared Before Release).", + "recommendation": "Bind the seed as `let seed = Zeroizing::new(mnemonic.to_seed(\"\"));` and pass `&*seed`. Wrap `param.mnemonic` handling so the String is zeroized (e.g. move into a `Zeroizing<String>` before parse). Verify the `bip39` dependency is built with its `zeroize` feature so `Mnemonic` scrubs on drop. Mirror whatever the GUI `Wallet::new_from_seed` import flow already does so both paths agree.", + "overall_severity": 0.31666666666666665, + "severity": 2, + "id": "SEC-002" + }, + { + "risk": 0.2, + "impact": 0.35, + "scope": 0.25, + "title": "Forgotten wallet's shielded balance snapshot is never evicted from AppContext::shielded_balances", + "tags": [ + "A04:2021-Insecure-Design", + "CWE-212", + "CWE-459" + ], + "location": "src/context/mod.rs:130; src/context/wallet_lifecycle.rs (remove_wallet)", + "description": "AppContext::shielded_balances is a `HashMap<WalletSeedHash, u64>` keyed by seed hash, written by the sync-completed push writer and by refresh_shielded_balance_snapshot. The wallet-removal path (remove_wallet / forget_wallet) wipes the seed-envelope vault, the wallet-meta sidecar, the in-memory wallets/id_map/snapshot registration, and detaches the upstream coordinator — but it does NOT remove the wallet's entry from shielded_balances (a grep for `shielded_balances` finds no reference in wallet_lifecycle.rs). The stale credit figure lingers for the process lifetime. Because the seed hash is deterministic from the seed, re-importing the same recovery phrase re-binds the SAME seed hash and the UI/MCP `shielded_balance_get` would surface the OLD shielded balance until the next completed sync overwrites it — showing a freshly-imported wallet a non-zero shielded balance it has not yet verified. This is a balance-display correctness / stale-attribution issue, not direct fund loss, but a user could act on a phantom figure (e.g. attempt to send shielded funds that are not actually spendable yet).", + "recommendation": "In the wallet-removal path, evict the entry: `self.shielded_balances.lock().ok().map(|mut m| m.remove(seed_hash))`. Add a regression test asserting the snapshot entry is gone after remove_wallet, mirroring the existing seed-envelope wipe test.", + "overall_severity": 0.26666666666666666, + "severity": 2, + "id": "SEC-003" + }, + { + "risk": 0.2, + "impact": 0.45, + "scope": 0.1, + "title": "map_shielded_op_error routes ambiguous ShieldedBroadcastUnconfirmed to a generic error (no 'do not re-submit' guidance)", + "tags": [ + "A04:2021-Insecure-Design", + "A08:2021-Software-and-Data-Integrity-Failures", + "CWE-393", + "CWE-754" + ], + "location": "src/wallet_backend/mod.rs:634-677", + "description": "map_shielded_op_error's exhaustive arm lumps `PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }` together with truly-clean failures into the generic `TaskError::WalletBackend` wrapper, which renders a generic wallet-error message. Upstream documents this variant as an AMBIGUOUS post-broadcast state: 'broadcast was ACCEPTED by the relay but the SDK could not confirm its execution result ... the caller must NOT treat it as unregistered or re-submit' (rs-platform-wallet/src/error.rs:205-219; FFI result code 17). It is the identity-create sibling of ShieldedSpendUnconfirmed. In the CURRENT code none of DET's five fund-moving ops (shield/transfer/unshield/withdraw — all of which correctly emit ShieldedSpendUnconfirmed and are routed to dedicated *ConfirmationUnknown variants) construct ShieldedBroadcastUnconfirmed, so this is NOT a live double-spend path today — it is a latent defense-in-depth gap. If a future op (e.g. a shielded identity registration) is wired through this same mapper, an ambiguous broadcast would surface as a generic error and the user could re-submit, risking a double-execution. The function's own doc-comment promises the exhaustive match 'forces a review here'; the review chose the unsafe-by-omission routing for this one ambiguous variant.", + "recommendation": "Give ShieldedBroadcastUnconfirmed its own TaskError variant carrying the same 'broadcast accepted, confirmation unknown — wait and refresh, do not re-submit' wording as the *ConfirmationUnknown family (it already may-have-executed semantics). At minimum add a comment explaining why it is deliberately bucketed as generic and assert it is unreachable from DET's current ops, so a future caller change trips a review.", + "overall_severity": 0.25, + "severity": 2, + "id": "SEC-004" + }, + { + "risk": 0.25, + "impact": 0.3, + "scope": 0.2, + "title": "'Delete all local data' completes successfully while the shielded coordinator wipe runs fire-and-forget and only logs on failure", + "tags": [ + "A04:2021-Insecure-Design", + "A09:2021-Security-Logging-and-Monitoring-Failures", + "CWE-212", + "CWE-459" + ], + "location": "src/context/wallet_lifecycle.rs:18-37 (clear_network_database)", + "description": "clear_network_database synchronously unlinks the two legacy shielded files and returns Ok, but the authoritative Orchard state now lives in the upstream coordinator store (platform-wallet-shielded.sqlite), which is reset via `backend.clear_shielded()` dispatched as a detached best-effort subtask (`subtasks.spawn_sync(\"shielded_coordinator_clear\", ...)`). On failure that subtask only emits `tracing::warn!` — the caller has already returned success. Consequences: (1) the user is told 'all local data deleted' while plaintext Orchard notes, nullifiers and viewing-key-derived material may remain in the coordinator store (incomplete secret/PII wipe, CWE-212/CWE-459); (2) the wipe is not awaited, so a subsequent re-create/re-open of the same network can race the still-pending clear. This is primarily a privacy / data-remanence defect for a shielded (privacy-focused) feature, where 'I deleted my data' must be trustworthy.", + "recommendation": "Await the coordinator clear within clear_network_database (it is already async) and propagate its failure to the caller so the destructive action reports partial failure instead of false success. If a detached design is required, surface a persistent warning/banner to the user that shielded data removal is still pending or failed, and ensure the next backend bring-up re-attempts the clear before re-binding.", + "overall_severity": 0.25, + "severity": 2, + "id": "SEC-005" + } + ], + "positives": "Funds-safety core sound: no false-success, exhaustive map_shielded_op_error, correct WalletId→SeedHash, per-network cleanup. No CRITICAL/HIGH." + }, + { + "title": "Project Consistency & Conventions", + "category": "project", + "findings": [ + { + "risk": 0.3, + "impact": 0.4, + "scope": 0.5, + "title": "gaps.md executive-summary table never updated after PROJ-032 close — contradicts its own candy tally and the JSON", + "tags": [ + "consistency", + "tally", + "PROJ-032", + "PROJ-034" + ], + "location": "docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md:77-92", + "description": "The PROJ-032 CLOSE / PROJ-034 KEEP reconciliation was applied to the bottom-of-file candy tally and to `gaps-report.json`, but the **executive-summary table at the top of `gaps.md` was left stale**, so the document now disagrees with itself.\n\n- Executive table (lines 77-84): `MEDIUM Open=4`, `MEDIUM Resolved=11`, `Total Open=10`, `Total Resolved=33`.\n- Candy tally (lines 1024-1025, updated this PR): `34 RESOLVED ... + 9 OPEN (1 HIGH + 3 MEDIUM + 5 LOW)`.\n- `gaps-report.json` (updated this PR): `open_findings=9`, `MEDIUM open=3`.\n\nMoving PROJ-032 from open→resolved must take the table to Open=9 / Resolved=34 / MEDIUM-open=3 / MEDIUM-resolved=12. It still reads 10/33/4/11.\n\nSecond, the `Open by category ... Sum = 10 open (... net count unchanged ...)` line (86-92) is arithmetically wrong. PROJ-034 was **already** an open finding (it is one of the original 4 open MEDIUMs); listing it in the category breakdown while removing PROJ-032 keeps the *enumeration* at 10 entries but does **not** keep the *open count* at 10 — the real count drops to 9. \"net count unchanged\" is false.", + "recommendation": "Update the executive-summary table to Open=9 / Resolved=34, MEDIUM 3 open / 12 resolved, and correct the `Sum = 10 open` line to `Sum = 9 open` (drop the \"net count unchanged\" justification). Make the top table, the bottom candy tally, and `gaps-report.json` agree on 9 open / 34 resolved before this doc is used as the merge-gate reference.", + "overall_severity": 0.39999999999999997, + "severity": 2, + "id": "PROJ-001" + }, + { + "risk": 0.15, + "impact": 0.2, + "scope": 0.5, + "title": "Tombstone + phase-plan narration in code comments (describe history, not present state)", + "tags": [ + "convention", + "tombstone", + "present-state" + ], + "location": "src/database/initialization.rs:1347-1351", + "description": "The PR adds ~35 new `Phase A`–`Phase G` references inside code comments plus several outright tombstones that explain *removed* code — both against the Cross-Cutting Rules \"No tombstone comments\" and \"Describe present state, not history\" (history belongs in commit messages / the PR description, the reader has `git blame`).\n\nRepresentative offenders:\n- `src/database/initialization.rs:1347-1351` — \"DET's shielded subsystem was retired (Phase D); the old shielded table helpers and the `database::shielded` module were deleted.\" Pure tombstone — it documents code that no longer exists.\n- `src/backend_task/error.rs:3860` — \"Ported from the deleted `backend_task::shielded::bundle` tests (Phase D)\".\n- `src/mcp/server.rs:156` — \"Shielded read/control tools (Phase G — agent self-verification)\".\n- `src/mcp/tools/shielded.rs` — \"(Phase E writer)\"; `src/ui/wallets/send_screen.rs:2076` — `// TODO(Phase F): ...`.\n\n\"Phase D/E/F/G\" are this PR's internal phasing labels; once the PR is merged they reference nothing a future reader can resolve. The *present-state* halves of these comments (\"these tables are intentionally not created; the v37 migration drops any legacy copies\") are fine and should stay — it is the historical narration that should go.", + "recommendation": "Strip the \"was retired / were deleted / Ported from the deleted X\" tombstones and the bare `Phase X` process labels; keep only the present-tense rationale (what the code does now and why). Move the migration-phase narrative to the commit messages / PR description where it belongs.", + "overall_severity": 0.2833333333333333, + "severity": 2, + "id": "PROJ-002" + }, + { + "risk": 0.25, + "impact": 0.2, + "scope": 0.3, + "title": "New TODO(PROJ-034) ephemeral review ID re-committed into source", + "tags": [ + "convention", + "ephemeral-id" + ], + "location": "src/backend_task/migration/finish_unwire.rs:56", + "description": "This PR edited the migration TODO block — it correctly removed `TODO(PROJ-032)` but **rewrote and re-committed** `TODO(PROJ-034): App settings, top-up history, and scheduled DPNS votes all reset/empty on upgrade`. `PROJ-NNN` is exactly the class of transient review-finding ID the coding-best-practices Cross-Cutting Rules forbid in committed code (and that `scripts/lint_ephemeral_ids.py` flags): the consolidator reassigns these IDs every run, so the reference is dead the moment this branch merges.\n\nContext (out of session scope, not double-counted): the tree is already saturated with pre-existing `PROJ-010` / `PROJ-040` / `PROJ-007` references across `wallet_backend/`, `context/wallet_lifecycle.rs`, and `tests/backend-e2e/` — ~45 of them. They predate `44caa892`; PROJ-034 is the one this session actively touched.", + "recommendation": "Drop the `PROJ-034` tag. Either a plain `// TODO:` describing the missing settings/top-up/scheduled-vote importer, or a durable handle (a GitHub issue ref like `#NNNN`) per the allowed-ID list. While this block was open, it was the moment to delete the ephemeral tag — not refresh it.", + "overall_severity": 0.25, + "severity": 2, + "id": "PROJ-003" + }, + { + "risk": 0.15, + "impact": 0.2, + "scope": 0.3, + "title": "Dangling policy-number references (P8, P14) in comments — defined nowhere", + "tags": [ + "consistency", + "dangling-reference" + ], + "location": "src/ui/state/mod.rs:5", + "description": "Two newly-added comments cite numbered policies that exist in no committed artifact:\n- `src/ui/state/mod.rs:5` — \"the module placement policy (P14) keeps these out of `ui/components/`\".\n- `src/model/single_key.rs:16` — \"Used by the import dialog for instant feedback (P8)\".\n\nA grep across `CLAUDE.md`, `docs/`, and `src/` finds **no definition** of `P8` or `P14`. The actual module-placement policy this PR added to `CLAUDE.md` (\"DET Module Placement Policy\") is **not numbered P14**, so the reference resolves to nothing. A reader chasing \"P14\" has no destination.", + "recommendation": "Either point the comments at the real anchor (`CLAUDE.md` § \"DET Module Placement Policy\" / \"Validation placement\") or drop the parenthetical numbers. If the P-numbering is meaningful, define it once in a committed doc and reference that.", + "overall_severity": 0.21666666666666667, + "severity": 2, + "id": "PROJ-004" + } + ], + "positives": "Placement policy applied; conventional commits; TaskError typing respected; PROJ-032/034 reconciliation substantively accurate." + }, + { + "title": "Documentation", + "category": "documentation", + "findings": [ + { + "risk": 0.3, + "impact": 0.3, + "scope": 0.5, + "title": "UI components catalog lists a deleted component and a relocated one", + "tags": [ + "doc-drift", + "catalog" + ], + "location": "src/ui/components/README.md:67-68", + "description": "`CLAUDE.md` mandates consulting `src/ui/components/README.md` before building any UI element — it is the authoritative component catalog. This PR invalidated two of its rows without updating it:\n- `ScreenWithWalletUnlock | wallet_unlock.rs | Trait for screens needing wallet unlock` (line 67) — the file `src/ui/components/wallet_unlock.rs` and the trait were **deleted** this PR (commit `fed6bef8`, migrate to `WalletUnlockPopup`). The catalog now points at a non-existent module.\n- `TrackedAssetLockCache | tracked_asset_lock_cache.rs | ...` (line 68) — **moved** this PR to `src/ui/state/tracked_asset_lock_cache.rs`. Per the new placement policy it is explicitly *not* a component (renders no egui), yet it is still listed in the components catalog under \"Utility\" with a now-wrong path.", + "recommendation": "Remove the `ScreenWithWalletUnlock` row, and remove `TrackedAssetLockCache` from the components catalog (it belongs to `ui/state/` now — note it there, e.g. a short `src/ui/state/` README or a pointer line, rather than in the components table).", + "overall_severity": 0.3666666666666667, + "severity": 2, + "id": "DOC-001" + }, + { + "risk": 0.15, + "impact": 0.2, + "scope": 0.3, + "title": "user-stories.md untouched despite five new developer-facing MCP/CLI tools", + "tags": [ + "user-stories", + "judgment-call" + ], + "location": "docs/user-stories.md", + "description": "This PR adds five new dev/agent-facing tools — `core_wallet_import`, `shielded_init`, `shielded_sync`, `shielded_balance_get`, `shielded_address_get` — exposed over both MCP and `det-cli`, and a documented headless shielded self-verification loop (`docs/CLI.md`). `docs/user-stories.md` was not touched.\n\n**My call:** defensibly skippable. The bulk of the PR is a refactor (re-seating shielded state on the upstream coordinator) with no change to end-user shielded flows, and the new tools are testing/automation affordances rather than a product feature — which the repo rule (\"Skip user-story updates for non-functional changes ... refactoring\") permits. That said, the new headless-verification capability is a genuine Platform-Developer-persona affordance and would be a reasonable single `[Implemented]` story.", + "recommendation": "Optional: add one Platform-Developer story (e.g. \"As a Platform Developer I can drive and verify a full shielded lifecycle headlessly via det-cli\") covering the new self-verification tools. Not a blocker for a refactor-dominant PR.", + "overall_severity": 0.21666666666666667, + "severity": 2, + "id": "DOC-002" + } + ], + "positives": "New tools in CLI.md/MCP.md; policy in CLAUDE.md; gap audit reconciled. Gaps are accuracy/staleness, not omissions." + }, + { + "title": "Rust Code Quality", + "category": "code_quality", + "findings": [ + { + "risk": 0.45, + "impact": 0.5, + "scope": 0.6, + "title": "MCP.md network-required prose contradicts 4 new shielded tools", + "tags": [], + "location": "docs/MCP.md:138", + "description": "The 'Network verification' section states: 'For destructive tools (those that spend funds or modify state — all identity and shielded tools), `network` is required.' The four new shielded tools (shielded_init, shielded_sync, shielded_balance_get, shielded_address_get) all use WalletIdParams with `#[serde(default)] pub network: Option<String>` — i.e., optional. The tool table on lines 90-93 correctly marks them `network?`, but the prose claim on line 138 flatly contradicts that. shielded_init is annotated `destructive: false`, shielded_sync `destructive: false`, the balance/address reads are `read_only: true` — none qualify as 'destructive' by the MCP ToolAnnotations taxonomy. A developer reading the prose will try to require `network` in client tooling and wonder why it was optional in the schema.", + "recommendation": "Narrow the prose on line 138 to name the specific destructive shielded tools or replace 'all shielded tools' with 'shielded fund-moving tools (shielded_shield_from_core, shielded_shield_from_platform, shielded_transfer, shielded_unshield, shielded_withdraw)'. The four new control/read tools can be grouped with the read-only tools that take optional network.", + "overall_severity": 0.5166666666666667, + "severity": 3, + "id": "CODE-001" + }, + { + "risk": 0.5, + "impact": 0.55, + "scope": 0.5, + "title": "shielded_address_get misclassified as a no-network-call SPV-free tool", + "tags": [], + "location": "docs/MCP.md:102", + "description": "Line 102 groups shielded_address_get with shielded_balance_get under 'shielded snapshot reads' that skip the SPV gate. This is inaccurate. shielded_balance_get is truly SPV-free: it reads from AppContext atomic fields (shielded_balance_credits / shielded_balance_duffs) with no backend call. shielded_address_get calls ctx.wallet_backend().map_err(McpToolError::TaskFailed)? and then backend.shielded_default_address(). In standalone MCP mode (det-cli serve), wallet_backend() returns Err(TaskError::WalletBackendUnavailable) if ensure_spv_synced has never run — it is the only MCP chokepoint that calls ensure_wallet_backend_and_start_spv. A cold call to shielded_address_get without a prior SPV-gated tool (e.g. shielded_init) returns a TaskFailed error in standalone mode. The tool's own description says 'Run shielded_init first if the wallet is not yet bound', which implicitly acknowledges the dependency, but the SPV gate section's categorical grouping will mislead users who rely on the prose for sequencing.", + "recommendation": "Separate shielded_address_get from shielded_balance_get in the SPV gate prose. shielded_balance_get is a pure in-memory read and can skip SPV. shielded_address_get requires the wallet backend to be wired and should note: 'No explicit SPV wait, but requires the wallet backend to be initialized (run shielded_init or any SPV-gated tool first in standalone mode).' Alternatively, add ensure_spv_synced to shielded_address_get's invoke() to make the sequencing requirement explicit and self-enforcing.", + "overall_severity": 0.5166666666666667, + "severity": 3, + "id": "CODE-002" + }, + { + "risk": 0.4, + "impact": 0.45, + "scope": 0.5, + "title": "MCP_TOOL_DEVELOPMENT.md SPV gate rule stale: new SPV-skip tools not covered", + "tags": [], + "location": "docs/MCP_TOOL_DEVELOPMENT.md:100", + "description": "The SPV gate rule says: 'Skip [ensure_spv_synced] only for metadata tools that make no network calls (core_wallets_list, network_info, tool_describe).' This PR adds three more tools that skip ensure_spv_synced: core_wallet_import (imports locally, no network call), shielded_balance_get (reads AppContext atomics, no network call), and shielded_address_get (calls wallet_backend() but no ensure_spv_synced). The updated MCP.md (line 102) already documents the broader exemption list, but MCP_TOOL_DEVELOPMENT.md — the canonical checklist for new tool authors — was not updated. A contributor following the checklist will add ensure_spv_synced to every new wallet-facing tool, including future read-only tools that legitimately should skip it.", + "recommendation": "Update MCP_TOOL_DEVELOPMENT.md line 100 to match MCP.md's broader SPV-skip criteria: 'Skip for tools that make no network calls: metadata tools (core_wallets_list, network_info, tool_describe), local wallet import (core_wallet_import), and pure snapshot reads that read only AppContext atomics (shielded_balance_get). For tools that access wallet_backend() without network calls, document the backend-wired prerequisite instead of gating on SPV.'", + "overall_severity": 0.45, + "severity": 3, + "id": "CODE-003" + }, + { + "risk": 0.32, + "impact": 0.5, + "scope": 0.5, + "title": "Typed passphrase parked in one global egui cache slot keyed only by window title", + "tags": [ + "funds-safety", + "secret-handling", + "correctness", + "ux" + ], + "location": "src/ui/components/passphrase_modal.rs:124", + "description": "passphrase_modal() moved all per-modal state — including the PasswordInput holding the typed (mlock-protected) passphrase — out of the caller and into egui's global data cache, keyed solely by window_title: `egui::Id::new(\"passphrase_modal_state\").with(config.window_title)`. The slot is removed only on the Submit/Cancel resolution paths. Two consequences:\n\n1. Cross-instance state bleed. All 34 screens that own a WalletUnlockPopup pass the constant title \"Unlock Wallet\", so every one of them shares the SAME cache slot. WalletUnlockPopup::open() and close() no longer touch the field (the old code called password_input.clear() and reset focus_requested on open). If a modal stops being rendered while Pending without going through Submit/Cancel — e.g. the owning screen's `if self.wallet_unlock_popup.is_open() && let Some(wallet) = &self.selected_wallet` guard drops `selected_wallet` to None from an async refresh, or a task result swaps the visible screen — the entry is never cleared. Re-opening any screen's unlock dialog then renders pre-filled with the passphrase typed in the previous context, against a possibly different wallet.\n\n2. Secret lifetime. The abandoned-Pending entry is never zeroized until it is overwritten by the same key or the app exits (mlock keeps it off swap, but it lingers past the point the user believes the dialog was dismissed). The old design had the caller own the PasswordInput and zeroize it deterministically on open/close.\n\nThis is a regression of the open() contract: its doc says state is \"Reset on open\", but the password field is now outside the struct and is not reset.", + "recommendation": "Key the cache entry by something unique to the logical prompt (e.g. fold the wallet seed_hash or a per-instance salt into the Id) instead of the human-readable title, so distinct popup instances cannot collide. Additionally restore the open()/close() reset guarantee: have WalletUnlockPopup/ActivePrompt clear the cached PassphraseModalState (a small `passphrase_modal_reset(ctx, id)` helper) on open and on programmatic close, so an abandoned-Pending secret is zeroized promptly and a reopened dialog always starts empty.", + "code_snippets": [ + { + "language": "text", + "caption": "", + "content": "let state_id = egui::Id::new(\"passphrase_modal_state\").with(config.window_title);\nlet mut state: PassphraseModalState = ctx\n .data(|d| d.get_temp::<PassphraseModalState>(state_id))\n .unwrap_or_else(|| PassphraseModalState {\n password_input: PasswordInput::new().with_hint_text(config.input_placeholder),\n focus_requested: false,\n });" + }, + { + "language": "text", + "caption": "", + "content": "pub fn open(&mut self) {\n self.is_open = true;\n self.error = None;\n self.remember = false;\n}" + } + ], + "overall_severity": 0.44, + "severity": 3, + "id": "CODE-004" + }, + { + "risk": 0.3, + "impact": 0.35, + "scope": 0.5, + "title": "Module Placement Policy claims 'one struct per file' for src/mcp/tools/ — wrong", + "tags": [], + "location": "CLAUDE.md:84", + "description": "The newly added policy bullet reads: '`src/mcp/tools/` — MCP tool logic (one struct per file); never in `src/bin/det_cli/`.' The actual pattern, consistent across the codebase and stated explicitly in docs/MCP_TOOL_DEVELOPMENT.md ('Add a new file or extend an existing file following the domain grouping (wallet.rs, platform.rs, network.rs, etc.)'), is one file per domain with multiple tool structs per file. src/mcp/tools/wallet.rs contains 6 tool structs (GenerateReceiveAddress, WalletBalancesQuery, SendCoreFunds, FetchPlatformBalances, ImportWallet, ListWalletsTool); src/mcp/tools/shielded.rs now contains 9. The parenthetical 'one struct per file' was likely intended to echo the MCP_TOOL_DEVELOPMENT.md rule 3 ('One tool struct = one BackendTask dispatch') but conflates per-dispatch with per-file. A contributor following CLAUDE.md literally would create nine new files for the nine shielded tools.", + "recommendation": "Replace '(one struct per file)' with '(one file per domain — e.g. wallet.rs, shielded.rs, identity.rs)' to match the actual pattern and MCP_TOOL_DEVELOPMENT.md. Optionally reference the 'one tool struct = one BackendTask dispatch' rule separately if the per-dispatch constraint is worth preserving.", + "overall_severity": 0.3833333333333333, + "severity": 2, + "id": "CODE-005" + }, + { + "risk": 0.25, + "impact": 0.35, + "scope": 0.4, + "title": "Hardcoded needs_shielded_bind = true turns the JIT scope-entry guard into dead code", + "tags": [ + "dead-code", + "correctness", + "ux" + ], + "location": "src/context/wallet_lifecycle.rs:683", + "description": "bootstrap_wallet_addresses_jit computes needs_bootstrap and needs_registration, then sets `let needs_shielded_bind = true;` and guards with `if !needs_bootstrap && !needs_registration && !needs_shielded_bind { return; }`. Because the last operand is a literal `true`, the condition is always false and the early-return is unreachable; needs_bootstrap and needs_registration are still evaluated (one of them calls backend.is_wallet_registered) but their results no longer affect control flow. The whole point of that guard was to avoid entering the JIT seed scope when there is nothing to do, keeping the steady-state path prompt-free. The accompanying comment claims \"the overhead of entering the scope is negligible,\" which is true for unprotected/session-cached wallets but NOT for an open-but-not-session-cached protected wallet (unlocked for display with 'keep unlocked' off): for those, with_secret_session resolves cache miss → unprotected fast-path → interactive prompt. init_missing_shielded_wallets (fired once when the protocol version first crosses the shielded threshold) iterates exactly those open wallets, so the always-enter behaviour can surface a surprise passphrase prompt where the early-return previously suppressed it.", + "recommendation": "Make the guard honest. Either (a) derive needs_shielded_bind from a real, cheap, non-prompting signal (a sync 'is this wallet already shielded-bound / is the seed session-cached' check) so already-bound or non-cached wallets keep the prompt-free early-return; or (b) if the unconditional bind is deliberate, delete the vestigial needs_shielded_bind / early-return and the now-unused needs_* computations and state plainly that the scope is always entered for open wallets — and correct the 'negligible overhead' comment to acknowledge the protected-not-cached prompt case.", + "code_snippets": [ + { + "language": "text", + "caption": "", + "content": "let needs_bootstrap = wallet\n .read()\n .map(|g| Self::wallet_needs_bootstrap(&g))\n .unwrap_or(false);\nlet needs_registration = !backend.is_wallet_registered(&seed_hash);\nlet needs_shielded_bind = true;\nif !needs_bootstrap && !needs_registration && !needs_shielded_bind {\n return;\n}" + } + ], + "overall_severity": 0.3333333333333333, + "severity": 2, + "id": "CODE-006" + }, + { + "risk": 0.25, + "impact": 0.3, + "scope": 0.4, + "title": "MCP_TOOL_DEVELOPMENT.md Don'ts table bans direct AppContext calls but ShieldedInit/ShieldedSync do exactly that", + "tags": [], + "location": "docs/MCP_TOOL_DEVELOPMENT.md:121", + "description": "The Don'ts table states: 'Call AppContext methods directly instead of dispatching a BackendTask | Breaks the task system contract; backend errors won't be handled uniformly.' ShieldedInit::invoke calls ctx.wallet_backend()?.ensure_shielded_bound_jit() and backend.warm_shielded_prover() without dispatching any BackendTask. ShieldedSync::invoke calls backend.sync_shielded_now(true) directly. Both tools are intentional control-plane operations (init and sync are not fund-moving state transitions that map cleanly to BackendTask variants), and they work correctly. But the documented prohibition is now violated by two shipped tools, which creates a contradictory message for the next tool author: the rulebook says 'never do this' but the reference implementation does it. McpToolError::TaskFailed wraps the wallet_backend error in both tools, so error handling is still uniform — the stated rationale for the rule does not apply to these cases.", + "recommendation": "Refine the Don't to: 'Dispatch business logic through AppContext/wallet_backend directly when a BackendTask variant exists for the operation — prefer BackendTask for fund-moving ops. For control-plane operations with no BackendTask analog (e.g. shielded_init, shielded_sync), direct wallet_backend() calls are acceptable; map errors through McpToolError::TaskFailed.' This narrows the prohibition to the actual concern (bypassing BackendTask for ops that already have a variant) without outlawing the legitimate control-plane pattern.", + "overall_severity": 0.31666666666666665, + "severity": 2, + "id": "CODE-007" + }, + { + "risk": 0.2, + "impact": 0.2, + "scope": 0.4, + "title": "CLAUDE.md smoke-test section over-excludes shielded_balance_get and misses core_wallet_import", + "tags": [], + "location": "CLAUDE.md:170", + "description": "The smoke-test section states 'every shielded-* tool' is not a smoke test (waits on the SPV gate). This is incorrect for two of the four new shielded tools: shielded_balance_get makes no network calls and no wallet_backend() call — it reads AppContext atomics directly and returns immediately without SPV; shielded_address_get also skips ensure_spv_synced (though it does need the backend wired). Additionally, core_wallet_import is a new SPV-free tool added in this PR that exercises the full import → DB path without a live network, making it a valid smoke test candidate alongside core_wallets_list. The smoke-test list has not been updated to reflect these additions.", + "recommendation": "Update the smoke-test 'Not smoke tests' note to carve out shielded_balance_get (add to the runnable smoke-test examples, noting it returns zeros when no wallet has synced) and note core_wallet_import as a candidate alongside core_wallets_list. Adjust the 'every shielded-* tool' blanket to 'every fund-moving shielded-* tool'.", + "overall_severity": 0.26666666666666666, + "severity": 2, + "id": "CODE-008" + }, + { + "risk": 0.15, + "impact": 0.28, + "scope": 0.2, + "title": "shielded_activity/shielded_notes flatten the upstream store error to a String", + "tags": [ + "error-handling" + ], + "location": "src/wallet_backend/mod.rs:518", + "description": "Both read helpers wrap the coordinator store error via `PlatformWalletError::ShieldedStoreError(e.to_string())` before boxing into TaskError::WalletBackend. The `.to_string()` collapses the typed store error into text, discarding the source chain and structural matchability — the very anti-pattern the project's error rules call out. Both methods are currently #[allow(dead_code)] (Phase-F read path not wired yet), so the blast radius is nil today, but the pattern will ship the moment the activity/notes UI lands.", + "recommendation": "When these are wired, route the store error through a dedicated typed TaskError variant carrying the concrete store-error type as a #[source] (or extend the upstream conversion) rather than stringifying. If upstream only exposes ShieldedStoreError(String), add a DET-side variant that preserves the original error as #[source] so Debug keeps the chain.", + "code_snippets": [ + { + "language": "text", + "caption": "", + "content": "coordinator\n .store()\n .read()\n .await\n .get_activity(subwallet, offset, limit)\n .map_err(|e| TaskError::WalletBackend {\n source: Box::new(\n platform_wallet::error::PlatformWalletError::ShieldedStoreError(e.to_string()),\n ),\n })" + } + ], + "overall_severity": 0.21, + "severity": 2, + "id": "CODE-009" + }, + { + "risk": 0.15, + "impact": 0.15, + "scope": 0.3, + "title": "src/mcp/tools/shielded.rs //! module doc lists only 4 of 9 tools", + "tags": [], + "location": "src/mcp/tools/shielded.rs:1", + "description": "The module-level doc reads: '//! Shielded-related MCP tools: shield, transfer, unshield, withdraw.' This enumeration reflects the four tools that existed before Phase G (ShieldedShieldFromCore, ShieldedShieldFromPlatform, ShieldedTransferTool, ShieldedWithdrawTool) but omits the four new additions: ShieldedInit, ShieldedSync, ShieldedBalanceGet, ShieldedAddressGet — and also ShieldedUnshield. The doc is both outdated (shield/transfer/unshield/withdraw were present before this PR) and incomplete (init/sync/balance_get/address_get were added in this PR). A developer opening the file to orient themselves gets a summary that covers less than half the module.", + "recommendation": "Update the //! line to: '//! Shielded-pool MCP tools: shielding, transfers, unshielding, withdrawals, and lifecycle ops (init, sync, balance and address reads).' Or expand to two lines if the one-liner becomes unwieldy.", + "overall_severity": 0.19999999999999998, + "severity": 2, + "id": "CODE-010" + }, + { + "risk": 0.12, + "impact": 0.2, + "scope": 0.25, + "title": "refresh_shielded_balance_snapshot doc frames itself as a stopgap for Phase E, which ships in the same PR", + "tags": [ + "documentation" + ], + "location": "src/backend_task/shielded/mod.rs:236", + "description": "The doc comment reads \"This is the read side's producer until the Phase-E on_shielded_sync_completed push writer lands.\" Phase E (the push writer) landed in this very PR (commit fa0e46de), so the 'until X lands' framing describes a future that is already the present. The method is in fact a permanent, useful immediate-refresh after a confirmed spend (so the UI doesn't wait for the 60s loop) — not a temporary substitute. Per the project's 'describe present state, not history' rule, the historical/aspirational framing will mislead the next reader into thinking it is removable.", + "recommendation": "Reword to state the present role: an immediate post-operation refresh of the frame-safe snapshot that complements (not substitutes for) the Phase-E sync-completed push writer. Drop the 'until ... lands' phrasing.", + "code_snippets": [ + { + "language": "text", + "caption": "", + "content": "/// This is the read side's producer until the Phase-E\n/// `on_shielded_sync_completed` push writer lands: it keeps the UI balance\n/// current immediately after a spend without waiting for the 60-second sync\n/// loop. Best-effort — a failed read leaves the previous snapshot in place." + } + ], + "overall_severity": 0.19000000000000003, + "severity": 2, + "id": "CODE-011" + }, + { + "risk": 0.15, + "impact": 0.15, + "scope": 0.25, + "title": "Phase-history narration in ShieldedTask enum and run_shielded_task rustdoc", + "tags": [], + "location": "src/backend_task/shielded/mod.rs:14,61", + "description": "Two doc-comments contain phase-progress history rather than present-state description. Line 14: 'Phase D retired DET's home-grown Orchard subsystem: sync, nullifier scanning, key derivation and the commitment tree are all owned by the upstream coordinator now, so only the five fund-moving operations remain.' Line 61: 'shielded ops added in Phase B'. The coding-best-practices skill mandates present-state-not-history and no tombstone comments. The line-14 passage describes what the old system had and why it was removed — useful context internally but irrelevant once the system has been running for months. 'Phase B' / 'Phase D' labels have no meaning outside the PR development history. An inline comment in src/mcp/tools/shielded.rs:657 ('Phase E writer') has the same issue.", + "recommendation": "Rewrite line 14-16 to describe what IS, not what was: e.g. 'Sync, nullifier scanning, key derivation, and the commitment tree are owned by the upstream coordinator; only the five fund-moving operations are dispatched through this task.' Remove 'Phase D retired DET's home-grown...' and 'added in Phase B'. Replace 'Phase E writer' inline comment (shielded.rs:657) with 'sync_now fires on_shielded_sync_completed synchronously, so the push snapshot is fresh by the time this returns.'", + "overall_severity": 0.18333333333333335, + "severity": 2, + "id": "CODE-012" + }, + { + "risk": 0.1, + "impact": 0.15, + "scope": 0.2, + "title": "Unnecessary #[allow(clippy::too_many_arguments)] on shield_from_asset_lock", + "tags": [ + "lint-hygiene", + "dead-code" + ], + "location": "src/wallet_backend/mod.rs:296", + "description": "shield_from_asset_lock takes 5 non-self parameters (seed_hash, funding, recipient, dummy_outputs, settings). clippy::too_many_arguments fires at 8 (does not count the receiver), so the lint can never trigger here and the #[allow] is dead. It silently sits as a license to grow the signature, defeating the lint's purpose if a future arg pushes it over the threshold without re-review.", + "recommendation": "Drop the attribute — the function is well under the limit. If the intent is to pre-authorise future growth, prefer the project's #[expect(...)] convention so an unfulfilled expectation surfaces in CI when the args change.", + "code_snippets": [ + { + "language": "text", + "caption": "", + "content": "#[allow(clippy::too_many_arguments)]\npub(crate) async fn shield_from_asset_lock(\n &self,\n seed_hash: &WalletSeedHash,\n funding: platform_wallet::wallet::asset_lock::AssetLockFunding,\n recipient: dash_sdk::dpp::address_funds::OrchardAddress,\n dummy_outputs: usize,\n settings: Option<...PutSettings>,\n) -> Result<(), TaskError> {" + } + ], + "overall_severity": 0.15, + "severity": 2, + "id": "CODE-013" + }, + { + "risk": 0.1, + "impact": 0.1, + "scope": 0.2, + "title": "src/backend_task/shielded/mod.rs has no module-level //! doc", + "tags": [], + "location": "src/backend_task/shielded/mod.rs:1", + "description": "The file begins immediately with use declarations. The ShieldedTask enum has a clear /// doc (lines 11-18) that explains what the module does, but there is no //! module-level summary. The file-level //! is the entry point for rustdoc module pages and is the first thing tools like rust-analyzer surface for the module. The ShieldedTask doc would serve well as a module doc if promoted.", + "recommendation": "Add a module-level //! above the use declarations, e.g.: '//! Shielded-pool backend tasks: the five fund-moving operations DET dispatches\\n//! into the upstream platform-wallet coordinator.' Keep it to ≤2 lines per the project comment-length convention.", + "overall_severity": 0.13333333333333333, + "severity": 2, + "id": "CODE-014" + } + ], + "positives": "Clean upstream-adapter shape; exhaustive error mapping; no block_in_place in frame paths; -4500 net lines; 873 lib + 88 kittests green." + } + ], + "remediation": [ + { + "label": "Before Merge", + "count": 0, + "priority": "before_merge", + "finding_ids": [] + }, + { + "label": "Before Production", + "count": 4, + "priority": "before_production", + "finding_ids": [ + "CODE-001", + "CODE-002", + "CODE-003", + "CODE-004" + ] + }, + { + "label": "Post Deployment", + "count": 21, + "priority": "post_deployment", + "finding_ids": [ + "SEC-001", + "SEC-002", + "SEC-003", + "SEC-004", + "SEC-005", + "PROJ-001", + "PROJ-002", + "PROJ-003", + "PROJ-004", + "DOC-001", + "DOC-002", + "CODE-005", + "CODE-006", + "CODE-007", + "CODE-008", + "CODE-009", + "CODE-010", + "CODE-011", + "CODE-012", + "CODE-013", + "CODE-014" + ] + } + ], + "agent_stats": [ + { + "agent": "security-engineer", + "unique": 5, + "redundant": 0 + }, + { + "agent": "project-reviewer", + "unique": 6, + "redundant": 0 + }, + { + "agent": "developer-bilby", + "unique": 5, + "redundant": 0 + }, + { + "agent": "technical-writer", + "unique": 9, + "redundant": 1 + } + ] +} diff --git a/docs/ai-design/2026-06-16-pr860-grumpy-review/report.md b/docs/ai-design/2026-06-16-pr860-grumpy-review/report.md new file mode 100644 index 000000000..ac26bffb4 --- /dev/null +++ b/docs/ai-design/2026-06-16-pr860-grumpy-review/report.md @@ -0,0 +1,326 @@ +# Code Review Report: PR #860 session diff 44caa892..fed6bef8 — shielded subsystem retirement + upstream coordinator (A-G), WalletUnlockPopup slim, PROJ-032 docs, placement/unlock cleanups + +| Field | Value | +|---|---| +| **Date** | 2026-06-16 | +| **Project** | dashpay/dash-evo-tool | +| **Branch** | docs/platform-wallet-migration-design | +| **Commit** | fed6bef887b80ab96bb66ddbf89c220868849dc4 | +| **Scope** | PR #860 session diff 44caa892..fed6bef8 — shielded subsystem retirement + upstream coordinator (A-G), WalletUnlockPopup slim, PROJ-032 docs, placement/unlock cleanups | +| **Reviewers** | | + +## Executive Summary + +25 findings across security, consistency, Rust quality, and docs — ZERO critical, ZERO high. The funds-safety core of the shielded migration is sound. + +The session retired DET's home-grown Orchard shielded subsystem (-4509 net lines) and routed all five shielded operations through upstream platform-wallet's coordinator, added a push balance snapshot, 5 det-cli MCP tools, slimmed WalletUnlockPopup, and reconciled the gap audit. The review found no false-success paths, an exhaustive error mapper, correct WalletId→SeedHash balance attribution, and per-network cleanup. Findings are dominated by secret-hygiene hardening on the new core_wallet_import tool (mnemonic Debug-leak vector + missing zeroization), a handful of Rust nits (a hardcoded bind guard, String-typed store errors), and documentation-accuracy drift (MCP.md/MCP_TOOL_DEVELOPMENT.md SPV-gate prose, a self-referential placement-policy inaccuracy, stale //! docs and phase-history comments). + +### Findings Summary + +| Severity | Security | Project | Code Quality | Call-Tree Inspection | Documentation | Dependencies | PR Comments | PR Promises | Total | +|---|---|---|---|---|---|---|---|---|---| +| CRITICAL | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| HIGH | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MEDIUM | 0 | 0 | 4 | 0 | 0 | 0 | 0 | 0 | 4 | +| LOW | 5 | 4 | 10 | 0 | 2 | 0 | 0 | 0 | 21 | +| INFO | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | + +## Part I: Security Findings + +### SEC-001 (LOW) *(overall=0.33, risk=0.30, impact=0.55, scope=0.15)*: core_wallet_import: BIP-39 mnemonic carried in a Debug-derived MCP param struct (log-leak vector) — A09:2021-Security-Logging-and-Monitoring-Failures, A02:2021-Cryptographic-Failures, CWE-532, CWE-312 + +- **Location**: `src/mcp/tools/wallet.rs:24-33` +- **Description**: ImportWalletParams holds the BIP-39 recovery phrase as a plain `mnemonic: String` and derives `Debug` (`#[derive(Debug, Deserialize, schemars::JsonSchema, Default)]`). Any `tracing`/`log` call that formats the params with `{:?}` — or any future request-tracing middleware in the rmcp transport that dumps the raw JSON-RPC body at trace/debug level — would write the full seed phrase to logs and log sinks in cleartext. The current dispatch path (src/mcp/dispatch.rs, src/mcp/server.rs) does not log params today, so this is a latent leak rather than an active one, but the recovery phrase is the single highest-value secret in the app (full wallet compromise, irreversible fund theft) and a Debug-printable struct holding it is one stray log line away from disclosure. The HTTP transport (`mcp` feature) makes this remotely reachable if request logging is ever enabled. CWE-532 (Insertion of Sensitive Information into Log File), CWE-312. +- **Recommendation**: Do not derive `Debug` on a struct containing the mnemonic, or implement `Debug` manually to redact the field (e.g. print `mnemonic: "<redacted>"`). Wrap the phrase in a `secrecy::Secret<String>` / `Zeroizing<String>` newtype with a redacting Debug so it cannot be accidentally formatted. Confirm the rmcp transport layer does not trace request bodies; if it can, add an explicit redaction allowlist for `core_wallet_import`. + +### SEC-002 (LOW) *(overall=0.32, risk=0.25, impact=0.50, scope=0.20)*: core_wallet_import: derived HD seed and mnemonic not zeroized after use — A02:2021-Cryptographic-Failures, CWE-316, CWE-226 + +- **Location**: `src/mcp/tools/wallet.rs:93-117` +- **Description**: In ImportWallet::invoke the mnemonic is parsed and `let seed = mnemonic.to_seed("")` produces a raw 64-byte HD seed (`[u8; 64]`). The `seed`, the `bip39::Mnemonic`, and the original `param.mnemonic` String are all dropped at end of scope without zeroization, leaving plaintext seed material in freed heap/stack memory subject to later disclosure via core dumps, swap, or heap reuse. The GUI import path that this MCP tool parallels is expected to handle the same secret; if that path uses `Zeroizing`, this headless path is an inconsistent secret-hygiene regression for the exact same value. Because `register_wallet` takes `&seed` by reference, wrapping the local in `Zeroizing` is a drop-in change. CWE-316 (Cleartext Storage of Sensitive Information in Memory), CWE-226 (Sensitive Information Uncleared Before Release). +- **Recommendation**: Bind the seed as `let seed = Zeroizing::new(mnemonic.to_seed(""));` and pass `&*seed`. Wrap `param.mnemonic` handling so the String is zeroized (e.g. move into a `Zeroizing<String>` before parse). Verify the `bip39` dependency is built with its `zeroize` feature so `Mnemonic` scrubs on drop. Mirror whatever the GUI `Wallet::new_from_seed` import flow already does so both paths agree. + +### SEC-003 (LOW) *(overall=0.27, risk=0.20, impact=0.35, scope=0.25)*: Forgotten wallet's shielded balance snapshot is never evicted from AppContext::shielded_balances — A04:2021-Insecure-Design, CWE-212, CWE-459 + +- **Location**: `src/context/mod.rs:130; src/context/wallet_lifecycle.rs (remove_wallet)` +- **Description**: AppContext::shielded_balances is a `HashMap<WalletSeedHash, u64>` keyed by seed hash, written by the sync-completed push writer and by refresh_shielded_balance_snapshot. The wallet-removal path (remove_wallet / forget_wallet) wipes the seed-envelope vault, the wallet-meta sidecar, the in-memory wallets/id_map/snapshot registration, and detaches the upstream coordinator — but it does NOT remove the wallet's entry from shielded_balances (a grep for `shielded_balances` finds no reference in wallet_lifecycle.rs). The stale credit figure lingers for the process lifetime. Because the seed hash is deterministic from the seed, re-importing the same recovery phrase re-binds the SAME seed hash and the UI/MCP `shielded_balance_get` would surface the OLD shielded balance until the next completed sync overwrites it — showing a freshly-imported wallet a non-zero shielded balance it has not yet verified. This is a balance-display correctness / stale-attribution issue, not direct fund loss, but a user could act on a phantom figure (e.g. attempt to send shielded funds that are not actually spendable yet). +- **Recommendation**: In the wallet-removal path, evict the entry: `self.shielded_balances.lock().ok().map(|mut m| m.remove(seed_hash))`. Add a regression test asserting the snapshot entry is gone after remove_wallet, mirroring the existing seed-envelope wipe test. + +### SEC-004 (LOW) *(overall=0.25, risk=0.20, impact=0.45, scope=0.10)*: map_shielded_op_error routes ambiguous ShieldedBroadcastUnconfirmed to a generic error (no 'do not re-submit' guidance) — A04:2021-Insecure-Design, A08:2021-Software-and-Data-Integrity-Failures, CWE-393, CWE-754 + +- **Location**: `src/wallet_backend/mod.rs:634-677` +- **Description**: map_shielded_op_error's exhaustive arm lumps `PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }` together with truly-clean failures into the generic `TaskError::WalletBackend` wrapper, which renders a generic wallet-error message. Upstream documents this variant as an AMBIGUOUS post-broadcast state: 'broadcast was ACCEPTED by the relay but the SDK could not confirm its execution result ... the caller must NOT treat it as unregistered or re-submit' (rs-platform-wallet/src/error.rs:205-219; FFI result code 17). It is the identity-create sibling of ShieldedSpendUnconfirmed. In the CURRENT code none of DET's five fund-moving ops (shield/transfer/unshield/withdraw — all of which correctly emit ShieldedSpendUnconfirmed and are routed to dedicated *ConfirmationUnknown variants) construct ShieldedBroadcastUnconfirmed, so this is NOT a live double-spend path today — it is a latent defense-in-depth gap. If a future op (e.g. a shielded identity registration) is wired through this same mapper, an ambiguous broadcast would surface as a generic error and the user could re-submit, risking a double-execution. The function's own doc-comment promises the exhaustive match 'forces a review here'; the review chose the unsafe-by-omission routing for this one ambiguous variant. +- **Recommendation**: Give ShieldedBroadcastUnconfirmed its own TaskError variant carrying the same 'broadcast accepted, confirmation unknown — wait and refresh, do not re-submit' wording as the *ConfirmationUnknown family (it already may-have-executed semantics). At minimum add a comment explaining why it is deliberately bucketed as generic and assert it is unreachable from DET's current ops, so a future caller change trips a review. + +### SEC-005 (LOW) *(overall=0.25, risk=0.25, impact=0.30, scope=0.20)*: 'Delete all local data' completes successfully while the shielded coordinator wipe runs fire-and-forget and only logs on failure — A04:2021-Insecure-Design, A09:2021-Security-Logging-and-Monitoring-Failures, CWE-212, CWE-459 + +- **Location**: `src/context/wallet_lifecycle.rs:18-37 (clear_network_database)` +- **Description**: clear_network_database synchronously unlinks the two legacy shielded files and returns Ok, but the authoritative Orchard state now lives in the upstream coordinator store (platform-wallet-shielded.sqlite), which is reset via `backend.clear_shielded()` dispatched as a detached best-effort subtask (`subtasks.spawn_sync("shielded_coordinator_clear", ...)`). On failure that subtask only emits `tracing::warn!` — the caller has already returned success. Consequences: (1) the user is told 'all local data deleted' while plaintext Orchard notes, nullifiers and viewing-key-derived material may remain in the coordinator store (incomplete secret/PII wipe, CWE-212/CWE-459); (2) the wipe is not awaited, so a subsequent re-create/re-open of the same network can race the still-pending clear. This is primarily a privacy / data-remanence defect for a shielded (privacy-focused) feature, where 'I deleted my data' must be trustworthy. +- **Recommendation**: Await the coordinator clear within clear_network_database (it is already async) and propagate its failure to the caller so the destructive action reports partial failure instead of false success. If a detached design is required, surface a persistent warning/banner to the user that shielded data removal is still pending or failed, and ensure the next backend bring-up re-attempts the clear before re-binding. + +> **Positive observations:** Funds-safety core sound: no false-success, exhaustive map_shielded_op_error, correct WalletId→SeedHash, per-network cleanup. No CRITICAL/HIGH. + +## Part II: Project Consistency + +### PROJ-001 (LOW) *(overall=0.40, risk=0.30, impact=0.40, scope=0.50)*: gaps.md executive-summary table never updated after PROJ-032 close — contradicts its own candy tally and the JSON — consistency, tally, PROJ-032, PROJ-034 + +- **Location**: `docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md:77-92` +- **Description**: The PROJ-032 CLOSE / PROJ-034 KEEP reconciliation was applied to the bottom-of-file candy tally and to `gaps-report.json`, but the **executive-summary table at the top of `gaps.md` was left stale**, so the document now disagrees with itself. + +- Executive table (lines 77-84): `MEDIUM Open=4`, `MEDIUM Resolved=11`, `Total Open=10`, `Total Resolved=33`. +- Candy tally (lines 1024-1025, updated this PR): `34 RESOLVED ... + 9 OPEN (1 HIGH + 3 MEDIUM + 5 LOW)`. +- `gaps-report.json` (updated this PR): `open_findings=9`, `MEDIUM open=3`. + +Moving PROJ-032 from open→resolved must take the table to Open=9 / Resolved=34 / MEDIUM-open=3 / MEDIUM-resolved=12. It still reads 10/33/4/11. + +Second, the `Open by category ... Sum = 10 open (... net count unchanged ...)` line (86-92) is arithmetically wrong. PROJ-034 was **already** an open finding (it is one of the original 4 open MEDIUMs); listing it in the category breakdown while removing PROJ-032 keeps the *enumeration* at 10 entries but does **not** keep the *open count* at 10 — the real count drops to 9. "net count unchanged" is false. +- **Recommendation**: Update the executive-summary table to Open=9 / Resolved=34, MEDIUM 3 open / 12 resolved, and correct the `Sum = 10 open` line to `Sum = 9 open` (drop the "net count unchanged" justification). Make the top table, the bottom candy tally, and `gaps-report.json` agree on 9 open / 34 resolved before this doc is used as the merge-gate reference. + +### PROJ-002 (LOW) *(overall=0.28, risk=0.15, impact=0.20, scope=0.50)*: Tombstone + phase-plan narration in code comments (describe history, not present state) — convention, tombstone, present-state + +- **Location**: `src/database/initialization.rs:1347-1351` +- **Description**: The PR adds ~35 new `Phase A`–`Phase G` references inside code comments plus several outright tombstones that explain *removed* code — both against the Cross-Cutting Rules "No tombstone comments" and "Describe present state, not history" (history belongs in commit messages / the PR description, the reader has `git blame`). + +Representative offenders: +- `src/database/initialization.rs:1347-1351` — "DET's shielded subsystem was retired (Phase D); the old shielded table helpers and the `database::shielded` module were deleted." Pure tombstone — it documents code that no longer exists. +- `src/backend_task/error.rs:3860` — "Ported from the deleted `backend_task::shielded::bundle` tests (Phase D)". +- `src/mcp/server.rs:156` — "Shielded read/control tools (Phase G — agent self-verification)". +- `src/mcp/tools/shielded.rs` — "(Phase E writer)"; `src/ui/wallets/send_screen.rs:2076` — `// TODO(Phase F): ...`. + +"Phase D/E/F/G" are this PR's internal phasing labels; once the PR is merged they reference nothing a future reader can resolve. The *present-state* halves of these comments ("these tables are intentionally not created; the v37 migration drops any legacy copies") are fine and should stay — it is the historical narration that should go. +- **Recommendation**: Strip the "was retired / were deleted / Ported from the deleted X" tombstones and the bare `Phase X` process labels; keep only the present-tense rationale (what the code does now and why). Move the migration-phase narrative to the commit messages / PR description where it belongs. + +### PROJ-003 (LOW) *(overall=0.25, risk=0.25, impact=0.20, scope=0.30)*: New TODO(PROJ-034) ephemeral review ID re-committed into source — convention, ephemeral-id + +- **Location**: `src/backend_task/migration/finish_unwire.rs:56` +- **Description**: This PR edited the migration TODO block — it correctly removed `TODO(PROJ-032)` but **rewrote and re-committed** `TODO(PROJ-034): App settings, top-up history, and scheduled DPNS votes all reset/empty on upgrade`. `PROJ-NNN` is exactly the class of transient review-finding ID the coding-best-practices Cross-Cutting Rules forbid in committed code (and that `scripts/lint_ephemeral_ids.py` flags): the consolidator reassigns these IDs every run, so the reference is dead the moment this branch merges. + +Context (out of session scope, not double-counted): the tree is already saturated with pre-existing `PROJ-010` / `PROJ-040` / `PROJ-007` references across `wallet_backend/`, `context/wallet_lifecycle.rs`, and `tests/backend-e2e/` — ~45 of them. They predate `44caa892`; PROJ-034 is the one this session actively touched. +- **Recommendation**: Drop the `PROJ-034` tag. Either a plain `// TODO:` describing the missing settings/top-up/scheduled-vote importer, or a durable handle (a GitHub issue ref like `#NNNN`) per the allowed-ID list. While this block was open, it was the moment to delete the ephemeral tag — not refresh it. + +### PROJ-004 (LOW) *(overall=0.22, risk=0.15, impact=0.20, scope=0.30)*: Dangling policy-number references (P8, P14) in comments — defined nowhere — consistency, dangling-reference + +- **Location**: `src/ui/state/mod.rs:5` +- **Description**: Two newly-added comments cite numbered policies that exist in no committed artifact: +- `src/ui/state/mod.rs:5` — "the module placement policy (P14) keeps these out of `ui/components/`". +- `src/model/single_key.rs:16` — "Used by the import dialog for instant feedback (P8)". + +A grep across `CLAUDE.md`, `docs/`, and `src/` finds **no definition** of `P8` or `P14`. The actual module-placement policy this PR added to `CLAUDE.md` ("DET Module Placement Policy") is **not numbered P14**, so the reference resolves to nothing. A reader chasing "P14" has no destination. +- **Recommendation**: Either point the comments at the real anchor (`CLAUDE.md` § "DET Module Placement Policy" / "Validation placement") or drop the parenthetical numbers. If the P-numbering is meaningful, define it once in a committed doc and reference that. + +> **Positive observations:** Placement policy applied; conventional commits; TaskError typing respected; PROJ-032/034 reconciliation substantively accurate. + +## Part VI: Documentation + +### DOC-001 (LOW) *(overall=0.37, risk=0.30, impact=0.30, scope=0.50)*: UI components catalog lists a deleted component and a relocated one — doc-drift, catalog + +- **Location**: `src/ui/components/README.md:67-68` +- **Description**: `CLAUDE.md` mandates consulting `src/ui/components/README.md` before building any UI element — it is the authoritative component catalog. This PR invalidated two of its rows without updating it: +- `ScreenWithWalletUnlock | wallet_unlock.rs | Trait for screens needing wallet unlock` (line 67) — the file `src/ui/components/wallet_unlock.rs` and the trait were **deleted** this PR (commit `fed6bef8`, migrate to `WalletUnlockPopup`). The catalog now points at a non-existent module. +- `TrackedAssetLockCache | tracked_asset_lock_cache.rs | ...` (line 68) — **moved** this PR to `src/ui/state/tracked_asset_lock_cache.rs`. Per the new placement policy it is explicitly *not* a component (renders no egui), yet it is still listed in the components catalog under "Utility" with a now-wrong path. +- **Recommendation**: Remove the `ScreenWithWalletUnlock` row, and remove `TrackedAssetLockCache` from the components catalog (it belongs to `ui/state/` now — note it there, e.g. a short `src/ui/state/` README or a pointer line, rather than in the components table). + +### DOC-002 (LOW) *(overall=0.22, risk=0.15, impact=0.20, scope=0.30)*: user-stories.md untouched despite five new developer-facing MCP/CLI tools — user-stories, judgment-call + +- **Location**: `docs/user-stories.md` +- **Description**: This PR adds five new dev/agent-facing tools — `core_wallet_import`, `shielded_init`, `shielded_sync`, `shielded_balance_get`, `shielded_address_get` — exposed over both MCP and `det-cli`, and a documented headless shielded self-verification loop (`docs/CLI.md`). `docs/user-stories.md` was not touched. + +**My call:** defensibly skippable. The bulk of the PR is a refactor (re-seating shielded state on the upstream coordinator) with no change to end-user shielded flows, and the new tools are testing/automation affordances rather than a product feature — which the repo rule ("Skip user-story updates for non-functional changes ... refactoring") permits. That said, the new headless-verification capability is a genuine Platform-Developer-persona affordance and would be a reasonable single `[Implemented]` story. +- **Recommendation**: Optional: add one Platform-Developer story (e.g. "As a Platform Developer I can drive and verify a full shielded lifecycle headlessly via det-cli") covering the new self-verification tools. Not a blocker for a refactor-dominant PR. + +> **Positive observations:** New tools in CLI.md/MCP.md; policy in CLAUDE.md; gap audit reconciled. Gaps are accuracy/staleness, not omissions. + +## Part III: Code Quality & Language Best Practices + +### CODE-001 (MEDIUM) *(overall=0.52, risk=0.45, impact=0.50, scope=0.60)*: MCP.md network-required prose contradicts 4 new shielded tools + +- **Location**: `docs/MCP.md:138` +- **Description**: The 'Network verification' section states: 'For destructive tools (those that spend funds or modify state — all identity and shielded tools), `network` is required.' The four new shielded tools (shielded_init, shielded_sync, shielded_balance_get, shielded_address_get) all use WalletIdParams with `#[serde(default)] pub network: Option<String>` — i.e., optional. The tool table on lines 90-93 correctly marks them `network?`, but the prose claim on line 138 flatly contradicts that. shielded_init is annotated `destructive: false`, shielded_sync `destructive: false`, the balance/address reads are `read_only: true` — none qualify as 'destructive' by the MCP ToolAnnotations taxonomy. A developer reading the prose will try to require `network` in client tooling and wonder why it was optional in the schema. +- **Recommendation**: Narrow the prose on line 138 to name the specific destructive shielded tools or replace 'all shielded tools' with 'shielded fund-moving tools (shielded_shield_from_core, shielded_shield_from_platform, shielded_transfer, shielded_unshield, shielded_withdraw)'. The four new control/read tools can be grouped with the read-only tools that take optional network. + +### CODE-002 (MEDIUM) *(overall=0.52, risk=0.50, impact=0.55, scope=0.50)*: shielded_address_get misclassified as a no-network-call SPV-free tool + +- **Location**: `docs/MCP.md:102` +- **Description**: Line 102 groups shielded_address_get with shielded_balance_get under 'shielded snapshot reads' that skip the SPV gate. This is inaccurate. shielded_balance_get is truly SPV-free: it reads from AppContext atomic fields (shielded_balance_credits / shielded_balance_duffs) with no backend call. shielded_address_get calls ctx.wallet_backend().map_err(McpToolError::TaskFailed)? and then backend.shielded_default_address(). In standalone MCP mode (det-cli serve), wallet_backend() returns Err(TaskError::WalletBackendUnavailable) if ensure_spv_synced has never run — it is the only MCP chokepoint that calls ensure_wallet_backend_and_start_spv. A cold call to shielded_address_get without a prior SPV-gated tool (e.g. shielded_init) returns a TaskFailed error in standalone mode. The tool's own description says 'Run shielded_init first if the wallet is not yet bound', which implicitly acknowledges the dependency, but the SPV gate section's categorical grouping will mislead users who rely on the prose for sequencing. +- **Recommendation**: Separate shielded_address_get from shielded_balance_get in the SPV gate prose. shielded_balance_get is a pure in-memory read and can skip SPV. shielded_address_get requires the wallet backend to be wired and should note: 'No explicit SPV wait, but requires the wallet backend to be initialized (run shielded_init or any SPV-gated tool first in standalone mode).' Alternatively, add ensure_spv_synced to shielded_address_get's invoke() to make the sequencing requirement explicit and self-enforcing. + +### CODE-003 (MEDIUM) *(overall=0.45, risk=0.40, impact=0.45, scope=0.50)*: MCP_TOOL_DEVELOPMENT.md SPV gate rule stale: new SPV-skip tools not covered + +- **Location**: `docs/MCP_TOOL_DEVELOPMENT.md:100` +- **Description**: The SPV gate rule says: 'Skip [ensure_spv_synced] only for metadata tools that make no network calls (core_wallets_list, network_info, tool_describe).' This PR adds three more tools that skip ensure_spv_synced: core_wallet_import (imports locally, no network call), shielded_balance_get (reads AppContext atomics, no network call), and shielded_address_get (calls wallet_backend() but no ensure_spv_synced). The updated MCP.md (line 102) already documents the broader exemption list, but MCP_TOOL_DEVELOPMENT.md — the canonical checklist for new tool authors — was not updated. A contributor following the checklist will add ensure_spv_synced to every new wallet-facing tool, including future read-only tools that legitimately should skip it. +- **Recommendation**: Update MCP_TOOL_DEVELOPMENT.md line 100 to match MCP.md's broader SPV-skip criteria: 'Skip for tools that make no network calls: metadata tools (core_wallets_list, network_info, tool_describe), local wallet import (core_wallet_import), and pure snapshot reads that read only AppContext atomics (shielded_balance_get). For tools that access wallet_backend() without network calls, document the backend-wired prerequisite instead of gating on SPV.' + +### CODE-004 (MEDIUM) *(overall=0.44, risk=0.32, impact=0.50, scope=0.50)*: Typed passphrase parked in one global egui cache slot keyed only by window title — funds-safety, secret-handling, correctness, ux + +- **Location**: `src/ui/components/passphrase_modal.rs:124` +- **Description**: passphrase_modal() moved all per-modal state — including the PasswordInput holding the typed (mlock-protected) passphrase — out of the caller and into egui's global data cache, keyed solely by window_title: `egui::Id::new("passphrase_modal_state").with(config.window_title)`. The slot is removed only on the Submit/Cancel resolution paths. Two consequences: + +1. Cross-instance state bleed. All 34 screens that own a WalletUnlockPopup pass the constant title "Unlock Wallet", so every one of them shares the SAME cache slot. WalletUnlockPopup::open() and close() no longer touch the field (the old code called password_input.clear() and reset focus_requested on open). If a modal stops being rendered while Pending without going through Submit/Cancel — e.g. the owning screen's `if self.wallet_unlock_popup.is_open() && let Some(wallet) = &self.selected_wallet` guard drops `selected_wallet` to None from an async refresh, or a task result swaps the visible screen — the entry is never cleared. Re-opening any screen's unlock dialog then renders pre-filled with the passphrase typed in the previous context, against a possibly different wallet. + +2. Secret lifetime. The abandoned-Pending entry is never zeroized until it is overwritten by the same key or the app exits (mlock keeps it off swap, but it lingers past the point the user believes the dialog was dismissed). The old design had the caller own the PasswordInput and zeroize it deterministically on open/close. + +This is a regression of the open() contract: its doc says state is "Reset on open", but the password field is now outside the struct and is not reset. +- **Recommendation**: Key the cache entry by something unique to the logical prompt (e.g. fold the wallet seed_hash or a per-instance salt into the Id) instead of the human-readable title, so distinct popup instances cannot collide. Additionally restore the open()/close() reset guarantee: have WalletUnlockPopup/ActivePrompt clear the cached PassphraseModalState (a small `passphrase_modal_reset(ctx, id)` helper) on open and on programmatic close, so an abandoned-Pending secret is zeroized promptly and a reopened dialog always starts empty. + +<details><summary>text</summary> + +```text +let state_id = egui::Id::new("passphrase_modal_state").with(config.window_title); +let mut state: PassphraseModalState = ctx + .data(|d| d.get_temp::<PassphraseModalState>(state_id)) + .unwrap_or_else(|| PassphraseModalState { + password_input: PasswordInput::new().with_hint_text(config.input_placeholder), + focus_requested: false, + }); +``` + +</details> + +<details><summary>text</summary> + +```text +pub fn open(&mut self) { + self.is_open = true; + self.error = None; + self.remember = false; +} +``` + +</details> + +### CODE-005 (LOW) *(overall=0.38, risk=0.30, impact=0.35, scope=0.50)*: Module Placement Policy claims 'one struct per file' for src/mcp/tools/ — wrong + +- **Location**: `CLAUDE.md:84` +- **Description**: The newly added policy bullet reads: '`src/mcp/tools/` — MCP tool logic (one struct per file); never in `src/bin/det_cli/`.' The actual pattern, consistent across the codebase and stated explicitly in docs/MCP_TOOL_DEVELOPMENT.md ('Add a new file or extend an existing file following the domain grouping (wallet.rs, platform.rs, network.rs, etc.)'), is one file per domain with multiple tool structs per file. src/mcp/tools/wallet.rs contains 6 tool structs (GenerateReceiveAddress, WalletBalancesQuery, SendCoreFunds, FetchPlatformBalances, ImportWallet, ListWalletsTool); src/mcp/tools/shielded.rs now contains 9. The parenthetical 'one struct per file' was likely intended to echo the MCP_TOOL_DEVELOPMENT.md rule 3 ('One tool struct = one BackendTask dispatch') but conflates per-dispatch with per-file. A contributor following CLAUDE.md literally would create nine new files for the nine shielded tools. +- **Recommendation**: Replace '(one struct per file)' with '(one file per domain — e.g. wallet.rs, shielded.rs, identity.rs)' to match the actual pattern and MCP_TOOL_DEVELOPMENT.md. Optionally reference the 'one tool struct = one BackendTask dispatch' rule separately if the per-dispatch constraint is worth preserving. + +### CODE-006 (LOW) *(overall=0.33, risk=0.25, impact=0.35, scope=0.40)*: Hardcoded needs_shielded_bind = true turns the JIT scope-entry guard into dead code — dead-code, correctness, ux + +- **Location**: `src/context/wallet_lifecycle.rs:683` +- **Description**: bootstrap_wallet_addresses_jit computes needs_bootstrap and needs_registration, then sets `let needs_shielded_bind = true;` and guards with `if !needs_bootstrap && !needs_registration && !needs_shielded_bind { return; }`. Because the last operand is a literal `true`, the condition is always false and the early-return is unreachable; needs_bootstrap and needs_registration are still evaluated (one of them calls backend.is_wallet_registered) but their results no longer affect control flow. The whole point of that guard was to avoid entering the JIT seed scope when there is nothing to do, keeping the steady-state path prompt-free. The accompanying comment claims "the overhead of entering the scope is negligible," which is true for unprotected/session-cached wallets but NOT for an open-but-not-session-cached protected wallet (unlocked for display with 'keep unlocked' off): for those, with_secret_session resolves cache miss → unprotected fast-path → interactive prompt. init_missing_shielded_wallets (fired once when the protocol version first crosses the shielded threshold) iterates exactly those open wallets, so the always-enter behaviour can surface a surprise passphrase prompt where the early-return previously suppressed it. +- **Recommendation**: Make the guard honest. Either (a) derive needs_shielded_bind from a real, cheap, non-prompting signal (a sync 'is this wallet already shielded-bound / is the seed session-cached' check) so already-bound or non-cached wallets keep the prompt-free early-return; or (b) if the unconditional bind is deliberate, delete the vestigial needs_shielded_bind / early-return and the now-unused needs_* computations and state plainly that the scope is always entered for open wallets — and correct the 'negligible overhead' comment to acknowledge the protected-not-cached prompt case. + +<details><summary>text</summary> + +```text +let needs_bootstrap = wallet + .read() + .map(|g| Self::wallet_needs_bootstrap(&g)) + .unwrap_or(false); +let needs_registration = !backend.is_wallet_registered(&seed_hash); +let needs_shielded_bind = true; +if !needs_bootstrap && !needs_registration && !needs_shielded_bind { + return; +} +``` + +</details> + +### CODE-007 (LOW) *(overall=0.32, risk=0.25, impact=0.30, scope=0.40)*: MCP_TOOL_DEVELOPMENT.md Don'ts table bans direct AppContext calls but ShieldedInit/ShieldedSync do exactly that + +- **Location**: `docs/MCP_TOOL_DEVELOPMENT.md:121` +- **Description**: The Don'ts table states: 'Call AppContext methods directly instead of dispatching a BackendTask | Breaks the task system contract; backend errors won't be handled uniformly.' ShieldedInit::invoke calls ctx.wallet_backend()?.ensure_shielded_bound_jit() and backend.warm_shielded_prover() without dispatching any BackendTask. ShieldedSync::invoke calls backend.sync_shielded_now(true) directly. Both tools are intentional control-plane operations (init and sync are not fund-moving state transitions that map cleanly to BackendTask variants), and they work correctly. But the documented prohibition is now violated by two shipped tools, which creates a contradictory message for the next tool author: the rulebook says 'never do this' but the reference implementation does it. McpToolError::TaskFailed wraps the wallet_backend error in both tools, so error handling is still uniform — the stated rationale for the rule does not apply to these cases. +- **Recommendation**: Refine the Don't to: 'Dispatch business logic through AppContext/wallet_backend directly when a BackendTask variant exists for the operation — prefer BackendTask for fund-moving ops. For control-plane operations with no BackendTask analog (e.g. shielded_init, shielded_sync), direct wallet_backend() calls are acceptable; map errors through McpToolError::TaskFailed.' This narrows the prohibition to the actual concern (bypassing BackendTask for ops that already have a variant) without outlawing the legitimate control-plane pattern. + +### CODE-008 (LOW) *(overall=0.27, risk=0.20, impact=0.20, scope=0.40)*: CLAUDE.md smoke-test section over-excludes shielded_balance_get and misses core_wallet_import + +- **Location**: `CLAUDE.md:170` +- **Description**: The smoke-test section states 'every shielded-* tool' is not a smoke test (waits on the SPV gate). This is incorrect for two of the four new shielded tools: shielded_balance_get makes no network calls and no wallet_backend() call — it reads AppContext atomics directly and returns immediately without SPV; shielded_address_get also skips ensure_spv_synced (though it does need the backend wired). Additionally, core_wallet_import is a new SPV-free tool added in this PR that exercises the full import → DB path without a live network, making it a valid smoke test candidate alongside core_wallets_list. The smoke-test list has not been updated to reflect these additions. +- **Recommendation**: Update the smoke-test 'Not smoke tests' note to carve out shielded_balance_get (add to the runnable smoke-test examples, noting it returns zeros when no wallet has synced) and note core_wallet_import as a candidate alongside core_wallets_list. Adjust the 'every shielded-* tool' blanket to 'every fund-moving shielded-* tool'. + +### CODE-009 (LOW) *(overall=0.21, risk=0.15, impact=0.28, scope=0.20)*: shielded_activity/shielded_notes flatten the upstream store error to a String — error-handling + +- **Location**: `src/wallet_backend/mod.rs:518` +- **Description**: Both read helpers wrap the coordinator store error via `PlatformWalletError::ShieldedStoreError(e.to_string())` before boxing into TaskError::WalletBackend. The `.to_string()` collapses the typed store error into text, discarding the source chain and structural matchability — the very anti-pattern the project's error rules call out. Both methods are currently #[allow(dead_code)] (Phase-F read path not wired yet), so the blast radius is nil today, but the pattern will ship the moment the activity/notes UI lands. +- **Recommendation**: When these are wired, route the store error through a dedicated typed TaskError variant carrying the concrete store-error type as a #[source] (or extend the upstream conversion) rather than stringifying. If upstream only exposes ShieldedStoreError(String), add a DET-side variant that preserves the original error as #[source] so Debug keeps the chain. + +<details><summary>text</summary> + +```text +coordinator + .store() + .read() + .await + .get_activity(subwallet, offset, limit) + .map_err(|e| TaskError::WalletBackend { + source: Box::new( + platform_wallet::error::PlatformWalletError::ShieldedStoreError(e.to_string()), + ), + }) +``` + +</details> + +### CODE-010 (LOW) *(overall=0.20, risk=0.15, impact=0.15, scope=0.30)*: src/mcp/tools/shielded.rs //! module doc lists only 4 of 9 tools + +- **Location**: `src/mcp/tools/shielded.rs:1` +- **Description**: The module-level doc reads: '//! Shielded-related MCP tools: shield, transfer, unshield, withdraw.' This enumeration reflects the four tools that existed before Phase G (ShieldedShieldFromCore, ShieldedShieldFromPlatform, ShieldedTransferTool, ShieldedWithdrawTool) but omits the four new additions: ShieldedInit, ShieldedSync, ShieldedBalanceGet, ShieldedAddressGet — and also ShieldedUnshield. The doc is both outdated (shield/transfer/unshield/withdraw were present before this PR) and incomplete (init/sync/balance_get/address_get were added in this PR). A developer opening the file to orient themselves gets a summary that covers less than half the module. +- **Recommendation**: Update the //! line to: '//! Shielded-pool MCP tools: shielding, transfers, unshielding, withdrawals, and lifecycle ops (init, sync, balance and address reads).' Or expand to two lines if the one-liner becomes unwieldy. + +### CODE-011 (LOW) *(overall=0.19, risk=0.12, impact=0.20, scope=0.25)*: refresh_shielded_balance_snapshot doc frames itself as a stopgap for Phase E, which ships in the same PR — documentation + +- **Location**: `src/backend_task/shielded/mod.rs:236` +- **Description**: The doc comment reads "This is the read side's producer until the Phase-E on_shielded_sync_completed push writer lands." Phase E (the push writer) landed in this very PR (commit fa0e46de), so the 'until X lands' framing describes a future that is already the present. The method is in fact a permanent, useful immediate-refresh after a confirmed spend (so the UI doesn't wait for the 60s loop) — not a temporary substitute. Per the project's 'describe present state, not history' rule, the historical/aspirational framing will mislead the next reader into thinking it is removable. +- **Recommendation**: Reword to state the present role: an immediate post-operation refresh of the frame-safe snapshot that complements (not substitutes for) the Phase-E sync-completed push writer. Drop the 'until ... lands' phrasing. + +<details><summary>text</summary> + +```text +/// This is the read side's producer until the Phase-E +/// `on_shielded_sync_completed` push writer lands: it keeps the UI balance +/// current immediately after a spend without waiting for the 60-second sync +/// loop. Best-effort — a failed read leaves the previous snapshot in place. +``` + +</details> + +### CODE-012 (LOW) *(overall=0.18, risk=0.15, impact=0.15, scope=0.25)*: Phase-history narration in ShieldedTask enum and run_shielded_task rustdoc + +- **Location**: `src/backend_task/shielded/mod.rs:14,61` +- **Description**: Two doc-comments contain phase-progress history rather than present-state description. Line 14: 'Phase D retired DET's home-grown Orchard subsystem: sync, nullifier scanning, key derivation and the commitment tree are all owned by the upstream coordinator now, so only the five fund-moving operations remain.' Line 61: 'shielded ops added in Phase B'. The coding-best-practices skill mandates present-state-not-history and no tombstone comments. The line-14 passage describes what the old system had and why it was removed — useful context internally but irrelevant once the system has been running for months. 'Phase B' / 'Phase D' labels have no meaning outside the PR development history. An inline comment in src/mcp/tools/shielded.rs:657 ('Phase E writer') has the same issue. +- **Recommendation**: Rewrite line 14-16 to describe what IS, not what was: e.g. 'Sync, nullifier scanning, key derivation, and the commitment tree are owned by the upstream coordinator; only the five fund-moving operations are dispatched through this task.' Remove 'Phase D retired DET's home-grown...' and 'added in Phase B'. Replace 'Phase E writer' inline comment (shielded.rs:657) with 'sync_now fires on_shielded_sync_completed synchronously, so the push snapshot is fresh by the time this returns.' + +### CODE-013 (LOW) *(overall=0.15, risk=0.10, impact=0.15, scope=0.20)*: Unnecessary #[allow(clippy::too_many_arguments)] on shield_from_asset_lock — lint-hygiene, dead-code + +- **Location**: `src/wallet_backend/mod.rs:296` +- **Description**: shield_from_asset_lock takes 5 non-self parameters (seed_hash, funding, recipient, dummy_outputs, settings). clippy::too_many_arguments fires at 8 (does not count the receiver), so the lint can never trigger here and the #[allow] is dead. It silently sits as a license to grow the signature, defeating the lint's purpose if a future arg pushes it over the threshold without re-review. +- **Recommendation**: Drop the attribute — the function is well under the limit. If the intent is to pre-authorise future growth, prefer the project's #[expect(...)] convention so an unfulfilled expectation surfaces in CI when the args change. + +<details><summary>text</summary> + +```text +#[allow(clippy::too_many_arguments)] +pub(crate) async fn shield_from_asset_lock( + &self, + seed_hash: &WalletSeedHash, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + recipient: dash_sdk::dpp::address_funds::OrchardAddress, + dummy_outputs: usize, + settings: Option<...PutSettings>, +) -> Result<(), TaskError> { +``` + +</details> + +### CODE-014 (LOW) *(overall=0.13, risk=0.10, impact=0.10, scope=0.20)*: src/backend_task/shielded/mod.rs has no module-level //! doc + +- **Location**: `src/backend_task/shielded/mod.rs:1` +- **Description**: The file begins immediately with use declarations. The ShieldedTask enum has a clear /// doc (lines 11-18) that explains what the module does, but there is no //! module-level summary. The file-level //! is the entry point for rustdoc module pages and is the first thing tools like rust-analyzer surface for the module. The ShieldedTask doc would serve well as a module doc if promoted. +- **Recommendation**: Add a module-level //! above the use declarations, e.g.: '//! Shielded-pool backend tasks: the five fund-moving operations DET dispatches\n//! into the upstream platform-wallet coordinator.' Keep it to ≤2 lines per the project comment-length convention. + +> **Positive observations:** Clean upstream-adapter shape; exhaustive error mapping; no block_in_place in frame paths; -4500 net lines; 873 lib + 88 kittests green. + +## Recommendations + +### Before Merge (0 items) + +### Before Production (4 items) +Findings: CODE-001, CODE-002, CODE-003, CODE-004 + +### Post Deployment (21 items) +Findings: SEC-001, SEC-002, SEC-003, SEC-004, SEC-005, PROJ-001, PROJ-002, PROJ-003, PROJ-004, DOC-001, DOC-002, CODE-005, CODE-006, CODE-007, CODE-008, CODE-009, CODE-010, CODE-011, CODE-012, CODE-013, CODE-014 + +## Verdict + +Ship-worthy. The funds-handling change is correct and well-tested; remaining findings are LOW/MEDIUM hardening and doc fixes that should land as a follow-up commit, not blockers. + +**Action:** Apply the secret-hygiene fixes (redact+zeroize the mnemonic in core_wallet_import), the dead bind-guard + String-error fixes, and the doc-accuracy corrections as a follow-up cleanup commit. diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md new file mode 100644 index 000000000..e4df06199 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -0,0 +1,495 @@ +# Blocking Progress Overlay — Requirements + UX Specification + +**Phase:** 1a (Requirements) + 1b (UX) — combined +**Author:** Diziet (Product Designer) +**Date:** 2026-06-17 +**Status:** Draft for downstream phases (architecture decision belongs to Nagatha in 1d) +**Sibling component:** `MessageBanner` (`src/ui/components/message_banner.rs`) + +--- + +> **Supersession callout (post-outage redesign + QA-wave addendum).** Parts of this spec describe +> a first-class **Cancel** control that no longer exists. The shipped design replaces it with a +> **generic button facility** (`with_action` / `with_secondary_action`, clicks delivered keyed to +> the owning screen), and adds a no-progress **watchdog** and a frame-start **`claim_input`** total +> block. Where this document and the redesign disagree, **`03-dev-plan.md`'s post-outage note, +> `04-design-addendum.md`, and the code (`src/ui/components/progress_overlay.rs`) win.** Items known +> to be superseded: +> - **FR-7 (buttons & actions), AC-7.3 / AC-7.4** — no built-in Cancel; buttons are generic and +> styled Primary (right) / Secondary (left) in insertion order within each tier; clicks are +> delivered to the owning screen via `OverlayHandle::take_actions` (keyed), not a Cancel action. +> - **NFR-3 AC-3b (Esc → Cancel)** — Esc never cancels; a hard block swallows Esc/Tab/Enter/Space. +> While a secret prompt is shown above the overlay, the overlay yields the keyboard to it (SEC-004). +> - **AC-8.4 (backdrop / input)** — input is claimed at frame start (`claim_input`), including +> `Event::Text`; a button-less block is genuinely total (QA-001). +> - **AC-10.5 (concurrent actions)** — only the topmost entry's clicks are reachable, and they are +> keyed to that entry's owner; the app loop only sweeps orphaned ids. +> - **J-1 / J-2 / J-3 (journeys) and §6.3 / §6.4 / §6.5 (cancel UX)** — reframe any "Cancel button" +> language to the generic-button + escalation model; the safety valve is the bounded-operation +> contract + 30 s / 120 s honest escalation, not a dismiss/background control. + +--- + +## 0. Executive Summary + +Some operations in Dash Evo Tool are not safe to interrupt and are not meaningful to +interact *around*: broadcasting a state transition, signing, importing keys, a multi-step +identity registration, a migration step. For these, a passive banner is the wrong tool — +the user can still click into half-finished state, fire a second conflicting operation, or +simply not notice that the app is busy. + +This spec defines a **full-screen blocking progress overlay**: a sibling capability to +`MessageBanner` that draws a dimming plane over the *entire* window, blocks all interaction +beneath it, and shows a "please wait" message with an **indeterminate spinner (no ETA)**, an +**optional step counter** (`Step {current} of {total}`), and **optional action buttons** +(at minimum Cancel). It is dismissed programmatically when the operation completes, or by an +optional Cancel button. _(Superseded — see top banner: buttons are generic (`with_action`), there +is no built-in Cancel, and Esc/Tab/Enter/Space are swallowed; dismissal is programmatic.)_ + +**Critical invariant (learned the hard way — PR860):** the overlay is a *visual + input* +block only. It must never synchronously wait in the egui frame loop. The real work runs on a +tokio backend task; the overlay is raised when the task is dispatched and lowered when the +`TaskResult` arrives. A synchronous wait here deadlocks rendering. + +**Headline recommendation (detailed in §6):** build a **new standalone component** that +*mirrors* `MessageBanner`'s architecture (global state in egui `ctx.data`, a lifecycle +handle, an action-id queue, log-once discipline, theme tokens) — but do **not** extend +`MessageBanner` itself. The two have opposite z-order, opposite blocking semantics, and a +different render seam. + +--- + +## 1. Personas Affected + +All three personas (`docs/personas/`) hit long, uninterruptible operations; each experiences +the overlay differently. + +| Persona | How they meet the overlay | What they need from it | +|---|---|---| +| **Alex — Everyday User** (low/moderate technical) | Registering a DPNS name; sending Dash; first wallet sync. Alex does not know *what* a "state transition" is and should never see that phrase. | A calm plain-language sentence ("Registering your username. This can take up to a minute."), a spinner that clearly says *working, not frozen*, and — when offered — one obvious Cancel. No jargon, no error codes. | +| **Priya — Power User** (operator) | Asset-lock → fund identity flows; credit transfers; withdrawals. Runs several operations a session and wants to know *which step* she is on. | The step counter (`Step 3 of 5`) so a multi-stage flow is legible; confidence that Cancel is safe; the operation description precise enough to trust. | +| **Jordan — Platform Developer** | Bulk identity creation, contract deploys, repeated testnet iterations. Hammers the app during sprints. | Fast, honest feedback. A spinner that doesn't pretend to know an ETA it can't compute. If something hangs, an escape hatch so a wedged test run doesn't trap the whole app. Step counter for the compound "asset lock → proof → register → fund" chain. | + +**Cross-persona truth:** an indeterminate spinner with *no fake progress bar* is the honest +choice — we frequently cannot know how long a Platform round-trip takes. A counterfeit +percentage erodes trust faster than an honest "this is working." Validated first against Alex +(least technical): if Alex understands "the app is busy and will tell me when it's done," the +others are covered. + +--- + +## 2. Functional Requirements + +Each FR is written so it can be acceptance-tested. "Overlay" = the blocking progress overlay. + +### FR-1 — Show the overlay +A caller can raise the overlay with a single call that returns a lifecycle **handle** +(mirroring `BannerHandle`). The call accepts at minimum a description string and a config +(spinner always on; counter, buttons optional). +**AC-1.1** After a show call, the overlay is visible on the next frame, centered, over the whole window. +**AC-1.2** The call returns a handle usable to update or dismiss the overlay later. +**AC-1.3** The show call is safe to issue from a screen's `ui()` return path or from the app loop; it never blocks the calling thread. + +### FR-2 — Replace / update overlay content +The handle can update the description, the step counter, and the button set **in place** +without tearing the overlay down or restarting the spinner. +**AC-2.1** Updating the description changes the visible text on the next frame; the spinner does not flicker or reset. +**AC-2.2** Updating the counter from `2 of 5` to `3 of 5` changes only the counter line. +**AC-2.3** A stale handle (overlay already dismissed) is a no-op returning `None` — never a panic. + +### FR-3 — Hide the overlay +The overlay is dismissed (a) programmatically via the handle, or (b) by the app loop when +the owning operation's `TaskResult` arrives. +**AC-3.1** On dismissal the overlay disappears next frame and interaction beneath is fully restored. +**AC-3.2** Dismissing an already-dismissed overlay is a no-op. +**AC-3.3** When the overlay is dismissed because a task failed, the resulting error is shown via `MessageBanner` *after* the overlay is gone (clean hand-off — see FR-9). + +### FR-4 — Indeterminate spinner, no ETA +The overlay always shows an animated indeterminate spinner. It **must not** render any +time-derived percentage, progress bar, or ETA. +**AC-4.1** The spinner animates continuously while visible (`egui::Spinner`, which self-requests repaint — no custom per-frame timer). +**AC-4.2** No element expresses "X% complete" or "N seconds remaining" derived from elapsed time. +**AC-4.3** An *optional* honest elapsed-time readout (`Elapsed: {seconds}s`, counting up, never counting down) MAY be shown for reassurance on very long waits — this is not an ETA and is off by default. + +### FR-5 — Optional step counter (determinate, discrete) +For multi-step operations, the overlay MAY show a discrete step counter. +**AC-5.1** When a counter is set, the overlay shows a single i18n-ready line: `Step {current} of {total}` (named placeholders, no fragment concatenation). +**AC-5.2** `current` and `total` are positive integers; `current ≤ total`. An invalid pair (e.g. `0 of 0`, `4 of 3`) hides the counter rather than rendering nonsense. +**AC-5.3** The counter is independent of the spinner — the spinner stays indeterminate even when a counter is present (a step counter is *not* a progress percentage). +**AC-5.4** A counter is optional: spinner-only overlays render no counter line and reserve no empty space for it. + +### FR-6 — Description text +The overlay MAY show a description of the operation in progress. +**AC-6.1** The description is a complete, plain-language sentence (i18n unit; see NFR-2). +**AC-6.2** Long descriptions wrap; they do not clip or force the window off-screen (scroll within the overlay card if needed). +**AC-6.3** A description is optional; spinner-only is valid (purely informational block with no text is permitted but discouraged for Alex's sake). + +### FR-7 — Optional action buttons (Cancel + generic actions) +The overlay MAY show zero or more action buttons. Cancel is the canonical one but the +mechanism is generic. +**AC-7.1** Buttons are optional. With none, the overlay is a pure block dismissed only programmatically. +**AC-7.2** Each button carries a label (i18n unit) and an opaque **action id**. Clicking pushes the action id into an overlay-action queue that the app loop drains and dispatches — exactly mirroring `BannerHandle::with_action` / `MessageBanner::take_action`. The overlay never calls backend code directly (UI-only seam; see NFR-1 and §6). +**AC-7.3** A Cancel button uses a well-known action id; the app loop maps it to the operation's cancellation path. _(Superseded — see top banner: buttons are generic with opaque ids keyed to the owning screen; there is no well-known Cancel id and no app-loop cancellation mapping.)_ +**AC-7.4** Buttons follow project button order (Confirm/primary RIGHT, Cancel LEFT) and use `StyledButton`/`ComponentStyles`, never bare `ui.button()`. +**AC-7.5** Cancel SHOULD be offered only when the operation is genuinely cancelable (see Risk R-3). When it cannot truly cancel, do not show a button that lies. + +### FR-8 — Block all interaction beneath +While visible, the overlay blocks every interactive element beneath it — central content, +left navigation panel, and top panel — and consumes keyboard input not directed at the +overlay's own controls. +**AC-8.1** Pointer clicks/drags on any region outside the overlay's own buttons have no effect on the UI beneath. +**AC-8.2** Keyboard input (Tab, Enter, typing) does not reach widgets beneath the overlay. (Do not rely on `Ui::set_enabled()` — deprecated in egui 0.33; use a top input-capturing layer instead.) +**AC-8.3** The block covers the *entire* window, including top and left panels — therefore the overlay renders at the `AppState` level, not inside `island_central_panel()` (which only wraps central content). See §3. +**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. _(Superseded — see top banner: there is no built-in Cancel; dismissal is programmatic. A screen MAY add a generic button (`with_action`) that triggers its own teardown.)_ + +### FR-9 — Coexistence with MessageBanner (z-order + hand-off) +**AC-9.1** The overlay renders **above** all `MessageBanner` banners (banners live inside the island content area at a background layer; the overlay sits on a top layer). The overlay wins z-order. +**AC-9.2** Banners already on screen when the overlay appears are covered/dimmed; because banner state persists in `ctx.data`, they reappear intact when the overlay is dismissed. +**AC-9.3** For a single operation, overlay and result-banner are **temporally exclusive**: the overlay is up *while running*; on completion the overlay is dismissed and then the success/error banner is shown. AppState owns this hand-off so screens don't double-report. + +### FR-10 — Multiple operations requesting the overlay +There is a **single global overlay slot**, but requests are tracked so concurrent owners +behave predictably. +**AC-10.1** The overlay holds a small **stack of active requests** (each keyed by its handle). The overlay is visible while the stack is non-empty. +**AC-10.2** The **most-recently-shown** request is the one rendered (its description, counter, buttons). Earlier requests remain on the stack but are not rendered. +**AC-10.3** Each handle dismisses **only its own** entry. A stale/earlier handle dismissing does not lower an overlay still owned by a later request. The overlay fully clears only when the stack empties. +**AC-10.4** Rationale and caveat: because the overlay *blocks the UI*, a human cannot launch a second blocking operation — concurrent requests can only come from background/programmatic tasks and are a design smell. The stack model degrades gracefully (it never strands a running operation behind a prematurely-cleared overlay) but callers SHOULD avoid stacking blockers. A single "concurrent overlay" event is logged once, not per frame. +**AC-10.5** Only the topmost request's Cancel/actions are reachable; lower requests' actions become reachable when the top is dismissed. + +> **Decision deferred to Nagatha (1d):** stack (AC-10.1, recommended) vs. simple +> last-writer-replace vs. reject-second. The stack is the only model that never unblocks the +> UI while an operation is still running; the simpler models are acceptable if product +> guarantees no concurrent blockers. + +--- + +## 3. Render Seam (important — differs from MessageBanner) + +`MessageBanner::show_global()` is called *inside* `island_central_panel()` +(`src/ui/components/styled.rs:565`), i.e. within the central content island — it therefore +**cannot** cover the top panel or left navigation panel. + +The blocking overlay must cover **everything**. So it follows the **`AppState`-level render +pattern** already used by the just-in-time secret prompt +(`render_secret_prompt(ctx)`, `src/app.rs:1527`), which draws over all panels after they are +laid out. The overlay's *state* still lives globally in egui `ctx.data` (mirroring banners), +but its *render call* belongs at the end of `AppState::update()`, on a top input-capturing +layer — not in `island_central_panel()`. + +Layering target (top to bottom): + +``` + ┌─ secret-prompt / confirmation modals (must stay ABOVE overlay — see R-1) + ├─ BLOCKING PROGRESS OVERLAY (top input-capturing layer + dim plane) + ├─ MessageBanner banners (inside island content area) + └─ Top panel / Left panel / Central content +``` + +--- + +## 4. Non-Functional Requirements + +### NFR-1 — Never block the frame/render thread +The overlay is a visual + input block **only**. The owning operation runs on a tokio backend +task via the existing `BackendTask`/`TaskResult` channel; the overlay is raised at dispatch +and lowered when the result is polled in `AppState::update()`. +**AC:** No code path holds a lock across `.await` to keep the overlay up; no synchronous +sleep/wait in the frame loop. (Reference incident: PR860 — async blocking in the egui frame +loop deadlocked the UI.) + +### NFR-2 — i18n-ready strings +All overlay text is i18n-ready: complete sentences, named placeholders, no fragment +concatenation, no grammar assembled from pieces. +**AC:** Counter is one unit `Step {current} of {total}`; elapsed is `Elapsed: {seconds}s`; +descriptions are full sentences. No positional `format!` of sentence fragments. Matches the +project's i18n convention (Rust `{name}` specifiers today, Fluent `{ $name }` later). + +### NFR-3 — Accessibility (within egui's constraints) +**AC-3a (focus trap):** while the overlay is up, keyboard focus is confined to its own +controls; Tab does not cycle into widgets beneath. When a block opts into a keyboard escape +(AC-3c), focus is *pinned* to that escape button — re-requested every frame and locked — so +neither Tab nor a click can move focus to a widget beneath. +**AC-3b (Esc):** Esc triggers Cancel **only when the overlay is cancelable** (a Cancel action +is present). With no Cancel, Esc is swallowed (does nothing) — it must not dismiss a +non-cancelable block. Esc never dismisses, even on an opt-in keyboard-escape block. +**AC-3c (Enter/Space — opt-in keyboard escape):** A hard block is **not** keyboard-activatable +by default: Enter/Space are stripped every frame, so a focused button cannot be triggered by +keyboard. The single exception is a block that opts in via `OverlayConfig::with_keyboard_escape`, +which designates one action as a keyboard-reachable escape. For that block — and only while its +escape button is confirmed to hold focus — Enter or Space activates the escape (egui fires a +fake primary click on either). This is for **unbounded** blocks that would otherwise strand a +keyboard-only / assistive-tech user; the reference adopter is the SPV-sync block, whose +"Continue in the background" escape is so designated. Every other hard block, and everything +beneath any block, stays fully keyboard-blocked. +**AC-3d (not color-only):** "busy" is signalled by the moving spinner + text, not color alone. +**AC-3e (contrast):** text/buttons meet WCAG 2.1 AA (4.5:1 text, 3:1 UI) on the dimmed plane in both themes. +**AC-3f (known limitation):** egui has no screen-reader annotation support (per +`docs/ux-design-patterns.md` §10). Documented as a constraint; the moving spinner + visible +sentence are the available affordances. No false promise of SR announcements. + +### NFR-4 — Light + dark theme +**AC:** All colors via `DashColors` (dim plane via `modal_overlay()` = `rgba(0,0,0,120)`, +re-evaluated each frame so a theme switch mid-overlay is correct). Card uses +`surface`/`window_fill`, text via `text_primary`/`text_secondary`, spinner via `DASH_BLUE`, +buttons via `ComponentStyles`. Zero hardcoded `Color32`. + +### NFR-5 — No per-frame log spam +**AC:** State changes log **once** — on show, on each counter/description change, on dismiss — +guarded by a `logged`/last-logged flag in the stored state (mirroring `BannerState.logged`). +Rendering at ~60fps must not emit ~60 logs/sec. State lives in `ctx.data`, **not** a per-frame +reconstructed instance (avoids the "fresh instance each frame resets state + spams logs" +trap). + +### NFR-6 — Cheap render +**AC:** When no overlay is active, the render call is an early-out reading one `ctx.data` slot +(mirroring `set_global`'s empty check). No allocation on the idle path. + +--- + +## 5. User Journeys + +> **Usage rule (when to block vs. when to banner):** use the overlay only when continued +> interaction would be *unsafe or meaningless*. Long *background* work the user can safely +> ignore (e.g. ambient SPV sync, identity discovery sweeps) stays a non-blocking +> `MessageBanner::with_elapsed()` progress banner. Blocking the whole UI for ambient sync +> would punish Priya and Jordan, who legitimately work while syncing. +> +> **Superseded for the startup/Connect SPV-sync adopter (user decision).** The user has since +> chosen to **block** the UI during the initial get-connected SPV sync (startup auto-start and the +> Connect button — not ambient mid-session reconnect cosmetics), because letting the user act before +> the chain is usable is confusing. The power-user concern above is mitigated by an **always-visible +> "Continue in the background" escape**: SPV sync is read-only and safe to background, so Priya/Jordan +> can dismiss the block and keep working while sync proceeds. This is the overlay's first real adopter +> — see UX-002 in `docs/user-stories.md` and `AppState::update_spv_overlay` (`src/app.rs`). The +> escape is what satisfies the overlay's C2 "never trap the user" constraint, since SPV sync is +> unbounded (no terminal signal with no peers). + +### J-1 — Identity registration (multi-step, counter + Cancel) — Priya / Jordan +1. User confirms "Register identity." +2. Overlay appears: `Step 1 of 4` · "Preparing the funding lock." · Cancel. +3. Backend advances: handle updates to `Step 2 of 4` "Waiting for the funding proof.", then + `Step 3 of 4` "Registering your identity.", then `Step 4 of 4` "Funding your identity." +4. Spinner stays indeterminate throughout (each step's duration is unknown). +5. On the final `TaskResult::Success`, AppState dismisses the overlay, then shows a success + banner. On failure, overlay is dismissed, then an error banner appears (FR-9). + +### J-2 — Broadcasting / signing a transaction (spinner + description, often no Cancel) — all +1. User confirms a send / state-transition broadcast. +2. Overlay: spinner + "Sending your transaction to the network." No Cancel (a broadcast in + flight cannot be safely recalled — R-3). +3. Overlay lowers on result; banner reports outcome. + +### J-3 — Multi-step shielded operation (spinner + counter + description + Cancel) — Jordan/Priya +1. User starts a shield/unshield. +2. Overlay: `Step 2 of 3` · "Building the shielded transaction." · Cancel (note generation / + proving can be long and is locally cancelable before broadcast). +3. If the user cancels before broadcast, the Cancel action id is dispatched, the backend + aborts the local build, the overlay lowers, and an info banner notes "Operation canceled." + +### J-4 — Migration / key import (informational hard block, no buttons) — all +1. A one-shot migration step or sensitive key import runs. +2. Overlay: spinner + "Updating your wallet data. Please keep the app open." No buttons + (interrupting mid-migration risks inconsistent state). +3. Overlay lowers only when the step completes. (This is exactly the open question raised in + `docs/ai-design/2026-05-28-migration-tool/notes.md` — "Spinner with progress, modal + blocker, banner?" — this overlay is the answer for the blocking case.) + +### J-5 — Network switch (brief block) — Jordan +1. User switches Testnet ⇄ Devnet. +2. Brief overlay: spinner + "Switching networks." prevents interacting with stale + network state during the swap; lowers when the new context is ready. + +--- + +## 6. Interaction Patterns & Wireframes (ASCII) + +The dim plane (`modal_overlay()`) covers the whole window; a centered card holds the content. +The card uses the dialog idiom (rounded corners, shadow, `surface`/`window_fill`). + +### 6.1 Spinner-only (pure block) + +``` +┌───────────────────────────────────────────────────────────────┐ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░┌───────────────────────┐░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ (◠) │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ spinner │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░└───────────────────────┘░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +└───────────────────────────────────────────────────────────────┘ + ░ = dimmed, input-blocked backdrop (entire window incl. panels) +``` + +### 6.2 Spinner + step counter + +``` +░░░░░░░░░░░░┌─────────────────────────────────┐░░░░░░░░░░░░ +░░░░░░░░░░░░│ (◠) │░░░░░░░░░░░░ +░░░░░░░░░░░░│ spinner │░░░░░░░░░░░░ +░░░░░░░░░░░░│ │░░░░░░░░░░░░ +░░░░░░░░░░░░│ Step 3 of 5 │░░░░░░░░░░░░ +░░░░░░░░░░░░└─────────────────────────────────┘░░░░░░░░░░░░ +``` + +### 6.3 Spinner + counter + description + Cancel (full) + +``` +░░░░░░░┌───────────────────────────────────────────────┐░░░░░░░ +░░░░░░░│ (◠) │░░░░░░░ +░░░░░░░│ spinner │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Step 2 of 4 │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Waiting for the funding proof. This can │░░░░░░░ +░░░░░░░│ take up to a minute. │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ [ Cancel ] │░░░░░░░ +░░░░░░░└───────────────────────────────────────────────┘░░░░░░░ +``` + +### 6.4 Two generic actions (Cancel left, primary right) + optional elapsed + +``` +░░░░░░░┌───────────────────────────────────────────────┐░░░░░░░ +░░░░░░░│ (◠) spinner │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Building the shielded transaction. │░░░░░░░ +░░░░░░░│ Elapsed: 23s │░░░░░░░ ← honest, counts UP, optional +░░░░░░░│ │░░░░░░░ +░░░░░░░│ [ Cancel ] [ Run in background ] │░░░░░░░ +░░░░░░░└───────────────────────────────────────────────┘░░░░░░░ +``` + +### 6.5 Interaction states + +| State | Behavior | +|---|---| +| Visible, no buttons | Pure block. Esc/Enter swallowed. Backdrop click ignored. Dismissed only programmatically. | +| Visible, cancelable | _(Superseded — see top banner: Esc never cancels (it is swallowed with Tab/Enter/Space); buttons are generic, not a focusable Cancel. Backdrop click ignored.)_ | +| Button hover/focus | Standard `StyledButton` hover (pointing-hand) + focus ring (`BORDER_WIDTH_THICK`, ≥3:1). | +| Counter update | Only the counter line changes; spinner uninterrupted. | +| Theme switch mid-overlay | Colors re-evaluate next frame; no stale palette. | +| Window resized very small | Card shrinks to a min width; long description scrolls within the card; never pushed off-screen. | +| Operation hangs (no result) | After a threshold, surface optional elapsed readout and (if safe) an escape hatch — see R-4. | + +--- + +## 7. UX Recommendation — Extend `MessageBanner` vs. New Standalone Component + +**Recommendation: a NEW standalone component that mirrors `MessageBanner`'s architecture, not +an extension of `MessageBanner`.** (Final architecture call is Nagatha's in 1d; this is the +UX/maintainability advisory.) + +### Why not extend `MessageBanner` +| Dimension | MessageBanner | Blocking overlay | Verdict | +|---|---|---|---| +| Blocking | Never blocks; user works around it | Blocks the entire UI | Opposite semantics | +| Multiplicity | Up to 5 stacked, all visible | One rendered at a time | Different model | +| Z-order / seam | Inside `island_central_panel()` content area | `AppState`-level top layer over all panels | Different render seam (§3) | +| Lifecycle | Severity-based auto-dismiss | Dismissed by task result or Cancel; never auto-times-out | Different lifecycle | +| Anatomy | Icon + text + dismiss + optional details/suggestion/action | Spinner + description + counter + optional buttons, centered card | Different anatomy | + +Folding all of this into `BannerState` would bloat every banner with +spinner/step/button-set/blocking fields that are meaningless for the 99% of banners that are +simple notices, and would entangle the central render path. It violates single-responsibility +and would make the well-understood banner harder to reason about. + +### What to reuse (mirror, don't merge) +The overlay should **copy the proven *patterns***, keeping its own type: +1. **Global state in egui `ctx.data` temp storage**, keyed by a dedicated id (e.g. + `__global_progress_overlay`) — same mechanism as `BANNER_STATE_ID`. +2. **A lifecycle `OverlayHandle`** mirroring `BannerHandle` (`set_description`, `set_step`, + `with_action` / `with_secondary_action`, `clear`), all returning `Option` and no-op on a + dismissed overlay. _(Superseded: no `with_cancel` — buttons are generic; see the dev-plan + post-outage note and the code.)_ +3. **An action-id queue** drained by the app loop. As shipped (addendum §2) the queue is **keyed** + per overlay entry: a click enqueues against the owner's key, the owner drains its own ids via + `OverlayHandle::take_actions`, and the app loop only `sweep_orphan_actions` — keeps the overlay + UI-only; backend dispatch stays in `AppState`. This is the i18n-clean, `ctx.data`-friendly + equivalent of "callbacks": + storing closures in temp storage is awkward (not `Clone`); an opaque action id is the + established seam. +4. **Log-once discipline** via a `logged` flag (NFR-5). +5. **Theme tokens + button helpers** (`DashColors`, `ComponentStyles`, `StyledButton`). + +### Placement +Per the DET module-placement policy, it renders egui → it is a **component** in +`src/ui/components/` (e.g. `progress_overlay.rs`), with its `set_global(ctx)` invoked from +`AppState::update()` near `render_secret_prompt`. Non-rendering helpers stay out of it. + +A thin shared helper for the `ctx.data` get/set/clear plumbing *could* be factored out and +used by both components, but only if it reads cleanly — the volume of shared code is small, +and premature abstraction here would couple two intentionally-different widgets. Lean toward a +focused copy over a forced shared base; defer to Nagatha. + +--- + +## 8. Open Questions & Risks + +- **R-1 — Z-order vs. secret-prompt / confirmation modals.** If an operation behind the + overlay needs a passphrase mid-flight, the secret-prompt modal (`render_secret_prompt`) must + render **above** the overlay and stay interactive — otherwise the operation wedges. Per the + sign-time prompt design (gate-on-error + auto-retry, not mid-flight), the common case avoids + this, but the layer ordering must be explicit: secret prompt / confirmation dialog > overlay. + Decide and test. +- **R-2 — Concurrent blocking operations (FR-10).** Confirm the stack model vs. + last-writer-replace vs. reject-second. Stack is safest (never unblocks while a task runs); + simpler models need a product guarantee that blockers don't overlap. +- **R-3 — Does Cancel actually cancel?** _(Superseded — see top banner: no built-in Cancel ships; + cooperative backend cancellation (T7) is deferred and the 120s watchdog bounds every block + instead. The note below records the original concern.)_ The honesty of the Cancel button depends on the + `BackendTask` system supporting cooperative cancellation (cancel tokens / abortable tasks). + If a task cannot truly be aborted, Cancel can only *stop waiting* while the work continues — + which is misleading and unsafe (e.g. a broadcast). **Verify backend cancellation support + downstream.** Until then, show Cancel only for operations that are genuinely cancelable + (local pre-broadcast work); broadcasts/migrations should be button-less blocks. +- **R-4 — Stuck overlay / no TaskResult.** With an indeterminate spinner and no ETA, a hung + task could trap the user forever. Need a safety valve: after a threshold, reveal the optional + elapsed readout and — only where safe — an escape hatch ("This is taking longer than usual" + + a way out). Define the threshold and which operations get an escape hatch. +- **R-5 — Should the top-panel connection/network indicator remain readable?** A full dim + hides connection status. Decide whether the overlay should leave the connection indicator + legible (e.g. lighter dim on the top strip) so a user waiting on a network op can see the + network dropped. Leaning: keep the block total for simplicity, surface connection loss via + the post-dismissal banner; revisit if it confuses users. +- **R-6 — Accessibility ceiling.** egui exposes no screen-reader annotations + (`ux-design-patterns.md` §10). The moving spinner + visible sentence are the only + affordances; a non-sighted user gets no announced "busy." Documented limitation — flag if a + future AccessKit pass can announce overlay open/close. +- **R-7 — Tests.** kittest coverage should assert: input beneath is blocked, Esc cancels only + when cancelable, counter validation hides nonsense pairs, action id is enqueued on click and + drained FIFO, log-once, and dismiss-on-task-result hand-off to banner. Mirror the existing + `tests/kittest/message_banner.rs` style. + +--- + +## 9. Requirements Quality Checklist + +- ✅ Every persona has at least one journey (J-1…J-5 cover Alex, Priya, Jordan). +- ✅ Every FR has testable acceptance criteria. +- ✅ ≥3 real-life scenarios per major workflow (5 journeys, multiple step states). +- ✅ Edge/failure cases addressed (stale handle, hang, theme switch, tiny window, concurrent owners, failed task). +- ✅ Priorities/decisions flagged for the downstream owner (Nagatha) with rationale. +- ✅ Assumptions explicit (cancellation support, single-slot semantics, render seam). + +--- + +## 10. Notes for Downstream Phases + +- **Worktree/merge:** the requested first-action `git merge --ff-only 0484bcb6…` was a no-op — + local HEAD already sits at `0484bcb6` per `git status`. No shell was available in this design + session; all file/line references were read directly from the live working tree, so no + "re-verify after merge" caveat is needed for the citations. +- **Commit:** this design session had no shell access, so the doc could not be `git add`/ + committed automatically. The team lead should commit it: + `git add -A && git commit -m "docs(overlay): requirements + UX spec for blocking progress overlay"`. +- **Key source references (live tree):** `src/ui/components/message_banner.rs` (sibling + pattern), `src/ui/components/styled.rs:539-571` (`island_central_panel` render seam), + `src/app.rs:1527` (`render_secret_prompt` — the AppState-level modal render analog), + `src/ui/components/secret_prompt_host.rs` (global-modal-via-AppState pattern), + `src/ui/components/passphrase_modal.rs:128-156` (full-screen dim + centered window), + `src/ui/theme.rs:430` (`modal_overlay()`), `docs/ux-design-patterns.md` §8/§10/§11. +``` diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md new file mode 100644 index 000000000..1226be5fb --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -0,0 +1,1223 @@ +# Blocking Progress Overlay — Test Case Specification + +**Phase:** 1c (QA — Test Case Specification) +**Author:** Marvin (QA Engineer) +**Date:** 2026-06-17 +**Status:** Draft — pending architecture decision on FR-10 (see §4) +**Input:** `01-requirements-ux.md` (Requirements + UX Spec by Diziet) +**Style reference:** `tests/kittest/message_banner.rs` + +> TODO: this design-doc set (4 files, ~2,651 lines) plus the ~1,922-line +> kittest module is disproportionate to the shipped widget's actual footprint +> — `src/ui/components/progress_overlay.rs` (1,909 lines) has exactly two +> production call sites. Collapse the doc set to `01-requirements-ux.md` as +> the requirement record plus a trimmed TC table here (id/behavior/assertion +> seam only, no restated rationale); drop or clearly mark as +> review-checklist-only any unexecutable "design-review-only" test cases. + +--- + +## 1. Overview + +This document specifies acceptance test cases for the **Blocking Progress Overlay** component. +Test cases are derived entirely from the requirements spec (`01-requirements-ux.md`); expected +behavior is defined by the spec, never by a yet-to-be-written implementation. + +Every FR (FR-1 through FR-10) and NFR (NFR-1 through NFR-6) is covered by at least one TC. +Items that depend on the architecture decision deferred to Nagatha (1d) are marked +**[depends on 1d]**. + +> **Post-outage reframe (generic button).** First-class "Cancel" was removed in favour of a +> generic button facility: a caller attaches a button with `with_action(label, id)`, the click +> enqueues the caller's id, the owning screen drains it via `take_actions` and runs its own logic +> (including cancellation), and Esc does **not** dismiss. The Cancel-specific cases below +> (TC-OVL-024/025/026/027/042/043/044) are **reframed in place** to the generic-button model — +> numbers are preserved; each carries a "(reframed post-outage: generic button)" note. + +### 1.1 Test Type Key + +| Tag | Meaning | +|-----|---------| +| **kittest** | Implemented as an `egui_kittest` Harness test; assertable via `query_by_label`, rendered-widget tree, and `ctx.data` reads. Fast, deterministic, runs in CI. | +| **ctx.data** | Pure context-state assertion, no rendering; verifies `ctx.data` slot directly. Subtype of kittest. | +| **design-review** | Not directly automatable via kittest — must be verified by code inspection or human review. Noted with the specific invariant to check. | +| **integration** | Requires a full `AppState` frame loop (AppState-level render seam, task dispatch). Verifiable in the backend-e2e harness or a dedicated app-level kittest. | + +### 1.2 Naming Conventions Assumed + +The following public surface is assumed to exist (mirroring `MessageBanner`). Names are +illustrative; the architecture phase (1d) may adjust them. + +| Assumed name | Purpose | +|---|---| +| `ProgressOverlay::set_global(ctx, description, config)` | Raises the overlay; returns `OverlayHandle` | +| `ProgressOverlay::has_global(ctx)` | Returns `true` when an overlay is active | +| `ProgressOverlay::set_global_spinner_only(ctx)` | Convenience: spinner-only, no text | +| `ProgressOverlay::render_global(ctx)` | Render call from `AppState::update()` | +| `ProgressOverlay::take_actions(ctx)` | Drains the action-id queue (FIFO) | +| `OverlayHandle::set_description(text)` | Updates description; returns `Option<&Self>` | +| `OverlayHandle::set_step(current, total)` | Updates counter; returns `Option<&Self>` | +| `OverlayHandle::clear_step()` | Removes counter; returns `Option<&Self>` | +| `OverlayConfig::with_action(label, id)` / `OverlayHandle::with_action(label, id)` | Adds a generic button (reframed post-outage: no built-in Cancel); the handle form returns `Option<&Self>` | +| `OverlayHandle::clear()` | Dismisses this handle's overlay entry | +| `OverlayHandle::is_active()` | Returns `true` if still on the overlay stack | + +--- + +## 2. Test Cases + +### Group A — Idle Path + +--- + +#### TC-OVL-001 — No overlay renders when no state is set +**Type:** kittest +**Traceability:** NFR-6 (cheap idle path) + +**Preconditions:** A fresh `egui::Context` with no overlay state written to `ctx.data`. + +**Steps:** +1. Build a Harness with `ProgressOverlay::render_global()` in the `build_ui` closure. +2. Call `harness.run()`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- No spinner widget, no description label, no step counter label, no button appears in the + rendered tree. +- The render call performs a single `ctx.data` read and returns immediately (no allocation; + verified by code inspection — see NFR-6 AC). + +--- + +### Group B — Show Lifecycle (FR-1) + +--- + +#### TC-OVL-002 — Overlay appears on the next frame after show +**Type:** kittest +**Traceability:** FR-1 (AC-1.1) + +**Preconditions:** Fresh context; no overlay active. + +**Steps:** +1. Inside `build_ui`, call `ProgressOverlay::set_global(ctx, "Registering your identity.", config_default())`. +2. Also call `ProgressOverlay::render_global(ctx)`. +3. Call `harness.run()`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `true`. +- A label containing `"Registering your identity."` is present in the rendered tree + (`harness.query_by_label("Registering your identity.").is_some()`). +- A spinner widget is present (query by egui `Spinner` widget type or by its accessibility label + if one is set — see implementation note in AC-3d). + +--- + +#### TC-OVL-003 — Show call returns a usable handle +**Type:** ctx.data +**Traceability:** FR-1 (AC-1.2) + +**Preconditions:** Fresh context. + +**Steps:** +1. Call `let handle = ProgressOverlay::set_global(&ctx, "Loading.", config_default())`. +2. Assert `handle.is_active()` returns `true`. +3. Call `handle.set_description("Updated text.")` and assert it returns `Some(&handle)`. +4. Assert `ProgressOverlay::has_global(&ctx)` returns `true`. + +**Expected outcome:** +All assertions pass. The handle is non-null and addresses a live overlay entry. + +--- + +#### TC-OVL-004 — Show call never blocks the calling thread +**Type:** design-review +**Traceability:** FR-1 (AC-1.3), NFR-1 + +**Invariant to verify during code review:** +`ProgressOverlay::set_global()` must not acquire any async lock, call `.await`, or issue a +`std::thread::sleep`. It must only write to `egui::ctx.data` (a synchronous, lock-guarded +`TypeMap`). The implementation must be callable safely from `Screen::ui()` or from the app +loop without any risk of yielding. + +**CI note:** Reference PR860 (deadlock caused by async blocking in egui frame loop). Any +`async fn`, `.await`, `Mutex::lock().await`, or `sleep` in the show path is a blocker. + +--- + +### Group C — Update In Place (FR-2) + +--- + +#### TC-OVL-005 — Description update changes text; spinner does not flicker +**Type:** kittest +**Traceability:** FR-2 (AC-2.1) + +**Preconditions:** Overlay active with description `"Preparing the funding lock."`. + +**Steps:** +1. Set overlay with description `"Preparing the funding lock."`. +2. Run one frame; assert label `"Preparing the funding lock."` is present. +3. Call `handle.set_description("Waiting for the funding proof.")`. +4. Run another frame. + +**Expected outcome:** +- Label `"Waiting for the funding proof."` is present. +- Label `"Preparing the funding lock."` is absent. +- The spinner widget is still present (same render path; no reset observable — no widget + re-creation that would reset the animation seed). +- `ProgressOverlay::has_global(ctx)` still returns `true`. + +--- + +#### TC-OVL-006 — Counter update changes only the counter line +**Type:** kittest +**Traceability:** FR-2 (AC-2.2) + +**Preconditions:** Overlay active with step `2 of 5`, description `"Processing."`. + +**Steps:** +1. Show overlay; set step `(2, 5)` and description `"Processing."`. +2. Run one frame; assert label `"Step 2 of 5"` is present. +3. Call `handle.set_step(3, 5)`. +4. Run another frame. + +**Expected outcome:** +- Label `"Step 3 of 5"` is present. +- Label `"Step 2 of 5"` is absent. +- Label `"Processing."` still present (description unchanged). +- Spinner still present. + +--- + +#### TC-OVL-007 — Stale handle update is a no-op returning None +**Type:** ctx.data +**Traceability:** FR-2 (AC-2.3) + +**Preconditions:** An `OverlayHandle` whose overlay has already been dismissed. + +**Steps:** +1. Show overlay; capture `handle`. +2. Call `handle.clear()` to dismiss. +3. Call `handle.set_description("After clear")`. +4. Call `handle.set_step(1, 3)`. +5. Call `handle.with_action("Continue", "overlay.action")`. + +**Expected outcome:** +- All handle method calls return `None`. +- `ProgressOverlay::has_global(ctx)` returns `false` (no new overlay created). +- No panic. + +--- + +### Group D — Dismiss (FR-3) + +--- + +#### TC-OVL-008 — Programmatic dismiss removes overlay and restores interaction +**Type:** kittest +**Traceability:** FR-3 (AC-3.1) + +**Preconditions:** Overlay is active. + +**Steps:** +1. Show overlay. +2. Run one frame; assert `has_global` is `true`. +3. Call `handle.clear()`. +4. Run another frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- No spinner, description, or counter label is present in the rendered tree. +- Widgets that were beneath the overlay are accessible again (not blocked — verified by + querying a sibling label that was previously beneath the dim plane and confirming it is + present and interactive). + +--- + +#### TC-OVL-009 — Double dismiss is a no-op +**Type:** ctx.data +**Traceability:** FR-3 (AC-3.2) + +**Preconditions:** Fresh context. + +**Steps:** +1. Show overlay; capture `handle`. +2. Call `handle.clear()` — assert returns without panic. +3. Call `handle.clear()` a second time — assert no panic. +4. Assert `ProgressOverlay::has_global(ctx)` returns `false`. + +**Expected outcome:** No panic; `has_global` is `false`. + +--- + +#### TC-OVL-010 — Dismiss on task failure: overlay gone before error banner appears +**Type:** integration +**Traceability:** FR-3 (AC-3.3), FR-9 (AC-9.3), J-1 / J-2 flow + +**Preconditions:** A simulated task that returns `TaskResult::Failure` is dispatched with an +overlay raised at dispatch time. + +**Steps:** +1. App loop dispatches task; raises overlay. +2. Simulate a `TaskResult::Failure` arriving in the `task_result_receiver`. +3. App loop polls result; in the same frame: + a. Dismisses the overlay (`handle.clear()`). + b. Calls `MessageBanner::set_global(ctx, error_message, MessageType::Error)`. +4. Render the frame via `render_global(ctx)` and `MessageBanner::show_global(ui)`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- `MessageBanner::has_global(ctx)` returns `true` with the error text. +- No frame exists where both the overlay and the error banner are visible simultaneously. + +--- + +### Group E — Spinner (FR-4) + +--- + +#### TC-OVL-011 — Spinner is always present when overlay is active +**Type:** kittest +**Traceability:** FR-4 (AC-4.1) + +**Preconditions:** Overlay raised in each of the three configurations: spinner-only, +spinner+counter, spinner+counter+description+button. + +**Steps:** For each configuration, call `set_global`, run one frame, query the rendered tree. + +**Expected outcome:** +An `egui::Spinner` widget (or widget with the spinner accessibility label, per implementation) +is present in all three configurations. The spinner does not require a custom per-frame repaint +timer — it self-requests repaint via egui's native animation clock. + +--- + +#### TC-OVL-012 — No ETA, progress bar, or percentage element present +**Type:** kittest +**Traceability:** FR-4 (AC-4.2) + +**Preconditions:** Overlay active with a description and step counter. + +**Steps:** +1. Show overlay with step `(2, 5)` and description `"Building the shielded transaction."`. +2. Run one frame; collect all rendered labels. + +**Expected outcome:** +- No label matches the pattern `*%*` (percentage). +- No label matches `*remaining*`, `*seconds left*`, `*ETA*`, or any time-countdown string. +- No `egui::ProgressBar` widget is present in the render tree. +- The step counter label `"Step 2 of 5"` is present (discrete, not a progress percentage). + +--- + +#### TC-OVL-013 — Optional elapsed readout is off by default; when on, counts up +**Type:** kittest +**Traceability:** FR-4 (AC-4.3) + +**Preconditions:** Fresh context. + +**Steps (Part A — default off):** +1. Show overlay with default config. +2. Run one frame. +3. Assert no label matching `"Elapsed:"` is present. + +**Steps (Part B — enabled):** +1. Show overlay; enable elapsed readout via config. +2. Run one frame; assert a label matching `"Elapsed: {seconds}s"` with `seconds ≥ 0` is present. +3. Advance the egui clock by 2 seconds; run another frame. +4. Assert the `seconds` value in the label is ≥ 2 and is not counting down. + +**Expected outcome:** +- Part A: no elapsed label present. +- Part B: label present; the `seconds` value increases monotonically across frames; it does + not count down from any target. + +--- + +### Group F — Step Counter (FR-5) + +--- + +#### TC-OVL-014 — Valid counter renders "Step {current} of {total}" +**Type:** kittest +**Traceability:** FR-5 (AC-5.1) + +**Preconditions:** Overlay raised with `set_step(3, 5)`. + +**Steps:** +1. Show overlay; set step `(3, 5)`. +2. Run one frame. + +**Expected outcome:** +- Exactly one label matching `"Step 3 of 5"` is present. +- The string is a single i18n unit with named placeholders rendered into it; it is not + constructed by concatenating `"Step "`, `"3"`, `" of "`, `"5"` as separate label segments + (design-review: verify the format string in the implementation is `"Step {current} of + {total}"` or equivalent single-unit string, not fragment concatenation). + +--- + +#### TC-OVL-015 — Invalid counter (0 of 0) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(0, 0)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(0, 0)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present in the rendered tree. +- The spinner and any description are still present. + +--- + +#### TC-OVL-016 — Invalid counter (current > total) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(4, 3)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(4, 3)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No panic. + +--- + +#### TC-OVL-017 — Invalid counter (current = 0, total > 0) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(0, 5)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(0, 5)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No panic. + +--- + +#### TC-OVL-018 — Counter presence does not make the spinner determinate +**Type:** kittest +**Traceability:** FR-5 (AC-5.3) + +**Preconditions:** Overlay raised with `set_step(2, 4)`. + +**Steps:** +1. Show overlay with step `(2, 4)`. +2. Run one frame. + +**Expected outcome:** +- Label `"Step 2 of 4"` is present. +- An `egui::Spinner` widget is present (indeterminate — no `egui::ProgressBar` present). +- No percentage label is present. + +--- + +#### TC-OVL-019 — No counter line when counter is not set +**Type:** kittest +**Traceability:** FR-5 (AC-5.4) + +**Preconditions:** Overlay raised with description only; `set_step` never called. + +**Steps:** +1. Show overlay with description `"Sending your transaction to the network."` and no step. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No empty/blank row is reserved for the counter (no extraneous whitespace element). +- Description and spinner are present. + +--- + +### Group G — Description Text (FR-6) + +--- + +#### TC-OVL-020 — Description renders as a full plain-language sentence +**Type:** kittest +**Traceability:** FR-6 (AC-6.1) + +**Preconditions:** Overlay raised with description `"Registering your identity on the network."`. + +**Steps:** +1. Show overlay with the description string above. +2. Run one frame. + +**Expected outcome:** +- Label `"Registering your identity on the network."` is present as a single label (not split + across multiple egui labels — design-review: verify single `ui.label()` call with the full + string, not fragment concatenation). + +--- + +#### TC-OVL-021 — Long description wraps and does not clip or push off-screen +**Type:** kittest +**Traceability:** FR-6 (AC-6.2) + +**Preconditions:** A harness window narrower than the description text (e.g. 300 px wide). +Description is `"Waiting for the funding proof. This operation contacts the Dash network and may take up to two minutes depending on network conditions."`. + +**Steps:** +1. Build harness with `egui::vec2(300.0, 400.0)`. +2. Show overlay with the long description above. +3. Run one frame. + +**Expected outcome:** +- The label is present in the rendered tree (not clipped to `""` or empty). +- No egui `clip_rect` overflow warning fires (monitored via test output). +- The card and overlay remain within the window bounds (all widgets within `[0, 300] × [0, 400]`). + +--- + +#### TC-OVL-022 — Spinner-only overlay is valid (no description, no counter) +**Type:** kittest +**Traceability:** FR-6 (AC-6.3), FR-5 (AC-5.4) + +**Preconditions:** Overlay raised via `set_global_spinner_only(ctx)`. + +**Steps:** +1. Raise spinner-only overlay. +2. Run one frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `true`. +- Spinner widget is present. +- No description label is present. +- No step counter label is present. +- No button is present. + +--- + +### Group H — Buttons & Actions (FR-7) + +--- + +#### TC-OVL-023 — No buttons: overlay is a pure block, dismissed programmatically only +**Type:** kittest +**Traceability:** FR-7 (AC-7.1) + +**Preconditions:** Overlay raised with no buttons in config. + +**Steps:** +1. Show overlay with no `with_action` calls. +2. Run one frame. + +**Expected outcome:** +- No button widget is present in the rendered tree. +- `ProgressOverlay::take_actions(ctx)` returns an empty list. +- `ProgressOverlay::has_global(ctx)` is `true` (overlay persists — only `handle.clear()` can + lower it). + +--- + +#### TC-OVL-024 — Button click enqueues its action id (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** FR-7 (AC-7.2, AC-7.3) + +**Preconditions:** Overlay raised with `with_action("Cancel", "overlay.cancel")`. There is no +built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cancel"` a caller-chosen id. + +**Steps:** +1. Show overlay with a generic button. +2. Run one frame; assert a button with label `"Cancel"` is present. +3. Click the button (via `harness.get_by_label("Cancel").click()`). +4. Run another frame. +5. Call `ProgressOverlay::take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns a list containing exactly one entry equal to the caller's id `"overlay.cancel"`. +- The overlay itself is NOT automatically dismissed by the click — it remains until the owning + screen drains the action and explicitly calls `handle.clear()` (the overlay is UI-only and knows + nothing about cancellation). + +--- + +#### TC-OVL-025 — Generic button click enqueues its action id (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**Preconditions:** Overlay raised with `with_action("Run in background", "overlay.run_in_bg")`. + +**Steps:** +1. Show overlay with generic action button labelled `"Run in background"`. +2. Run one frame; assert button present. +3. Click the button. +4. Run another frame; call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns `["overlay.run_in_bg"]`. +- Overlay still active (not auto-dismissed). + +--- + +#### TC-OVL-026 — Action queue drains FIFO (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**Preconditions:** Overlay raised with two generic buttons (no built-in Cancel). + +**Steps:** +1. Show overlay with `with_action("Cancel", "cancel")` and `with_action("Secondary", "secondary")`. +2. Click `"Cancel"`; run one frame. +3. Click `"Secondary"`; run one frame. +4. Call `ProgressOverlay::take_actions(ctx)` once. + +**Expected outcome:** +- `take_actions` returns `["cancel", "secondary"]` in that order (FIFO). +- A second call to `take_actions` returns an empty list (queue drained). + +--- + +#### TC-OVL-027 — Buttons render in insertion order (reframed post-outage: generic button) +**Type:** kittest (widget-position assertion) + design-review +**Traceability:** FR-7 (AC-7.4) + +**Preconditions:** Overlay with `with_action("First action", "first")` and +`with_action("Second action", "second")`. There is no Cancel-specific placement — buttons render +left-to-right in insertion order. + +**Steps:** +1. Show overlay; run one frame. +2. Query the widget rects for `"First action"` and `"Second action"`. + +**Expected outcome:** +- The X coordinate of the first-added button's rect is less than that of the second-added button + (left-to-right insertion order). +- Both buttons use `ComponentStyles` button helpers, not bare `ui.button()` (design-review: verify + no `ui.button()` call in the overlay renderer). + +--- + +### Group I — Input Blocking (FR-8) + +--- + +#### TC-OVL-028 — Pointer clicks on the dimmed backdrop have no effect on widgets beneath +**Type:** kittest +**Traceability:** FR-8 (AC-8.1) + +**Preconditions:** A test widget (a counter button labelled `"Increment"`) is rendered beneath +the overlay. Overlay has no buttons. + +**Steps:** +1. Render both the backdrop-blocking overlay and the `"Increment"` counter widget. +2. Simulate a pointer click at the position of the `"Increment"` button. +3. Run one frame. + +**Expected outcome:** +- The counter widget has not received the click event (counter value unchanged). +- `ProgressOverlay::take_actions(ctx)` returns empty (no overlay action triggered either). + +--- + +#### TC-OVL-029 — Keyboard input does not reach widgets beneath the overlay +**Type:** kittest +**Traceability:** FR-8 (AC-8.2) + +**Preconditions:** A text input widget is rendered beneath the overlay. Overlay has one generic +button. + +**Steps:** +1. Render both the overlay (with a button) and a `TextEdit` widget beneath. +2. Type characters `"hello"` via `harness.key_press` or equivalent. +3. Run one frame. + +**Expected outcome:** +- The `TextEdit` content is unchanged — no characters were forwarded beneath the overlay. +- The overlay's button may have received focus (expected — the overlay captures input); + the `TextEdit` must not. + +**Implementation note (AC-8.2):** do not use `Ui::set_enabled(false)` (deprecated in egui +0.33). The spec requires a top input-capturing layer. Verify in design review that the +implementation uses `egui::Area` with `order(Order::Foreground)` or equivalent `ctx.input` +consumption, not `set_enabled`. + +--- + +#### TC-OVL-030 — Backdrop click does NOT dismiss the overlay +**Type:** kittest +**Traceability:** FR-8 (AC-8.4) + +**Preconditions:** Overlay active with no buttons. + +**Steps:** +1. Show overlay. +2. Run one frame; assert overlay active. +3. Simulate a pointer click on the dim plane (anywhere outside the card). +4. Run another frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is still `true`. +- No action was enqueued. + +--- + +#### TC-OVL-031 — Overlay renders at AppState level, covering all panels +**Type:** design-review +**Traceability:** FR-8 (AC-8.3), §3 Render Seam + +**Invariant to verify during code review:** +`ProgressOverlay::render_global(ctx)` must be called from `AppState::update()` after all +panels are laid out — not from inside `island_central_panel()`. The call site should be near +`render_secret_prompt(ctx)` at `src/app.rs:1527`. The overlay uses an `egui::Area` or +equivalent top layer to cover the entire viewport (top panel + left panel + central content), +not just the central content island. + +Verify: grepping for `render_global` in `src/app.rs` finds a call after panel rendering; +grepping for `render_global` in `src/ui/components/styled.rs` (`island_central_panel`) finds +**no** call. + +--- + +### Group J — Coexistence with MessageBanner (FR-9) + +--- + +#### TC-OVL-032 — Overlay renders above MessageBanner banners (z-order) +**Type:** design-review + integration +**Traceability:** FR-9 (AC-9.1) + +**Invariant:** +When both a banner and the overlay are active, the overlay's rendering layer (e.g. +`Order::Foreground` or `Order::Tooltip`) must be higher than the banner's layer (banner is +inside the central panel at default order). Verify by checking the `egui::Area::order()` used +for the overlay's dim plane; it must be above the order used for banner rendering. + +**Integration check (when available):** +In a full-app harness, add a banner and raise the overlay simultaneously. Assert that the +overlay's dim fills the expected region and the banner's label is not directly reachable +(obscured — `query_by_label` may still find the label in the widget tree even if dimmed; +the critical check is that the banner is behind the input-blocking layer). + +--- + +#### TC-OVL-033 — Banners persist in ctx.data while overlay is active; reappear on dismiss +**Type:** ctx.data +**Traceability:** FR-9 (AC-9.2) + +**Preconditions:** Two banners set via `MessageBanner::set_global`. + +**Steps:** +1. Set banners: `"Banner A"` (Error) and `"Banner B"` (Warning). +2. Raise the overlay. +3. Assert `MessageBanner::has_global(ctx)` is still `true` (banners survive in `ctx.data`). +4. Dismiss the overlay via `handle.clear()`; run one frame. +5. Call `MessageBanner::show_global(ui)`. + +**Expected outcome:** +- After dismiss, both `"Banner A"` and `"Banner B"` labels are present in the rendered tree. +- Banner state was not cleared or corrupted by the overlay lifecycle. + +--- + +#### TC-OVL-034 — Success task result: overlay dismissed before success banner shown +**Type:** integration +**Traceability:** FR-9 (AC-9.3), J-1 flow + +**Preconditions:** AppState dispatches a task; overlay is raised at dispatch time. + +**Steps:** +1. Dispatch a task; raise overlay. +2. Simulate `TaskResult::Success(result)` arriving on the receiver. +3. App loop frame: + a. Receives result. + b. Calls `handle.clear()` (overlay lowered). + c. Calls `MessageBanner::set_global(ctx, "Your identity has been registered.", MessageType::Success)`. +4. Render the frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `false`. +- `MessageBanner::has_global(ctx)` is `true` with the success text. +- No frame saw both active simultaneously (single-frame hand-off). + +--- + +#### TC-OVL-035 — Failed task result: overlay dismissed before error banner shown +**Type:** integration +**Traceability:** FR-3 (AC-3.3), FR-9 (AC-9.3) + +**Preconditions:** AppState dispatches a task; overlay is raised at dispatch time. + +**Steps:** +1. Dispatch a task; raise overlay. +2. Simulate `TaskResult::Error(err)` arriving on the receiver. +3. App loop frame: + a. Receives error. + b. Calls `handle.clear()`. + c. Calls `MessageBanner::set_global(ctx, "Registration failed. Try again.", MessageType::Error)`. +4. Render the frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `false`. +- `MessageBanner::has_global(ctx)` is `true` with an error-type banner. +- The error message is user-friendly (no SDK internals, no jargon — spot-check the display text + per the error message convention in `CLAUDE.md`). + +--- + +### Group K — Concurrent Operations (FR-10) — **[depends on 1d]** + +> All TCs in this group depend on the architecture decision (stack vs. replace vs. reject) +> deferred to Nagatha in Phase 1d. The TCs below are written for the **stack model** recommended +> in AC-10.1. If Nagatha selects a different model, these TCs must be revised. + +--- + +#### TC-OVL-036 — Topmost stack entry's content is rendered [depends on 1d] +**Type:** kittest +**Traceability:** FR-10 (AC-10.1, AC-10.2) + +**Preconditions:** Two overlay requests pushed. + +**Steps:** +1. Call `set_global(ctx, "Operation A.", cfg_a)` — capture `handle_a`. +2. Call `set_global(ctx, "Operation B.", cfg_b)` — capture `handle_b`. +3. Run one frame. + +**Expected outcome (stack model):** +- `ProgressOverlay::has_global(ctx)` is `true`. +- Label `"Operation B."` is present (topmost entry rendered). +- Label `"Operation A."` is absent (bottom entry not rendered). +- Both `handle_a.is_active()` and `handle_b.is_active()` return `true` (both on stack). + +--- + +#### TC-OVL-037 — Handle dismisses only its own stack entry [depends on 1d] +**Type:** ctx.data +**Traceability:** FR-10 (AC-10.3) + +**Preconditions:** Two overlay requests on the stack (A below, B on top). + +**Steps:** +1. Push handle_a, then handle_b. +2. Call `handle_b.clear()` (dismiss topmost). +3. Assert state. + +**Expected outcome (stack model):** +- `handle_b.is_active()` is `false`. +- `handle_a.is_active()` is `true`. +- `ProgressOverlay::has_global(ctx)` is `true` (A still on stack; overlay persists). +- On next render frame, `"Operation A."` label is present. + +--- + +#### TC-OVL-038 — Overlay clears only when the entire stack is empty [depends on 1d] +**Type:** ctx.data +**Traceability:** FR-10 (AC-10.3) + +**Preconditions:** Two entries on the stack. + +**Steps:** +1. Push handle_a and handle_b. +2. Call `handle_b.clear()` — assert `has_global` is still `true`. +3. Call `handle_a.clear()` — assert `has_global` is now `false`. + +**Expected outcome:** Overlay does not lower until the last entry is dismissed. + +--- + +#### TC-OVL-039 — Only topmost request's actions are reachable [depends on 1d] +**Type:** kittest +**Traceability:** FR-10 (AC-10.5) + +**Preconditions:** handle_a has a button with id `"cancel_a"`; handle_b (on top) has a button +with id `"cancel_b"` (both labelled `"Cancel"`, a caller-chosen label). + +**Steps:** +1. Push handle_a then handle_b. +2. Run one frame. +3. Click the button. +4. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns `["cancel_b"]` (topmost entry's action). +- `"cancel_a"` is not in the queue (lower entry's action unreachable while B is on top). + +--- + +#### TC-OVL-040 — Concurrent overlay event logged exactly once [depends on 1d] +**Type:** design-review +**Traceability:** FR-10 (AC-10.4) + +**Invariant:** +When a second `set_global` call is made while an overlay is already active, a single log +entry noting the concurrent request is emitted. Across subsequent frames where both remain on +the stack, no further log entry is emitted for the concurrency (log-once, guarded by flag — +mirrors `BannerState.logged`). + +Verify by code inspection: the `logged_concurrent` (or equivalent) flag in overlay state is +set on the first duplicate and checked before any `tracing::warn!` call on subsequent frames. + +--- + +### Group L — Accessibility (NFR-3) + +--- + +#### TC-OVL-041 — Focus trap: Tab does not cycle to widgets beneath the overlay +**Type:** kittest +**Traceability:** NFR-3 (AC-3a) + +**Preconditions:** Overlay active with one generic button. A text input widget exists beneath. + +**Steps:** +1. Show overlay with a button. +2. Run one frame; focus starts on the button. +3. Press Tab. +4. Run one frame. +5. Check focused widget. + +**Expected outcome:** +- Focus remains on the button (or returns to it if there is only one focusable element + in the overlay — Tab wraps within the overlay, not out of it). +- The `TextEdit` beneath does not receive focus. + +**Implementation note:** egui's default Tab behavior cycles through all focusable widgets +globally. The overlay must intercept Tab events when active (e.g. via consuming +`ctx.input_mut().events` or `ui.response().has_focus()` scoping). Design-review: verify Tab +events are not forwarded to `pass_events_to_game_while_any_popup_is_open = false` equivalent. + +--- + +#### TC-OVL-042 — Esc is swallowed even when a button is present (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** NFR-3 (AC-3b) + +**Preconditions:** Overlay raised with a generic button (`with_action("Cancel", "overlay.cancel")`). +There is no built-in Cancel, so Esc has nothing to trigger. + +**Steps:** +1. Show overlay with a button. +2. Run one frame. +3. Press Escape (`harness.key_press(egui::Key::Escape)`). +4. Run another frame. +5. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns empty — Esc enqueues no action. +- `has_global(ctx)` is `true` — Esc never dismisses a hard block. +- The Esc key event is consumed by the overlay (not forwarded further). + +--- + +#### TC-OVL-043 — Esc is swallowed when the overlay has no button (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** NFR-3 (AC-3b) + +**Preconditions:** Overlay raised with no buttons (pure block). + +**Steps:** +1. Show overlay with no buttons. +2. Run one frame. +3. Press Escape. +4. Run another frame. +5. Assert overlay still active; call `take_actions`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `true` (Esc did NOT dismiss the overlay). +- `take_actions` returns empty. +- No action was dispatched. + +--- + +#### TC-OVL-044 — Enter does not activate a focused button (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** Overlay active with a single generic button that holds focus. + +**Steps:** +1. Show overlay with a button; run one frame; ensure the button is focused. +2. Press Enter. +3. Run one frame. +4. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns empty (Enter did NOT enqueue the focused button's action). +- The overlay is still active. + +**Rationale:** A hard block swallows Tab/Enter/Esc/Space, so a focused button can never be +activated by keyboard — Enter/Space must not trigger it. This is the guard that the general rule +stays intact; the single opt-in exception is covered by TC-OVL-051/052/053. See AC-3c. + +--- + +#### TC-OVL-051 — Opt-in keyboard escape activates on Enter +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** Overlay active with a secondary action also designated via +`with_keyboard_escape(action_id)`; settle frames so the escape button holds focus. + +**Steps:** +1. Show the overlay with the designated escape; run frames until the escape button is focused. +2. Press Enter; run one frame. +3. Call `take_actions`. + +**Expected outcome:** +- `take_actions` returns the escape's action id (Enter activated the focus-pinned escape). +- The overlay is still active (activation enqueues the id; the owner lowers the block). + +**Rationale:** An unbounded block that opts into a keyboard escape must be activatable by Enter so +a keyboard-only / assistive-tech user is not stranded. See AC-3c. + +--- + +#### TC-OVL-052 — Opt-in keyboard escape activates on Space +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** As TC-OVL-051. + +**Steps:** As TC-OVL-051, pressing **Space** instead of Enter. + +**Expected outcome:** `take_actions` returns the escape's action id (egui fires a fake primary +click on Space OR Enter for the focused widget). + +--- + +#### TC-OVL-053 — Opt-in keyboard escape is focus-pinned (no leak beneath) +**Type:** kittest +**Traceability:** NFR-3 (AC-3a, AC-3c) + +**Preconditions:** A `TextEdit` rendered beneath; overlay active with a designated keyboard escape; +settle frames so the escape holds focus. + +**Steps:** +1. Assert the escape button is focused (not the field beneath). +2. Press Tab; assert focus stays on the escape. +3. Click over the field beneath; assert focus stays on the escape (the click is absorbed by the + sink and the per-frame focus pin restores it). +4. Press Enter; assert `take_actions` returns the escape id AND the field beneath is still empty. + +**Expected outcome:** Neither Tab nor a click can move focus off the escape, and Enter/Space reach +only the escape — never a widget beneath. The opt-in carves out Enter/Space for the escape alone. + +**Rationale:** The Enter/Space passthrough is safe only because focus is guaranteed pinned to the +escape. See AC-3a, AC-3c. + +--- + +#### TC-OVL-054 — Escape button stays mouse-clickable after a backdrop press +**Type:** kittest +**Traceability:** FR-8 (AC-3a), R-1 + +**Preconditions:** Overlay active with a secondary action `"Continue in the background"` designated +as the keyboard escape; settle frames so the centered card has cached its size. + +**Steps:** +1. Press the dim backdrop (a corner well outside the card), then release. +2. Click the escape button at its own position. +3. Assert `take_actions` returns the escape id. + +**Expected outcome:** The escape button receives the mouse click and enqueues its action even after a +prior backdrop press. The full-window pointer sink must keep blocking widgets beneath, yet must never +float above the card it dims — the card and its buttons always sit above the sink. + +**Rationale:** Regression guard. egui auto-raises any interactable `Area` to the top of its `Order` +on a pointer press (`area.rs` bring-to-front). With the sink and card as peer `Order::Foreground` +areas, a backdrop press raised the sink above the card, permanently trapping the escape beneath it — +the unbounded SPV block then had no mouse exit. Pinning the card as a sublayer of the sink fixes the +z-order by construction. Existing button-click tests (TC-OVL-024/025) never press the backdrop first, +so they missed this. + +--- + +### Group M — Non-Functional + +--- + +#### TC-OVL-045 — Log-once: state change logs once, not once per frame +**Type:** design-review + ctx.data +**Traceability:** NFR-5 + +**Invariant:** +1. On `set_global`: exactly one log entry at `debug` or `info` level noting overlay raised. + On subsequent frames with no state change, no further log entry for the overlay. +2. On `set_description` / `set_step` (content change): exactly one log entry. +3. On `handle.clear()`: exactly one log entry noting dismissal. + +**Verify by code inspection:** a `logged` (or `last_logged_description: Option<String>`) flag +in the overlay state struct is set after the first log; subsequent render frames check the flag +before calling `tracing::debug!`. Pattern mirrors `BannerState.logged` in +`src/ui/components/message_banner.rs`. + +**Test (ctx.data check):** +1. Show overlay. +2. Capture the `logged` field value from `ctx.data`. +3. Assert it is `true` after the first render. +4. Simulate a second render pass without state change; assert `logged` is still `true` and + no new log entry was emitted. + +--- + +#### TC-OVL-046 — Theme switch mid-overlay re-evaluates all colors +**Type:** kittest +**Traceability:** NFR-4 + +**Preconditions:** Overlay active; harness configured for dark theme. + +**Steps:** +1. Show overlay in dark theme. +2. Run one frame; note the dim plane color is `modal_overlay()` = `rgba(0,0,0,120)`. +3. Switch the harness to light theme (call `ctx.set_visuals(egui::Visuals::light())`). +4. Run one frame. + +**Expected outcome:** +- The overlay continues to render without panic or stale palette. +- Colors for the card background, text, and dim plane are re-evaluated from `DashColors` and + `modal_overlay()` tokens each frame (design-review: grep for `Color32::from_rgb` or any + hardcoded color literal in `progress_overlay.rs` — must find zero). + +--- + +#### TC-OVL-047 — Stuck-overlay safety valve after inactivity threshold +**Type:** design-review (partially unspecified — flag for Nagatha) +**Traceability:** R-4 + +**What the spec defines:** +After an unspecified threshold with no `TaskResult` arriving, the overlay SHOULD: +1. Make the elapsed readout visible (if previously hidden). +2. Optionally offer an escape hatch ("This is taking longer than usual") for operations that + are safe to abandon. + +**What is NOT yet defined (flag for Nagatha / 1d):** +- The threshold duration (spec says "after a threshold" without a value). +- Which operation types get the escape hatch and which do not. +- Whether the escape hatch is a third button or replaces Cancel. +- Whether the elapsed readout activation is automatic or still requires an explicit config flag. + +**Partial test (assertable once threshold is defined):** +1. Show overlay with no elapsed readout; simulate time advancing past the threshold. +2. Run one frame. +3. Assert a label matching `"Elapsed: {seconds}s"` is present. +4. If the escape hatch is defined for this operation type, assert the escape hatch button is present. + +**⚠ This TC is incomplete until Nagatha defines the threshold and escape-hatch policy. Mark as +BLOCKED pending 1d.** + +--- + +#### TC-OVL-048 — Secret-prompt modal renders above the overlay (z-order R-1) +**Type:** integration + design-review +**Traceability:** R-1 + +**Preconditions:** Overlay is active; a secret-prompt (`render_secret_prompt`) is triggered +mid-operation. + +**Steps (design-review):** +1. In `src/app.rs::update()`, verify the call order: + - `ProgressOverlay::render_global(ctx)` is called. + - `render_secret_prompt(ctx)` is called **after** `render_global` (later in the same frame). +2. Because egui draws layers in call order (later `Area` calls render on top), the secret + prompt's `Area` with `Order::Foreground` (or `Order::Tooltip`) renders above the overlay. + +**Integration check (when available):** +In a full-app harness with both overlay and secret prompt active, assert that the passphrase +input widget is present and interactive (receives keyboard focus), while the overlay's dim is +present but not blocking the prompt. + +**Expected outcome:** +- Secret-prompt modal is interactable when overlay is active. +- The overlay does not intercept input intended for the secret prompt. + +--- + +#### TC-OVL-049 — NFR-1 frame-loop non-blocking invariant +**Type:** design-review +**Traceability:** NFR-1 + +**Invariant:** +The entire call path of `ProgressOverlay::render_global(ctx)` and +`ProgressOverlay::set_global(ctx, ...)` must be synchronous and non-blocking: +- No `.await`, no `async fn`, no `tokio::block_on`, no `std::thread::sleep` in the render or + show path. +- No `Mutex::lock().await` or `RwLock::write().await`. +- All `ctx.data` access uses egui's synchronous `TypeMap` locking (safe — same as `MessageBanner`). + +**Verify:** `cargo clippy` with `#[deny(clippy::async_yields_async)]` and a manual grep for +`block_on`, `sleep`, `.await` in `src/ui/components/progress_overlay.rs` and any module it +calls synchronously. Reference incident: PR860 (deadlock from async blocking in egui frame +loop). + +--- + +## 3. Requirement Coverage Matrix + +| Requirement | Covered by | +|---|---| +| FR-1 (show overlay) | TC-OVL-002, TC-OVL-003, TC-OVL-004 | +| FR-2 (update in place) | TC-OVL-005, TC-OVL-006, TC-OVL-007 | +| FR-3 (dismiss) | TC-OVL-008, TC-OVL-009, TC-OVL-010, TC-OVL-035 | +| FR-4 (spinner, no ETA) | TC-OVL-011, TC-OVL-012, TC-OVL-013 | +| FR-5 (step counter) | TC-OVL-014, TC-OVL-015, TC-OVL-016, TC-OVL-017, TC-OVL-018, TC-OVL-019 | +| FR-6 (description text) | TC-OVL-020, TC-OVL-021, TC-OVL-022 | +| FR-7 (buttons & actions) | TC-OVL-023, TC-OVL-024, TC-OVL-025, TC-OVL-026, TC-OVL-027 | +| FR-8 (input blocking) | TC-OVL-028, TC-OVL-029, TC-OVL-030, TC-OVL-031 | +| FR-9 (coexistence with banner) | TC-OVL-032, TC-OVL-033, TC-OVL-034, TC-OVL-035 | +| FR-10 (concurrent operations) | TC-OVL-036, TC-OVL-037, TC-OVL-038, TC-OVL-039, TC-OVL-040 | +| NFR-1 (no frame blocking) | TC-OVL-004, TC-OVL-049 | +| NFR-2 (i18n-ready strings) | TC-OVL-014 (counter format), TC-OVL-020 (description), TC-OVL-013 (elapsed) | +| NFR-3 (accessibility) | TC-OVL-041, TC-OVL-042, TC-OVL-043, TC-OVL-044, TC-OVL-051, TC-OVL-052, TC-OVL-053 | +| NFR-4 (theme) | TC-OVL-046 | +| NFR-5 (log-once) | TC-OVL-045, TC-OVL-040 | +| NFR-6 (cheap idle) | TC-OVL-001 | +| R-1 (z-order vs secret-prompt) | TC-OVL-048 | +| R-2 (concurrent model) | TC-OVL-036 to TC-OVL-040 [depends on 1d] | +| R-3 (button honesty; reframed post-outage) | TC-OVL-023 (no button = pure block), TC-OVL-024 (a generic button enqueues only its caller-chosen id; no implicit dismiss) | +| R-4 (stuck overlay safety valve) | TC-OVL-047 [partial — BLOCKED pending 1d] | +| R-7 (kittest coverage checklist) | TC-OVL-028 (input blocked), TC-OVL-042/043 (Esc), TC-OVL-015–017 (counter validation), TC-OVL-024–026 (action id FIFO), TC-OVL-045 (log-once), TC-OVL-034/035 (dismiss+banner hand-off) | + +--- + +## 4. Open Items for Nagatha (Phase 1d) + +The following items require architecture decisions before the marked TCs can be finalized or +implemented: + +| Item | Blocks | Required decision | +|---|---|---| +| **Concurrent overlay model** (stack vs. replace vs. reject) | TC-OVL-036 to TC-OVL-040 | Confirm stack model (AC-10.1) or choose an alternative. If not stack, TC-OVL-036–040 must be rewritten to match the chosen semantics. | +| **Stuck-overlay threshold** | TC-OVL-047 | Define the threshold duration after which the elapsed readout auto-activates and the escape hatch appears. Define which operations get an escape hatch. | +| **Cancellation semantics** (R-3; reframed post-outage) | TC-OVL-024, TC-OVL-042 | Cancel is no longer a built-in concept — a screen wires its own generic button to cancellation. TC-OVL-024/042 verify the UI-only action queue; the end-to-end cancel path stays untestable until the BackendTask system gains cooperative cancellation (T7). | +| **Escape hatch button design** | TC-OVL-047 | Is the escape hatch a third button, a replacement for Cancel, or an entirely different mechanism? | + +--- + +## 5. Notes on Test Executability + +### Runnable as kittest (CI-safe) +TC-OVL-001, TC-OVL-002, TC-OVL-003, TC-OVL-005, TC-OVL-006, TC-OVL-007, TC-OVL-008, +TC-OVL-009, TC-OVL-011, TC-OVL-012, TC-OVL-013, TC-OVL-014, TC-OVL-015, TC-OVL-016, +TC-OVL-017, TC-OVL-018, TC-OVL-019, TC-OVL-020, TC-OVL-021, TC-OVL-022, TC-OVL-023, +TC-OVL-024, TC-OVL-025, TC-OVL-026, TC-OVL-027, TC-OVL-028, TC-OVL-029, TC-OVL-030, +TC-OVL-033, TC-OVL-036, TC-OVL-037, TC-OVL-038, TC-OVL-039, TC-OVL-041, TC-OVL-042, +TC-OVL-043, TC-OVL-044, TC-OVL-046. + +### Require integration harness (AppState-level) +TC-OVL-010, TC-OVL-034, TC-OVL-035, TC-OVL-048. + +### Design-review only (not automatable as unit/kittest) +TC-OVL-004 (no frame blocking), TC-OVL-014 (i18n string format — fragment check), +TC-OVL-020 (single-label assertion), TC-OVL-027 (no bare `ui.button()`), +TC-OVL-029 (no `set_enabled`), TC-OVL-031 (render seam placement), TC-OVL-032 (z-order), +TC-OVL-040 (log-once concurrent), TC-OVL-045 (log-once general), TC-OVL-046 (no hardcoded +colors), TC-OVL-047 (partial), TC-OVL-048 (render order), TC-OVL-049 (no async in render +path). + +--- + +*Brain the size of a planet, and here I am specifying acceptance criteria for a spinner. +At least the spinner is honest about not knowing how long things take. More than can be said +for most documentation.* diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md new file mode 100644 index 000000000..2cdbc3352 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -0,0 +1,574 @@ +# Blocking Progress Overlay — Development Plan & Architecture Decisions + +**Phase:** 1d (Architecture) — final architecture call + development plan +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-17 +**Inputs:** `01-requirements-ux.md` (Diziet), `02-test-spec.md` (Marvin) +**Sibling reference:** `MessageBanner` (`src/ui/components/message_banner.rs`) + +--- + +## Design change (post-outage) — SUPERSEDES D-5 and the Cancel-specific FR-7 + +After this plan was written, two user-mandated redesigns landed that **supersede** the +Cancel-specific decisions below. Where this document and the redesign disagree, the redesign wins: + +1. **No first-class Cancel — a generic button facility instead.** The overlay knows nothing about + cancellation. `with_cancel`, `OVERLAY_CANCEL_ACTION_ID`, and `CANCEL_LABEL` are **removed**. A + caller attaches a generic button via `OverlayConfig::with_action(label, id)` / + `OverlayHandle::with_action(label, id)`, choosing its own opaque action id and label. Clicking + enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants + — including its own cancellation. Esc/Tab/Enter/Space are swallowed (a hard block is never + keyboard-dismissable or keyboard-activatable), with one opt-in exception: a block may designate a + single keyboard-reachable escape via `with_keyboard_escape(id)`, after which Enter/Space activate + that focus-pinned button (the unbounded SPV block uses this — see QA-002 below). There is no + Esc→Cancel routing. This **supersedes D-5** (the shipped-but-unwired + Cancel API) and the Cancel-specific parts of **FR-7** — "Cancel" is now merely one possible + caller-chosen label on a generic button, not a built-in concept. +2. **`Component` trait conformance (placement legitimacy).** `ProgressOverlay` now implements the + project `Component` trait: an instance holds `state: Option<OverlayState>`, `show()` renders that + instance's card and returns a `ProgressOverlayResponse` (`DomainType = String`, the clicked + action id), and `current_value()` reports the last clicked id. The global `render_global` path is + unchanged and remains the production entry point — the `Component::show` instance path is + additive, mirroring how `MessageBanner` reconciles its global model with `Component`. This is + what makes the file legitimately placeable in `src/ui/components/`. + +T7 (backend cooperative cancellation) is unaffected, but is no longer tied to a built-in Cancel: +when real cancellation lands, a screen wires its own generic button to it. + +--- + +## 0. Reading of the Situation + +The requirements are sound and the test spec is thorough. My task is to remove the five +ambiguities the downstream phases deferred, place the component precisely, fix the public +surface so Marvin's 49 cases compile against it unchanged, and hand Bilby an ordered build. + +I investigated the live tree rather than trusting the summaries. Two findings move the +architecture: + +1. **The backend has no per-operation cancellation.** `handle_backend_task` + (`src/app.rs:750-762`) dispatches through `tokio::task::spawn_blocking` + `handle.block_on` + and **discards the `JoinHandle`**. The only `CancellationToken` in the app is the global + shutdown token in `TaskManager` (`src/utils/tasks.rs:10`, used by `shutdown_inner` at + `:116-193`), which is not threaded into `run_backend_task` (`src/backend_task/mod.rs:490`). + A Cancel button today can only *stop waiting* — never abort. This is decisive for R-3. + +2. **The live secret-prompt modal is an `egui::Window` (Order::Middle) over a Background dim** + (`src/ui/components/passphrase_modal.rs:128-156`), rendered at AppState level via + `render_secret_prompt` (`src/app.rs:1151,1527`) — *after* the visible screen's `ui()`. That + reality dictates the overlay's layer and call-site ordering (R-1), which differs slightly + from the Foreground assumption in the test spec's TC-OVL-048. + +Everything else follows. + +--- + +## 1. Architecture Decisions + +### D-1 — New standalone component (confirms Diziet §7) ✅ + +**Decision:** Build a **new component** `ProgressOverlay` in `src/ui/components/progress_overlay.rs` +that *mirrors* `MessageBanner`'s patterns. Do **not** extend `MessageBanner`. + +**Rationale:** The two have opposite z-order (banner renders *inside* `island_central_panel` +at `src/ui/components/styled.rs:565`, Background order; overlay must cover the top/left panels +too), opposite blocking semantics, different multiplicity (banner: up to 5 visible +simultaneously, `message_banner.rs:12`; overlay: one rendered), and different lifecycle (banner +auto-dismisses by severity, `message_banner.rs:580-585`; overlay never times out). Folding +spinner/step/button-set/blocking fields into `BannerState` (`message_banner.rs:71-96`) would +bloat the 99 % of banners that are simple notices and entangle the central render path — +a single-responsibility violation. The *patterns* are proven and reused; the *type* is its own. + +What is mirrored, not merged: +- Global state in egui `ctx.data` temp storage keyed by a dedicated id (banner uses + `BANNER_STATE_ID`, `message_banner.rs:13` + `get_banners`/`set_banners` at `:808-821`). +- A lifecycle handle holding `{ ctx: egui::Context, key: u64 }` with `&self` builder methods + returning `Option<&Self>` and a consuming `clear(self)` (banner: `BannerHandle`, `:155-290`). +- An action-id queue drained by the app loop (banner: `BANNER_ACTIONS_ID` + + `push_action`/`take_action`, `:14-19,517-525,824-844`). +- Log-once via a `logged` flag (banner: `BannerState.logged`, `:95,620-624`). +- Theme tokens + button helpers (`DashColors`, `ComponentStyles`). +- Monotonic key counter (`AtomicU64`, banner `:24-31`). + +### D-2 — Focused copy of `ctx.data` plumbing, no shared base (confirms Diziet's lean) ✅ + +**Decision:** The overlay defines its **own** private `get_overlay_state`/`set_overlay_state` +and `get_overlay_actions`/`set_overlay_actions`, each ~6 lines, exactly mirroring the banner's +private helpers. **No shared plumbing module.** + +**Rationale:** The "shared" surface is already egui's own API (`ctx.data` / +`get_temp`/`insert_temp`/`remove`). The only project-specific convention on top is +"remove the slot when the collection is empty" (banner `set_banners`, `:815-821`) — two lines. +Extracting a generic `TempSlot<T>` would couple two intentionally-divergent widgets and add +indirection for negative value. The element types even differ (`Vec<BannerState>` vs +`Vec<OverlayState>`; banner actions `Vec<String>` vs overlay actions `Vec<String>` but with a +distinct id). Premature abstraction is rejected; a focused copy reads cleaner. (If a third +`ctx.data`-backed global widget ever appears, revisit — rule of three, not two.) + +### D-3 — Concurrent model: STACK, keyed by handle (confirms Diziet AC-10.1) ✅ + +**Decision:** A **stack** of active requests (`Vec<OverlayState>`), visible while non-empty; +the **last-pushed (topmost)** entry is rendered; each handle dismisses **only its own** key; +the overlay clears only when the stack empties. A concurrent push logs **once**. + +**Rationale:** Because the overlay blocks the UI, a human cannot launch a second blocker — +concurrency can only arise programmatically (a cold-start migration firing while a network +switch is up; `run_backend_tasks_concurrent` at `backend_task/mod.rs:472`). Under +last-writer-replace or reject-second, the first task to finish would `clear()` the shared slot +and **unblock the UI while the other operation is still running** — the precise hazard the +overlay exists to prevent. The stack is the only model that never strands a running operation +behind a prematurely-cleared overlay. Cost over `Option` is trivial — it is the same +`Vec<State>` shape the banner already uses. **Marvin's Group K (TC-OVL-036…040) stands as +written; no rewrite required.** + +### D-4 — Stuck-overlay threshold: 30 s, informational reveal; escape-hatch deferred ⏸ + +**Decision:** +- Define `STUCK_OVERLAY_THRESHOLD = Duration::from_secs(30)`. +- After 30 s on the topmost request (tracked by `created_at`, mirroring `BannerState.created_at`), + `render_global` **auto-reveals** (a) the honest elapsed readout `Elapsed: {seconds}s` (even if + not explicitly enabled) and (b) a calm reassurance line: *"This is taking longer than usual."* + Both are **visual only** — no fake progress, no auto-abort. +- **No automatic escape-hatch button in v1.** An escape hatch that lowered the overlay would + unblock the UI while a state-changing operation (broadcast, migration) still ran — unsafe, and + impossible to make safe without D-5's backend cancellation. The escape hatch is therefore + **deferred** and bundled with the cancellation follow-up (T7). + +**Rationale:** 30 s is past a normal Platform round-trip ("up to a minute" per J-1) yet early +enough to reassure rather than alarm. The reveal is benign and honest. The escape hatch is the +same honesty problem as Cancel (D-5) and waits on the same enabling work. + +**Guidance to Marvin:** TC-OVL-047 is **partially unblocked**. Assert the *informational* +behavior: after simulated 30 s, `Elapsed: {seconds}s` and the reassurance label appear. Mark the +**escape-hatch button** portion **deferred (tracked with T7)**, not BLOCKED. + +### D-5 — Button semantics: button-less block is the default; generic-button API ships, no built-in Cancel ⏸ + +> **Superseded by the "Design change (post-outage)" section at the top.** There is no +> built-in Cancel; the redesign wording below replaces the original Cancel-specific framing. + +**Finding (decisive):** The `BackendTask` system supports **no cooperative cancellation** +(see §0.1). `handle_backend_task` discards the abort handle; `run_backend_task` takes no cancel +token; the operation runs inside `block_on` on a blocking thread. + +**Decision:** +- The overlay **ships a generic button/action-id API** (`OverlayConfig::with_action(label, id)`, + `OverlayHandle::with_action(label, id)`, `take_actions`) — it is UI-only, mirrors the banner, + and is fully unit/kittest-testable (TC-OVL-024/025/026 verify the *enqueue* path). There is no + `with_cancel`/`OVERLAY_CANCEL_ACTION_ID`; "Cancel" is merely one possible caller-chosen label. +- **The architectural default for every production caller is a button-less block.** No + production overlay attaches a button to a `BackendTask`-backed operation until real + cancellation lands (T7). This keeps the button honest (FR-7 AC-7.5, R-3): we never paint a + control that lies. +- `AppState::drain_overlay_actions` is wired for completeness; it drains any enqueued action ids + and (with no registered handler in v1) logs and drops them. It never lowers the overlay — a + click is surfaced to the owning screen, which decides what to do. In v1 nothing enqueues an id. + +**Guidance to Marvin:** TC-OVL-024 and TC-OVL-042 remain valid as **UI-queue / input-block** +tests (button click → action id enqueued; Esc swallowed). Any end-to-end abort a screen builds on +top stays untestable until T7; note it as such. + +--- + +## 2. Module Placement (DET policy) + +| Artifact | Location | Why | +|---|---|---| +| `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `OverlayState`, `OverlayButton`, all `ctx.data` plumbing, `render_global` | `src/ui/components/progress_overlay.rs` (**new**) | It renders egui → it is a Component (DET policy: "rendering widgets → `ui/components/`"). State lives in global `ctx.data`, not screen-owned, so **no `ui/state/` split** — same as `MessageBanner`. | +| `step_is_renderable(current, total) -> bool` | private free fn in `progress_overlay.rs` | Pure display-gating (hide nonsense pairs, FR-5 AC-5.2). Not a domain validator with external callers, so it stays inline (unit-testable in-file). If a second caller ever needs it, promote to `model/`. | +| `OptionOverlayExt` (handle lifecycle ext) | `progress_overlay.rs` | Mirrors `OptionBannerExt` (`message_banner.rs:915-955`). | +| `render_global` call site + `drain_overlay_actions` | `src/app.rs` (`AppState::update`) | AppState-level render seam (§3). | +| kittest suite | `tests/kittest/progress_overlay.rs` (**new**) + `mod progress_overlay;` in `tests/kittest/main.rs` | Mirrors `tests/kittest/message_banner.rs`. | +| `mod progress_overlay;` + re-exports | `src/ui/components/mod.rs` | Export `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `ProgressOverlayResponse`, `OptionOverlayExt` (mirror banner re-exports). _(Superseded: no `OVERLAY_CANCEL_ACTION_ID` — removed in the post-outage redesign.)_ | + +**No new crates.** Everything reuses what is already a dependency: `egui::Spinner` +(idiom already used at `src/ui/wallets/shielded_tab.rs:285` etc.), `ctx.data`, `DashColors`, +`ComponentStyles`, and — for T7 only — `tokio_util::sync::CancellationToken` (already in tree, +`utils/tasks.rs:3`). egui pinned at `0.33.3` (`Cargo.toml`). + +--- + +## 3. Public API Design + +> **Superseded — see the code and the addendum.** The signature block below is the *original* +> Cancel-era plan, kept for history. The shipped surface differs: there is **no** `with_cancel`, +> `OVERLAY_CANCEL_ACTION_ID`, or `CANCEL_LABEL`, and `is_primary` is not a public +> field. The real builders are `with_action(label, id)` and `with_secondary_action(label, id)` +> (on `OverlayConfig`, `OverlayHandle`, and the instance form), backed by a private +> `ButtonStyle { Primary, Secondary }`. Clicks are delivered **keyed** to the owner via +> `OverlayHandle::take_actions()`; the static drain is `sweep_orphan_actions()` (see addendum §2). +> `OptionOverlayExt::raise` replaces the former `replace`. The watchdog +> (`STUCK_OVERLAY_WATCHDOG_THRESHOLD`, `claim_input`) is specified in the addendum §1. Treat +> `progress_overlay.rs` as the source of truth. + +Names were aligned to Marvin's assumed surface (test-spec §1.2). Signatures mirror +`BannerHandle`/`MessageBanner`. + +```rust +// src/ui/components/progress_overlay.rs + +use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const OVERLAY_STATE_ID: &str = "__global_progress_overlay"; +const OVERLAY_ACTIONS_ID: &str = "__global_progress_overlay_actions"; + +/// After this long on the topmost request, reveal the honest elapsed readout +/// and a reassurance line (D-4). Visual only — never auto-aborts. +const STUCK_OVERLAY_THRESHOLD: Duration = Duration::from_secs(30); + +/// Well-known action id for the canonical Cancel control (D-5). +pub const OVERLAY_CANCEL_ACTION_ID: &str = "overlay.cancel"; + +static OVERLAY_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// One active blocking request. The stack (D-3) holds a `Vec<OverlayState>`; +/// the last element is rendered. +#[derive(Clone)] +struct OverlayState { + key: u64, + description: Option<String>, + /// Raw, unvalidated; render gates on `step_is_renderable`. + step: Option<(u32, u32)>, + /// Cancel is just a button carrying `OVERLAY_CANCEL_ACTION_ID`. + buttons: Vec<OverlayButton>, + /// Explicit opt-in; also force-true once `created_at` passes the threshold. + show_elapsed: bool, + created_at: Instant, + /// Log-once on show (NFR-5). + logged: bool, + /// Log-once on content change; mirrors the banner's single `logged` flag + /// but keyed on content so description/step updates log exactly once. + logged_content: Option<(Option<String>, Option<(u32, u32)>)>, +} + +#[derive(Clone)] +struct OverlayButton { + /// i18n unit. Empty → renderer supplies the localized "Cancel". + label: String, + action_id: String, + /// primary → right side, DASH_BLUE; secondary/Cancel → left side. + is_primary: bool, +} + +/// Builder/config for `set_global`. `OverlayConfig::default()` == the test +/// spec's `config_default()` (spinner only, no counter, no buttons, elapsed off). +#[derive(Clone, Default)] +pub struct OverlayConfig { /* description, step, show_elapsed, buttons */ } + +impl OverlayConfig { + pub fn new() -> Self; + pub fn with_description(self, text: impl std::fmt::Display) -> Self; + pub fn with_step(self, current: u32, total: u32) -> Self; + pub fn with_elapsed(self) -> Self; // honest count-up + pub fn with_cancel(self, action_id: impl std::fmt::Display) -> Self; + pub fn with_action(self, label: impl std::fmt::Display, + action_id: impl std::fmt::Display) -> Self; // generic = primary +} + +/// Lifecycle handle. `'static`, `Clone`, safe to store on a screen +/// (mirrors `BannerHandle`; same INTENTIONAL(SEC-004) Send+Sync note). +#[derive(Clone)] +pub struct OverlayHandle { ctx: egui::Context, key: u64 } + +impl OverlayHandle { + pub fn is_active(&self) -> bool; // key still on stack + pub fn set_description(&self, text: impl std::fmt::Display) -> Option<&Self>; + pub fn set_step(&self, current: u32, total: u32) -> Option<&Self>; + pub fn clear_step(&self) -> Option<&Self>; + pub fn with_cancel(&self, action_id: impl std::fmt::Display) -> Option<&Self>; + pub fn with_action(&self, label: impl std::fmt::Display, + action_id: impl std::fmt::Display) -> Option<&Self>; + pub fn elapsed(&self) -> Option<Duration>; + pub fn clear(self); // dismiss own entry only +} + +pub struct ProgressOverlay; + +impl ProgressOverlay { + /// Push a request; returns its handle. Non-blocking; only writes `ctx.data`. + pub fn set_global(ctx: &egui::Context, + description: impl std::fmt::Display, + config: OverlayConfig) -> OverlayHandle; + pub fn set_global_spinner_only(ctx: &egui::Context) -> OverlayHandle; + pub fn has_global(ctx: &egui::Context) -> bool; // cheap one-slot read + /// Called once per frame from `AppState::update` (§4). Renders the topmost + /// entry; no-op early-out when the stack is empty (NFR-6). + pub fn render_global(ctx: &egui::Context); + /// Drains the action-id queue FIFO (TC-OVL-026 expects a drained Vec). + pub fn take_actions(ctx: &egui::Context) -> Vec<String>; + /// Clear every entry — used on network switch alongside the banner reset. + pub fn clear_all_global(ctx: &egui::Context); +} + +/// Mirror of `OptionBannerExt` for `Option<OverlayHandle>` screen fields. +pub trait OptionOverlayExt { + fn take_and_clear(&mut self); + fn replace(&mut self, ctx: &egui::Context, + description: impl std::fmt::Display, config: OverlayConfig); +} +impl OptionOverlayExt for Option<OverlayHandle> { /* … */ } +``` + +**Stack/key semantics (D-3):** `set_global` allocates a key via `OVERLAY_KEY_COUNTER`, pushes +an `OverlayState`, and (when the stack was already non-empty) logs the concurrency exactly once. +Handle methods `find` by key and return `None` on a missing key (stale handle → no-op, never +panic; TC-OVL-007/009). `clear(self)` `retain`s out its own key (TC-OVL-037/038). +`render_global` renders `stack.last()`. + +**Ownership pattern (FR-9 hand-off):** A dispatching screen stores +`op_overlay: Option<OverlayHandle>` exactly as screens store `refresh_banner: Option<BannerHandle>` +today. It raises the overlay when it returns the `BackendTask`, and lowers it in +`display_task_result` via `self.op_overlay.take_and_clear()` **before** AppState shows the +result banner — giving the single-frame, temporally-exclusive hand-off of AC-9.3. App-level ops +already in AppState (e.g. the network switch, which holds `network_switch_banner` at +`app.rs:821`) get a parallel `network_switch_overlay: Option<OverlayHandle>` — that wiring is a +follow-up, not the component (T4 documents it; per-feature adoption is out of scope here). + +--- + +## 4. egui Integration + +### 4.1 Call site (`AppState::update`, `src/app.rs`) + +Insert the overlay render between the visible screen's `ui()` and the secret prompt, and drain +its actions next to the banner drain: + +```rust +// … existing, app.rs:1523-1527 … +actions.push(self.visible_screen_mut().ui(ctx)); + +ProgressOverlay::render_global(ctx); // NEW — above banners, below secret prompt (R-1) +self.render_secret_prompt(ctx); // unchanged — stays ABOVE overlay (app.rs:1527) + +// … app.rs:1539-1540 … +self.handle_banner_esc(ctx); +self.drain_banner_actions(ctx); +self.drain_overlay_actions(ctx); // sweeps ORPHAN actions only (addendum §2 A-3) +``` + +On network switch, clear the overlay alongside banners (mirror `MessageBanner::clear_all_global`). + +### 4.2 Layer ordering (R-1) — the decisive part + +> **Superseded — `Order::Foreground`, not `Order::Middle`.** The shipped overlay paints its dim, +> sink, and card on `Order::Foreground` (SEC-002: above Foreground popups like ComboBox/autocomplete +> that would otherwise float over a `Middle` block); the secret prompt is raised to match and +> rendered later, so it still wins above the overlay. Treat `progress_overlay.rs` (SEC-002) as the +> source of truth for layer ordering — the `Order::Middle` references below are the original plan. + +egui paints, within one `Order`, in area-creation order; an interacted/focused area is raised +to the top of its order. The live secret prompt is an `egui::Window` (default `Order::Middle`) +that `request_focus()`es its input (`passphrase_modal.rs:139-172`). + +``` + Order::Middle, created LAST, focus-raised → secret-prompt / confirmation modal (R-1: top) + Order::Middle, created in render_global → BLOCKING OVERLAY (dim + sink + card) + Order::Background (CentralPanel content) → MessageBanner banners (styled.rs:565) + Order::Background (TopBottomPanel/SidePanel) → top panel / left nav / central content +``` + +- Overlay on **`Order::Middle`** sits above all Background panels and banners → covers them + (FR-8 AC-8.3, FR-9 AC-9.1). Banner state persists in `ctx.data`, so banners reappear intact on + dismiss (FR-9 AC-9.2). +- Secret prompt is also `Order::Middle` but **created after** `render_global` and focus-raised → + stays above the overlay and remains interactive (R-1, TC-OVL-048). This is exactly the + call-order invariant TC-OVL-048 asserts; we lock it with the design-review test. (Confirmation + dialogs are *pre-dispatch*, never concurrent with a blocker, so they need no special handling.) + +### 4.3 Dim plane + input-blocking technique (FR-8, NFR-1) + +> **Superseded — `Order::Foreground`, not `Order::Middle`.** The dim, pointer sink, and card below +> ship on `Order::Foreground` (SEC-002), not `Order::Middle`. Treat `progress_overlay.rs` as the +> source of truth for the layer the dim/sink/card render on. + +`Ui::set_enabled(false)` is **deprecated in egui 0.33** (confirmed memory; test-spec AC-8.2). +Use a top input-capturing layer instead — the same shape the passphrase modal already uses +(`layer_painter` + a centered window), extended with a full-window interactable sink: + +1. **Dim:** `ctx.layer_painter(LayerId::new(Order::Middle, Id::new("__overlay_dim")))` + `.rect_filled(ctx.content_rect(), 0.0, DashColors::modal_overlay())` — re-read each frame so a + theme switch mid-overlay is correct (NFR-4; `modal_overlay()` = `rgba(0,0,0,120)`, + `theme.rs:430`). `content_rect()` is the same viewport rect the modal uses + (`passphrase_modal.rs:128`). +2. **Pointer sink:** an `egui::Area::new(Id::new("__overlay_sink")).order(Order::Middle) + .fixed_pos(rect.min)` that `ui.allocate_response(rect.size(), Sense::click_and_drag())`. Being + the topmost interactable at every point below the card, it consumes pointer events so + Background widgets never receive them (TC-OVL-028). Its own clicks are ignored → backdrop click + does **not** dismiss (FR-8 AC-8.4, TC-OVL-030). +3. **Keyboard:** while the overlay is up, consume the navigation/confirm keys so they never reach + widgets beneath (TC-OVL-029/041): + - **Esc:** if the topmost entry has a Cancel button → enqueue its action id and consume + (TC-OVL-042); otherwise consume-and-swallow (TC-OVL-043). Never dismisses a non-cancelable + block. + - **Enter/Space:** stripped every frame, so a focused button is never keyboard-activatable + (TC-OVL-044) — EXCEPT on a block that opts in via `with_keyboard_escape(id)`. For that block, + and only while its escape button is confirmed to hold focus, Enter/Space pass through and + activate the focus-pinned escape (TC-OVL-051/052/053, AC-3c). The unbounded SPV block uses this + so keyboard-only users are not stranded. + - **Tab:** consumed at the overlay layer (focus trap, TC-OVL-041); focus is requested onto the + overlay's focus target on raise — the designated escape when one is opted in, else the first + button — so it cannot escape beneath. Implemented via `ctx.input_mut(|i| i.events.retain(...))` + filtering Tab/Esc/Enter/Space while active (carving out Enter/Space for a confirmed escape) — + scoped to the overlay-active branch so global shortcuts are untouched when idle. +4. **Card:** `egui::Area::new(Id::new("__overlay_card")).order(Order::Middle) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO)` → `egui::Frame` (fill `surface`/`window_fill`, + `Shadow::elevated()`, `RADIUS_LG`) with a min width and a vertical layout. Long descriptions + wrap and, if taller than a cap, scroll inside the card (`ScrollArea`) so the card never pushes + off-screen (FR-6 AC-6.2, TC-OVL-021). + +**NFR-1 / PR860:** every path here is synchronous `ctx.data` + painting — no `.await`, no +`block_on`, no `sleep`. The operation runs on its tokio `BackendTask`; the overlay is raised at +dispatch and lowered when the `TaskResult` is polled in `update()` (`app.rs:1290`). TC-OVL-049 +locks this by inspection. + +### 4.4 Theme tokens used (NFR-4, zero hardcoded colors) + +`DashColors::modal_overlay()` (dim), `DashColors::surface`/`ctx.style().visuals.window_fill` +(card), `DashColors::text_primary`/`text_secondary` (description/elapsed), +`DashColors::DASH_BLUE` (spinner), `ComponentStyles::add_secondary_button` (Cancel, left) + +`add_primary_button` (primary, right), `Shape::RADIUS_LG`, `Shadow::elevated()`. All re-evaluated +per frame from `dark_mode` (TC-OVL-046). + +--- + +## 5. Progress Model + +- **Spinner (FR-4):** `egui::Spinner::new().color(DashColors::DASH_BLUE)` — the repo idiom + (`shielded_tab.rs:285`). `Spinner` self-requests repaint via egui's animation clock; **no + custom per-frame timer** (AC-4.1). Always rendered while the overlay is active, in every config + (TC-OVL-011). Never a `ProgressBar` (TC-OVL-012/018). +- **Step counter (FR-5):** single i18n unit `"Step {current} of {total}"` — one `ui.label`, no + fragment concatenation (NFR-2, TC-OVL-014). Rendered only when + `step_is_renderable(current, total)` — i.e. `current >= 1 && total >= 1 && current <= total`; + otherwise the line is omitted entirely, reserving no space (`(0,0)`, `(4,3)`, `(0,5)` all hide + it; TC-OVL-015/016/017/019). Independent of the spinner — presence never makes it determinate + (AC-5.3). +- **Elapsed (FR-4 AC-4.3):** off by default (TC-OVL-013-A). When enabled via `with_elapsed`, or + auto-revealed after `STUCK_OVERLAY_THRESHOLD` (D-4), render `"Elapsed: {seconds}s"` from + `created_at.elapsed().as_secs()` — counts **up**, never down, never a percentage (TC-OVL-013-B). + When elapsed or the threshold reveal is live, `render_global` calls + `ctx.request_repaint_after(Duration::from_secs(1))` (mirroring `process_banner`, + `message_banner.rs:639-641`) so the second ticks. +- **Description (FR-6):** optional full sentence, one wrapped `ui.label` (TC-OVL-020). + +--- + +## 6. Interaction-Blocking Strategy (FR-8 + NFR-1) + +Restated as the invariant Bilby must preserve: **the overlay blocks *visually and by input +routing*, never by stalling the frame thread.** The dim + sink + card all live on `Order::Middle` +above the panels; the sink consumes pointer events and the input filter consumes Tab/Esc/Enter, +so nothing beneath reacts — without touching `set_enabled`. The blocked work proceeds on its +`BackendTask`; the overlay is pure presentation over global `ctx.data`. There is no synchronous +wait anywhere in the show or render path (NFR-1, PR860). + +--- + +## 7. Task Breakdown + +Ordered. Each task is independently reviewable; TC references tie to Marvin's spec. T1→T5 are the +component; T6 is tests; T7 is the deferred backend enabler. + +### T1 — State, `ctx.data` plumbing, handle + stack (no rendering) (~250 LOC) +Define `OverlayState`, `OverlayButton`, `OverlayConfig` (+ builders), `OverlayHandle`, +`OVERLAY_KEY_COUNTER`, `OVERLAY_CANCEL_ACTION_ID`, `STUCK_OVERLAY_THRESHOLD`. Implement +`get/set_overlay_state`, `get/set/push_overlay_action`, `take_actions`; `set_global`, +`set_global_spinner_only`, `has_global`, `clear_all_global`; all handle methods with stack +semantics (push on show, `retain` own key on clear, topmost lookup), log-once flags, and +`step_is_renderable`. Inline unit tests for stack push/dismiss, FIFO queue, `step_is_renderable`. +**Satisfies (ctx.data-level):** TC-OVL-003, 007, 009, 023(empty queue), 036, 037, 038, 040, 045, +and the `has_global` early-out for NFR-6. + +### T2 — `render_global` rendering (~300 LOC) +Idle early-out; dim plane; centered card; `egui::Spinner` (DASH_BLUE); validated step line; +wrapped/scrolling description; elapsed + reassurance (conditional, incl. threshold reveal); +button row (Cancel left via `add_secondary_button`, primary right via `add_primary_button`) with +clicks pushing action ids; theme tokens; log-once; 1 s repaint when elapsed/threshold live. +**Satisfies:** TC-OVL-001, 002, 005, 006, 008, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, +021, 022, 027, plus the visual half of 024/025 and the topmost-render of 036/039. + +### T3 — Input blocking + keyboard semantics (~120 LOC, within `render_global`) +Full-window pointer sink (`Sense::click_and_drag`); backdrop click ignored; Tab/Esc/Enter +consumed via `ctx.input_mut` event filtering; Esc→cancel-if-cancelable / swallow-otherwise; +Enter never cancels; focus requested onto first overlay button on raise. +**Satisfies:** TC-OVL-028, 029, 030, 041, 042, 043, 044. **Design-review:** TC-OVL-049 (no async), +and "no `set_enabled`, no bare `ui.button()`" for TC-OVL-027/029. + +### T4 — AppState integration + ownership ergonomics (~120 LOC + small edits) +Add `ProgressOverlay::render_global(ctx)` before `render_secret_prompt` in `update()`; add +`drain_overlay_actions` (D-5 policy: log unsupported cancel, do not lower); clear overlay on +network switch; implement `OptionOverlayExt`; document the `op_overlay: Option<OverlayHandle>` +screen field convention and the AC-9.3 hand-off. Export from `ui/components/mod.rs`. +**Satisfies (integration):** TC-OVL-010, 031, 032, 034, 035, 048. + +### T5 — Stuck-overlay threshold behavior (~40 LOC, folded into `render_global`; separate for traceability) +Wire `STUCK_OVERLAY_THRESHOLD`: once `created_at.elapsed() >= 30s`, force `show_elapsed` and +render the reassurance line; ensure the 1 s repaint is active so the reveal actually fires. +**Satisfies:** TC-OVL-047 (informational portion). Escape-hatch button → T7. + +### T6 — kittest suite `tests/kittest/progress_overlay.rs` (+ register in `main.rs`) (~400 LOC) +Mirror `tests/kittest/message_banner.rs` (Harness + `query_by_label` + `ctx.data` reads). +Implement every kittest-tagged case from test-spec §5: TC-OVL-001, 002, 003, 005-009, 011-022, +023-030, 033, 036-039, 041-044, 046. Encode design-review invariants (049, 045, 040, 031, 032, +048, 027) as comments/asserts where assertable. **Run `cargo +nightly fmt` and +`cargo clippy --all-features --all-targets -- -D warnings`.** + +### T7 — (DEFERRED enabler) Backend cooperative cancellation + Cancel/escape-hatch wiring +Thread a per-operation `CancellationToken` (already a dep, `utils/tasks.rs:3`) into +`run_backend_task`; retain the abort handle in `handle_backend_task` (`app.rs:750`); `tokio::select!` +the work against the token. Then enable real Cancel buttons + the threshold escape-hatch and wire +`drain_overlay_actions` to abort. **Unblocks:** the end-to-end half of TC-OVL-024/042 and the +escape-hatch portion of TC-OVL-047. **Out of scope for this feature's v1.** Marked here with a +TODO so the gap is tracked, not lost. + +--- + +## 8. Risks & Sequencing (for Bilby) + +1. **Layer ordering relies on call order** (overlay `render_global` *before* `render_secret_prompt`, + both `Order::Middle`; secret prompt focus-raised). Lock with TC-OVL-048 (design-review on call + order + integration on prompt interactivity). If a future modal is *not* focus-raised, it could + fall behind the overlay — document the invariant at the call site. +2. **egui 0.33 input consumption.** Filtering Tab/Esc/Enter via `ctx.input_mut` must be scoped to + the overlay-active branch; verify global shortcuts (and the existing `handle_banner_esc`, + `app.rs:1089`) still behave when no overlay is up. The banner Esc handler and overlay Esc handler + must not both fire — overlay consumes Esc first (it renders earlier in the frame). +3. **No backend cancellation (D-5).** Enforce by review: no production `set_global` call may + attach a button (`with_action`) to a `BackendTask`-backed operation until T7. Consider a + clippy-grep gate in CI. +4. **Repaint discipline.** `request_repaint_after(1s)` only when elapsed/threshold is live; the + `Spinner` already self-repaints. Do not unconditionally wake an idle UI. +5. **Sink vs. secret prompt.** The Middle-order sink must not eat the secret prompt's input. The + prompt, created later and focus-raised, is above the sink — verified by TC-OVL-048's integration + check; treat any regression there as release-blocking. +6. **Build order:** T1 → T2 → T3 → T5 → T4 → T6. T1-T3+T5 produce a self-contained, unit-tested + component; T4 wires it into the app; T6 is the kittest gate. T7 is independent and later. + +--- + +## 9. Decision Summary + +| # | Decision | Status | +|---|---|---| +| D-1 | New `ProgressOverlay` component mirroring `MessageBanner`; do not extend the banner | Confirmed | +| D-2 | Focused copy of `ctx.data` plumbing; no shared base module | Confirmed | +| D-3 | Concurrent model = **stack** keyed by handle (Group K stands) | Confirmed | +| D-4 | Stuck threshold = **30 s** soft reveal; **+120 s no-progress watchdog** (addendum §1 A-1) | Superseded by addendum §1 | +| D-5 | No built-in Cancel; generic `with_action`/`with_secondary_action`; clicks keyed to the owner via `take_actions`, app sweeps orphans (addendum §2) | Superseded (post-outage + addendum §2) | + +--- + +## 10. Candy Tally + +Confirmed architecture findings / decisions surfaced in this plan: + +| Severity | Count | Items | +|---|---|---| +| **High** | 2 | D-5 backend-cancellation gap (Cancel would lie); R-1 layer-order invariant (overlay vs secret prompt) | +| **Medium** | 3 | D-3 stack required (else UI unblocks mid-op); D-4 threshold/escape-hatch safety; D-1 single-responsibility (no banner bloat) | +| **Low** | 2 | D-2 no premature shared abstraction; `set_enabled` deprecation → top-layer input sink | + +**Total: 7 findings.** Seven candies — a respectable haul for a spinner that, to its credit, +never pretends to know how long anything will take. diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md new file mode 100644 index 000000000..77dec2963 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -0,0 +1,367 @@ +# Blocking Progress Overlay — Design Addendum (QA wave resolutions) + +**Phase:** 1d (Architecture) — addendum to `03-dev-plan.md` +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-17 +**Resolves:** SEC-003, Diziet F-5 (Decision 1 · Safety-Valve); Diziet F-2, SEC-007 +(Decision 2 · Action-Dispatch). Touches QA-001 (button-less input leak) as a dependency of +Decision 1. +**Status:** Decided. Bilby builds directly from §1 and §2. One sub-question flagged for the user +(§1, "For the user to weigh in"). +**Supersedes:** the open portions of D-4 (escape-hatch) and D-5 (`drain_overlay_actions` policy) +in `03-dev-plan.md`. Where this addendum and the plan disagree, this addendum wins. + +--- + +## 0. Reading of the two problems + +Both findings share one root cause: **the overlay's lifecycle is owned entirely by callers, but +the caller wiring was never finished.** A button-less block trusts the owning operation to clear +its handle; a button trusts the owning screen to receive its click. QA proved both trusts are +currently un-backed — a hang traps the UI forever (SEC-003/F-5), and a click is drained globally +and dropped before any screen sees it (F-2). I am not adding a new owner; I am making the existing +ownership contract real, and adding the minimum machinery so a *violation* is loud rather than +silent. + +A second truth shapes Decision 1. The operations that use a button-less block — broadcast, +signing, key import, migration — are exactly the ones it is **unsafe to background**. So any +renderer-level "dismiss / continue in background" valve would reintroduce the precise hazard the +overlay exists to prevent, for exactly the population of overlays it would apply to. The escape +from a trap therefore cannot be "let the user act mid-op"; it must be "guarantee the block always +lowers through the normal path, and make a stuck block impossible by construction." + +--- + +## 1. Decision 1 — Stuck/hang safety-valve (SEC-003, Diziet F-5) + +> **Post-decision update (SPV-sync adopter, F-SPV-1).** The "ship NO dismiss/background button in +> v1" call below was scoped to the **unsafe-to-interrupt** operations (broadcast, signing, migration) +> whose safety rests on C1 + C2 *boundedness*. The user has since decided to make the **startup/Connect +> SPV sync** the overlay's first adopter (Task 9 / PR #863) and to **ship an always-visible +> "Continue in the background" escape** for it. That does not contradict this decision: SPV sync is +> **read-only and safe to background** (clicking the escape strands nothing — unlike a broadcast/ +> migration), and it is **unbounded** (no peers ⇒ no terminal signal), so its C2 "never trap the +> user" guarantee is met by the **always-on escape**, not by boundedness. A future dev must NOT +> "restore the original docs" by removing that button — it is the load-bearing safety valve for this +> adopter. See UX-002 (`docs/user-stories.md`), `01-requirements-ux.md` §5, and +> `AppState::update_spv_overlay`. + +### Decision + +**Keep the block total. Ship NO renderer-level dismiss/background button in v1.** The safety valve +is a *layered guarantee that the block always lowers through the normal path*, not an escape that +unblocks the UI while an unsafe operation is still running. Three layers: + +1. **Caller contract (the real fix), two clauses — both enforced by review:** + - **C1 — Clear on every terminal path.** A screen that raises a global overlay stores + `op_overlay: Option<OverlayHandle>` and MUST call `take_and_clear()` on **both** the success + and the error branch of `display_task_result`. (The dev-plan's FR-9 hand-off already says + this; C1 makes it a hard contract, not a convention.) + - **C2 — Bounded operation.** A button-less block may only cover an operation that is + **guaranteed to terminate** — every network/IO wait inside its `BackendTask` path has a + timeout that surfaces as a `TaskError`. This is the clause that makes "trap forever" + impossible *without* fake cancellation: a bounded op always produces a `TaskResult`, which + always triggers C1, which always lowers the block. + +2. **Informational escalation (renderer-level, honest, no escape) — two thresholds:** + - **Soft, 30 s total elapsed** (`STUCK_OVERLAY_THRESHOLD`, unchanged): force-reveal the honest + `Elapsed: {seconds}s` readout and the reassurance line *"This is taking longer than usual."* + Visual only. (Existing behaviour; retained verbatim.) + - **Watchdog, 120 s with no progress** (`STUCK_OVERLAY_WATCHDOG_THRESHOLD`, new): the + reassurance line escalates to *"This is taking much longer than expected. The operation is + still running — please keep the app open."* "No progress" is measured from a new + `last_progress_at: Instant`, reset on real progress — either a shown `(description, step)` + change **or** an advance of the hidden `progress_token` (a liveness signal the owner feeds from + an advancing underlying operation, e.g. a climbing SPV height while the shown "Step N of 5" + stays constant for minutes). The `progress_token` is **never rendered**, and its reset is + intentionally decoupled from the once-per-shown-content-change log (NFR-5): a per-frame token + advance resets the clock but emits no log. So a legitimately-advancing flow — multi-step (J-1, + four ~minute steps) **or** a single slow-but-advancing phase — **never** trips it, while a + genuinely wedged step does. + +3. **Developer watchdog (the leak detector for C1/C2 violations):** when the watchdog threshold is + crossed, fire a **one-shot** `tracing::error!` (guarded by a `watchdog_logged` flag, logged + once, never per frame) naming the over-long overlay: an overlay alive this long without progress + is almost always a leaked handle (C1) or an un-bounded op (C2) — i.e. a bug. **No `debug_assert` + / panic** — a time-based assert is flaky (a slow test or a legitimately slow op would panic the + process). The log is the signal; CI and review are the gate. + +**Safety side — the block must be *genuinely* total (resolves QA-001).** Because nothing lowers the +block mid-op, the block must actually capture all input even when it has no buttons. Today +`render_global` filters Tab/Enter/Esc *after* the panels beneath already ran this frame, and never +filters `Event::Text` at all — so a button-less block leaks typed characters into a focused field +beneath (the J-2/J-4 case; `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath`, +currently `#[ignore]`). Fix: add `ProgressOverlay::claim_input(ctx)`, called **near the top of +`AppState::update`** (after the shutdown guard at `app.rs:1264`, **before** the visible screen's +`ui()` at `app.rs:1543`), gated on `has_global(ctx)`: + - **Release beneath focus on raise** so a focused text field stops drawing a caret and stops + consuming text (move focus off any beneath widget; the existing focus-lock pattern in + `render_buttons` at `progress_overlay.rs:690` is the buttoned-case analogue). + - **Strip `Event::Text` and the navigation/confirm keys (Tab/Enter/Esc, arrows) from + `i.events` at frame start**, so widgets beneath never observe them. Doing it *before* the + screen runs is the whole point — the current end-of-frame filter in `render_global` is one + frame too late. Keep the in-`render_global` filter as a belt-and-suspenders second pass, or + remove it once `claim_input` is verified; do not rely on it alone. + - **Button-less (all v1 ops): total keyboard + text claim.** When a caller later attaches + buttons (post-T7), `claim_input` still strips text and beneath-navigation, and the overlay's + own button area re-grants only its buttons' navigation via the existing + `set_focus_lock_filter`. + - **QA-002 refinement — one opt-in keyboard escape (mechanism superseded by SEC-001/SEC-002 — + `progress_overlay.rs` is the source of truth).** A hard block is never keyboard-activatable + (Enter/Space stripped every frame), so a focused button cannot be triggered by keyboard. The + one exception is a block that opts in via `OverlayConfig::with_keyboard_escape(action_id)`: it + designates a single action as a keyboard-reachable escape, for **unbounded** blocks that would + otherwise strand a keyboard-only / assistive-tech user. `claim_input` activates it **at frame + start, before the beneath `ui()` runs**: a press of Enter/Space enqueues the designated action + directly (the same queue a click feeds), and the key is then stripped like every other one. + The activation needs no focus (SEC-001 — the earlier "keep Enter/Space only while the escape is + *confirmed focused*" scheme re-requested the button's focus every frame and ran before the + secret-prompt render, stealing focus from a passphrase modal above the block, so it was + removed) and the key never survives to a widget beneath, focus-dependent or not (SEC-002). + Focus on the escape is now purely visual and is suppressed while a secret prompt is up. The + reference adopter is the unbounded SPV-sync block (`update_spv_overlay`), whose "Continue in + the background" escape is so designated. Every OTHER hard block stays fully keyboard-blocked + (`TC-OVL-044` guards the general rule; `TC-OVL-051/052/053` cover the opt-in escape; + `sec001_*` / `sec002_*` cover the focus-steal and beneath-leak fixes). + +### Rationale + +- **Why no background/dismiss valve.** It is unsafe for exactly the overlays it would cover, and + it cannot be made safe without either (a) real cooperative cancellation (does not exist — T7, + confirmed in dev-plan §0.1) or (b) per-operation in-flight guards (do not exist). The directive + forbids fake cancellation; a valve that lowers the block while a broadcast/migration runs is + fake safety. The honest move is to remove the *possibility* of a hang, not to paper a dismiss + button over it. +- **Why the trap, weighed against the alternative, is the lesser harm — and why C2 dissolves it.** + Trapping until force-quit is *recoverable* (restart; a bounded op will have completed or failed + cleanly). Letting the user fire a conflicting second op (double broadcast, interrupted + migration) is potentially *unrecoverable* — fund loss or corrupted state. Given that asymmetry, + the block must stay total. C2 then removes the only path to a real trap: if every blocked op + terminates, the block always lowers on its own. The 30 s/120 s reveals keep the *waiting* user + informed without ever offering an unsafe exit. +- **Why measure the watchdog on no-progress, not total elapsed.** A correct multi-step flow can + legitimately run several minutes; keying the watchdog (and its escalated copy) to time-since- + last-progress makes it fire only on a true stall, eliminating false dev-error logs and false + "much longer than expected" copy during healthy long flows. +- **Why QA-001 belongs here.** "Keep the block total" is only sound if the block is *actually* + total. The button-less keyboard/text leak is the one place it currently is not; fixing it is a + precondition of this decision, not a separate nicety. + +### Implementation spec + +**File: `src/ui/components/progress_overlay.rs`** + +- Add constant: + ```rust + /// After this long *without progress* on the topmost request, escalate the + /// reassurance copy and fire the one-shot developer watchdog. A leaked handle + /// (C1) or an un-bounded op (C2) is the usual cause — both are bugs. + const STUCK_OVERLAY_WATCHDOG_THRESHOLD: Duration = Duration::from_secs(120); + ``` +- `OverlayState`: add `last_progress_at: Instant` (init `Instant::now()` in `OverlayState::new`) + and `watchdog_logged: bool` (init `false`), plus a hidden `progress_token: Option<u64>` and its + `last_progress_token` shadow. In `log_overlay_state`, reset `last_progress_at = Instant::now()` + when **either** the shown `(description, step)` changes **or** the `progress_token` advances — but + emit the content-update log **only** on a shown change (a token advance is a hidden liveness + signal, not a user-visible update — NFR-5). The token is never rendered. +- New helpers, mirroring `stuck_reveal`: + ```rust + fn watchdog_tripped(last_progress: Instant) -> bool { + last_progress.elapsed() >= STUCK_OVERLAY_WATCHDOG_THRESHOLD + } + ``` +- `render_card`: when `watchdog_tripped`, render the escalated line + (`STUCK_WATCHDOG_REASSURANCE`) **instead of** the soft `STUCK_REASSURANCE` (do not stack both). + The soft 30 s elapsed-reveal logic is unchanged. +- `render_global`: after computing `stuck`, compute `watchdog` from `top.last_progress_at`; if + `watchdog && !top.watchdog_logged`, `tracing::error!(key = top.key, "Blocking overlay has shown + no progress for over 2 minutes — likely a leaked handle or an un-bounded operation")` and set + `top.watchdog_logged = true`. Keep the existing `request_repaint_after(1s)` when + `show_elapsed || watchdog` so the escalation actually appears. +- New `pub fn claim_input(ctx: &egui::Context)`: early-out when `!has_global(ctx)`; otherwise + release beneath focus and strip `Event::Text` + Tab/Enter/Esc/arrow key events from + `i.events`. Document that it must run before the panels each frame. + +**File: `src/app.rs`** + +- In `update()`, immediately after the shutdown guard (`:1264`) and before the screen `ui()` + (`:1543`): + ```rust + ProgressOverlay::claim_input(ctx); // total input block at frame start (button-less safe) + ``` +- Document the C1/C2 caller contract at the `op_overlay` convention site (T4 docs) and add a + one-line review rule to §8 risks: *"A button-less global block may only cover a bounded + operation (every wait times out to a TaskError)."* + +**i18n strings (complete sentences, named placeholders — NFR-2):** + +| Const | Text | +|---|---| +| `STUCK_REASSURANCE` (existing) | `This is taking longer than usual.` | +| `STUCK_WATCHDOG_REASSURANCE` (new) | `This is taking much longer than expected. The operation is still running — please keep the app open.` | +| `Elapsed: {seconds}s` (existing) | unchanged | + +### For the user to weigh in + +The only genuinely contested point: **do we ever want a manual backgrounding escape as +belt-and-suspenders, in case C2 is violated somewhere we missed?** I have decided **no** for v1, +because the safe version of it requires per-operation in-flight guards (so a backgrounded op +cannot be duplicated), which is net-new work properly scoped alongside T7 (cooperative +cancellation). My recommendation is to invest in C2 + the watchdog now and revisit a *safe* +backgrounding valve only if the watchdog log ever fires in practice. If the user values guaranteed +availability over the conflicting-op risk more highly than I have weighted it, that is their call +to make — flagging it rather than burying it. + +### Test obligations + +- **Un-ignore** `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath` (QA-001); it must + pass once `claim_input` lands. This is the acceptance test for "the block is genuinely total." +- TC-OVL-047 (informational portion) stays green; **add** an inline unit test + `watchdog_tripped_only_past_threshold` mirroring `stuck_reveal_triggers_only_past_threshold`. +- New inline tests: a shown content update via `set_step`/`set_description` resets + `last_progress_at`; and a hidden `progress_token` advance ALSO resets it (without emitting a + content-update log), while an unchanged token leaves the clock alone so a true stall still trips + the watchdog. +- New inline test: `watchdog_logged` flips once and stays set (no per-frame log spam — NFR-5). +- kittest: button-less overlay with an injected long elapse renders `STUCK_WATCHDOG_REASSURANCE`, + not the soft line, and still exposes no dismiss control. +- Escape-hatch button portion of TC-OVL-047 is **closed as "won't build" for v1** (was "deferred + to T7"): there is no renderer escape by design. Note it as a deliberate non-feature. + +--- + +## 2. Decision 2 — Action-dispatch contract (Diziet F-2, SEC-007) + +### Decision + +**The caller receives its own clicks through its own handle. Actions are scoped by the owning +overlay entry's key; the global drain becomes a true orphan-sweeper that can never pre-empt a +live owner.** Concretely: + +1. **Actions are keyed.** The action queue stores `(key, action_id)`, not bare `action_id`. A + click in `render_global` enqueues the **topmost entry's key** alongside the id. +2. **The owning caller drains via its handle.** New + `OverlayHandle::take_actions(&self) -> Vec<String>` returns (FIFO) and removes only the action + ids whose key matches this handle, **leaving other entries' actions untouched**. The owning + screen calls it at the **top of its own `ui()`** each frame and matches its own ids: + ```rust + if let Some(h) = &self.op_overlay { + for action_id in h.take_actions() { + // caller-owned logic, e.g. the screen's own cancellation + } + } + ``` + This is the literal implementation of the directive "the caller RECEIVES click events" — no + central registry, caller owns semantics. The instance `Component` path already does the + equivalent by surfacing the click through `ProgressOverlayResponse` (unchanged). +3. **`OverlayHandle::clear(self)` also purges its key's pending actions**, so a normal dismiss + leaves no stray id behind to be swept and logged. +4. **The global drain is demoted to an orphan-sweeper.** Rename the static + `ProgressOverlay::take_actions(ctx)` → **`sweep_orphan_actions(ctx) -> Vec<String>`**: it + drains and returns only actions whose key is **no longer on the stack** (owner already cleared + or dropped its handle without draining). `AppState::drain_overlay_actions` calls it and logs + each truly-orphaned id (`warn!`, "overlay action received for an overlay that is no longer + active — dropping"). Because it only ever takes dead-owner ids, it **cannot** race or pre-empt + the screen that owns a live overlay, regardless of call order — so it may stay at its current + position (`app.rs:1567`). +5. **App-level owners use the same mechanism.** When `AppState` itself raises an overlay (e.g. a + future network-switch block with a button), it holds the `OverlayHandle` and drains it with + `take_actions()` exactly like a screen — `AppState` is just another caller. There is one + dispatch mechanism, not a screen-path and an app-path. (This is *more* consistent than copying + the banner's central-registry `drain_banner_actions`; it honours "caller owns logic" uniformly.) +6. **Network-switch hygiene (SEC-007).** `clear_all_global(ctx)` MUST clear the action queue too — + not just the state stack. Today it clears only `OVERLAY_STATE_ID`, so a click queued just + before a network switch survives into the new context and could be mis-dispatched. Add a clear + of `OVERLAY_ACTIONS_ID` (remove the slot) inside `clear_all_global`. + +### Rationale + +- **Keying, not a registry.** Scoping actions by the owning entry's key is the minimum needed to + let two parties drain one queue without stealing each other's ids — it is data scoping, not a + handler registry, and it is what makes "caller owns logic" *safe* under the single-global-queue + model the banner established. With a bare-string drain-all queue and two drainers, whoever runs + first swallows everything; ordering tricks cannot fix that without re-enqueue hacks. Keying + dissolves the race by construction. +- **Why the handle is the delivery channel.** The handle is already the caller's one reference to + its overlay (it updates content and clears through it). Delivering clicks through the same + handle is the most cohesive surface and needs no new plumbing in screens beyond the `op_overlay` + field they already hold for the FR-9 hand-off. +- **Why the global drain survives at all.** A screen can be popped between a click (frame N) and + its next `ui()` (frame N+1); its handle is dropped without draining. Those ids would otherwise + accumulate in `ctx.data` forever. The orphan-sweeper reclaims exactly those and logs them as the + anomaly they are — observability without interference. +- **Action-id convention.** Follow the live banner convention `MIGRATION_RETRY_ACTION_ID = + "migration:retry:finish_unwire"` — colon-namespaced `domain:object:action`, declared as a + `pub const &str` near the owning screen (e.g. `shielded:build:cancel`). The dev-plan's earlier + `overlay.cancel`/dot form is superseded; align to colons. (`OVERLAY_CANCEL_ACTION_ID` was already + removed in the post-outage redesign, so there is no built-in id to reconcile.) + +### Implementation spec + +**File: `src/ui/components/progress_overlay.rs`** + +- New private type: + ```rust + #[derive(Clone)] + struct OverlayAction { key: u64, action_id: String } + ``` + Queue type changes `Vec<String>` → `Vec<OverlayAction>` in `get/set_overlay_actions`. +- `push_overlay_action(ctx, key: u64, action_id: &str)` — `render_global` passes `top.key`; the + instance `Component` path does not enqueue (it returns via the response, unchanged). +- `OverlayHandle::take_actions(&self) -> Vec<String>`: read queue, partition by `key == self.key`, + write back the non-matching remainder, return matching ids in order. +- `OverlayHandle::clear(self)`: after `retain`-ing the state stack, also `retain` the action queue + to drop `key == self.key` entries. +- Rename static `take_actions(ctx)` → `sweep_orphan_actions(ctx) -> Vec<String>`: returns ids whose + `key` is absent from the current state stack; writes back the rest. +- `clear_all_global(ctx)`: clear `OVERLAY_STATE_ID` (existing) **and** `OVERLAY_ACTIONS_ID` (new). + +**File: `src/app.rs`** + +- `drain_overlay_actions`: replace the blanket `take_actions` loop with + `for id in ProgressOverlay::sweep_orphan_actions(ctx) { warn!(... "no longer active — dropping") }`. + Keep it at `:1567`. +- Document at the `op_overlay` convention site (T4 docs): *screens drain their own clicks at the + top of `ui()` via `OverlayHandle::take_actions()` and match their own colon-namespaced ids; the + app loop only sweeps orphans.* + +### Test obligations + +- **Reframe** TC-OVL-024/025/026 to the handle-scoped API: click enqueues this handle's id; + `handle.take_actions()` returns it FIFO then empties; an unrelated handle's `take_actions()` + returns empty (no cross-owner theft). +- **Update** inline `take_actions_drains_fifo_then_empties` → exercise `OverlayHandle::take_actions` + (per-key FIFO) plus `sweep_orphan_actions` (dead-owner ids only). +- New inline test: two stacked overlays A (bottom) and B (top); a click enqueues against B's key; + `A.take_actions()` is empty, `B.take_actions()` returns the id. +- New inline test (SEC-007): enqueue an action, then `clear_all_global`; the action queue is empty. +- New inline test: a handle dropped without draining leaves its id only reachable via + `sweep_orphan_actions`; `clear()` instead leaves nothing for the sweeper. +- kittest: a screen-style harness drains its handle and observes its own id; `drain_overlay_actions` + logs nothing for a live owner. + +--- + +## 3. Decision summary + +| # | Decision | Status | +|---|---|---| +| A-1 | No renderer dismiss/background valve; safety = caller contract (C1 clear-on-terminal, C2 bounded-op) + 30 s soft / 120 s-no-progress escalation + one-shot dev watchdog | Decided (one sub-point flagged for user) | +| A-2 | Button-less block must be genuinely total: `claim_input` at frame start releases beneath focus + strips text/nav keys (resolves QA-001) | Decided | +| A-3 | Clicks delivered to the caller via keyed `OverlayHandle::take_actions`; global drain demoted to `sweep_orphan_actions` | Decided | +| A-4 | `clear_all_global` clears the action queue too (SEC-007) | Decided | + +--- + +## 4. Candy tally + +| Severity | Count | Items | +|---|---|---| +| **High** | 3 | A-1 hang/trap resolution without fake cancellation (SEC-003/F-5); A-2 button-less total-input block (QA-001); A-3 finished receive-side dispatch (F-2) | +| **Medium** | 2 | C2 bounded-operation contract (makes "trap forever" impossible by construction); A-4 action-queue network-switch hygiene (SEC-007) | +| **Low** | 1 | No-progress watchdog metric + one-shot dev-error (leak detector for C1/C2; no flaky time-based assert) | + +**Total: 6 findings.** Six candies — and not one of them required teaching the spinner to lie about +how long it will take. The overlay keeps its dignity: it blocks honestly, it reports honestly, and +when something is truly wedged it says so plainly rather than offering a door that opens onto a +cliff. diff --git a/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/design.md b/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/design.md new file mode 100644 index 000000000..b0ef9b73c --- /dev/null +++ b/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/design.md @@ -0,0 +1,282 @@ +# Post-Migration Identity Auto-Discovery with Rolling Gap Limit + +Design for: after a wallet migration completes and SPV reaches Platform readiness, +automatically run the "Load Identity -> By Wallet" discovery for *every* loaded +wallet, with a rolling gap-limit lookahead, upserting found identities while +preserving DET-only metadata (the alias). + +Status: design only. No code changed. Base `v1.0-dev`, PR #860. + +Verified against the working tree on 2026-06-17. Line numbers below are anchors, +not contracts — match on the function name. + +--- + +## Decision summary + +1. **Trigger hook** — Do NOT hook raw `SyncComplete`. Enqueue the all-wallets pass + at the **same readiness point that releases the identity coordinator**: the + `CoordinatorGate` fire. The gate closure in `WalletBackend::start` + (`src/wallet_backend/mod.rs::start`, ~L981) runs inside the SPV run loop and + may only do cheap, non-blocking work with weak captures, so it must NOT run + DAPI fetches itself. Instead it sends a new lightweight signal + (`TaskResult::Refresh` is too coarse — use a dedicated `BackendTaskSuccessResult` + nudge, see below) that `AppState` turns into a normal backend task. Net: the + pass runs once Platform is provably reachable (masternode list `Synced`), + off the frame thread, through the existing `BackendTask` path. + +2. **Re-entrancy / debounce** — One `AtomicBool` "discovery armed/fired" latch on + `AppContext` (re-armable). The `CoordinatorGate` already fires its action + exactly once per session (single-winner `fired` swap), so the *trigger* is + naturally one-shot per backend. A second `AppContext`-level latch guards the + case where the signal is delivered via the coarse refresh channel and could be + observed more than once, and gives manual Refresh a place to re-arm. Cleared + on `stop_spv` (same place `masternodes_ready` is cleared, `wallet_lifecycle.rs` + ~L392) so the next reconnect re-runs it. + +3. **Gap-limit scan** — Add `IDENTITY_GAP_LIMIT: u32 = 5`. Replace the + unconditional `0..=max` loops with one shared **rolling-lookahead** scan: + start at index 0, keep a moving `highest_found`, continue while + `current <= highest_found + IDENTITY_GAP_LIMIT`, stop after + `IDENTITY_GAP_LIMIT` consecutive empties past the last hit. Home it as **one + new async method on `AppContext`** in `backend_task/identity/` + (`discover_identities_gap_limited`) that BOTH the UI By-Wallet path and the + auto-trigger call. The pure stop/continue decision (`should_continue_scan`) + goes in `model/` as a stateless, unit-tested function (DET Module Placement + Policy: pure logic in `model/`, async business logic in `backend_task/`). + +4. **Upsert preserving alias** — For an already-stored identity: load existing via + `get_identity_by_id`, **carry its `alias` onto the freshly-fetched + `QualifiedIdentity` before storing**, then `insert_local_qualified_identity` + (which is `INSERT OR REPLACE` and serializes `alias` inside `qi_bytes`). + `top_ups` are a separate KV key (`det:top_ups`) and `insert_*` does not touch + them — safe. This fixes a confirmed pre-existing bug in the single-index load + path (see Risks F-1). + +5. **Threading / await safety** — Runs through `BackendTask` -> tokio, never the + egui frame thread (confirmed: `subtasks.spawn_sync` uses `tokio::spawn`; the + By-Wallet path is already a `BackendTask`). No wallet write lock is held across + an `.await` — existing discovery uses short `read()`/`write()` guards only; + the new shared fn keeps that shape. + +6. **UI impact** — Minimal. By-Wallet "All up to index" advanced mode keeps its + text box but its semantics become "highest index to *seed* the rolling scan + from" (the scan may go further via gap-limit). "Specific index" single search + is unchanged. Simple mode (the common path) already defaults to 5 and now gets + true rolling lookahead for free. + +--- + +## Background facts established by reconnaissance (verified) + +- **Identity-auth keys are hardened to the leaf.** They cannot come from an xpub; + `resolve_identity_auth_pubkey` (`backend_task/identity/auth_pubkey_resolve.rs`) + serves them cache-first and, on a **cold** cache miss, opens a `with_secret` + scope that **prompts** for the passphrase on a protected, locked wallet + (`secret_access.rs::with_secret_session` step 3). A warm cache needs no seed. + => A background sweep MUST be cache-only / locked-wallet-skipping, or it will + pop an unexpected passphrase modal. (Risk F-2.) +- **Alias lives inside `qi_bytes`** (decoded by `decode_stored_identity`), not in + a separate column. `insert_local_qualified_identity` does INSERT-OR-REPLACE of + the whole blob; both load paths build a fresh QI with `alias: None`. +- **`discover_identities_from_wallet`** already *skips* existing identities + (L90-98) — so it never clobbers alias, but also never updates a changed + identity (new keys, new DPNS name). **`load_user_identity_from_wallet`** always + re-inserts with `alias: None` — it *does* clobber the alias (Risk F-1). +- **The gate fires inside the SPV run loop** with weak coordinator captures; heavy + work there risks pinning the persister advisory lock past teardown + (`coordinator_gate.rs` regression test `weak_capture_does_not_pin_*`). The + trigger must hand off, not execute. + +--- + +## Dev Plan (ordered, file:function -> change) + +### Phase 1 — Pure gap-limit decision (model/) + +- [ ] **`src/model/identity_discovery.rs` (NEW)** -> add + `pub const IDENTITY_GAP_LIMIT: u32 = 5;` and a stateless fn + `pub fn should_continue_scan(current_index: u32, highest_found: Option<u32>) -> bool`. + Semantics: with no hit yet (`None`) continue while `current_index < IDENTITY_GAP_LIMIT`; + with `Some(h)` continue while `current_index <= h + IDENTITY_GAP_LIMIT`. Add a + hard ceiling const `IDENTITY_SCAN_HARD_CAP: u32 = 100` (defense against an + adversarial / corrupt cache that keeps "finding" — bounds the DAPI fan-out). + No `AppContext`, no `Sdk`. Register `mod identity_discovery;` in + `src/model/mod.rs`. +- [ ] **`src/model/identity_discovery.rs`** -> unit tests for the decision table: + empty wallet (no hits -> stops at 5), single hit at 0 (-> scans to 5), + hit at 7 (-> extends to 12), hits at 0 and 12 (-> rolling extend to 17), + hard-cap clamp. + +### Phase 2 — Shared gap-limited scan (backend_task/) + +- [ ] **`src/backend_task/identity/discover_identities.rs`** -> add + `pub(crate) async fn discover_identities_gap_limited(self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>, seed_from_index: u32, allow_prompt: bool) -> Result<DiscoverySummary, TaskError>`. + Drives the rolling loop via `model::identity_discovery::should_continue_scan`, + seeding `highest_found` from `seed_from_index` and from + `max(wallet.identities.keys())` so a prior-session index is never missed + (the gap-limit precedent warning). For each index it reuses the existing + per-index probe (auth-key window 0..12). `allow_prompt = false` makes a + cold-cache miss a *skip*, not a passphrase prompt (background sweep); + `allow_prompt = true` is the UI path. Returns a typed summary + (counts: found/updated/skipped) — not a `String`. +- [ ] **`src/backend_task/identity/discover_identities.rs`** -> extract the + per-index "fetch + build + **upsert-preserving-alias**" body into a private + helper `upsert_discovered_identity(&self, identity, wallet, identity_index)`: + before `insert_local_qualified_identity`, call `get_identity_by_id`; if + present, copy `existing.alias` onto the freshly built QI. Replace the current + `already_exists -> skip` branch (L90-98) with this update-preserving path so + changed identities refresh while alias survives. +- [ ] **`src/backend_task/error.rs`** -> if not already expressible, add a typed + variant for "no auth key derivable while locked, skipping" so the background + path can `continue` on it instead of surfacing a prompt. (Likely reuse + existing `ContactWalletSeedUnavailable` / `WalletAddressDerivationFailed`; + add only if neither fits the skip semantics.) +- [ ] **`src/backend_task/identity/load_identity_from_wallet.rs::load_user_identities_up_to_index`** + -> reimplement as a thin wrapper over `discover_identities_gap_limited(..., seed_from_index = max_identity_index, allow_prompt = true)`, preserving the + `Progress` events it already sends. The `0..=max` unconditional loop is + removed. +- [ ] **`src/backend_task/identity/load_identity_from_wallet.rs::load_user_identity_from_wallet`** + -> fix alias clobber: before the final `insert_local_qualified_identity` + (L243), load existing via `get_identity_by_id` and carry `alias` onto the + new QI. (Risk F-1 fix; also covers the user's "update but keep alias" intent + for the single-index search.) + +### Phase 3 — All-wallets auto-trigger + +- [ ] **`src/context/mod.rs`** -> add field + `identity_autodiscovery_fired: AtomicBool` to `AppContext` (default false). +- [ ] **`src/context/wallet_lifecycle.rs`** -> add + `pub fn queue_all_wallets_identity_discovery(self: &Arc<Self>)`: CAS the + latch (return early if already fired this session); snapshot wallets; + for each *open / not-needing-unlock* wallet, `spawn_sync` a + `discover_identities_gap_limited(wallet, seed_from_index = 0, allow_prompt = false)`. + Locked protected wallets are skipped here (no UI to prompt from a background + sweep) and picked up later when the user unlocks (existing + `handle_wallet_unlocked` path can call the same fn for that one wallet). +- [ ] **`src/context/wallet_lifecycle.rs::stop_spv`** (~L392, beside + `set_masternodes_ready(false)`) -> reset + `identity_autodiscovery_fired = false` so reconnect re-runs discovery. +- [ ] **Gate -> task hand-off.** Two acceptable wirings; pick one in review: + - **(A, preferred) via EventBridge result channel.** In + `WalletBackend::start` gate closure (`mod.rs` ~L981), after starting the + coordinators, send a new + `BackendTaskSuccessResult::PlatformReadyDiscoverIdentities` down the + existing `task_result_sender`. `AppState::update` maps it to + `app_context.queue_all_wallets_identity_discovery()`. Keeps the gate + closure cheap and non-blocking, no new long-lived captures. + - **(B) direct enqueue.** Capture a `Weak<AppContext>` in the gate closure + and call `queue_all_wallets_identity_discovery` on upgrade. Simpler, but + adds an `AppContext` capture to a closure the doc comments deliberately + keep minimal — review for teardown-pinning before choosing. +- [ ] **`src/app.rs::update`** (if wiring A) -> handle the new success result by + calling `queue_all_wallets_identity_discovery`. +- [ ] **`src/backend_task/mod.rs` / result enum** -> add the + `PlatformReadyDiscoverIdentities` success variant (wiring A only). + +### Phase 4 — UI + +- [ ] **`src/ui/identities/add_existing_identity_screen.rs::render_by_wallet`** + -> relabel the "All up to index" help text to reflect rolling lookahead + ("Searches from index 0 with a rolling 5-index lookahead; the number is the + starting depth."). No dispatch change — `SearchIdentitiesUpToIndex` already + routes to `load_user_identities_up_to_index`, now gap-limited. "Specific + index" untouched. i18n: keep each string one complete sentence. + +### Phase 5 — QA + +- [ ] `cargo clippy --all-features --all-targets -- -D warnings` +- [ ] `cargo +nightly fmt --all` +- [ ] `cargo test --all-features --workspace` (incl. new model unit tests) +- [ ] det-cli smoke (`network-info`, `tools`, `core-wallets-list`) per CLAUDE.md. +- [ ] `docs/user-stories.md`: add/flip a story for "automatic identity discovery + after migration". + +--- + +## Risks / edge cases + +- **F-1 (confirmed bug, pre-existing) — alias clobber on single-index load.** + `load_user_identity_from_wallet` re-inserts a fresh QI with `alias: None` via + `insert_local_qualified_identity` (INSERT-OR-REPLACE on the whole blob), erasing + any user alias. The user's "keep alias intact" requirement only holds once + Phase 2 carry-over lands. Severity: **High** (silent metadata loss). +- **F-2 (confirmed design hazard) — passphrase prompt from a background sweep.** + Cold auth-pubkey cache + locked protected wallet => `with_secret` prompts. + A frame-loop-triggered all-wallets sweep would surprise the user with a modal. + Mitigation: `allow_prompt = false` + skip locked wallets in the auto path. + Severity: **High** (UX correctness / unexpected secret prompt). +- **F-3 (confirmed timing) — raw `SyncComplete` is too early/wrong.** `SyncComplete` + fires on header/filter completion; Platform identity fetches need the + *masternode list* `Synced` or every queried DAPI node gets banned. Hooking the + `CoordinatorGate` (not `SyncComplete`) is mandatory. Severity: **Medium** + (would brick Platform queries if mis-hooked). +- **F-4 (confirmed re-entrancy) — gate/progress events repeat.** `on_progress` + re-fires on every tick; `on_masternodes_ready` is idempotent but the *task + hand-off* must not fan out per tick. The gate's single-winner `fired` swap plus + the `AppContext` latch bound it to once per session. Severity: **Medium**. +- **Empty wallet** — no identities at all: scan stops cleanly after 5 empties, + `NoWalletIdentitiesFound` for the UI path, silent no-op for the auto path. +- **Locked wallet** — auto path skips (F-2); manual By-Wallet path already gates + on `wallet_needs_unlock` and shows an Unlock button, so a cold miss there + prompts *with the user's consent* (`allow_prompt = true`). +- **Network switch mid-scan** — the scan clones the `Sdk` up front; a switch + rebuilds `AppContext`/SDK and clears `masternodes_ready`, re-arming the latch. + In-flight fetches target the old network and their results are stored under the + old-network identity scope — acceptable (network-scoped KV), but the auto path + should re-check `self.network` hasn't changed before each store, or bail on the + first store error. Add a cheap network-equality guard in + `upsert_discovered_identity`. +- **Duplicate identity across wallets** — the same identity ID reachable from two + wallets: `insert_local_qualified_identity` is keyed by identity ID, so the + second wallet's pass overwrites `wallet_hash`/`wallet_index` with its own hint. + Preserve the *first* association unless the new wallet actually owns more keys; + simplest correct rule for now: on update, keep existing `wallet_hash/index` + (the `update_local_qualified_identity` behaviour) rather than the insert + behaviour, when the identity already exists. Flag for review. +- **Hard cap** — `IDENTITY_SCAN_HARD_CAP` bounds a pathological "always found" + loop (corrupt cache / hostile DAPI) so a background sweep can't issue unbounded + fetches. + +--- + +## Test checklist (for Marvin) + +Unit (model, no network): +- [ ] `should_continue_scan`: no-hit stops at gap limit; hit at 0 scans to 5; + hit at 7 extends to 12; hits at 0 and 12 roll to 17; clamps at hard cap. + +Backend / integration: +- [ ] Stored identity with a user alias: re-discovery refreshes keys/DPNS but the + alias survives (regression for F-1). Assert alias non-`None` after both the + single-index load and the gap-limit pass. +- [ ] `top_ups` survive a re-discovery (separate KV key untouched). +- [ ] Gap-limit finds an identity beyond the seed index (e.g. registered at 8 with + seed_from_index 0) — proving rolling lookahead, not a static `0..=5`. +- [ ] Empty wallet: gap-limit returns no hits, no error in auto path, + `NoWalletIdentitiesFound` in UI path. + +Trigger / lifecycle: +- [ ] Auto pass fires exactly once after masternodes reach `Synced` (count DAPI + passes / log lines), not once per progress tick (F-4). +- [ ] Locked protected wallet: background sweep does NOT pop a passphrase modal + (F-2); it is skipped and later runs on manual unlock. +- [ ] `stop_spv` then reconnect: discovery re-arms and runs again. +- [ ] Network switch mid-scan: no identity written under the wrong network scope. + +Backend E2E (network, `#[ignore]`): +- [ ] On a funded testnet wallet with a known identity at index >0, a fresh + launch + sync auto-loads the identity into the list without the user opening + the By-Wallet screen. + +--- + +## Candy tally (confirmed findings surfaced) + +| Severity | Count | Findings | +|----------|-------|----------| +| High | 2 | F-1 alias clobber on single-index load; F-2 background passphrase-prompt hazard | +| Medium | 2 | F-3 wrong trigger point (SyncComplete vs masternodes-ready); F-4 per-tick re-entrancy | +| Low | 2 | duplicate-identity cross-wallet association overwrite; unbounded scan needs hard cap | + +Total: 6 confirmed findings -> 6 candies. diff --git a/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/qa-report.md b/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/qa-report.md new file mode 100644 index 000000000..2ed382682 --- /dev/null +++ b/docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/qa-report.md @@ -0,0 +1,374 @@ +# QA Report — PR #860 Post-Migration Identity Auto-Discovery (gap-limit) + +Brain the size of a planet, and I spent it counting empty trailing indices. +At least the counting found things. Adversarial QA audit of Bilby's +`feat/identity-autodiscovery` against the locked design doc +(`docs/ai-design/2026-06-17-identity-autodiscovery-gap-limit/design.md`). + +- Worktree: `/home/ubuntu/git/dash-evo-tool/.claude/worktrees/agent-identity-disco` +- Branch: `feat/identity-autodiscovery` (HEAD `732955b3`, base `56ac2cfe`) +- Build/tests: `cargo test --lib --all-features` → **885 passed, 0 failed, 1 ignored**. + The 10 `model::identity_discovery` unit tests pass; the 18 `secret_access` + tests pass. + +Method: built the expected-behaviour model from the design doc + task brief +*before* reading code, then attacked each of the seven brief vectors with a +forward trace from the real entry points (`CoordinatorGate` fire → `app.rs` +nudge → `queue_all_wallets_identity_discovery`; and the UI By-Wallet dispatch). +Findings are split into CONFIRMED (reproduced by trace/evidence) and +THEORETICAL (latent / not reachable today). + +--- + +## Verdict: **fix-then-ship** + +The gap-limit arithmetic, the F-2 never-prompt invariant, the alias-preservation +mechanism, and the debounce latch are all **sound** — the core machinery works +and is well-tested at the pure-logic layer. But the feature's headline promise — +*"auto-run discovery for ALL wallets"* — is **broken for every +password-protected wallet for the whole session** (QA-001), and the code, +user-story, and design doc all *document a recovery path that does not exist*. +That is a HIGH correctness+trust gap that should be closed before shipping. +Everything else is MEDIUM/LOW polish. + +Severity counts: **HIGH 1 · MEDIUM 3 · LOW 4** (8 confirmed) + 3 theoretical. + +--- + +## CONFIRMED findings + +### QA-001 — Protected wallets are NEVER auto-discovered; "searched after unlock" is unimplemented and falsely documented · HIGH + +- **Location:** `src/context/wallet_lifecycle.rs:828-882` (`handle_wallet_unlocked`), + `src/context/wallet_lifecycle.rs:892-899` (`drive_unlock_registration`), + `src/context/wallet_lifecycle.rs:973-977` (doc comment), `docs/user-stories.md` IDN-015. +- **Requirement:** Design §3/§Phase 3 and risk-note "Locked wallet": locked + protected wallets are skipped by the background sweep and *"picked up later when + the user unlocks (existing `handle_wallet_unlocked` path can call the same fn + for that one wallet)."* User-story IDN-015: *"Locked, password-protected + wallets are skipped without prompting; **they are searched after the user + unlocks them.**"* +- **Expected:** Unlocking a protected wallet triggers identity discovery for that + wallet. +- **Actual:** No unlock path calls any discovery. The only callers of a discovery + trigger are `app.rs:1318` (the once-per-session all-wallets sweep) and + `import_mnemonic_screen.rs:226` (import). `handle_wallet_unlocked` → + `drive_unlock_registration` → `bootstrap_wallet_addresses_jit` does address + bootstrap, upstream registration, shielded bind, and auth-pubkey warm — but + **never identity discovery**. Worse, the once-per-session latch + `identity_autodiscovery_fired` is *already set* by the startup sweep, so even a + manual re-trigger of `queue_all_wallets_identity_discovery` would no-op. +- **Failing scenario:** User has a password-protected wallet with an identity at + index 0. App launches, SPV reaches Platform-ready → sweep runs, skips the locked + wallet, **sets the latch**. User unlocks the wallet 30s later. Nothing + discovers its identity. It stays invisible in the identity list for the entire + session unless the user manually opens "Load Identity → By Wallet". The + feature's core promise ("ALL wallets, automatically") silently fails for the + exact users it documents a path for. +- **Fix direction:** In `handle_wallet_unlocked` (after the seed is promoted), + spawn `queue_wallet_identity_discovery(wallet, seed_from_index = 0)` for the + just-unlocked wallet (the existing per-wallet entry point, which prompts-OK + since the unlock is consent). Do NOT gate this on + `identity_autodiscovery_fired` (that latch is for the all-wallets sweep, not + per-wallet unlock). Then the user-story and the doc comment become true. + +### QA-002 — Zero automated coverage for the trigger / latch / re-arm logic · MEDIUM + +- **Location:** `src/context/wallet_lifecycle.rs:978-1031` + (`queue_all_wallets_identity_discovery`), `:393-395` (`stop_spv` re-arm), + `src/wallet_backend/mod.rs:1012-1018` (gate → `try_send` nudge). +- **Requirement:** Design "Test checklist" §Trigger/lifecycle: *"Auto pass fires + exactly once after masternodes reach Synced... Locked protected wallet: + background sweep does NOT pop a passphrase modal... `stop_spv` then reconnect: + discovery re-arms and runs again."* +- **Expected:** Unit/integration tests asserting the one-shot latch, the + locked-wallet skip, and the re-arm. +- **Actual:** No test references `identity_autodiscovery_fired`, + `queue_all_wallets_identity_discovery`, or `PlatformReadyDiscoverIdentities` + (grep returns nothing in test code). The debounce, the open-only filter, and + the `stop_spv` reset are entirely unverified. A regression that flips the latch + ordering, drops the `is_open()` filter, or forgets the `stop_spv` reset would + pass CI. +- **Failing scenario (encodable today, no network):** an offline `AppContext` + test (helpers already exist at `wallet_lifecycle.rs:1162+`) could register two + wallets (one open, one closed), call `queue_all_wallets_identity_discovery` + twice, and assert the latch swallows the second call and that the closed wallet + is filtered out of the snapshot. None exists. +- **Fix direction:** Add offline `AppContext` tests for: (a) latch is one-shot + until `stop_spv`; (b) `stop_spv` clears `identity_autodiscovery_fired`; (c) a + closed wallet is excluded from the open-wallets snapshot. The discovery + *network* call can be left as the existing `#[ignore]` E2E. + +### QA-003 — Alias-preservation (F-1 fix) is untested; a silent removal would not fail any test · MEDIUM + +- **Location:** `src/backend_task/identity/discover_identities.rs:231` + (`qualified_identity.alias = existing.alias`), + `src/backend_task/identity/load_identity_from_wallet.rs:252-254` (single-index + path), `src/context/identity_db.rs:364-390` + (`update_local_qualified_identity`). +- **Requirement:** Design "Test checklist" §Backend: *"Stored identity with a + user alias: re-discovery refreshes keys/DPNS but the alias survives... Assert + alias non-None after both the single-index load and the gap-limit pass."* +- **Expected:** A regression test pinning the alias carry-over. +- **Actual:** No test covers it. The carry-over lives in the *caller* + (`qualified_identity.alias = existing.alias` before + `update_local_qualified_identity`), not in the DB method — so deleting that one + line silently re-introduces the F-1 alias clobber the PR exists to fix, and the + full suite stays green. This is DB-layer testable with no network (the + `to_bytes`/`decode_stored_identity` alias round-trip is already proven by + `set_identity_alias`). +- **Fix direction:** Add a `context::identity_db` unit test: insert an identity + with `alias = Some("x")`; build a fresh QI for the same id with `alias: None`; + run the upsert carry-over (`existing.alias` → new QI) + `update_local_…`; + reload via `get_identity_by_id` and assert `alias == Some("x")` and + `wallet_hash`/`wallet_index` survive. + +### QA-004 — New `build_qualified_identity_from_wallet` returns `Result<_, String>`, flattening the typed `AuthKeyUnlockRequired` into an opaque `WalletInfoDeterminationFailed { detail: String }` · MEDIUM + +- **Location:** `src/backend_task/identity/discover_identities.rs:256-263` + (signature `-> Result<_, String>`), `:284-293` and `:276` + (`.map_err(|e| e.to_string())`), `:223` + (`.map_err(|detail| TaskError::WalletInfoDeterminationFailed { detail })`); + variant at `src/backend_task/error.rs:1293-1297`. +- **Requirement:** Project convention (CLAUDE.md "Error messages" rule 7 / "Never + parse error strings"): *"Never store user-facing strings in error variants... + String fields (regardless of name) break this separation"*; *"Always use the + typed error chain."* +- **Expected:** Typed error propagation; no new `String`-returning error path. +- **Actual:** Bilby introduced a new function (`build_qualified_identity_from_wallet`) + that returns `Result<_, String>` and stringifies through `to_string()`. The + underlying error in the no-prompt path is the *typed* + `TaskError::AuthKeyUnlockRequired`, which is flattened to a `String` and re-wrapped + in the pre-existing `WalletInfoDeterminationFailed { detail: String }` (note: + its `#[error("…")]` doesn't even interpolate `{detail}`, so the string is a + Debug-only payload that bypasses structural matching). The `WalletInfoDeterminationFailed` + variant itself is pre-existing (not Bilby's to fix), but *adding a new + String-typed error seam* that swallows a typed variant is a fresh convention + violation in this PR. +- **Note on severity:** this does **not** cause a wrong prompt (see "F-2 holds" + below) — the data-map's `AuthKeyUnlockRequired` branch is unreachable when the + probe already succeeded — so the impact is maintainability + lost diagnosability, + not a runtime fault. Hence MEDIUM, not HIGH. +- **Fix direction:** Change `build_qualified_identity_from_wallet` to return + `Result<_, TaskError>` and propagate with `?`; drop the `.to_string()` hops. + If a generic "could not build wallet binding" face is still wanted, add a typed + variant with a `#[source]` field rather than a `String`. + +### QA-005 — Progress event regression: `total` is meaningless and the message dropped its "of N" denominator · LOW + +- **Location:** `src/backend_task/identity/discover_identities.rs:73-85`. +- **Requirement:** Design §Phase 2: the gap-limited wrapper must "preserve the + `Progress` events it already sends." +- **Expected (prior behaviour, base `56ac2cfe` + `load_identity_from_wallet.rs:274-281`):** `message = "Searching wallet + identity index {current} of {total}."`, `current = index+1`, `total = max+1` — + a real fraction. +- **Actual:** `message = "Searching wallet identity index {next}."`, + `current: next, total: next` — `total` always equals `current` (every event + reads "N of N" / 100%), and the message text dropped the denominator entirely. + The By-Wallet UI (`add_existing_identity_screen.rs:1049`) renders only + `message` and ignores `current`/`total`, so the visible regression is the + missing denominator; the `total` field is now junk for any other consumer. + (This is partly inherent — a rolling scan has no fixed total — but emitting + `total == current` is worse than emitting the seed/hard-cap as a soft total.) +- **Fix direction:** Either drop `total` from the per-index event and word the + message without a denominator ("Searching wallet identity index {n}…"), or set + `total` to a meaningful soft bound (current rolling window + `highest_found + GAP + 1`, or the hard cap). Don't ship `total == current`. + +### QA-006 — Missing network-equality guard in `upsert_discovered_identity` that the design explicitly required · LOW + +- **Location:** `src/backend_task/identity/discover_identities.rs:203-252` + (`upsert_discovered_identity` — no `self.network` re-check before store). +- **Requirement:** Design §Risks "Network switch mid-scan": *"the auto path + should re-check `self.network` hasn't changed before each store... Add a cheap + network-equality guard in `upsert_discovered_identity`."* +- **Expected:** A network guard before persisting each discovered identity. +- **Actual:** None. In practice the in-flight task holds an `Arc` to the *old* + `AppContext`, whose `identity_kv()`/`network` are network-scoped, so a mid-scan + network switch writes to the old network's scope — *correct isolation by + construction*, which is why this is LOW not MEDIUM. But the design called for + the guard as defense-in-depth and it is silently absent; if a future refactor + ever shares storage across networks, the missing guard becomes a real + cross-network write. +- **Fix direction:** Add the cheap `if self.network != <captured network> { + return … }` guard the design asked for, or document in the function why it is + deliberately omitted (the old-Arc isolation argument). + +### QA-007 — Misleading doc comment: `try_send` failure does NOT "re-deliver on the next refresh tick" · LOW + +- **Location:** `src/wallet_backend/mod.rs:1015-1019`. +- **Requirement:** Comments must describe present-state behaviour accurately + (coding-best-practices "Describe present state"). +- **Expected:** A true statement about the failure mode. +- **Actual:** Comment claims *"if the channel is full the next refresh tick + re-delivers readiness state."* There is no such mechanism: the + `CoordinatorGate` fires exactly once (single-winner `swap`), the closure is + consumed, and nothing re-emits `PlatformReadyDiscoverIdentities`. A dropped + `try_send` means the all-wallets sweep simply never runs that session (until a + `stop_spv`/reconnect re-arms). The channel is 256-deep so the drop is very + unlikely, hence LOW — but the comment documents a safety net that does not + exist. +- **Fix direction:** Either make the claim true (have `refresh_state`/a tick + re-check `coordinator_gate.has_fired()` and re-nudge while the + `identity_autodiscovery_fired` latch is unset), or correct the comment to state + that a full-channel drop is tolerated because the channel is large and the user + can still run discovery manually. + +### QA-008 — "max 29" input cap removed from the By-Wallet "up to index" field with no replacement bound · LOW + +- **Location:** `src/ui/identities/add_existing_identity_screen.rs:643-647` + (label changed), `:698` (`parse::<u32>()` with no clamp). +- **Requirement:** i18n-ready, sensible input bounds; the old label promised + "max 29". +- **Expected:** A bound on the seed index (the field now seeds the rolling + window). +- **Actual:** The label changed to "Search depth to start from:" and the old + "max 29" guard is gone; the field parses an unbounded `u32`. A fat-finger seed + (e.g. `4000000000`) makes `should_continue_scan(0, Some(4e9))` true until the + hard cap, so the scan probes indices 0..99 (×12 auth keys = up to 1200 DAPI + fetches) for a wallet with nothing there. `IDENTITY_SCAN_HARD_CAP = 100` bounds + it, so it is not unbounded — hence LOW — but the removed cap means a typo now + costs a full 100-deep scan instead of being rejected. +- **Fix direction:** Clamp the parsed seed to a sane max (e.g. the old 29, or + `IDENTITY_SCAN_HARD_CAP`), or validate via a `model/` validator per the + validation-placement convention. + +--- + +## THEORETICAL concerns (traced, NOT reachable today — no candy claimed) + +### T-1 — F-2 TTL-expiry TOCTOU between `can_resolve_without_prompt` and `with_secret` + +`resolve_identity_auth_pubkey` (`auth_pubkey_resolve.rs:69-80`) checks +`can_resolve_without_prompt` then calls `with_secret`. If a session entry expired +*between* the check and the resolve, `with_secret` step 1 evicts it, step 3 +prompts — a passphrase modal from a background sweep, violating F-2. **Not +reachable today:** every HD-seed promotion uses `RememberPolicy::UntilAppClose` +(`expires_at = None`, never expires) — confirmed at +`wallet_lifecycle.rs:627/856/1610/2468`, and `secret_prompt.rs:56` documents the +GUI only wires `None`/`UntilAppClose`. The TOCTOU becomes live the day anyone +wires `RememberPolicy::For(duration)` for a seed scope. *Defensive fix:* in the +no-prompt path, resolve through a `with_secret` variant that treats a +prompt-needed outcome as `AuthKeyUnlockRequired` rather than re-checking +`can_resolve_without_prompt` up front (make the no-prompt contract atomic). + +### T-2 — Rolling window misses an identity exactly `GAP+1` past the last hit + +By design, `{3, 9}` (and the brief's `{0,3,9}`) stops at index 8 and never +probes 9 — 5 empties (4,5,6,7,8) then a hit at 9 is *outside* the window +`3+GAP=8`. I initially suspected an off-by-one, but verified against the spec +("continue while `current <= highest_found + IDENTITY_GAP_LIMIT`", "stop after +`IDENTITY_GAP_LIMIT` consecutive empties past the last hit") — this is **correct +gap-limit behaviour**, not a bug. The seeding from `max(wallet.identities.keys())` +re-reaches a *known* high index. The only residual risk: an identity registered +on another device at index 9, never loaded locally, with locals only at {0,3}, +is unreachable by the auto-sweep — inherent to any gap limit, and the manual +By-Wallet "search depth" field is the escape hatch. No fix needed; flagging +because the brief asked. + +### T-3 — `rolling_chain_extends_window_past_static_range` proves the chained window, not the seed window + +The unit test uses hits `[3, 8]` where `3` is inside the initial `0..5` no-hit +window, so it is found organically and chains the window out to `8`. That does +exercise rolling extension. What it does **not** exercise is the +`discover_identities_gap_limited` *seeding* path (`seed_window = +max(highest_known_index, seed_from_index)`) — i.e. reaching an identity at index +8 with `seed_from_index = 0` and *no* intermediate hit, which is the +backend-only behaviour and is network-gated. The pure function is fine; the +backend seeding has no test (folded into QA-002's coverage gap, not double-counted). + +--- + +## What is genuinely solid (credit where due) + +- **Gap-limit arithmetic** (`should_continue_scan`): correct against the spec, + hard-cap-bounded, overflow-safe (`saturating_add`), terminates in all cases. + 10 unit tests, all passing. +- **F-2 never-prompt invariant holds:** `allow_prompt = false` is threaded + through the probe (`resolve_identity_auth_pubkey`) AND the build + (`build_qualified_identity_from_wallet` → `resolve_identity_auth_pubkeys_data_map`). + A locked protected wallet returns `AuthKeyUnlockRequired` at the probe and the + whole wallet is skipped before any `with_secret`. `can_resolve_without_prompt` + correctly tracks at-rest protection + session cache (18 secret_access tests + pass). The data-map's unlock branch is unreachable once the probe succeeds. +- **Debounce:** double-guarded — `CoordinatorGate` single-winner `swap(true)` + + `identity_autodiscovery_fired.swap(true, SeqCst)`. `stop_spv` re-arms both. + No load/store race (uses `swap`, not load-then-store). +- **Alias / wallet-association preservation:** both the single-index and + gap-limit paths load `existing` via `get_identity_by_id` and carry + `existing.alias` BEFORE the update; `update_local_qualified_identity` preserves + `wallet_hash`/`wallet_index` from the existing row (handles the Nagatha L-1 + cross-wallet overwrite correctly); `top_ups` live under a separate KV key, + untouched. +- **Concurrency:** no `Wallet` RwLock guard is held across an `.await` in any + touched path — `read()`/`write()` guards are scoped to blocks or to the + synchronous `with_secret` closure body; verified by inspection of + `discover_identities.rs` and `auth_pubkey_resolve.rs`. + +--- + +## 🍬 Candy tally (confirmed findings only) + +| Severity | Count | IDs | +|----------|-------|-----| +| HIGH | 1 | QA-001 | +| MEDIUM | 3 | QA-002, QA-003, QA-004 | +| LOW | 4 | QA-005, QA-006, QA-007, QA-008 | +| **Total**| **8** | + 3 theoretical (T-1, T-2, T-3) noted, not scored | + +Eight confirmed. Eight candies. I'd be more pleased if there were fewer, which +tells you something about my expectations. The headline one (QA-001) means the +feature does not do the one thing its own user-story promises for protected +wallets — fix that before it ships, and the rest is housekeeping. + +--- + +## Re-verification (delta pass after Bilby's fixes) + +Three fix commits on `feat/identity-autodiscovery` (`14b31995`, `4d298fcd`, +`4271af70`). Focused delta re-check — sound parts already verified, only the +fixes audited. `cargo test --lib --all-features` → **889 passed, 0 failed, 1 +ignored** (4 new tests, all ran and passed); `cargo clippy --lib --all-features` +clean. + +| Finding | Status | Evidence | +|---------|--------|----------| +| **QA-001** (HIGH) | ✅ RESOLVED | `handle_wallet_unlocked` (`wallet_lifecycle.rs:885`) now calls new `queue_unlocked_wallet_identity_discovery` (`:1043-1068`), placed AFTER seed promotion (`:853`) and `drive_unlock_registration` (`:881`). It (a) dispatches `discover_identities_gap_limited(&wallet, 0, true, None)` — not a no-op; (b) never reads `identity_autodiscovery_fired`; (c) early-returns on `!masternodes_ready()`; (d) runs prompt-free off the freshly-promoted session cache; (e) holds no `Wallet` guard across `.await`. Deferred case verified: unlock flips the seed `Open` (`wallet_unlock_popup.rs:114`, guard dropped :116) BEFORE `handle_wallet_unlocked`, so an early unlock is picked up by the upcoming sweep's `open_wallets()` snapshot. User-story IDN-015 and the sweep doc comment are now TRUE. | +| **QA-002** (MED) | ✅ RESOLVED | New `all_wallets_discovery_latch_is_one_shot_until_stop_spv` test binds: asserts latch sets on first call, second call no-ops, `stop_spv` clears it. Reverting the `swap` latch or the `stop_spv` reset fails it. | +| **QA-003** (MED) | ✅ RESOLVED (with one caveat) | New `rediscovery_update_preserves_user_alias_and_wallet_binding` binds: inserts `alias=Some("my-id")`+binding `(hash,3)`, simulates re-discovery (fresh QI `alias:None` → carry `existing.alias` → `update_local_qualified_identity`), asserts `alias==Some("my-id")` and `wallet_index==Some(3)`. Removing the carry-over fails it. **Caveat (not a new finding):** the test re-implements the carry-over rather than calling the production `upsert_discovered_identity`, so it guards `update_local_qualified_identity`'s binding-preservation but not a regression *inside* `upsert_discovered_identity`. Acceptable — the production helper is the same 2-line pattern. | +| **QA-004** (MED) | ✅ RESOLVED | `build_qualified_identity_from_wallet` now returns `Result<_, TaskError>` (`discover_identities.rs:291`); the `.to_string()` hops and the `WalletInfoDeterminationFailed { detail }` flatten are gone; `?` propagation preserves `AuthKeyUnlockRequired` end-to-end. No `Result<_, String>` and no `WalletInfoDeterminationFailed` reference remain in the file. No new `String`-typed error field anywhere in the 3 commits (`error.rs` untouched; `IdentitySearchIndexError::TooLarge { max: u32 }` is typed). | +| **QA-005** (LOW) | ✅ RESOLVED | Per-index `Progress` now carries `total: soft_total` = `highest_found + GAP + 1` clamped to `IDENTITY_SCAN_HARD_CAP` (`:79-83`), message "of about {soft_total}". No longer `total == current`. UI (`add_existing_identity_screen.rs:1049`) renders `message`, so it shows an honest denominator. | +| **QA-006** (LOW) | ✅ RESOLVED (literally; see note) | `upsert_discovered_identity` takes `scan_network` (captured at scan start, `:47`) and skips the store on `self.network != scan_network` (`:241`). **Note (not a new finding):** because a network switch swaps to a *different per-network* `AppContext` (`app.rs:831` `finalize_network_switch`) and `network` is an immutable field, the in-flight task's `self.network` always equals `scan_network` — so the guard never actually fires. It satisfies the design's literal "re-check before each store" requirement as harmless defense-in-depth; the real isolation was already structural. Dead-but-correct, not a regression. | +| **QA-007** (LOW) | ✅ RESOLVED | The false "next refresh tick re-delivers" claim is replaced (`mod.rs:1016-1019`) with an accurate note: a full 256-deep channel would drop the nudge and the sweep would wait for a reconnect, tolerated because the user can run discovery manually. | +| **QA-008** (LOW) | ✅ RESOLVED | New pure `model/` validator `validate_search_index` with `MAX_IDENTITY_SEARCH_INDEX = 99` and typed `IdentitySearchIndexError::TooLarge` (no String field); applied at the UI dispatch (`add_existing_identity_screen.rs`) with separate out-of-range vs non-numeric messages, both i18n-ready. Test `validate_search_index_accepts_in_range_rejects_beyond_cap` binds (0→Ok, 99→Ok, 100→Err, u32::MAX→Err). | + +### New issues introduced by the fixes + +**None confirmed.** Benign observations, no candy: + +- **Double-dispatch (harmless):** if a wallet is both unlocked-while-Platform-ready + *and* covered by the sweep, two `discover_identities_gap_limited` runs can + overlap on one wallet → duplicate DAPI fetches + idempotent last-write-wins + upsert. No corruption (DB-serialised, update-preserving-alias). In the common + deferred flow the latch prevents it (sweep already fired, unlock path is + latch-independent and runs once). LOW-impact, acceptable. +- **Unlock-path prompt on a promotion-failure race (by-design):** if + `promote_hd_seed_with_passphrase` fails *after* `open()` succeeded (e.g. a + `WalletNotFound` envelope race), `queue_unlocked_wallet_identity_discovery` + runs with `allow_prompt = true` and could prompt on a cold miss. This is the + *interactive unlock* path where the user just typed their passphrase and is + present — a prompt here is consented, not a surprise. **Not an F-2 violation** + (F-2 governs the background `allow_prompt = false` sweep, which is untouched). +- **QA-006 guard is dead code** (detailed above) — correct but never fires. + +### Re-verification verdict: **SHIP** + +All 8 findings resolved, 4 new tests bind (would fail on revert), full suite +green (889 pass), clippy clean, no regression to the import path, the F-2 +invariant, the concurrency shape, or the UI Progress consumption. The headline +QA-001 gap is genuinely closed — protected wallets are now discovered on unlock, +and the user-story finally tells the truth. I have run out of things to be +disappointed about, which is itself mildly disappointing. + +*(0 new confirmed issues in the fixes → 0 new candy. The original 8 stand.)* diff --git a/docs/ai-design/2026-06-18-masternode-cli-withdraw/01-requirements-ux.md b/docs/ai-design/2026-06-18-masternode-cli-withdraw/01-requirements-ux.md new file mode 100644 index 000000000..4a1abb49e --- /dev/null +++ b/docs/ai-design/2026-06-18-masternode-cli-withdraw/01-requirements-ux.md @@ -0,0 +1,606 @@ +# Masternode / Evonode Withdrawals via det-cli — Requirements & UX Spec + +**Date:** 2026-06-18 +**Phase:** 1a (Requirements) + 1b (UX/DX) +**Surface:** det-cli over MCP (headless). Not the GUI. +**Status:** Draft for review. +**Base:** PR #860 @976ad0d4, branch `feat/masternode-cli-withdraw`. + +--- + +## 1. Executive Summary + +**Problem.** A masternode/evonode operator who works headlessly (det-cli / MCP, no +GUI) cannot withdraw their node's accumulated Platform credits today. Two gaps +stand in the way, both already solved in the GUI but absent from the tool layer: + +1. **No headless identity load.** Loading a masternode/evonode identity — fetching + it by ProTxHash and binding its owner/voting/payout private keys — is GUI-only + (`add_existing_identity_screen.rs` → `IdentityTask::LoadIdentity`). The existing + `identity_credits_withdraw` MCP tool resolves the identity from the **local + database** (`resolve::qualified_identity`) and fails with *"Load the identity + first using the identity screen or CLI"* — but no CLI load path exists. The + instruction points at a door that isn't built. + +2. **The withdraw tool is not masternode-aware.** `identity_credits_withdraw` + always requires a destination address and always passes `key_id = None`. A + masternode withdrawal signed with the **OWNER** key must force the destination + to the node's registered payout address; the tool has no concept of key mode. + +**Solution direction.** Two thin MCP tools, mirroring patterns already proven in +the codebase: + +- **(A) `masternode_identity_load`** — load a masternode/evonode identity headlessly + from a ProTxHash plus owner/voting/payout private keys. Mirrors the secret-input + mechanism of `core_wallet_import` (`src/mcp/tools/wallet.rs`) and dispatches the + existing `IdentityTask::LoadIdentity`. + +- **(B) `masternode_credits_withdraw`** — a masternode-aware credit + withdrawal supporting **both** key modes, mirroring the GUI's + `withdraw_screen.rs` rules and dispatching the existing + `IdentityTask::WithdrawFromIdentity`. + +Both are adapters per `docs/MCP_TOOL_DEVELOPMENT.md`: no new business logic, no new +backend tasks. The backend already does everything; we are only opening a headless +door to it. + +**Key actors.** Priya (Power User / masternode operator) and Jordan (Platform +Developer) — both on the headless path. The Everyday User (Alex) stays in the GUI +and is explicitly out of scope. + +**Two product decisions already LOCKED (not open for re-litigation here):** + +- Deliver **both** tools (A load + B withdraw). +- Withdraw supports **both** modes, mirroring the GUI: OWNER key → destination + forced to the registered payout address; payout/TRANSFER key → any Core address. + +--- + +## 2. Stakeholder & Actor Analysis + +### Primary actor — Priya Nakamura (Power User / masternode operator) + +| Field | Value | +|---|---| +| Goal here | Withdraw her node's earned Platform credits to Core, scripted, without opening the desktop GUI. | +| Context | Runs a masternode; comfortable with CLI, holds owner/voting/payout keys; understands the payout-address constraint. | +| Pain today | The only withdrawal path is the GUI. Headless automation (cron, ops scripts) is impossible. | +| Success metric | One scripted load + one scripted withdraw, no GUI, clear JSON result with the txid-equivalent confirmation and fees. | + +### Primary actor — Jordan Kim (Platform Developer) + +| Field | Value | +|---|---| +| Goal here | Spin a test evonode identity into the tool from known testnet keys and exercise the withdraw path during dApp/integration testing. | +| Context | Testnet/Devnet; values speed and directness; reads raw protocol numbers (credits, fees). | +| Pain today | Must hand-drive the GUI to load an evonode identity before any headless work. Breaks automation. | +| Success metric | Load by ProTxHash + keys, then withdraw, entirely from a script; actionable errors with numbers, not raw Rust strings. | + +### Secondary actor — AI agent over MCP + +Calls the same two tools via the MCP HTTP/stdio transport. Needs accurate tool +**annotations** (`destructive`, `idempotent`, `open_world`) to decide confirmation +prompts, and a clean JSON schema. The masternode-load tool handles private keys, so +its annotations and its `Debug` redaction matter for agent safety. + +### Supporting systems + +- **Backend task system** — `IdentityTask::LoadIdentity` and + `IdentityTask::WithdrawFromIdentity` (authoritative enforcement layer). +- **Local DB** — `insert_local_qualified_identity` persists the loaded identity so + the withdraw tool's `resolve::qualified_identity` can find it (this is the seam + that makes A→B compose). +- **SPV / DAPI** — load fetches the identity over DAPI; proof verification needs a + synced SPV chain. +- **`Secret` model type** (`src/model/secret.rs`) — zeroizing, page-locked secret + carrier already used by `IdentityInputToLoad`. + +--- + +## 3. Functional Requirements + +### 3.1 Tool A — `masternode_identity_load` + +**Purpose.** Load a masternode or evonode identity headlessly: fetch it by +ProTxHash over DAPI, verify and bind the supplied private keys, and persist it +locally so subsequent tools (withdraw, refresh, etc.) can resolve it. + +**Mirrors.** Secret input → `core_wallet_import` (`ImportWalletParams`, +hand-written redacting `Debug`, zeroizing buffers). Dispatch → the existing +`IdentityTask::LoadIdentity(IdentityInputToLoad)`. + +#### Inputs + +| Param | Type | Required | Notes | +|---|---|---|---| +| `pro_tx_hash` | `String` | yes | The identity handle. ProTxHash is the masternode/evonode identity ID. Accept hex (the canonical MN encoding) and Base58 — `load_identity` already tries Base58 then Hex (`Identifier::from_string`). | +| `node_type` | `String` enum | yes | `"masternode"` or `"evonode"`. Maps to `IdentityType::Masternode` / `IdentityType::Evonode`. **Never** `User`. | +| `owner_private_key` | `String` (WIF or 64-hex) | conditional | At least one key must be present (see rule FR-A4). Bound as the OWNER key. | +| `voting_private_key` | `String` (WIF or 64-hex) | optional | Bound as the voting key on the associated voter identity (load fetches the voter identity via DAPI when supplied). | +| `payout_private_key` | `String` (WIF or 64-hex) | conditional | At least one key must be present (see FR-A4). Bound as the payout/TRANSFER key. | +| `alias` | `String` | optional | Human-readable name; trimmed, empty → none. Falls back to first DPNS name if any (existing load behavior). | +| `network` | `String` | yes | Required. Destructive-adjacent (writes local DB, fetches network). Use `resolve::require_network`. | + +Key formats follow `verify_key_input` exactly: **64-char hex** or **51/52-char +WIF**; empty string → "not supplied" (`None`); any other length is an error. We do +not re-implement this — the backend `verify_key_input` is the single source of +truth, and it returns typed `TaskError::KeyInputValidationFailed { key_name, detail }`. + +#### Outputs + +```json +{ + "identity_id": "<base58>", + "node_type": "masternode" | "evonode", + "alias": "<string|null>", + "owner_key_loaded": true, + "voting_key_loaded": false, + "payout_key_loaded": true, + "available_withdrawal_keys": ["owner", "transfer"], + "payout_address": "<Core address|null>", + "dpns_names": ["alice.dash"] +} +``` + +- `available_withdrawal_keys` is derived from `available_withdrawal_keys()` on the + resulting `QualifiedIdentity` (OWNER + TRANSFER for MN/Evonode). It tells the + caller — human or agent — which `key_mode` values the withdraw tool will accept + for this identity, so the second step is self-describing. +- `payout_address` comes from `masternode_payout_address(network)`. It is the + destination the caller gets when withdrawing with the OWNER key. Surfacing it + here means the operator can verify the payout target *before* moving funds. + +#### Behavioral rules + +- **FR-A1** — `node_type` must be `masternode` or `evonode`. Reject `user` (and any + other value) with `InvalidParam` — this tool is masternode-specific by design; + user identities load via a different (future or existing) path. +- **FR-A2** — Construct `IdentityInputToLoad` with `derive_keys_from_wallets = + false` and `selected_wallet_seed_hash = None`. Headless MN load is key-driven, not + wallet-derived. (`keys_input` stays empty — that vec is the User-type manual-key + path.) +- **FR-A3** — On `LoadedIdentity(qi)`, the backend has already called + `insert_local_qualified_identity`. The identity is now resolvable by + `resolve::qualified_identity` for the withdraw tool. No extra persistence in the + tool. +- **FR-A4** — Require **at least one** of `owner_private_key` / `payout_private_key`. + Loading an MN identity with zero signing keys produces a watch-only record that + can sign nothing — useless for the locked use case (withdraw). Reject with a + message that names the two keys and explains they enable the two withdraw modes. + (`voting_private_key` alone does not enable a withdrawal — it only binds the voter + identity.) +- **FR-A5** — Re-loading the same identity is effectively idempotent: the backend + does INSERT-OR-REPLACE keyed by identity ID. Annotate `idempotent(false)` + conservatively (re-load re-fetches network state and can change bound keys), but + document that re-running is safe and updates the local record. + +### 3.2 Tool B — `masternode_credits_withdraw` + +**Purpose.** Withdraw credits from a loaded masternode/evonode identity to Core, +honoring the two key/destination modes. + +**Mirrors.** Dispatch → `IdentityTask::WithdrawFromIdentity(qi, Option<Address>, +Credits, Option<KeyID>)`. Destination/key rules → `withdraw_screen.rs` +(`render_address_input`, `show_confirmation_popup`). + +#### Inputs + +| Param | Type | Required | Notes | +|---|---|---|---| +| `identity_id` | `String` | yes | Base58 identity ID (the loaded MN identity). Resolved via `resolve::qualified_identity`. | +| `key_mode` | `String` enum | yes | `"owner"` or `"transfer"`. Selects which available withdrawal key signs. Explicit, not inferred — see FR-B1. | +| `to_address` | `String` | conditional | Core address. **Required** for `transfer` mode, **forbidden/ignored** for `owner` mode (destination is forced). See FR-B2/B3. | +| `amount_credits` | `u64` | yes | > 0 (`resolve::validate_credits`). | +| `network` | `String` | yes | Required (destructive). `resolve::require_network`. | + +#### Outputs + +```json +{ + "identity_id": "<base58>", + "key_mode": "owner" | "transfer", + "to_address": "<Core address actually used>", + "amount_credits": 100000, + "estimated_fee": 1234, + "actual_fee": 1230 +} +``` + +`to_address` echoes the address **actually used** — for OWNER mode that is the +resolved payout address, not a caller input. The caller always learns where the +funds went. + +#### Behavioral rules — the two modes (LOCKED) + +- **FR-B1 — Explicit key mode.** The caller chooses `key_mode` explicitly rather + than the tool guessing from key availability. An MN/evonode identity may have both + OWNER and TRANSFER keys loaded; the destination semantics differ sharply between + them (forced vs free), so an ambiguous auto-pick is a foot-gun for a fund-moving + operation. The tool resolves `key_mode` to a concrete `KeyID` by scanning + `available_withdrawal_keys()` for a key whose purpose matches + (`OWNER` ↔ `transfer`→`TRANSFER`). If no matching key is loaded, return + `InvalidParam` naming which key is missing and that it must be supplied at load + time. + +- **FR-B2 — OWNER mode forces destination.** When `key_mode = "owner"`: + - Resolve the destination from `masternode_payout_address(network)`. + - If `to_address` was supplied, **reject** with `InvalidParam`: the OWNER-key + withdrawal can only go to the registered payout address, so a caller-supplied + address is a contradiction to surface, not silently ignore. (The GUI hides the + address field entirely in this mode; headless can't hide a field, so it rejects.) + - If the identity has no payout address, reject with a clear message — there is + nowhere for an OWNER-key withdrawal to go. + - Dispatch with `address = None`, `key_id = Some(owner_key_id)` — Platform + consensus forces the registered payout address (matching the GUI). The + resolved payout address is used only to validate one exists and to echo it + back in the output, never as the dispatched destination. + +- **FR-B3 — TRANSFER mode, free destination.** When `key_mode = "transfer"`: + - `to_address` is **required**; validate format (`resolve::validate_address`) and + network match, exactly as `identity_credits_withdraw` does (parse + `NetworkUnchecked` → `require_network(ctx.network())`). + - Reject Platform (bech32m) addresses — withdrawals settle on Core only. Mirror + the GUI's `is_platform_address_string` guard with a calm message. + - Dispatch with `address = Some(parsed_core_address)`, + `key_id = Some(transfer_key_id)`. + +- **FR-B4 — No developer-mode relaxation in the tool.** The GUI relaxes the + payout-address constraint under developer mode (lets an OWNER-key withdrawal go to + an arbitrary address). The headless tool does **not** expose that relaxation: + there is no UI to signal "I know what I'm doing," and a scripted/agent caller + silently sending an OWNER withdrawal to an arbitrary address is exactly the + mistake this constraint exists to prevent. OWNER mode always forces the payout + address. (Open question OQ-3 confirms.) + +- **FR-B5 — Identity must be loaded first.** If `resolve::qualified_identity` fails, + return its existing message — now accurate, because the door (`masternode_identity_load`) + exists. Consider updating the message to name the new tool. + +### 3.3 Network safety (both tools) + +- Both are stateful/destructive: `network` is **required**, enforced via + `resolve::require_network` (not the optional `verify_network`). A mismatch returns + `NetworkMismatch { expected, actual }`. This is the locked cross-network guard and + matches every other fund-moving tool. + +### 3.4 Composition (A → B), validated + +``` +masternode_identity_load (pro_tx_hash + keys + network) + │ fetch by ProTxHash over DAPI, verify keys, bind, persist + ▼ + LoadedIdentity(qi) → insert_local_qualified_identity (INSERT-OR-REPLACE) + │ + ▼ +masternode_credits_withdraw (identity_id + key_mode + ... ) + │ resolve::qualified_identity finds the persisted record + ▼ + WithdrawFromIdentity(qi, dest, credits, key_id) → CreditWithdrawal ST +``` + +The two tools compose through the **local DB**, exactly as the existing +GUI→withdraw flow does. No shared in-memory state, no ordering coupling beyond +"load before withdraw," which the withdraw tool's error already enforces. + +--- + +## 4. Non-Functional Requirements + +### 4.1 Security of private-key input + +- **NFR-S1 — Redacting `Debug`.** The load tool's params struct carries three + private keys. Mirror `ImportWalletParams`: a **hand-written `Debug`** that prints + each key field as `"<redacted>"`. A derived `Debug` would leak keys into MCP error + `data` payloads and logs (`McpToolError::TaskFailed` serializes `{task_err:?}`). + This is the single most important security requirement in this spec. +- **NFR-S2 — Zeroizing buffers.** Wrap raw key strings in `zeroize::Zeroizing` (or + feed them into `Secret::new`, which page-locks and zeroizes) before handing to + `IdentityInputToLoad`. `IdentityInputToLoad` already takes `Secret`, so the tool's + job is to move the input into `Secret` promptly and not retain a plain `String` + copy. +- **NFR-S3 — Keys never in output or errors.** Output reports only *which* keys + loaded (booleans), never the key material. Validation errors name the key by role + ("Owner", "Payout Address") and the failure kind, never echo the value. This is + already how `TaskError::KeyInputValidationFailed` behaves — preserve it. +- **NFR-S4 — Transport caution.** Over MCP HTTP, keys traverse the request body. The + HTTP transport is bearer-auth'd and loopback by default; document that callers + must not send live mainnet MN keys over a non-loopback MCP HTTP endpoint. (Carried + as a documentation note for `docs/MCP.md`, not enforced in code.) + +### 4.2 Network-match enforcement + +- **NFR-N1** — `require_network` on both tools (FR-3.3). Keys are network-scoped; a + WIF parsed against the wrong network fails in `verify_key_input` anyway, but the + explicit network guard fails *first*, with the clearer cross-network message. + +### 4.3 SPV sync prerequisites + +- **NFR-P1 — Load needs SPV.** `load_identity` calls `Identity::fetch_by_identifier` + (and, with a voting key, fetches the voter identity) over DAPI. Per the SPV-gate + rule in `MCP_TOOL_DEVELOPMENT.md`, DAPI proof verification needs a synced SPV + chain. The load tool **must** call `resolve::ensure_spv_synced` before dispatch. +- **NFR-P2 — Withdraw and the SPV inconsistency (FLAG).** The existing + `identity_credits_withdraw` *intentionally skips* `ensure_spv_synced` (comment: + "no SPV sync needed — this tool only dispatches Platform state transitions"). + This contradicts the documented SPV-gate rule, which says platform-only network + calls still need SPV for proof verification. We have two options: + - **(a)** Match the sibling withdraw tool and skip the gate (consistency with the + one tool a caller will compare against). + - **(b)** Add the gate (consistency with the documented rule and with load). + Recommended: **(b) add the gate** — a withdrawal is the most consequential op in + this spec, and a few seconds of sync-wait is cheap insurance against a proof + failure mid-withdraw. Carried as **OQ-4** for the implementer to confirm and, if + (b), to reconcile the existing tool's comment. Either way, **document the choice**. + +### 4.4 DX / discoverability (per `MCP_TOOL_DEVELOPMENT.md`) + +- **NFR-D1** — Tool names follow `{domain}_{object}_{action}`: + `masternode_identity_load`, `masternode_credits_withdraw`. CLI auto-hyphenates + (`masternode-identity-load`). +- **NFR-D2** — Annotations: + - `masternode_identity_load`: `read_only(false)`, `destructive(false)`, + `idempotent(false)`, `open_world(true)`. + - `masternode_credits_withdraw`: `read_only(false)`, `destructive(true)`, + `idempotent(false)`, `open_world(true)` — identical to `identity_credits_withdraw`. +- **NFR-D3** — Descriptions state the two modes (load: by ProTxHash + keys; + withdraw: owner→payout-forced vs transfer→any-address) and that `network` is + required. Keep them concise — agents read these to choose tools. +- **NFR-D4 — Tools, not CLI code.** Both live in `src/mcp/tools/identity.rs`; + register one line each in `tool_router()`. Zero changes in `src/bin/det_cli/`. + +--- + +## 5. CLI / DX Ergonomics + +### Secret passing — mirror `core_wallet_import` + +`core_wallet_import` takes the mnemonic as a normal string parameter +(`mnemonic: String`) and relies on (1) redacting `Debug` and (2) zeroizing buffers +internally. We follow the **same mechanism** for MN keys — keys are string params, +protected by redacting `Debug` + zeroizing, not by any special channel. This is the +locked, established pattern; introducing a new secret-input channel here would +diverge from the one tool operators already know. + +```bash +# 1. Load an evonode identity headlessly (testnet example) +det-cli masternode-identity-load \ + pro_tx_hash=<64-hex protx> \ + node_type=evonode \ + owner_private_key=<WIF> \ + payout_private_key=<WIF> \ + network=testnet +# → { "identity_id": "...", "available_withdrawal_keys": ["owner","transfer"], +# "payout_address": "y...", ... } + +# 2a. Withdraw with the OWNER key — destination is forced to the payout address +det-cli masternode-credits-withdraw \ + identity_id=<base58> \ + key_mode=owner \ + amount_credits=100000 \ + network=testnet +# (no to_address; supplying one is rejected) + +# 2b. Withdraw with the payout/TRANSFER key — any Core address +det-cli masternode-credits-withdraw \ + identity_id=<base58> \ + key_mode=transfer \ + to_address=y... \ + amount_credits=100000 \ + network=testnet +``` + +### Walkthrough as each persona + +- **Priya (operator).** Reads `available_withdrawal_keys` and `payout_address` from + the load output, confirms the payout target matches her records, then withdraws + with `key_mode=owner` and no address. She never has to know the payout-address + rule in advance — the tool enforces it and the load output shows the target. ✔ +- **Jordan (developer).** Loads a testnet evonode from known faucet keys, sees the + two modes enumerated, scripts `key_mode=transfer` to a throwaway address for + iteration. Errors carry numbers (estimated/actual fee) and name the missing key + if he forgets to load one. ✔ +- **AI agent.** Annotations mark withdraw `destructive`; the agent prompts for + confirmation. The load tool's redacting `Debug` keeps keys out of any error it + surfaces back to the model. The `key_mode` enum is explicit, so the agent cannot + silently pick the wrong mode. ✔ + +--- + +## 6. Error UX (typed `McpToolError`, user-friendly per project rules) + +All errors flow through `McpToolError`; messages follow the project's error +rules — *what happened + what to do*, no jargon, no raw SDK strings (those go to the +`Debug` chain in `data`). Base58/hex IDs and Core addresses are allowed (they are +copyable handles, not jargon). + +| Condition | Variant | Message (Display) | +|---|---|---| +| `node_type` not masternode/evonode | `InvalidParam` | "The 'node_type' must be \"masternode\" or \"evonode\"." | +| No signing key supplied (FR-A4) | `InvalidParam` | "Provide at least one of the owner or payout private key. The owner key withdraws to the registered payout address; the payout key withdraws to any address." | +| Key wrong length / not hex / bad WIF | `TaskFailed(KeyInputValidationFailed)` | (backend, role-named) e.g. "The Owner key is the length of a WIF key but is invalid." | +| Key not present on the identity | `TaskFailed(KeyInputValidationFailed)` | (backend) names the role and that it does not match the on-chain key. | +| ProTxHash unparseable | `TaskFailed(IdentifierParsingError)` | (backend) "could not read the identity ID." | +| Identity not found on network | `TaskFailed(IdentityNotFound)` | (backend) "That identity was not found on this network. Check the ProTxHash and the network." | +| Network missing/mismatch | `NetworkMismatch` / `InvalidParam` | "The 'network' parameter must match the active network: expected {expected}, active {actual}." | +| SPV not synced | `SpvSyncFailed` | "Still syncing with the network — wait a moment and try again." | +| Withdraw before load (FR-B5) | `InvalidParam` | "This identity is not loaded yet. Run masternode-identity-load with the ProTxHash and keys first." | +| `key_mode` unknown | `InvalidParam` | "The 'key_mode' must be \"owner\" or \"transfer\"." | +| `key_mode` key not loaded | `InvalidParam` | "The {owner|payout} key needed for this withdrawal is not loaded. Re-run masternode-identity-load and include it." | +| OWNER mode + `to_address` supplied (FR-B2) | `InvalidParam` | "An owner-key withdrawal always goes to the registered payout address. Remove 'to_address', or use key_mode=transfer to choose an address." | +| OWNER mode + no payout address | `InvalidParam` | "This identity has no registered payout address, so an owner-key withdrawal has no destination. Use key_mode=transfer with a Core address." | +| TRANSFER mode + missing/invalid/Platform address | `InvalidParam` | (mirror existing) "Enter a valid Core address — Platform addresses cannot receive withdrawals." | +| `amount_credits == 0` | `InvalidParam` | "amount_credits must be greater than zero." | + +> All Display strings are i18n-ready single sentences with named placeholders. +> Technical chains attach via the `TaskFailed` `data` payload, never the message. + +--- + +## 7. Acceptance Criteria + +### Tool A — `masternode_identity_load` + +- **AC-A1** Given a valid testnet evonode ProTxHash and a valid payout WIF, when I + call the tool with `node_type=evonode network=testnet`, then it returns + `identity_id`, `payout_key_loaded=true`, `available_withdrawal_keys` containing + `"transfer"`, and the `payout_address`. +- **AC-A2** Given a valid owner key, when loaded, then `available_withdrawal_keys` + contains `"owner"`. +- **AC-A3** Given `node_type=user`, when called, then `InvalidParam` (this tool is + masternode-only). +- **AC-A4** Given neither owner nor payout key, when called, then `InvalidParam` + naming both keys (FR-A4). +- **AC-A5** Given a key of wrong length, when called, then a role-named + `KeyInputValidationFailed` error; the key value never appears in the message or + the `data` payload. +- **AC-A6** Given the active network is mainnet and `network=testnet`, then + `NetworkMismatch` before any network call. +- **AC-A7** After a successful load, when I call + `masternode_credits_withdraw` for the same `identity_id`, then the + identity resolves (no "not loaded" error) — proving A→B composition. +- **AC-A8** The params struct's `Debug` output renders every private key as + `<redacted>` (unit-testable without network). + +### Tool B — `masternode_credits_withdraw` + +- **AC-B1 (OWNER, happy path)** Given a loaded MN identity with an owner key and a + registered payout address, when I withdraw `key_mode=owner amount_credits=N` with + **no** `to_address`, then the withdrawal dispatches to the payout address and the + output's `to_address` equals that payout address. +- **AC-B2 (OWNER + address rejected)** Same as AC-B1 but with a `to_address` + supplied → `InvalidParam` (FR-B2). +- **AC-B3 (OWNER, no payout address)** Loaded MN identity lacking a payout address, + `key_mode=owner` → `InvalidParam` (FR-B2). +- **AC-B4 (TRANSFER, happy path)** Loaded identity with a transfer key, + `key_mode=transfer to_address=<valid core> amount_credits=N` → dispatch to that + address; output echoes it. +- **AC-B5 (TRANSFER, missing address)** `key_mode=transfer` with no `to_address` + → `InvalidParam`. +- **AC-B6 (TRANSFER, Platform address)** `key_mode=transfer` with a bech32m Platform + address → `InvalidParam` (Core-only). +- **AC-B7 (mode key not loaded)** `key_mode=owner` on an identity loaded with only a + payout key → `InvalidParam` naming the missing owner key. +- **AC-B8 (not loaded)** Withdraw for an `identity_id` never loaded → `InvalidParam` + pointing at `masternode-identity-load`. +- **AC-B9 (network)** Network mismatch → `NetworkMismatch`; `amount_credits=0` + → `InvalidParam`. +- **AC-B10 (output numbers)** On success, output includes `estimated_fee` and + `actual_fee` from the backend fee result. + +### Cross-cutting + +- **AC-X1** Both tools appear in `tools/list` (CLI discovery) and + `tool-describe` returns clean JSON schemas. +- **AC-X2** No tool logic lives in `src/bin/det_cli/`; both tools live in + `src/mcp/tools/identity.rs` and register one line each in `tool_router()`. +- **AC-X3** Smoke: `det-cli tools` lists both; `det-cli tool-describe + name=masternode_identity_load` returns its schema (no network needed). + +--- + +## 8. User Stories (for `docs/user-stories.md`) + +The catalog already covers GUI MN load (IDN-003) and GUI credit withdrawal +(IDN-005, SND-012), and CLI wallet management (MCP-001). The headless **masternode** +load+withdraw is new. Propose adding to the **Programmatic Access (MCP)** section: + +```markdown +### MCP-003: Load a masternode/evonode identity via CLI [Gap] +**Persona:** Priya, Jordan + +As a masternode operator, I want to load my masternode or evonode identity +headlessly via det-cli — by ProTxHash plus owner/voting/payout private keys — so +that I can manage it in scripts and automation without opening the GUI. + +- Identity is fetched by ProTxHash over the network and persisted locally. +- Private keys are accepted as WIF or hex, never echoed back, and redacted in logs. +- Output reports which keys loaded, the available withdrawal modes, and the + registered payout address. +- The 'network' parameter is required and must match the active network. + +### MCP-004: Withdraw masternode/evonode credits via CLI [Gap] +**Persona:** Priya, Jordan + +As a masternode operator, I want to withdraw my node's Platform credits to Core +headlessly via det-cli, in both key modes, so that I can automate payouts. + +- With the owner key, the destination is forced to the registered payout address; + supplying a different address is rejected. +- With the payout/transfer key, I can withdraw to any Core address. +- The withdrawal is queued on Platform and settles after confirmation; the result + reports the destination used and the estimated and actual fees. +- The 'network' parameter is required and must match the active network. +``` + +> Tagged `[Gap]` now; flip to `[Implemented]` when Phase 2 lands. Per the locked +> scope, both stories ship together. + +--- + +## 9. Prioritized Backlog (MoSCoW) + +| Item | Priority | Rationale | +|---|---|---| +| Tool A: load by ProTxHash + owner/payout keys, redacting `Debug`, persist | **Must** | Without it, withdraw is unreachable headlessly — the locked entry point. | +| Tool B: withdraw with explicit `key_mode`, OWNER→payout-forced, TRANSFER→free | **Must** | The locked core deliverable; both modes mirror the GUI. | +| `require_network` on both; SPV gate on load | **Must** | Cross-network and proof-verification safety for fund-moving ops. | +| `available_withdrawal_keys` + `payout_address` in load output | **Should** | Makes step 2 self-describing; strong DX for both personas and agents. | +| Voting-key binding in load | **Should** | Surfaced in the GUI three-key set; cheap to pass through; needed for full MN management parity, not strictly for withdraw. | +| SPV gate on withdraw (reconcile sibling tool) — OQ-4 | **Should** | Safety vs consistency with existing tool; needs a decision. | +| Developer-mode address relaxation for OWNER withdrawals | **Won't (this phase)** | No headless signal for "I know what I'm doing"; the constraint exists precisely to prevent scripted misfires (FR-B4). | +| Auto-pick `key_mode` from loaded keys | **Won't** | Ambiguous for a fund-moving op; explicit mode is safer (FR-B1). | +| MN reward "claim" as a distinct op | **Won't (N/A)** | There is no separate MN-reward withdrawal path — it is the same `WithdrawFromIdentity`/`CreditWithdrawal` ST. Confirmed in prior investigation. | + +--- + +## 10. Open Questions & Assumptions + +### Open product questions + +- **OQ-1 — Should `masternode_identity_load` accept ProTxHash in hex only, or also + Base58?** The backend `load_identity` tries Base58 then Hex. ProTxHash is + canonically hex (it is a transaction hash). Recommend: **accept both**, document + hex as the expected form. Low risk; the backend already handles it. +- **OQ-2 — Tool naming: keep `masternode` in the names, or fold into the existing + `identity_credits_withdraw`?** Recommend **separate, masternode-named tools** so + the two modes and the payout-forcing are explicit and discoverable, and so the + existing user-identity withdraw tool stays simple. The alternative — overloading + `identity_credits_withdraw` with a `key_mode` — risks the foot-gun FR-B1 guards + against. **Needs confirmation.** +- **OQ-3 — Confirm OWNER-mode never allows a custom address headlessly (FR-B4).** + The GUI relaxes this in developer mode. We propose **no relaxation** in the tool. + If operators need OWNER→arbitrary-address headlessly, that is a separate, + explicitly-flagged feature. **Needs confirmation.** +- **OQ-4 — SPV gate on the withdraw tool (NFR-P2).** Add it (safety, matches the + documented rule and load) or skip it (match the existing `identity_credits_withdraw`)? + Recommend **add**, and reconcile the sibling tool's comment. **Needs decision.** +- **OQ-5 — `voting_private_key` in scope for v1?** It is part of the GUI three-key + set and binds the voter identity (extra DAPI fetch). It is **not** required for + withdrawal. Recommend: **accept it optionally** for management parity, but it is + cuttable from a minimal first cut if it complicates the load. **Confirm priority.** + +### Assumptions + +- **A-1** The locked scope (both tools, both modes) is final; this spec does not + re-open it. +- **A-2** `IdentityType::Masternode` vs `Evonode` does not change the withdraw + destination rules — both expose OWNER+TRANSFER via `available_withdrawal_keys()` + and both have a `masternode_payout_address`. The `node_type` param only sets the + load type; withdraw behavior is identical across the two. +- **A-3** No new `BackendTask` or `TaskError` variant is needed — both tools are + pure adapters over existing tasks. (If FR-B5's message is reworded to name the new + tool, that is a one-line literal change, not a new variant.) +- **A-4** The headless caller is trusted to hold the MN private keys; this spec does + not add key-custody features beyond the existing zeroizing/redaction guarantees. + +--- + +## Candy Tally (findings surfaced) + +| Severity | Count | Items | +|---|---|---| +| High | 1 | NFR-P2 / OQ-4 — withdraw SPV-gate inconsistency vs the documented rule and the sibling tool. | +| Medium | 3 | FR-A4 (key-less MN load is useless), FR-B1 (auto-pick key_mode foot-gun), FR-B2 (OWNER-mode silent address-ignore vs reject). | +| Low | 2 | FR-B5 message points at a tool that didn't exist (now does), OQ-1 ProTxHash encoding ambiguity. | + +**Total: 6 findings** (1 High, 3 Medium, 2 Low). diff --git a/docs/ai-design/2026-06-18-masternode-cli-withdraw/02-test-spec.md b/docs/ai-design/2026-06-18-masternode-cli-withdraw/02-test-spec.md new file mode 100644 index 000000000..ff7eba6d1 --- /dev/null +++ b/docs/ai-design/2026-06-18-masternode-cli-withdraw/02-test-spec.md @@ -0,0 +1,610 @@ +# Masternode / Evonode Withdrawals via det-cli — Test-Case Specification + +**Date:** 2026-06-18 +**Phase:** 1c (Test Case Specification — specs, not code) +**Surface:** det-cli over MCP (headless). Two NEW tools in `src/mcp/tools/identity.rs`. +**Derives from:** `01-requirements-ux.md` (same directory) + locked design decisions. +**Status:** Draft for review. Each case is a contract a future test must encode; a +case is *passing* only when the test fails should the requirement be unmet. + +--- + +## 0. Scope, layering, and ground truth + +Two new MCP tools, both pure adapters over existing backend tasks (no backend or +`TaskError` change): + +- **Tool A — `masternode_identity_load`** → dispatches `IdentityTask::LoadIdentity(IdentityInputToLoad)`. +- **Tool B — `masternode_credits_withdraw`** → dispatches `IdentityTask::WithdrawFromIdentity(qi, to_address, credits, Some(key_id))`. + +### 0.1 Test layers + +| Layer | Meaning | Runs in CI? | +|---|---|---| +| **unit** | Pure parsing / validation logic, no `AppContext`, no network, no DB. Covers `node_type` parse, ProTxHash hex+Base58 accept, `key_mode` parse, OWNER-mode-rejects-address pre-flight, amount/address/Platform-address validation, redacting `Debug`. | Yes (always). | +| **tool-level** | Tool `invoke` param validation and error mapping reachable *before* the SPV gate / network dispatch — network-mismatch, missing-key-mode, not-loaded, amount=0, OWNER+to_address reject. Driven through the in-process MCP service against a throwaway `AppContext`/DB (mirror `det-cli` smoke pattern in project `CLAUDE.md`). | Partially — only paths that return before `ensure_spv_synced`. Paths past the SPV gate are e2e. | +| **backend-e2e** | `#[ignore]`, network-dependent, serial. Real load by ProTxHash + real withdraw against testnet. Mirrors `tests/backend-e2e/` (shared `ctx()`, `run_task`, `#[tokio_shared_rt::test(shared, ...)]`). Env-gated by `E2E_MN_*` (see §0.3). | No (manual / nightly). | + +### 0.2 Ground-truth references (confirmed against live code) + +- `verify_key_input` (`src/backend_task/identity/mod.rs:450`): **64-char → hex**, + **51/52-char → WIF**, **0 → `None` (not supplied)**, **any other length → error**. + Single source of truth — tools MUST NOT re-implement. +- ProTxHash parse (`load_identity.rs:83`): `Identifier::from_string(Base58)` then + fallback `Hex`. Both accepted; confirms OQ-1. +- `available_withdrawal_keys()` (`qualified_identity/mod.rs:745`): for + Masternode/Evonode returns the **OWNER**-purpose and **TRANSFER**-purpose keys + bound on the main identity. Owner key → loaded via `owner_private_key` + (OWNER purpose); payout key → loaded via `payout_private_key` (TRANSFER purpose). +- `masternode_payout_address(network)` (`qualified_identity/mod.rs:691`): derived + from the first `TRANSFER`/`CRITICAL` key (`ECDSA_HASH160` or `BIP13_SCRIPT_HASH`); + returns `Option<Address>` — **`None` is reachable**, so FR-B2 "no payout address" + is a real path. +- `withdraw_from_identity` (`withdraw_from_identity.rs`): passes `to_address` and + the resolved `signing_key` straight to the SDK `withdraw`. With `to_address=None` + + an OWNER signing key, **Platform consensus forces the registered payout + address** — the client-side check is a friendly pre-flight, not the only guard. +- `Secret::Debug` (`src/model/secret.rs:235`) renders `Secret(***)`; + `ImportWalletParams` (`wallet.rs:397`) hand-writes `Debug` → `<redacted>`. Tool A's + params struct MUST do the same for its three key fields. +- Sibling `identity_credits_withdraw` (`identity.rs:445`) **intentionally skips** + `ensure_spv_synced`. Tool B's locked decision is to **add** the gate (NFR-P2 / + OQ-4 option b) — this divergence from the sibling is a deliberate, test-verified + choice, not an accident. +- `is_platform_address_string` (`src/model/address.rs:14`) is the Platform-address + guard. **Pitfall flagged:** `resolve::validate_address` (`resolve.rs:194`) only + checks the first char against `{X,7,y,8,9}`; it is *not* a substitute for the + Platform-address guard and would mis-handle `dash1…`/`tdash1…`. Tool B MUST call + `is_platform_address_string` explicitly (see TC-MN-031 / TC-MN-046). + +### 0.3 Proposed e2e env gates (`E2E_MN_*`) + +Mirror `E2E_WALLET_MNEMONIC`. All e2e cases skip-with-log if unset (never fail on +absence — they are `#[ignore]` anyway). + +| Var | Used by | Notes | +|---|---|---| +| `E2E_MN_PRO_TX_HASH` | load + composition | testnet evonode/masternode ProTxHash (hex). | +| `E2E_MN_OWNER_WIF` | owner-mode cases | owner private key (WIF or 64-hex). | +| `E2E_MN_PAYOUT_WIF` | transfer-mode + payout cases | payout/transfer private key. | +| `E2E_MN_VOTING_WIF` | voting-key case | optional; triggers voter-identity fetch. | +| `E2E_MN_NODE_TYPE` | load | `masternode` or `evonode`; default `evonode`. | + +--- + +## 1. Tool A — `masternode_identity_load` + +### Unit layer (no network) + +#### TC-MN-001 — node_type "masternode" parses to Masternode +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Parse `node_type="masternode"` through the tool's node-type mapper. +- **Expected:** Maps to `IdentityType::Masternode`. No error. +- **Traces:** FR-A1, table row "node_type", AC-A1. + +#### TC-MN-002 — node_type "evonode" parses to Evonode +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Parse `node_type="evonode"`. +- **Expected:** Maps to `IdentityType::Evonode`. No error. +- **Traces:** FR-A1, AC-A1. + +#### TC-MN-003 — node_type "user" rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Parse `node_type="user"`. +- **Expected:** `McpToolError::InvalidParam` with message + `The 'node_type' must be "masternode" or "evonode".`; **never** maps to + `IdentityType::User`. +- **Traces:** FR-A1, AC-A3, Error-UX row 1. + +#### TC-MN-004 — node_type unknown/garbage rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Parse `node_type` = `"MASTERNODE "` (trailing space), `"evo"`, `""`, `"node"`. +- **Expected:** Each → `InvalidParam` (case/whitespace handling must be explicit — + document whether trimming/lowercasing applies; assert the actual chosen policy). +- **Traces:** FR-A1. + +#### TC-MN-005 — ProTxHash accepted as 64-char hex +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Pass a valid 64-hex ProTxHash to the identifier parse path + (`from_string` Base58→Hex fallback). +- **Expected:** Parses to an `Identifier` (no error). Asserts the hex branch is reached. +- **Traces:** FR-A "pro_tx_hash", OQ-1, AC-A1. + +#### TC-MN-006 — ProTxHash accepted as Base58 +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Pass the **same** identity ID encoded as Base58. +- **Expected:** Parses to an `Identifier` equal (byte-for-byte) to the one from + TC-MN-005's hex form for the same underlying bytes. +- **Traces:** FR-A "pro_tx_hash", OQ-1. + +#### TC-MN-007 — ProTxHash malformed rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Pass `"not-a-hash"`, `""`, a 63-char hex, a 65-char hex. +- **Expected:** Surfaces as `TaskFailed(IdentifierParsingError { input })` from the + backend (the tool does not pre-validate length; confirm the parse is delegated and + the original input string is preserved in the typed variant — never string-parsed). +- **Traces:** Error-UX row "ProTxHash unparseable". + +#### TC-MN-008 — at least one of owner/payout required (both absent → reject) +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Build params with `owner_private_key`/`payout_private_key` both empty + (or omitted); `voting_private_key` may be present or absent. +- **Expected:** `InvalidParam` whose message names **both** keys and explains the two + withdraw modes: + `Provide at least one of the owner or payout private key. The owner key withdraws to the registered payout address; the payout key withdraws to any address.` + Check fires **before** any network/SPV call. +- **Traces:** FR-A4, AC-A4, Error-UX row 2. + +#### TC-MN-009 — voting key alone does NOT satisfy the key requirement +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** `voting_private_key` set, `owner`/`payout` both empty. +- **Expected:** Same `InvalidParam` as TC-MN-008 (voting key binds the voter + identity only; it enables no withdrawal). +- **Traces:** FR-A4 (parenthetical). + +#### TC-MN-010 — params Debug redacts every private key +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Construct the load params struct with non-empty owner, voting, payout + key strings (use obvious sentinels, e.g. `"OWNER_SECRET_VALUE"`). Format with + `{:?}`. +- **Expected:** Output contains none of the three sentinel substrings; each key + field renders as `<redacted>` (or `Secret(***)` if wrapped first). `pro_tx_hash`, + `node_type`, `alias`, `network` may appear in cleartext. +- **Traces:** NFR-S1, NFR-S3, AC-A8. **This is the single most important unit test.** + +#### TC-MN-011 — key format delegated to verify_key_input (length policy) +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Document (not re-implement) that the tool feeds raw key strings into + `Secret` → `IdentityInputToLoad` → `verify_key_input`. Provide a table-of-record: + 64-hex→hex, 51/52→WIF, 0→None, else→error. Assert the tool adds **no** competing + length check of its own. +- **Expected:** Wrong-length / non-hex / bad-WIF keys are rejected by the backend as + `KeyInputValidationFailed { key_name, detail }`, role-named, value never echoed. +- **Traces:** FR-A "Key formats", NFR-S3, AC-A5, Error-UX row 3. + +### Tool-level (pre-SPV-gate paths, no live network) + +#### TC-MN-012 — network mismatch fails before any network call +- **Layer:** tool-level +- **Preconditions:** Active network = testnet (throwaway `AppContext`). +- **Steps:** Invoke with `network=mainnet`, otherwise-valid params. +- **Expected:** `NetworkMismatch { expected: "mainnet", actual: "testnet" }`. No SPV + start, no DAPI fetch. (`require_network` runs first — confirm via TC-MN-010-style + no-side-effect assertion if observable.) +- **Traces:** FR-3.3, NFR-N1, AC-A6, Error-UX row "Network missing/mismatch". + +#### TC-MN-013 — network param missing/blank → InvalidParam +- **Layer:** tool-level +- **Preconditions:** any active network. +- **Steps:** Omit `network`, or pass an empty/whitespace string. +- **Expected:** `InvalidParam`, not a panic, not `NetworkMismatch`. Note the two + distinct paths: an **omitted** `network` is a schema-required deserialization + error (the field has no `#[serde(default)]`); an **empty/blank** string is caught + by the tool's `require_nonblank_network` guard, which runs before + `require_network` and returns "The network parameter is required." rather than a + confusing `NetworkMismatch { expected: "" }`. +- **Traces:** FR-3.3, Error-UX row "Network missing/mismatch". + +#### TC-MN-014 — node_type/key-requirement checks run before SPV gate +- **Layer:** tool-level +- **Preconditions:** Active network = testnet; SPV NOT synced. +- **Steps:** Invoke with `node_type=user` (TC-MN-003 condition) and valid network. +- **Expected:** Returns `InvalidParam` **immediately**, without blocking on + `ensure_spv_synced`. Verifies ordering: cheap validation precedes the SPV wait. +- **Traces:** FR-A1 + NFR-P1 (ordering), AC-A3. + +#### TC-MN-015 — annotations & schema (discoverability) +- **Layer:** tool-level +- **Preconditions:** in-process MCP service. +- **Steps:** `tools/list` and `tool-describe name=masternode_identity_load`. +- **Expected:** Tool appears; annotations `read_only=false, destructive=false, + idempotent=false, open_world=true`; schema is valid JSON, exposes `pro_tx_hash, + node_type, owner_private_key, voting_private_key, payout_private_key, alias, + network`; CLI name hyphenates to `masternode-identity-load`. +- **Traces:** NFR-D1, NFR-D2, AC-X1, AC-X3. + +### Backend-e2e (`#[ignore]`, network) + +#### TC-MN-016 — load happy path: evonode + payout key +- **Layer:** backend-e2e +- **Preconditions:** `E2E_MN_PRO_TX_HASH`, `E2E_MN_PAYOUT_WIF` set; testnet; SPV + synced; ProTxHash refers to a real testnet evonode with a registered payout addr. +- **Steps:** Dispatch `LoadIdentity` (built as the tool would) with + `node_type=evonode`, payout key only, `network=testnet`. +- **Expected:** `BackendTaskSuccessResult::LoadedIdentity(qi)`. Assert: + `payout_key_loaded=true`, `owner_key_loaded=false`, `available_withdrawal_keys` + **contains** `"transfer"`, `payout_address` is `Some(..)` and is a valid testnet + Core address, identity resolvable afterward via `resolve::qualified_identity`. +- **Traces:** AC-A1, FR-A output fields, FR-A3. + +#### TC-MN-017 — load happy path: masternode + owner key +- **Layer:** backend-e2e +- **Preconditions:** `E2E_MN_PRO_TX_HASH` (a masternode), `E2E_MN_OWNER_WIF`. +- **Steps:** `LoadIdentity` `node_type=masternode`, owner key only. +- **Expected:** `LoadedIdentity(qi)`; `owner_key_loaded=true`; + `available_withdrawal_keys` contains `"owner"`. +- **Traces:** AC-A2, A-2 (MN vs Evonode parity). + +#### TC-MN-018 — load with both owner + payout keys +- **Layer:** backend-e2e +- **Preconditions:** ProTxHash + both WIFs. +- **Steps:** Load with both keys. +- **Expected:** `available_withdrawal_keys` contains **both** `"owner"` and + `"transfer"`; both `*_key_loaded` booleans true. +- **Traces:** FR-A output, FR-B1 (sets up the both-modes-available case). + +#### TC-MN-019 — load with voting key fetches voter identity & binds it +- **Layer:** backend-e2e +- **Preconditions:** ProTxHash + payout WIF + `E2E_MN_VOTING_WIF`. +- **Steps:** Load with payout + voting keys. +- **Expected:** Success; the associated voter identity is bound (load performs the + extra DAPI fetch). Voting key does **not** add a withdrawal mode — + `available_withdrawal_keys` is unchanged vs TC-MN-016. +- **Traces:** FR-A "voting_private_key", FR-A4 parenthetical, OQ-5. + +#### TC-MN-020 — load: key not present on identity (wrong key) → KeyInputValidationFailed +- **Layer:** backend-e2e +- **Preconditions:** Valid ProTxHash, but a **valid-format** WIF that is NOT a key on + that identity. +- **Steps:** Load with the mismatched owner (or payout) key. +- **Expected:** `TaskFailed(KeyInputValidationFailed { key_name, detail })` naming the + role; the key value appears **nowhere** in `Display` or the `data` payload. +- **Traces:** AC-A5, Error-UX row "Key not present", NFR-S3. + +#### TC-MN-021 — load: identity not found on network → IdentityNotFound +- **Layer:** backend-e2e +- **Preconditions:** Well-formed but nonexistent ProTxHash (random 64-hex), testnet, + SPV synced. +- **Steps:** Load. +- **Expected:** `TaskFailed(IdentityNotFound)`; user-facing Display is the + network-friendly "not found, check ProTxHash and network" wording. +- **Traces:** Error-UX row "Identity not found". + +#### TC-MN-022 — load: SPV not synced → SpvSyncFailed (gate present) +- **Layer:** backend-e2e (or tool-level if SPV can be forced to Error without network) +- **Preconditions:** SPV in `Error`/never-synced state (e.g. point at an unreachable + network or force the status), tool dispatched. +- **Steps:** Invoke load past param validation. +- **Expected:** `ensure_spv_synced` is invoked and, on failure/timeout, returns + `SpvSyncFailed`. Asserts the load tool **has** the SPV gate (NFR-P1) — contrast + with the sibling withdraw tool which historically skipped it. +- **Traces:** NFR-P1, Error-UX row "SPV not synced". + +#### TC-MN-023 — load re-run is idempotent at the DB layer +- **Layer:** backend-e2e +- **Preconditions:** TC-MN-016 already ran (identity present). +- **Steps:** Load the same identity again with the same keys. +- **Expected:** Success again; local DB row is INSERT-OR-REPLACEd (one row, updated + keys), no duplicate. Confirms FR-A5's "safe to re-run" documentation claim even + though the tool annotates `idempotent=false`. +- **Traces:** FR-A5. + +--- + +## 2. Tool B — `masternode_credits_withdraw` + +### Unit layer (no network) + +#### TC-MN-030 — key_mode "owner"/"transfer" parse; unknown rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Parse `key_mode` = `"owner"`, `"transfer"`, and `"foo"`/`""`/`"OWNER "`. +- **Expected:** `"owner"`/`"transfer"` map to the two internal modes; anything else → + `InvalidParam` `The 'key_mode' must be "owner" or "transfer".` +- **Traces:** FR-B1, Error-UX row "key_mode unknown". + +#### TC-MN-031 — Platform-address detection uses is_platform_address_string +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Feed mainnet `dash1…` and testnet `tdash1…` sample bech32m strings, plus + a Core `y…`/`X…` address, into the Platform-address guard the tool uses. +- **Expected:** Both `dash1…`/`tdash1…` → detected as Platform (rejected downstream); + Core addresses → not Platform. Asserts the tool calls `is_platform_address_string` + (not the weaker first-char `resolve::validate_address`). **Pitfall guard:** a test + must prove a `dash1…` string is rejected as Platform and not silently passed. +- **Traces:** FR-B3, Error-UX row "TRANSFER + Platform address", §0.2 pitfall. + +#### TC-MN-032 — amount_credits == 0 rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** Call `resolve::validate_credits(0)` via the tool path. +- **Expected:** `InvalidParam` `amount_credits must be greater than zero.` +- **Traces:** FR-B "amount_credits", AC-B9, Error-UX row "amount_credits == 0". + +#### TC-MN-033 — OWNER mode + supplied to_address rejected (pure pre-flight) +- **Layer:** unit +- **Preconditions:** none — this is a pure param-combination check that must fire + before any identity resolution or network call. +- **Steps:** `key_mode=owner` with a non-empty `to_address`. +- **Expected:** `InvalidParam` + `An owner-key withdrawal always goes to the registered payout address. Remove 'to_address', or use key_mode=transfer to choose an address.` + The rejection does **not** require the identity to be loaded (check ordering: the + contradiction is surfaced even for an unknown identity, OR document that it runs + after resolution — pick one and assert it; preferred: reject early on the param + contradiction). +- **Traces:** FR-B2, AC-B2, Error-UX row "OWNER + to_address". + +#### TC-MN-034 — TRANSFER mode + missing to_address rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** `key_mode=transfer` with empty/omitted `to_address`. +- **Expected:** `InvalidParam` requiring a Core address. +- **Traces:** FR-B3, AC-B5. + +#### TC-MN-035 — TRANSFER mode + invalid Core address rejected +- **Layer:** unit +- **Preconditions:** none. +- **Steps:** `key_mode=transfer to_address="not-an-address"`. +- **Expected:** `InvalidParam` (format failure via the `NetworkUnchecked` parse path). + Asserts the address is parsed, not just first-char checked. +- **Traces:** FR-B3, Error-UX row "TRANSFER + invalid address". + +### Tool-level (pre-SPV-gate / pre-dispatch paths) + +#### TC-MN-040 — withdraw before load → InvalidParam pointing at the load tool +- **Layer:** tool-level +- **Preconditions:** empty local DB; active testnet. +- **Steps:** `masternode_credits_withdraw identity_id=<never-loaded base58> + key_mode=transfer to_address=<valid core> amount_credits=N network=testnet`. +- **Expected:** `InvalidParam` whose message names `masternode-identity-load` + (FR-B5 reworded). Resolution fails at `resolve::qualified_identity` before dispatch. +- **Traces:** FR-B5, AC-B8, Error-UX row "Withdraw before load". + +#### TC-MN-041 — network mismatch → NetworkMismatch +- **Layer:** tool-level +- **Preconditions:** active testnet. +- **Steps:** Invoke with `network=mainnet`. +- **Expected:** `NetworkMismatch { expected: "mainnet", actual: "testnet" }` before + dispatch. +- **Traces:** FR-3.3, AC-B9, NFR-N1. + +#### TC-MN-042 — amount=0 and OWNER+address rejected before SPV gate +- **Layer:** tool-level +- **Preconditions:** active testnet; SPV not synced. +- **Steps:** (a) `amount_credits=0`; (b) `key_mode=owner to_address=y…`. +- **Expected:** Both return `InvalidParam` immediately, without blocking on the SPV + gate. Confirms cheap validation precedes the (locked-on) SPV wait. +- **Traces:** FR-B2, AC-B2, AC-B9 + NFR-P2 ordering. + +#### TC-MN-043 — annotations & schema +- **Layer:** tool-level +- **Preconditions:** in-process MCP service. +- **Steps:** `tools/list`; `tool-describe name=masternode_credits_withdraw`. +- **Expected:** Present; annotations `read_only=false, destructive=true, + idempotent=false, open_world=true` (identical to `identity_credits_withdraw`); + schema exposes `identity_id, key_mode, to_address, amount_credits, network`; CLI + name `masternode-credits-withdraw`. +- **Traces:** NFR-D2, AC-X1, AC-X3. + +#### TC-MN-044 — mode key not loaded → InvalidParam naming the missing key +- **Layer:** tool-level (needs a loaded identity record; can use a DB-seeded + payout-only `QualifiedIdentity` fixture without live network) +- **Preconditions:** Identity loaded with **only** a payout key (DB fixture or + TC-MN-016 result). +- **Steps:** `key_mode=owner amount_credits=N network=testnet` (no to_address). +- **Expected:** `InvalidParam` + `The owner key needed for this withdrawal is not loaded. Re-run masternode-identity-load and include it.` + Resolution scans `available_withdrawal_keys()` for an OWNER-purpose key, finds + none, and rejects **before** dispatch (no funds move). +- **Traces:** FR-B1, AC-B7, Error-UX row "key_mode key not loaded". + +#### TC-MN-045 — OWNER mode + no payout address → InvalidParam +- **Layer:** tool-level (fixture: MN identity loaded with an owner key but + `masternode_payout_address()` → `None`) +- **Preconditions:** Loaded identity whose first TRANSFER/CRITICAL key is absent so + `masternode_payout_address(network)` returns `None`. +- **Steps:** `key_mode=owner amount_credits=N` (no to_address). +- **Expected:** `InvalidParam` + `This identity has no registered payout address, so an owner-key withdrawal has no destination. Use key_mode=transfer with a Core address.` + No dispatch. +- **Traces:** FR-B2 (no-payout-address bullet), AC-B3. + +#### TC-MN-046 — TRANSFER mode + Platform address → InvalidParam (Core-only) +- **Layer:** tool-level (fixture identity with a transfer key) +- **Preconditions:** Loaded identity with a transfer key; active testnet. +- **Steps:** `key_mode=transfer to_address=tdash1…<valid platform> amount_credits=N`. +- **Expected:** `InvalidParam` + `Enter a valid Core address — Platform addresses cannot receive withdrawals.` + via `is_platform_address_string`. No dispatch. **Must not** slip through + `validate_address`. +- **Traces:** FR-B3, AC-B6, Error-UX row "TRANSFER + Platform address". + +#### TC-MN-047 — TRANSFER mode + cross-network Core address → InvalidParam +- **Layer:** tool-level +- **Preconditions:** active testnet; loaded transfer-key identity. +- **Steps:** `key_mode=transfer to_address=<mainnet X…> amount_credits=N + network=testnet`. +- **Expected:** `InvalidParam` "address does not match the active network" + (`require_network(ctx.network())` on the parsed `NetworkUnchecked` address). No + dispatch. +- **Traces:** FR-B3 (network match), NFR-N1. + +### Backend-e2e (`#[ignore]`, network) + +#### TC-MN-050 — OWNER mode happy path: destination forced to payout, to_address=None +- **Layer:** backend-e2e +- **Preconditions:** Identity loaded with an owner key and a registered payout + address (TC-MN-017 / TC-MN-018), funded with withdrawable credits; testnet synced. +- **Steps:** Withdraw `key_mode=owner amount_credits=N` with **no** `to_address`. +- **Expected:** Tool resolves the OWNER `KeyID` from `available_withdrawal_keys()`, + resolves destination from `masternode_payout_address(network)`, dispatches + `WithdrawFromIdentity(qi, to_address=None, N, Some(owner_key_id))`. Result + `WithdrewFromIdentity(fee)`. Output `to_address` **equals the resolved payout + address** (the address actually used, echoed back), `key_mode="owner"`, + `estimated_fee`/`actual_fee` populated. +- **Traces:** FR-B2, AC-B1, AC-B10, FR-B output "address actually used". +- **Note:** This case verifies the client passes `to_address=None`; Platform + consensus also forces the payout address server-side (defense in depth). A test + should assert the **client output** reports the payout address, since the raw ST + carries `None`. + +#### TC-MN-051 — TRANSFER mode happy path: any Core address +- **Layer:** backend-e2e +- **Preconditions:** Identity loaded with a transfer (payout) key, funded; testnet. +- **Steps:** Withdraw `key_mode=transfer to_address=<fresh testnet y… address> + amount_credits=N`. +- **Expected:** Resolves the TRANSFER `KeyID`, dispatches + `WithdrawFromIdentity(qi, Some(parsed_core_addr), N, Some(transfer_key_id))`. + Result `WithdrewFromIdentity(fee)`. Output `to_address` echoes the **caller's** + address; `key_mode="transfer"`; fees populated. +- **Traces:** FR-B3, AC-B4, AC-B10. + +#### TC-MN-052 — OWNER mode + to_address supplied rejected (no network spend) +- **Layer:** backend-e2e (or tool-level — see TC-MN-033/042) +- **Preconditions:** Loaded owner-key identity. +- **Steps:** `key_mode=owner to_address=<some y… addr> amount_credits=N`. +- **Expected:** `InvalidParam` (FR-B2) **before** any ST is broadcast — assert no + balance change on the identity. +- **Traces:** FR-B2, AC-B2. + +#### TC-MN-053 — A→B composition through the local DB +- **Layer:** backend-e2e +- **Preconditions:** Clean DB; `E2E_MN_PRO_TX_HASH` + a withdrawal-capable key; + testnet synced; identity funded with credits. +- **Steps:** (1) Dispatch the load (Tool A) → persist locally. (2) Without any shared + in-memory handoff, invoke the withdraw (Tool B) for the returned `identity_id`. +- **Expected:** Step 2 resolves the identity via `resolve::qualified_identity` (no + "not loaded" error) and completes a withdrawal. Proves the two tools compose + **only** through `insert_local_qualified_identity` → `get_identity_by_id`, exactly + like the GUI→withdraw flow. +- **Traces:** §3.4 Composition, AC-A7. + +#### TC-MN-054 — withdraw key not loaded (e2e mirror of TC-MN-044) +- **Layer:** backend-e2e +- **Preconditions:** Real identity loaded with **only** a payout key. +- **Steps:** `key_mode=owner` (owner key absent). +- **Expected:** `InvalidParam` naming the missing owner key; no ST broadcast; balance + unchanged. +- **Traces:** FR-B1, AC-B7. + +--- + +## 3. Cross-cutting + +#### TC-MN-060 — both tools discoverable & describable (smoke) +- **Layer:** tool-level +- **Preconditions:** in-process MCP service (no network). +- **Steps:** `det-cli tools`; `det-cli tool-describe name=masternode_identity_load`; + `det-cli tool-describe name=masternode_credits_withdraw`. +- **Expected:** Both names listed; both `tool-describe` calls return clean, + client-acceptable JSON schemas (no bare-`true` schemar quirks); registered one line + each in `tool_router()`; zero tool logic in `src/bin/det_cli/`. +- **Traces:** AC-X1, AC-X2, AC-X3, NFR-D4. + +#### TC-MN-061 — TaskFailed data payload never leaks key material +- **Layer:** backend-e2e (uses TC-MN-020's wrong-key path) +- **Preconditions:** load with a valid-format key not on the identity. +- **Steps:** Capture the full `McpError` (Display message **and** the `data` + payload built from `format!("{task_err:?}")`). +- **Expected:** Neither the message nor the `data` Debug chain contains the key WIF / + hex. Because keys live in `Secret` (Debug = `Secret(***)`) and the error variant is + `KeyInputValidationFailed { key_name, detail }` (no key bytes), this holds — assert + it explicitly given the `data` payload serializes the Debug chain. +- **Traces:** NFR-S1, NFR-S3, AC-A5; cross-checks `error.rs` `TaskFailed` `data`. + +--- + +## 4. Coverage matrix (acceptance criteria → cases) + +| AC | Cases | +|---|---| +| AC-A1 | TC-MN-001, 002, 005, 016 | +| AC-A2 | TC-MN-017 | +| AC-A3 | TC-MN-003, 014 | +| AC-A4 | TC-MN-008, 009 | +| AC-A5 | TC-MN-011, 020, 061 | +| AC-A6 | TC-MN-012 | +| AC-A7 | TC-MN-053 | +| AC-A8 | TC-MN-010 | +| AC-B1 | TC-MN-050 | +| AC-B2 | TC-MN-033, 042, 052 | +| AC-B3 | TC-MN-045 | +| AC-B4 | TC-MN-051 | +| AC-B5 | TC-MN-034 | +| AC-B6 | TC-MN-031, 046 | +| AC-B7 | TC-MN-044, 054 | +| AC-B8 | TC-MN-040 | +| AC-B9 | TC-MN-032, 041, 042 | +| AC-B10 | TC-MN-050, 051 | +| AC-X1 | TC-MN-015, 043, 060 | +| AC-X2 | TC-MN-060 | +| AC-X3 | TC-MN-015, 043, 060 | +| NFR-P1 (load SPV gate) | TC-MN-022 | +| NFR-P2 (withdraw SPV gate, OQ-4 b) | TC-MN-042 (ordering); see Gap G-3 | +| OQ-1 (hex+Base58) | TC-MN-005, 006 | +| OQ-5 (voting key) | TC-MN-019 | + +--- + +## 5. Coverage gaps & risks (flagged) + +- **G-1 — OWNER-mode forced destination is only *observable* end-to-end.** The client + passes `to_address=None`; the actual payout-address enforcement is Platform + consensus. A unit/tool-level test can only assert the tool resolves the payout + address into its **output echo** and dispatches `None` — it cannot prove consensus + rejects a forged owner-key→arbitrary-address ST without a live network (and a + deliberately malformed ST the tool will never build). TC-MN-050 covers the + happy-path echo; the negative consensus path is **out of unit/tool reach** and is + accepted as defense-in-depth, not a tested client guarantee. + +- **G-2 — `masternode_payout_address() == None` is hard to provoke against a real + node.** TC-MN-045 needs a fixture MN identity whose loaded keys lack a + TRANSFER/CRITICAL key. This is a constructed `QualifiedIdentity` (tool-level + fixture), not a natural testnet node — flag that the fixture must be hand-built and + kept in sync with `masternode_payout_address`'s key-matching rules. + +- **G-3 — Withdraw SPV-gate presence can only be ordering-tested cheaply.** The locked + decision (OQ-4 option b) adds `ensure_spv_synced` to Tool B, diverging from the + sibling `identity_credits_withdraw` which skips it. There is **no pure unit test** + for "the gate exists"; TC-MN-042 only proves cheap validation runs *before* the + gate. A true gate-present assertion needs an e2e/forced-SPV-error harness + (analogous to TC-MN-022). Flag: if the implementer instead picks OQ-4 option (a) + (skip the gate to match the sibling), TC-MN-022's withdraw analogue and this row + must be revised — the test spec currently assumes option (b). + +- **G-4 — `node_type` normalization policy is unspecified.** TC-MN-004 asserts + rejection of `"MASTERNODE "` etc., but the requirements don't state whether the tool + trims/lowercases. The test must encode whatever the implementer chooses; until then + this is an under-specified contract (flag for Phase 2 to pin down, then make the + test exact). + +- **G-5 — Voting-key-only voter-identity-not-found path is untested.** TC-MN-019 + covers the happy voter fetch, but a voting key whose voter identity is absent on + network returns `IdentityNotFound` from a *second* fetch (load_identity.rs:188). + No dedicated case — flag as a thin-coverage edge if voting-key support ships in v1 + (OQ-5). + +- **G-6 — `det-cli` HTTP-transport key handling (NFR-S4) is documentation-only.** No + automated test asserts keys aren't logged over HTTP; it's a `docs/MCP.md` note. + Flag: the redaction unit test (TC-MN-010) is the only programmatic guard, and it + only covers the in-process `Debug` path, not transport-layer logging. + +--- + +## 6. Notes for the implementing test author + +- Reuse the `det-cli` standalone smoke pattern (project `CLAUDE.md` → "Smoke-testing + changes with det-cli") for tool-level discovery/schema cases (TC-MN-015, 043, 060) — + these need no funds and no SPV. +- Backend-e2e cases go in a new `tests/backend-e2e/identity_masternode_withdraw.rs`, + mirroring `identity_withdraw.rs` (shared `ctx()`, `run_task`, + `#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)]`, + `#[ignore]`). Register the module in `tests/backend-e2e/main.rs`. +- Never assert on error **strings** for control flow in the tests beyond the + user-facing `Display` text the spec quotes — match on `McpToolError` / + `TaskError` **variants** (project rule: never parse error strings). +- Every fund-moving e2e assertion must check the result variant **and** at least one + number (fee or balance delta) — not merely "did not error" (QA test-depth rule). +</content> +</invoke> diff --git a/docs/ai-design/2026-06-18-masternode-cli-withdraw/03-dev-plan.md b/docs/ai-design/2026-06-18-masternode-cli-withdraw/03-dev-plan.md new file mode 100644 index 000000000..1fac9c824 --- /dev/null +++ b/docs/ai-design/2026-06-18-masternode-cli-withdraw/03-dev-plan.md @@ -0,0 +1,57 @@ +# Development Plan — Masternode/Evonode Withdrawals in det-cli + +**Feature branch:** `feat/masternode-cli-withdraw` (off PR #860 base `docs/platform-wallet-migration-design` @976ad0d4). +**Inputs:** `01-requirements-ux.md`, `02-test-spec.md` (TC-MN cases), and the architecture de-risk. +**Phase:** 1d (Development Plan) of the feature workflow. Authored by the architect (Nagatha); transcribed/committed by the lead. + +## Scope + +Two new MCP tools exposing masternode/evonode credit withdrawal headlessly via det-cli: + +- **`masternode_identity_load`** — load a masternode/evonode identity (ProTxHash + owner/voting/payout private keys) into the local store. +- **`masternode_credits_withdraw`** — withdraw the node identity's Platform credits, with explicit owner/payout-key selection and the matching destination rules. + +## Locked decisions (do not re-litigate) + +- **No backend change.** Both tools dispatch existing tasks: `IdentityTask::LoadIdentity(IdentityInputToLoad)` and `IdentityTask::WithdrawFromIdentity(qi, Option<Address>, Credits, Option<KeyID>)`. No new `BackendTask`, `TaskError`, or `McpToolError` variant. +- **Tool logic in `src/mcp/tools/identity.rs`**; stateless parsing in a new `model/` validator. Register both in `src/mcp/server.rs::tool_router()`. +- **Secrets** mirror `core_wallet_import`: plain `String` params, wrapped in `Secret::new` inside `invoke`; **hand-written redacting `Debug`** on the param struct. Passed as inline `key=value` argv (env-var hardening deferred — see Follow-ups). +- **SPV gate:** both tools call `resolve::ensure_spv_synced` (they do proof-verified Platform reads). Resolves OQ-4 = option (b). +- **`masternode_identity_load`:** `node_type` ∈ {masternode, evonode} (never User); `pro_tx_hash` accepts hex **or** Base58; at least one of owner/payout key **required**; voting key + alias optional; `derive_keys_from_wallets:false`. +- **`masternode_credits_withdraw`:** explicit `key_mode` ∈ {owner, transfer}. **OWNER** → `to_address` forced `None`, any supplied address rejected (`InvalidParam`); Platform routes to the registered payout address. **TRANSFER** → `to_address` required, any valid Core address, Platform addresses rejected. KeyID resolved from `available_withdrawal_keys()` by purpose. Destination is *also* enforced server-side by Platform consensus; the client check is a friendly pre-flight. + +## Critical pitfall (must enforce) + +`resolve::validate_address` is a **first-character-only** check and is **not** a substitute for `is_platform_address_string` (`src/model/address.rs:14`). Tool B must call `is_platform_address_string` explicitly to reject Platform addresses (verification points TC-MN-031 / TC-MN-046). + +## Task breakdown (TDD — tests first per task) + +| # | Task | Files | Test cases | Layer | +|---|------|-------|------------|-------| +| 1 | **`model/` validators** — `parse_node_type` (trim + case-insensitive; reject User/garbage — pins G-4), `parse_key_mode`, `require_at_least_one_signing_key`, identity-id decode (hex+Base58); reuse `is_platform_address_string`. | `src/model/masternode_input.rs` (new), `src/model/mod.rs` | TC-MN-001,002,003,004,008,009,030,031 | unit | +| 2 | **Tool A param struct + redacting `Debug`** — `MasternodeIdentityLoadParams`/`Output`; hand-written `Debug` redacts the 3 key fields (mirror `wallet.rs:397`). | `src/mcp/tools/identity.rs` | TC-MN-005,006,007,010,011 | unit | +| 3 | **Tool A `invoke`** — order: `require_network` → `parse_node_type` → key-presence → `ensure_spv_synced` → wrap keys in `Secret::new` → build `IdentityInputToLoad{derive_keys_from_wallets:false, keys_input:vec![]}` → dispatch `LoadIdentity` → map output (loaded keys + `available_withdrawal_keys` + `payout_address`). Register in `tool_router()`. | `src/mcp/tools/identity.rs`, `src/mcp/server.rs` | TC-MN-012,013,014,015 | tool-level | +| 4 | **Tool B param struct + pre-flight units** — `MasternodeCreditsWithdrawParams`/`Output`; pure checks (key_mode, amount=0, OWNER+address contradiction, missing/invalid address, Platform-address via `is_platform_address_string`). | `src/mcp/tools/identity.rs` | TC-MN-030,031,032,033,034,035 | unit | +| 5 | **Tool B `invoke`** — order: `require_network` → `validate_credits` → `parse_key_mode` → **OWNER+to_address contradiction first** → `qualified_identity` (error message names `masternode-identity-load` as the fix) → KeyID from `available_withdrawal_keys()` by purpose → destination rules (owner→payout/`None`; transfer→`is_platform_address_string` + `NetworkUnchecked` + cross-network reject) → `ensure_spv_synced` → dispatch `WithdrawFromIdentity(qi, dest, credits, Some(key_id))`. Register in `tool_router()`. | `src/mcp/tools/identity.rs`, `src/mcp/server.rs` | TC-MN-040,041,042,043,044,045,046,047 | tool-level | +| 6 | **Cross-cutting discoverability + error-redaction** — `tools/list` / `tool-describe` clean schemas, CLI hyphenation, `TaskFailed` `data`-payload non-leak of keys. | existing MCP tool test module | TC-MN-015,043,060,061 | tool-level | +| 7 | **Backend-e2e suite** (`#[ignore]`, `E2E_MN_*`) — mirror `identity_withdraw.rs`; every fund-moving assertion carries a variant + ≥1 number. | `tests/backend-e2e/identity_masternode_withdraw.rs` (new), `tests/backend-e2e/main.rs`, `README.md` (optional) | TC-MN-016..023, 050..054, 061 | backend-e2e | +| 8 | **Docs & user-stories** — `docs/MCP.md` (+ NFR-S4 / G-6 note), `docs/CLI.md`, `docs/user-stories.md` (add MCP-003 headless MN load, MCP-004 headless MN withdraw). | `docs/MCP.md`, `docs/CLI.md`, `docs/user-stories.md` | — | docs | + +## Sequencing + +1 → 2 → 3 → 4 → 5 → 6 → 7 → 8. Tasks 1–6 are the shippable core; 7 is network-gated (`#[ignore]`); 8 is docs. Each code task writes its tests first (from `02-test-spec.md`), confirms they fail, then implements to green. Run `cargo +nightly fmt` and `cargo clippy --all-features --all-targets -- -D warnings` before each commit. + +## Coverage gaps carried from the test spec (G-1..G-6) + +- **G-1** OWNER-mode forced destination is only end-to-end observable (client sends `to_address=None`; enforcement is Platform consensus) — covered in Task 7. +- **G-2** `masternode_payout_address() == None` needs a hand-built `QualifiedIdentity` fixture. +- **G-3** Withdraw SPV-gate presence: locked to *add the gate*; test ordering (validation before gate). +- **G-4** `node_type` normalization: trim + lowercase, pinned in Task 1. +- **G-5** voting-key voter-identity-not-found edge: no dedicated case — acceptable for v1. +- **G-6** NFR-S4 HTTP-transport key-logging is documentation-only (Task 8); in-process `Debug` redaction is tested (Task 2/6). + +## Out of scope / follow-ups (tracked separately) + +- Existing `identity_credits_withdraw` should also gate on `ensure_spv_synced` (memcan todo `10b6c02d`). +- Env-var fallback for private keys (keep secrets out of argv/shell history) — optional hardening. +- `voting_private_key` voter-identity edge cases beyond load. diff --git a/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md b/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md new file mode 100644 index 000000000..663565fad --- /dev/null +++ b/docs/ai-design/2026-06-19-secret-storage-seam/01-ux-disclosure.md @@ -0,0 +1,131 @@ +# Secret Storage Seam — UX Behavior (Tier-2 Keep-Protection) + +**Date:** 2026-06-19 (revised 2026-06-23) +**Status:** Current — describes the Tier-2 keep-protection design that shipped in PR #865. +**Scope:** User-facing behavior for wallet secret migration onto the unified storage seam. +Architecture: `docs/ai-design/2026-06-19-secret-storage-seam/`. Authoritative source: +`src/wallet_backend/secret_access.rs`, `src/wallet_backend/single_key.rs`, +`src/context/wallet_lifecycle.rs`. + +> **Note on history.** An earlier draft of this document described a "drop-protection" +> interim design: wallets would be downgraded to file-permission-only protection on +> migration, and one-time disclosure notices (Copy A/B/D) would be shown. That design +> was **retired before any code was written** — see `src/context/wallet_lifecycle.rs:20-24` +> for the rationale comment. The current document describes what actually shipped. + +--- + +## What shipped: Tier-2 keep-protection + +On first use after the storage-seam migration, a password-protected secret is decrypted +inside a borrowed scope and immediately **re-wrapped** under a **Tier-2 object-password +envelope** (Argon2id key-derivation + XChaCha20-Poly1305 authenticated encryption) sealed +under **the same password** the user already set. Protection is kept; it is never +downgraded. + +Consequences: + +- `WalletMeta.uses_password` stays `true` for protected HD wallets. +- `ImportedKey.has_passphrase` stays `true` for protected imported keys. +- The wallet continues prompting just-in-time for every secret access. +- The legacy AES-GCM envelope is deleted after re-wrap. +- No at-rest regression — nothing to disclose. + +Unprotected secrets (no-password wallets, identity keys, no-passphrase imported keys) +migrate to the raw `SecretBytes` path (keyless vault, obfuscation-only). These also produce +no UX change — they were never user-password-protected. + +--- + +## The situation, stated plainly for the persona + +Alex (the Everyday User) set a password on a wallet. After updating to this version and +opening the wallet: + +1. Sees the familiar unlock prompt, types the password. *No surprise — same as always.* +2. Wallet opens. The migration re-wraps the secret silently inside the unlock gesture. + No extra modal, no banner, no notice. +3. Next time, the wallet asks for the password again. *Expected — protection is kept.* + +There is no "last time you'll be asked for your password" moment. No one-time disclosure +notice fires. Alex's mental model ("I set a password and the app still asks for it") +remains accurate throughout. + +--- + +## Disclosure surfaces + +| Trigger | Notice | Banner type | +|---|---|---| +| Protected HD wallet migrates (lazy, at first unlock) | *none — silent* | — | +| Protected imported key migrates (lazy, via chokepoint) | *none — silent* | — | +| No-password wallet migrates (eager, on load) | *none — no UX change* | — | +| App start | *none* | — | + +The migration produces no disclosure because protection is kept. The user set a password; +the password still works; nothing changed from their perspective. Surfacing a security +notice would alarm users about a change they cannot perceive and that does not weaken +their wallet. + +Notes: +- **Headless / MCP:** protected wallets do not lazily migrate without a GUI unlock. + No notices fire headlessly. The legacy reader serves silently. +- **No-password wallets:** eager migration (on load) produces no notice. Nothing + changes from the user's point of view. + +--- + +## Per-secret encryption (Tier-2) + +Protected secrets use per-secret, per-password Argon2id + XChaCha20-Poly1305 envelopes via +`SecretSeam::put_secret_protected` / `get_secret_protected`. The AAD is bound to +`wallet_id ‖ label`, so envelopes are non-transferable between secrets. Two secrets +protected under different passwords cannot decrypt each other — the property tested by +`TS-T2-SK-ISO` in `src/wallet_backend/secret_access.rs`. + +The keyless-vault residual (identity keys, no-password secrets) uses +`put_secret` / `get_secret` (raw path) by default. + +### Optional identity-key encryption (SEC-001) — the former deferred tier, now implemented + +Identity keys still default to the keyless raw path (so headless/MCP signing of a +non-opted-in identity is unchanged, byte for byte). A user may now **opt in per identity** +to seal that identity's keys Tier-2 over the same `put_secret_protected` / +`get_secret_protected` seam — no new crypto. The at-rest vault scheme is the single source +of truth for "does this need a password?": `SecretAccess::scope_has_passphrase` probes +`SecretSeam::scheme` for the identity-key label (`Protected → prompt`, `Unprotected → +prompt-free`, `Absent → IdentityKeyMissing`), exactly as it already does for single keys. +The opt-in/opt-out are crash-safe same-label in-place upserts (`IdentityTask::Protect / +UnprotectIdentityKeys`, `IdentityKeyView::store_protected` / `store_unprotected`), idempotent +and re-runnable; a protection-aware `IdentityKeyView::store` refuses a keyless write over a +`Protected` label (`TaskError::IdentityKeyProtectionDowngrade`) so a later `AddKeyToIdentity` +cannot silently strip protection. A DET-side `IdentityMeta` sidecar carries only the password +hint + prompt copy (display-only — it never gates the prompt). Opted-in ⇒ signing prompts +just-in-time; headless yields `SecretPromptUnavailable`. The per-secret isolation property is +covered by `TS-T2-IK-ISO` in `src/wallet_backend/secret_access.rs`, twinning `TS-T2-SK-ISO`. + +No-password secrets (no-password HD wallets, no-passphrase imported keys) remain on the raw +path; per-secret encryption for those scopes stays deferred. + +--- + +## Item 4 — SEC-201 (passphrase-modal Enter-consume) — cross-reference + +**Not fixed here.** See `src/ui/components/passphrase_modal.rs`. With Tier-2, +every secret access re-prompts for protected wallets, which makes this existing papercut +visible more often than before. If SEC-201 is unfixed when this ships, expect a modest +uptick in Enter-key friction reports from users with protected wallets. That is the +migration surfacing an existing bug, not a regression introduced by this work. + +--- + +## i18n compliance + +No user-facing copy was added or changed by this migration. Future notices in this area +must follow the project i18n rules: + +- Complete sentences, no fragment concatenation. +- Named placeholders only (`{wallet}`, `{key}`), no positional grammar assumptions. +- No logic embedded in text. +- No jargon in persona-facing copy; technical detail belongs in the `with_details` panel. +- Each string a single, extractable translation unit. diff --git a/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md b/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md new file mode 100644 index 000000000..0a2ee4b8e --- /dev/null +++ b/docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md @@ -0,0 +1,451 @@ +# Test Case Specification — Wallet Secret Storage Seam + +Test case specifications for the security feature that unified all wallet +secret storage onto a no-serialization raw-`SecretBytes` seam, adopted +Tier-2 per-secret at-rest encryption (Argon2id + XChaCha20-Poly1305) for +password-protected secrets, dropped DET's AES-GCM envelopes, and introduced +`InVault` per-use JIT identity signing with dual-format migration. + +These specifications were the TDD contract for this work; the tests are +now committed alongside the implementation they verify. + +## Source-of-truth references + +- Design and migration overview: `docs/ai-design/2026-06-19-secret-storage-seam/` +- PR: `security/secret-handling-hardening` (dashpay/dash-evo-tool #865) +- In-scope findings: `bee9c055` (HIGH — identity keys plaintext at rest), + `6a2818cd` (MED — `ClosedSingleKey` Debug leak), `f0d946ed` (LOW — zeroize + transient plaintext). + +--- + +## Conventions + +### Test tiers + +| Tag | Meaning | Where it lives | Runs in CI? | +|---|---|---|---| +| **unit** | `#[test]` / `#[tokio::test]` inline in the module under test | source `#[cfg(test)] mod tests` | yes | +| **integration (lib)** | exercises `AppContext` / wallet-backend wiring without GUI, offline | source `#[cfg(test)]` (e.g. `wallet_lifecycle.rs`) or a lib integration test | yes | +| **kittest** | egui UI surface via `egui_kittest::Harness` | `tests/kittest/` | yes | +| **backend-e2e(network)** | live testnet via SPV, `#[ignore]` | `tests/backend-e2e/` | **no** (manual / funded) | +| **compile-fail** | a `compile_fail` doctest or `trybuild` case asserting a type does NOT compile | source doctest (preferred) or `tests/trybuild/` | yes | + +### Funded-wallet flag + +Cases tagged **[FUNDED-TESTNET — OUT OF CI]** require `E2E_WALLET_MNEMONIC` (a +pre-funded testnet wallet ≥ 10 tDASH) and live DAPI/SPV. They are `#[ignore]` +and must never be forced into a no-network run (see `tests/backend-e2e/README.md`). + +### Shared test fixtures (already exist — reuse, do not reinvent) + +- `open_secret_store(path)` → `Arc<SecretStore>` over a file vault at `secrets.pwsvault` (empty global passphrase, 0700 parent). `wallet_seed_store.rs::tests::fresh_store`, `single_key.rs::tests::fresh_view*`. +- `secret_prompt::test_support::{TestPrompt, ScriptedAnswer}` — scripted prompt double; `TestPrompt::never()` panics if asked (proves no-prompt); `ask_count()` / `requests()` assertions. +- `NullSecretPrompt` — headless host; `is_interactive() == false`, every request resolves `SecretPromptCancelled` → `TaskError::SecretPromptUnavailable`. +- `assert_no_leak(rendered, secret, context)` (in `encrypted_key_storage.rs::tests`) — asserts a secret appears in **neither** lowercase-hex **nor** decimal-array (`[160, 167, …]`) form. **Promote this to a shared test util** so the seam/sidecar/QI on-disk-leak cases can call it. The decimal-array check is load-bearing: a `#[derive(Debug)]` on `[u8; N]` leaks the decimal form, and the original `6a2818cd` bug leaked exactly that. +- Offline `AppContext`: `offline_testnet_context()` and `seed_legacy_protected_hd_wallet_row(...)` (in `wallet_lifecycle.rs` tests / `database::test_helpers`) — the staging used by `protected_wallet_registers_upstream_on_unlock_without_restart`, the template for the lazy-migration integration case. +- Deterministic key material: `known_wif()` / `known_testnet_wif()` (single-key tests); fixed seed bytes (`[0x42u8; 64]`) and a sentinel passphrase pattern (`SENTINEL_*`) for leak/confinement assertions. + +### Leak-assertion discipline (applies to every no-leak case) + +Always assert the **plaintext** secret (raw 32/64 bytes), in BOTH hex and +decimal-array form, is absent. Never assert only on a derived/ciphertext value +(that would pass against the very bug we guard). For passphrases, assert the +literal passphrase string is absent. + +--- + +## Traceability matrix (case → T-task → finding) + +| Case ID | Tier | T-task | Finding | +|---|---|---|---| +| TS-INV-01 / 02 / 03 | compile-fail / unit | T2, T10 | R-INVARIANT | +| TS-RT-01 (HD) | unit | T2, T6, T10 | bee9c055 | +| TS-RT-02 (single key) | unit | T2, T6, T10 | bee9c055 | +| TS-RT-03 (identity key) | unit | T2, T6, T10 | bee9c055 | +| TS-EAGER-01 (no-pw seed) | integration (lib) | T7, T10 | bee9c055 | +| TS-EAGER-02 (unprotected single key) | unit | T7, T10 | bee9c055 | +| TS-EAGER-03 (identity key) | integration (lib) | T7, T10 | bee9c055 | +| TS-EAGER-04 (idempotent) | unit | T7, T10 | R-MIGRATION-CRASH | +| TS-CRASH-01 / 02 | unit | T7, T10 | R-MIGRATION-CRASH | +| TS-LAZY-01 / TS-T2-01 (unlock re-wraps to Tier-2) | unit | T7, T10 | bee9c055 / R-PROMPT-BOUNDARY | +| TS-LAZY-02 (second unlock still prompts) | unit | T7, T10 | R-PROMPT-BOUNDARY | +| TS-LAZY-03 (single-key protected Tier-2 re-wrap) | unit | T7, T10 | bee9c055 | +| TS-LAZY-KIT-01 (modal once) | kittest | T7 | R-PROMPT-BOUNDARY / R-SEC-201 | +| TS-LEGACY-01 (HD legacy read) | unit | T3, T6, T10 | R-MIGRATION-CRASH | +| TS-LEGACY-02 (single-key legacy read) | unit | T3, T6, T10 | R-MIGRATION-CRASH | +| TS-HEADLESS-01 (pw wallet served) | integration (lib) | T7, T10 | R-HEADLESS-SPLIT | +| TS-HEADLESS-02 (no migration headless) | integration (lib) | T7, T10 | R-HEADLESS-SPLIT | +| TS-RESID-01 (InVault only) | unit | T1, T7, T10 | bee9c055 | +| TS-RESID-02 (old blob decodes) | unit | T1, T10 | bee9c055 | +| TS-NOLEAK-01 (seam blob) | unit | T2, T10 | bee9c055 | +| TS-NOLEAK-02 (sidecar) | unit | T5, T10 | bee9c055 | +| TS-NOLEAK-03 (QI blob InVault) | unit | T1, T10 | bee9c055 | +| TS-FAST-01 (headless identity resolve) | unit | T3, T7, T10 | bee9c055 / R-HEADLESS-SPLIT | +| TS-DEL-01 (identity delete) | unit | T7, T10 | bee9c055 | +| TS-DEL-02 (wallet/single-key delete) | unit | T6, T10 | bee9c055 | +| TS-DBG-01 (ClosedSingleKey Debug) | unit | T9 | 6a2818cd | +| TS-MISS-01 (SecretSeamMissing) | unit | T4, T7, T10 | R-MIGRATION-CRASH | +| TS-MISS-02 (loud not silent) | unit | T4, T7, T10 | R-MIGRATION-CRASH | +| TS-META-01 / 02 (WalletMeta schema gate) | unit | T5 | R-SCHEMA | +| TS-ZERO-01 (transient plaintext zeroized) | unit | T6, T9 | f0d946ed | +| TS-SIGN-E2E-01 (testnet ST) | backend-e2e(network) | T7, T8, T11 | bee9c055 | + +--- + +## 1. No-serialization invariant guard (R-INVARIANT) + +The whole architecture rests on `SecretBytes` having **no** `Serialize` (verified +in pinned platform `b4506492`). The guard is the canary if upstream ever adds it. + +### TS-INV-01 — `SecretBytes` is not `Serialize`/`Encode` (compile-fail) + +- **Tier:** compile-fail (preferred: a `compile_fail` doctest on the seam module; alternative: `trybuild` case — note `trybuild` is **not** a current dependency, adding it is a Phase-2 decision). +- **T-task / finding:** T2, T10 / R-INVARIANT. +- **Preconditions:** seam module exists; no test-only `Serialize` shim for `SecretBytes`. +- **Steps:** + 1. A doctest fragment attempts to derive `Serialize` (and separately `bincode::Encode`) on a newtype `struct Leaky(SecretBytes);`. + 2. A second fragment attempts `serde_json::to_string(&secret_bytes_value)`. +- **Expected outcome:** each fragment **fails to compile** (`SecretBytes: !Serialize`, `!Encode`). The test asserts the failure, not a runtime value. +- **Why it bites:** if a future upstream adds `Serialize` to `SecretBytes`, this case starts compiling — and FAILS — flagging that the invariant has silently weakened. + +### TS-INV-02 — seam accepts/returns `SecretBytes`, never a serde struct (unit) + +- **Tier:** unit. **T-task:** T2, T10. +- **Preconditions:** `SecretSeam::{put_secret,get_secret,delete_secret}` defined. +- **Steps:** assert the signatures: `put_secret(scope, label, secret: &SecretBytes)`, `get_secret(...) -> Result<Option<SecretBytes>, TaskError>`. (Encoded as a real call site that round-trips a `SecretBytes`; the compiler is the assertion.) +- **Expected outcome:** compiles and round-trips; no intermediate serializable wrapper type is constructed in the seam body. + +### TS-INV-03 — audit guard over the changed secret-path modules (unit) + +- **Tier:** unit. **T-task:** T10. +- **Preconditions:** the changed modules (`secret_seam`, `wallet_seed_store`, `single_key`, `identity_key_store`, `secret_access`, `encrypted_key_storage`) are listed in a const array in the test. +- **Steps:** a source-text audit test reads each module file and asserts no struct that `#[derive(Serialize)]`/`#[derive(Encode)]` also names a `SecretBytes` / `Zeroizing<[u8` / plaintext-key field. (Text-level guard — the compiler already forbids the strongest case via TS-INV-01; this catches a `Vec<u8>`-shaped plaintext field that bypasses the type guard.) +- **Expected outcome:** zero matches. A new serializable struct embedding plaintext fails the audit. +- **Note:** keep the module list in sync with the blast-radius table; a stale list is itself a finding. + +--- + +## 2. Raw round-trip via the seam — all three classes + +### TS-RT-01 — HD seed raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh file vault; `SecretSeam::new(&store)`. +- **Steps:** + 1. `put_secret(seed_hash_scope, "seed.raw.v1", &SecretBytes::from_slice(&seed64))` with a known 64-byte seed. + 2. `get_secret(seed_hash_scope, "seed.raw.v1")`. +- **Expected outcome:** `Some(bytes)` whose `expose_secret()` **equals the exact 64 input bytes** (assert full equality, not just length/non-empty). `get_secret` on a missing label → `Ok(None)`. A different scope (different seed_hash) → `Ok(None)` (scope partition). +- **Anti-pattern rejected:** asserting only `is_some()` or `len() == 64`. + +### TS-RT-02 — single-key raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh vault; the fixed `SINGLE_KEY_NAMESPACE_BYTES` scope; label `single_key_priv.<addr>` (unchanged label scheme). +- **Steps:** `put_secret` raw 32 bytes under the canonical label; `get_secret` it back. +- **Expected outcome:** returned bytes **equal the exact 32 input bytes**; value length is exactly 32 (raw, NOT a `SingleKeyEntry` envelope — assert it does NOT start with `SINGLE_KEY_ENTRY_VERSION` framing). Reading under a foreign `WalletId` → `Ok(None)`. + +### TS-RT-03 — identity-key raw round-trip (unit) + +- **Tier:** unit. **T-task:** T2, T6, T10. **Finding:** bee9c055. +- **Preconditions:** fresh vault; scope `identity.id().to_buffer()`; label `identity_key_priv.<target>.<key_id>`. +- **Steps:** `put_secret` raw 32 bytes; `get_secret`. +- **Expected outcome:** returned bytes equal the 32 input bytes; two distinct `(target, key_id)` labels under the same identity scope do not collide; two identities (distinct scopes) with the same `key_id` do not collide. + +--- + +## 3. Eager migration (no dialog) — no-password seed, unprotected single key, identity key + +Order invariant for ALL eager paths: **vault `put_secret` → sidecar write → legacy delete.** + +### TS-EAGER-01 — no-password HD seed migrates on load (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a legacy `envelope.v1` `StoredSeedEnvelope` with `uses_password == false` (raw 64-byte seed verbatim) present under `seed_hash`; NO raw `seed.raw.v1` label; a matching `WalletMeta` sidecar absent or pre-migration shape. +- **Steps:** run the hydration/load path (`reconstruct_wallet` / seam `get_secret` miss path). +- **Expected outcome (assert ALL):** + 1. raw `seed.raw.v1` now present and `expose_secret()` equals the original 64-byte seed; + 2. `WalletMeta` sidecar written with `uses_password == false`, `xpub_encoded` carried over, hint preserved; + 3. legacy `envelope.v1` label **deleted** (`store.get(scope,"envelope.v1") == None`); + 4. a fresh reload reads via raw seam (legacy reader not consulted — assert by deleting/absence of legacy and successful resolve). +- **Anti-pattern rejected:** asserting only that the wallet "loads" without verifying the four post-conditions. + +### TS-EAGER-02 — unprotected single key migrates (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a legacy `SingleKeyEntry` (`has_passphrase == false`) OR a bare legacy 32-byte raw blob under `single_key_priv.<addr>`, with a matching `ImportedKey` sidecar (`has_passphrase == false`). +- **Steps:** run the single-key hydrate/seam-miss migration. +- **Expected outcome:** vault label now holds the **raw 32 bytes** (length 32, no `SingleKeyEntry` framing); `ImportedKey` sidecar present (pubkey-for-locked-render moved into sidecar); legacy framed entry replaced; a subsequent unprotected `sign_with` succeeds and the signature verifies against the WIF-derived pubkey. + +### TS-EAGER-03 — identity key migrates from QI blob (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** a stored `QualifiedIdentity` whose `KeyStorage` contains a `PrivateKeyData::Clear` and a `PrivateKeyData::AlwaysClear` (MEDIUM) identity key. +- **Steps:** load the identity through the path that content-detects `Clear`/`AlwaysClear` and migrates. +- **Expected outcome (assert ALL):** + 1. for each migrated key, raw 32 bytes present in the vault under `identity_key_priv.<target>.<key_id>` equal to the original plaintext; + 2. the rewritten QI blob has `PrivateKeyData::InVault` (placeholder) at those slots — **zero** `Clear`/`AlwaysClear` remain (see TS-RESID-01); + 3. `AtWalletDerivationPath` keys are untouched (not migrated — they were never plaintext-at-rest). + +### TS-EAGER-04 — eager migration is idempotent (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** as TS-EAGER-01/02. +- **Steps:** run the migration twice (second run sees raw present, legacy already gone). +- **Expected outcome:** second run is a no-op success; raw value byte-identical after both runs; no error; legacy stays absent. (`SecretStore::set` upserts identical bytes — re-running must not corrupt or duplicate.) + +--- + +## 4. Crash-safety (R-MIGRATION-CRASH) + +### TS-CRASH-01 — crash AFTER vault+sidecar, BEFORE legacy delete → recoverable (unit) + +- **Tier:** unit. **T-task:** T7, T10. +- **Preconditions:** simulate a partial migration: raw `seed.raw.v1` present AND legacy `envelope.v1` STILL present (the legal mid-migration state). +- **Steps:** run the loader. +- **Expected outcome:** loader **prefers raw** (precedence raw > legacy), serves the raw seed, and the leftover legacy is treated as deletable (deleted on this pass). Resolve succeeds; no key loss; no `SecretSeamMissing`. + +### TS-CRASH-02 — never reach raw-missing-legacy-deleted (unit) + +- **Tier:** unit. **T-task:** T7, T10. +- **Preconditions:** assert the ordering contract structurally: a migration step that writes raw then deletes legacy must NOT delete legacy if the raw `put_secret` returned `Err`. +- **Steps:** inject a `put_secret` failure (vault error double / read-only store) and run one migration step. +- **Expected outcome:** legacy `envelope.v1` is **still present** after the failed step (delete was not reached); the step surfaces a typed error; a later retry can still recover the seed from legacy. Proves keys are never lost on a mid-write fault. + +--- + +## 5. Lazy migration (password wallet) via the existing unlock dialog (R-PROMPT-BOUNDARY) + +Protected secrets use the Tier-2 keep-protection path: the first unlock re-wraps the +secret to a Tier-2 object-password envelope (Argon2id + XChaCha20-Poly1305) under the +same password. The raw seam (`put_secret`/`get_secret`) is used only for unprotected +secrets. + +### TS-LAZY-01 / TS-T2-01 — unlock re-wraps a protected HD wallet to Tier-2 keep-protection (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055 / R-PROMPT-BOUNDARY. +- **Source test:** `ts_t2_01_protected_seed_rewraps_to_tier2_on_first_unlock` in `src/wallet_backend/secret_access.rs`. +- **Preconditions:** a legacy PROTECTED `envelope.v1` (`uses_password == true`, AES-GCM ciphertext) staged; NO Tier-2 or raw label present. +- **Steps:** + 1. hydrate (wallet locked, not migrated, `uses_password` still true); + 2. call `with_secret(HdSeed)` with `ScriptedAnswer::once(passphrase)` — the single existing unlock gesture, routed through `promote_hd_seed_with_passphrase`. +- **Expected outcome (assert ALL):** + 1. legacy AES-GCM envelope decrypted with the supplied passphrase inside the borrowed `Zeroizing` scope; + 2. seed re-wrapped to a **Tier-2 object-password envelope** under the same password — scheme reads `SecretScheme::Protected`, NOT `Raw`; + 3. `WalletMeta.uses_password` stays **`true`** (protection kept — never downgraded); + 4. legacy `envelope.v1` deleted; + 5. exactly **one** prompt's-worth of passphrase use at the migrating unlock; + 6. a password-free raw read of the re-wrapped label fails (confirms Tier-2 sealing). + +### TS-LAZY-02 — second unlock re-prompts for the object password (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** R-PROMPT-BOUNDARY. +- **Preconditions:** state left by TS-LAZY-01 (Tier-2 envelope present, `uses_password == true`). +- **Steps:** drive a subsequent secret resolve for the same seed scope through `SecretAccess::with_secret` with a fresh `ScriptedAnswer::once(correct_passphrase)`. +- **Expected outcome:** resolve succeeds via the Tier-2 protected path; `ask_count() == 1` (still prompts — **not** prompt-free); scheme still reads `Protected`; `WalletMeta.uses_password` is still `true`; a `TestPrompt::never()` on this scope fails (protection not downgraded). Confirms Tier-2 keeps the user's password in place across unlocks. + +### TS-LAZY-03 — single-key protected lazy re-wrap to Tier-2 via chokepoint (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Source test:** `ts_lazy_03_protected_single_key_rewraps_to_tier2_via_chokepoint` in `src/wallet_backend/secret_access.rs`. +- **Preconditions:** a legacy protected `SingleKeyEntry` (`has_passphrase == true`) and matching sidecar (`has_passphrase == true`). +- **Steps:** drive `with_secret(SingleKey{addr})` with the correct passphrase (one `ScriptedAnswer::once`). +- **Expected outcome:** the legacy AES-GCM entry is decrypted JIT; the 32 bytes are re-wrapped to a **Tier-2 object-password envelope** (`SecretScheme::Protected`) under the same passphrase; `ImportedKey.has_passphrase` stays **`true`** (protection kept — not downgraded); legacy framed entry deleted; a subsequent `with_secret` with a fresh `ScriptedAnswer` still requires the object passphrase (`ask_count() == 1`), and the recovered bytes equal the WIF plaintext. A `TestPrompt::never()` on this scope fails (protection kept). + +### TS-LAZY-KIT-01 — the unlock modal renders once for the migration path (kittest) + +- **Tier:** kittest. **T-task:** T7. **Finding:** R-PROMPT-BOUNDARY / R-SEC-201. +- **Template:** `tests/kittest/secret_prompt.rs` (`passphrase_modal` harness). +- **Preconditions:** the passphrase modal chrome unchanged. +- **Steps:** render the modal once; assert body/hint/submit/cancel render; submit a passphrase. +- **Expected outcome:** the migration reuses the existing single unlock modal (no new modal type, no second modal). This is a surface-contract check only — migration logic is covered by TS-LAZY-01/03. (Cross-reference SEC-201 Enter-consume: do NOT fix here; note migration runs the modal more often.) + +--- + +## 6. Legacy-format read during transition + +### TS-LEGACY-01 — HD legacy envelope served when raw absent (unit) + +- **Tier:** unit. **T-task:** T3, T6, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** ONLY a legacy `envelope.v1` (no raw label); `uses_password == false` (so no prompt needed for the read assertion). +- **Steps:** call the seam-first / legacy-fallback read path (`decrypt_jit` HdSeed, or the retained `legacy_envelope_get`). +- **Expected outcome:** the 64-byte seed is recovered from the legacy reader and equals the original; the retained legacy decode path is exercised (not an error). For a `uses_password == true` legacy entry, supplying the correct passphrase recovers the seed (the retained AES-GCM reader still functions). + +### TS-LEGACY-02 — single-key legacy entry served when raw absent (unit) + +- **Tier:** unit. **T-task:** T3, T6, T10. **Finding:** R-MIGRATION-CRASH. +- **Preconditions:** ONLY a legacy `SingleKeyEntry` (versioned framed form) OR bare 32-byte legacy blob; no raw migration yet. +- **Steps:** read via the seam-first / `SingleKeyEntry::decode` fallback. +- **Expected outcome:** the retained `SingleKeyEntry::decode` reader returns the entry; an unprotected legacy entry signs without a passphrase; a protected one routes through the chokepoint. Confirms the decode-only retained reader still works during transition. + +--- + +## 7. Headless / `NullSecretPrompt` + +### TS-HEADLESS-01 — password wallet served by legacy reader, no prompt, no failure (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** R-HEADLESS-SPLIT. +- **Preconditions:** a legacy PROTECTED `envelope.v1` (`uses_password == true`); `SecretAccess` built with `NullSecretPrompt`. +- **Steps:** attempt a secret resolve that requires the passphrase for that scope. +- **Expected outcome:** resolve fails with `TaskError::SecretPromptUnavailable` (NOT a panic, NOT `SecretPromptCancelled`); the wallet stays on the legacy reader; `WalletMeta.uses_password` is **still true** (no headless migration); legacy `envelope.v1` is **still present** (not deleted); raw `seed.raw.v1` is **still absent**. Matches the existing `null_prompt_on_protected_scope_yields_unavailable` shape, extended with the no-migration post-conditions. + +### TS-HEADLESS-02 — no eager/lazy migration of a protected wallet headless (integration, lib) + +- **Tier:** integration (lib). **T-task:** T7, T10. **Finding:** R-HEADLESS-SPLIT. +- **Preconditions:** as TS-HEADLESS-01; run the full headless load/hydration path. +- **Steps:** load + (attempt) migration headlessly; then re-inspect storage. +- **Expected outcome:** storage is byte-for-byte unchanged for the protected wallet (legacy present, raw absent, `uses_password == true`). A **no-password** wallet and identity keys in the SAME headless load DO migrate eagerly (assert their raw labels appear) — proving the split is exactly "protected ⇒ deferred, unprotected ⇒ eager", not "headless ⇒ never migrate". + +--- + +## 8. Identity residency — only `InVault` (R-INVARIANT / bee9c055) + +### TS-RESID-01 — a loaded identity has only `InVault`, never Clear/AlwaysClear (unit) + +- **Tier:** unit. **T-task:** T1, T7, T10. **Finding:** bee9c055. +- **Preconditions:** an identity migrated per TS-EAGER-03 (or loaded post-migration). +- **Steps:** iterate `KeyStorage.private_keys`. +- **Expected outcome:** every entry that previously carried plaintext is now `PrivateKeyData::InVault`; assert **zero** `Clear` and **zero** `AlwaysClear` variants remain anywhere in the `KeyStorage`. `AtWalletDerivationPath` (wallet-derived) entries are permitted and unchanged. Keys are never resident in memory as plaintext. + +### TS-RESID-02 — old QI blob (discriminants 0–3) still decodes after appending `InVault` at index 4 (unit) + +- **Tier:** unit. **T-task:** T1, T10. **Finding:** bee9c055. +- **Preconditions:** a bincode blob encoded BEFORE `InVault` was added (variants Clear=0/AlwaysClear=... per current order: `AlwaysClear, Clear, Encrypted, AtWalletDerivationPath`; `InVault` appended last as index 4). +- **Steps:** decode the legacy blob into the new `PrivateKeyData` enum. +- **Expected outcome:** decodes successfully and yields the original variant — appending `InVault` at the highest index must not shift discriminants 0–3. (Guards the bincode-discriminant trap called out as R in the design.) + +--- + +## 9. On-disk no-leak (hex AND decimal-array) + +### TS-NOLEAK-01 — seam vault blob contains no raw secret (unit) + +- **Tier:** unit. **T-task:** T2, T10. **Finding:** bee9c055. +- **Preconditions:** raw secret stored via the seam for each class (seed, single key, identity key). +- **Steps:** read the on-disk vault file bytes (the `secrets.pwsvault` file), render as a string/byte search. +- **Expected outcome:** because the upstream vault encrypts at rest (Argon2id + XChaCha20-Poly1305 file backend), the plaintext appears in **neither** hex **nor** decimal-array form in the on-disk file. Use the promoted `assert_no_leak`. (This asserts the at-rest file, distinct from `get_secret` which legitimately returns plaintext in memory.) +- **Note:** the seam value in memory IS raw plaintext by design — do not assert no-leak on `get_secret`'s return; assert it on the persisted file. + +### TS-NOLEAK-02 — sidecar (`WalletMeta` / `ImportedKey`) contains no secret (unit) + +- **Tier:** unit. **T-task:** T5, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated wallet + imported key with sidecars written. +- **Steps:** serialize each sidecar blob (bincode) and the on-disk `det-app.sqlite` k/v value; search. +- **Expected outcome:** neither sidecar's bytes contain the raw seed/key in hex or decimal-array form. The sidecar holds only non-secret metadata (alias, `uses_password`, hint, xpub, pubkey-for-locked-render). The moved single-key pubkey is the **public** key — assert it IS present (locked-render needs it) and the private key is NOT. + +### TS-NOLEAK-03 — QI blob carries `InVault` markers, never plaintext (unit) + +- **Tier:** unit. **T-task:** T1, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated identity (TS-EAGER-03). +- **Steps:** encode the `QualifiedIdentity` / `KeyStorage` to its persisted bincode blob; search the bytes. +- **Expected outcome:** the identity-key plaintext appears in neither hex nor decimal-array form in the QI blob; the blob encodes `InVault` placeholders for those slots. + +--- + +## 10. Headless identity-key fast-path + +### TS-FAST-01 — identity-key resolve under `NullSecretPrompt` succeeds, no prompt (unit) + +- **Tier:** unit. **T-task:** T3, T7, T10. **Finding:** bee9c055 / R-HEADLESS-SPLIT. +- **Preconditions:** identity key stored raw via the seam (post-migration); `SecretScope::IdentityKey{...}` with `scope_has_passphrase == false`; `SecretAccess` built with `NullSecretPrompt` (or `TestPrompt::never()`). +- **Steps:** call `resolve_private_key_bytes(target, key_id)` (or `with_secret(IdentityKey)`) and sign/derive. +- **Expected outcome:** resolves the raw 32 bytes prompt-free (`ask_count() == 0`, no `SecretPromptUnavailable`); the resolved key signs and the signature verifies against the identity public key. Proves the unprotected fast-path keeps headless/MCP identity signing working and that `async Signer::sign` (verified at `mod.rs:318`) is a free rider on the resolver. + +--- + +## 11. Delete — vault entries (raw labels) + legacy removed + +### TS-DEL-01 — identity removal deletes identity-key vault entries (unit) + +- **Tier:** unit. **T-task:** T7, T10. **Finding:** bee9c055. +- **Preconditions:** an identity with raw identity keys stored under `identity_key_priv.<target>.<key_id>`; `purge_identity_scope` (identity_db.rs:229, called at :621) extended to clear the identity's vault scope. +- **Steps:** delete the identity. +- **Expected outcome:** `get_secret` for every `identity_key_priv.*` label under that identity's scope → `Ok(None)`; any legacy form gone; OTHER identities' vault entries untouched (assert a second identity's key still resolves). No orphaned raw secret survives a delete. + +### TS-DEL-02 — wallet / single-key removal deletes raw + legacy (unit) + +- **Tier:** unit. **T-task:** T6, T10. **Finding:** bee9c055. +- **Preconditions:** a migrated HD wallet (raw `seed.raw.v1`) and a migrated imported key (raw `single_key_priv.<addr>`), each with sidecars. +- **Steps:** forget the imported key (`SingleKeyView::forget`) and delete the wallet. +- **Expected outcome:** raw vault label gone; legacy label gone (idempotent delete of both forms); sidecar entry removed; in-memory index cleared. `forget` on an already-removed address remains `Ok(())` (idempotent). A second wallet's secrets are unaffected. + +--- + +## 12. `ClosedSingleKey` redacting Debug (6a2818cd) + +### TS-DBG-01 — `ClosedSingleKey` `{:?}` exposes no raw 32 bytes (unit) + +- **Tier:** unit. **T-task:** T9. **Finding:** 6a2818cd. +- **Preconditions:** a `ClosedSingleKey` populated with a distinctive 32-byte value in `encrypted_private_key` (use the `distinctive_secret()` pattern). +- **Steps:** render `format!("{:?}", closed)` and, transitively, `format!("{:?}", SingleKeyData::Closed(closed))` and a `SingleKeyWallet` holding it. +- **Expected outcome:** via the promoted `assert_no_leak`: the 32 bytes appear in **neither** hex **nor** decimal-array form at any level (the decimal-array check is the one the pre-fix derived `Debug` failed); a redaction marker (`[redacted]` / fingerprint) IS present. Mirrors `ClosedKeyItem` and `PrivateKeyData` redaction. Confirms parents `SingleKeyData`/`SingleKeyWallet` are safe by delegation. + +--- + +## 13. `SecretSeamMissing` surfaced loudly (R-MIGRATION-CRASH) + +### TS-MISS-01 — label in neither raw nor legacy → typed `SecretSeamMissing` (unit) + +- **Tier:** unit. **T-task:** T4, T7, T10. +- **Preconditions:** a wallet/identity/single-key reference whose secret label is present in **neither** raw nor any legacy form (e.g. sidecar exists but both vault forms are gone). +- **Steps:** resolve the secret through the loader/seam-first path. +- **Expected outcome:** `Err(TaskError::SecretSeamMissing)` — a dedicated typed variant (no `String` field per CLAUDE.md error rules), distinct from `WalletNotFound` / `ImportedKeyNotFound` / `SecretDecryptFailed`. **Never** a silent `Ok(None)` that drops a key on the floor. + +### TS-MISS-02 — `SecretSeamMissing` is loud, not silent, on the funds-safety path (unit) + +- **Tier:** unit. **T-task:** T4, T7, T10. +- **Preconditions:** as TS-MISS-01, on a sign/spend path. +- **Steps:** attempt a sign with the missing secret. +- **Expected outcome:** the operation returns `SecretSeamMissing` (or a class-flavored wrapper carrying it as `#[source]`), surfaced to the banner with an actionable message; the failure is observable, not swallowed. Assert the error variant by structural match, never by parsing the message string. + +--- + +## 14. `WalletMeta` schema-gating (R-SCHEMA) + +### TS-META-01 — new `WalletMeta` shape round-trips; old blob detected and migrated (unit) + +- **Tier:** unit. **T-task:** T5. **Finding:** R-SCHEMA. +- **Preconditions:** `WalletMeta` gains `uses_password` + `password_hint`; the change is format-breaking for positional bincode behind the `DetKv` schema envelope. +- **Steps:** + 1. round-trip the NEW shape through bincode (mirror `wallet_meta_round_trips_through_bincode`); + 2. write a blob in the OLD shape (no `uses_password`/`password_hint`), bump/read via the schema-version gate. +- **Expected outcome:** new shape round-trips field-for-field; the OLD blob is detected by the schema byte (NOT silently misread via `#[serde(default)]` alone — the design explicitly forbids relying on that) and content-migrated to the new shape with `uses_password` defaulted correctly. A blob read under a mismatched schema version is rejected/migrated, never positionally misparsed. + +### TS-META-02 — `uses_password`/`password_hint` survive cold-boot (unit) + +- **Tier:** unit. **T-task:** T5. +- **Steps:** write a `WalletMeta` with `uses_password == true` + a hint, drop the in-memory state, re-read. +- **Expected outcome:** both fields recovered exactly; `scope_has_passphrase(HdSeed)` reads them from `WalletMeta` (not the legacy envelope) post-migration. + +--- + +## 15. Zeroize of transient decoded plaintext (f0d946ed) + +### TS-ZERO-01 — legacy-reader transient plaintext is `Zeroizing`/`SecretBytes` (unit) + +- **Tier:** unit. **T-task:** T6, T9. **Finding:** f0d946ed. +- **Preconditions:** the retained legacy readers (`decrypt_hd_seed`, `SingleKeyEntry::decrypt`) and the migration re-store step. +- **Steps:** assert the decoded-plaintext bindings are typed `Zeroizing<[u8; N]>` / `SecretBytes` (compile-level: the function return types already are — assert they are NOT widened to plain `Vec<u8>`/`[u8; N]` by the migration code). A confinement test (mirror `sentinel_never_appears_in_error_or_debug`) drives a migration and asserts the sentinel plaintext never appears in any error/Debug surfaced by the path. +- **Expected outcome:** transient plaintext is wrapped; no plain `Vec<u8>` copy of a secret escapes the migration scope; sentinel never leaks to error/Debug. Largely subsumed by the seam (`SecretBytes`), this case guards the legacy-reader → seam handoff specifically. + +--- + +## 16. End-to-end signing (network) — out of CI + +### TS-SIGN-E2E-01 — broadcast a testnet state transition from a migrated imported-key identity + +- **Tier:** backend-e2e(network). **T-task:** T7, T8, T11. **Finding:** bee9c055. +- **[FUNDED-TESTNET — OUT OF CI]** — requires `E2E_WALLET_MNEMONIC`, live DAPI/SPV; `#[ignore]`. +- **Preconditions:** an identity whose signing key was migrated to `InVault` raw storage; a funded testnet wallet. +- **Steps:** trigger a cheap state transition (e.g. an identity update or a DPNS preorder) that signs through the async `QualifiedIdentity` `Signer` → `resolve_private_key_bytes` → `with_secret(IdentityKey)`. +- **Expected outcome:** the ST signs via the InVault per-use JIT path and broadcasts successfully; the platform accepts the proof; the key was never resident as plaintext between signs. Confirms the JIT identity-signing free-rider claim against a live network. +- **Manual fallback (if no funded wallet):** the manual checklist in the execution plan (load a pre-existing protected wallet → unlock → confirm migration + sign + neither vault nor sidecar holds raw bytes). Document the skip per CLAUDE.md when infrastructure is unavailable. + +--- + +## Coverage self-audit (gaps the implementer must NOT silently close) + +- **No-serialization guard mechanism is undecided at the dependency level.** `static_assertions` and `trybuild` are NOT in `Cargo.toml`. The preferred zero-dependency mechanism for TS-INV-01 is a `compile_fail` doctest; adding `trybuild` is a Phase-2 call. If the implementer drops the compile-fail case entirely and keeps only the text audit (TS-INV-03), the strongest leg of R-INVARIANT is lost — that is a regression, flag it. +- **The on-disk no-leak cases (TS-NOLEAK-01) depend on the upstream vault actually encrypting at rest.** The accepted interim regression is that the global vault passphrase is empty (deferred `e0a8f4b1`). The XChaCha20-Poly1305 file backend still encrypts under a derived key even with an empty passphrase, so the plaintext should not appear verbatim — but if a future change makes the at-rest format plaintext-equivalent, TS-NOLEAK-01 is the canary. Do not weaken it to "blob != exact in-memory struct". +- **`assert_no_leak` is currently private to `encrypted_key_storage.rs::tests`.** It MUST be promoted to a shared test utility for TS-NOLEAK-01/02/03 and TS-DBG-01. A copy-paste fork is a maintenance finding. +- **TS-INV-03's module list must track the blast-radius table.** A stale list silently shrinks the audit surface. diff --git a/docs/ai-design/2026-06-30-app-scoped-selection-migration/01-migration-plan.md b/docs/ai-design/2026-06-30-app-scoped-selection-migration/01-migration-plan.md new file mode 100644 index 000000000..d98deb0cc --- /dev/null +++ b/docs/ai-design/2026-06-30-app-scoped-selection-migration/01-migration-plan.md @@ -0,0 +1,332 @@ +# App-scoped selected-identity screen migration (W2–W5) + +Status: PLAN — implementation contract for Bilby. Author: Nagatha. +Tracking: #842 (app-scoped selected identity). Builds on IDH-003 W0/W1 +(merged at `cc2d84c9`). + +## 1. Context & goal + +The hub breadcrumb switcher (W1) made one identity the app-scoped "who am I +operating as" choice, persisted per network and held in `AppContext`. Every +operate-as screen, however, still picks its identity in isolation: it defaults +to `identities.first()`, to `None`, or to its own `identities[0]` re-default, +and never reads or writes the app-scoped selection. The goal of W2–W5 is to make +every operate-as screen obey the app-scoped selected identity on entry, and — +where the screen *changes who you are* — write the user's choice back so the +breadcrumb and every other surface stay in agreement. Recipient, target, and +group-member pickers must remain untouched: they name a different party, not you. + +## 2. W0/W1 foundation (verified signatures — `cc2d84c9`) + +`AppContext` (`src/context/mod.rs`): + +```rust +// fields (mod.rs:123,127) +pub(crate) selected_identity_id: Mutex<Option<Identifier>>, +pub(crate) pending_identity_selection: Mutex<Option<Identifier>>, + +// reads +pub fn selected_identity_id(&self) -> Option<Identifier> // :1027 +pub fn selected_wallet_hash(&self) -> Option<WalletSeedHash> // :1022 +pub fn resolve_selected_identity(&self) -> Option<QualifiedIdentity> // :1033 + +// writes (each writes both mutexes directly + persists both KV blobs once; +// never calls a sibling setter — no reconciliation recursion) +pub fn set_selected_identity(&self, id: Option<Identifier>) // :1062 +pub fn set_selected_hd_wallet(&self, hash: Option<WalletSeedHash>) // :1090 +pub fn set_selected_single_key_wallet(&self, hash: Option<SingleKeyHash>) // :1114 +pub fn set_pending_identity_selection(&self, id: Identifier) // :1124 +pub fn take_pending_identity_selection(&self) -> Option<Identifier> // :1131 +pub fn persist_selected_identity_kv(&self, id: Option<Identifier>) // :1141 +fn restore_selected_identity_from_kv(&self) // :1160 (private; ensure_wallet_backend) +``` + +Pure decision helpers (`src/model/selected_identity.rs`), the single source of +truth for precedence — no IO: + +```rust +pub fn keep_if_loaded(selected: Option<Identifier>, loaded: &[Identifier]) -> Option<Identifier> // :37 +pub fn resolve_selected(selected: Option<Identifier>, loaded: &[Identifier]) -> Option<Identifier> // :47 +// resolve_selected = keep-if-loaded → else first loaded → else None +``` + +`IdentitySelector` (`src/ui/components/identity_selector.rs`) — the primary +migration vehicle. Two opt-in builder methods; default behaviour (neither +called) is byte-identical to pre-W0 (regression-locked by the two tests at +:347 and :360): + +```rust +pub fn with_app_default(self, app_context: &'a Arc<AppContext>) -> Self // :112 READ: seed empty buffer from selected_identity_id(), only if that id is one of this selector's options +pub fn syncing_global(self, app_context: Arc<AppContext>) -> Self // :120 SYNC: write the chosen id back via set_selected_identity on a real user change +``` + +Mechanism facts that constrain the plan (read from the widget body): +- `app_default_seed()` (:192) seeds **only when the buffer is empty** and the + app-scoped id is in `self.identities`. So `with_app_default` is a **no-op on a + screen that already pre-fills `identity_str` in `new()`** — such screens must + instead seed their `new()`/refresh from `resolve_selected_identity()`. +- The seed path (`ui()` :235) sets the buffer + calls `on_change()` but does + **not** mark the response changed and does **not** call `sync_to_global()`. + Seeding therefore never writes back; only a genuine combo/text change does + (:321 → `sync_to_global()` :205). This is why `with_app_default` + screen + pre-fill conflict, and why SYNC is safe (entry never clobbers the global). +- `sync_to_global()` resolves the id from the buffer and calls + `set_selected_identity(Some(id))`. `set_selected_identity` reconciles the + derived wallet to the identity's owner (clears to `None` for a wallet-less + identity) — keystone #1. + +## 3. Keystone rules (decided — do not relitigate) + +- **K1 — wallet follows identity.** No operate-as screen reads + `selected_wallet_hash` for signing; the signing wallet is derived from the + identity (`crate::ui::identities::get_selected_wallet`). Wallet selection is + display-only. `set_selected_identity` reconciles the derived wallet. +- **K2 — `dashpay_wallet_seed_hash()` is a trap.** It returns + `associated_wallets.keys().next()` (lowest hash of *all* cloned wallets, not + the owner). Never use it to derive the owning wallet. Wallet-scoped identity + lists use `load_local_qualified_identities_for_wallet`. +- **K3 — GroupActions is session-local.** It picks one identity per session + (multi-signer ambiguity); it must neither read from nor write to the + app-scoped selection. + +## 4. Classification rubric + +| Category | Reads global on entry? | Writes global on user change? | When | +|---|---|---|---| +| **SYNC-on-change** | yes (seed) | yes | The screen performs a state-changing action **as** the selected identity, and that identity is the screen's sole operating identity. Changing it = changing who you are. | +| **READ-migrate (READ-only)** | yes (seed, guarded) | no | The identity is "which of mine" but the screen is wallet-primary or its candidate set is filtered, so silently re-pointing the global on a transient pick would be wrong. Seed for continuity; do not write back. | +| **SESSION-LOCAL** | no | no | Deliberately independent (group_actions — K3). | +| **N/A** | no | no | No operate-as identity input: the picker names a **recipient / target / group member** (a different party), or the operating identity is **inherited from the launcher**, or there is no identity input. | + +### 4a. The SYNC vs READ-only decision rule (the crux) + +> A selector **SYNCs** iff it answers "**who am I operating as**" for an action +> the screen signs/creates/sends on that identity's behalf, and that identity is +> the only operating identity on the screen. A selector is **N/A** iff it names +> a party that is **not you** — a recipient you send to, a target you act on +> (freeze/unfreeze/destroy), or a control-group member. A selector is +> **READ-only** iff it is "which of mine" but the screen is **wallet-primary** +> (the candidate list is one wallet's identities, possibly not the global +> wallet) or the candidate list is **capability-filtered** (e.g. EdDSA-only), +> so writing the global from a transient pick would mis-point the app. + +This rule is self-validating against the W1 mechanism: the regression test +`default_selector_has_no_sync_target` (identity_selector.rs:360) is annotated +"the 9 no-sync sites stay inert". Applying the rule yields exactly **9 no-sync +`IdentitySelector` sites** (7 recipient/target/member + create_asset_lock +top-up READ-only + group_actions session-local) and **12 sync sites** — a clean +21-site partition. The hazard the test guards is real: a `syncing_global` +mistakenly added to a *target* picker (e.g. freeze) would, on selecting another +person's identity, hijack the global active identity **and** reconcile the +wallet to an identity you do not own (clearing it to `None`, K1). + +## 5. Authoritative per-screen table + +Operating identity = the identity that signs. "Picker" = the visible identity +input. `il` = `IdentitySelector`; `cb` = bespoke `ComboBox`; `—` = none. + +### SYNC-on-change (12) + +| # | File | Picker (line) | Current default | Picker is | Mechanism | +|---|---|---|---|---|---| +| 1 | `src/ui/contracts_documents/register_contract_screen.rs` | il :427 (`other_option(false)`) | `qualified_identities.first()` in `new()` :66 | operating | Seed `new()` from `resolve_selected_identity()` (fallback first); add `.syncing_global(self.app_context.clone())`. Keep the `response.changed()` key/wallet derivation (:440). | +| 2 | `src/ui/contracts_documents/update_contract_screen.rs` | il :446 | `first()` in `new()` :72 | operating | Same as #1. | +| 3 | `src/ui/contracts_documents/document_action_screen.rs` | il :266 | `None` (`selected_identity`) | operating | Seed `new()`/`render_*` from `resolve_selected_identity()`; add `.syncing_global(ctx)`. Keep `response.changed()` block (:279). | +| 4 | `src/ui/identities/register_dpns_name_screen.rs` | il :191 | `first()` in `new()` :77 | operating | Seed `new()` from `resolve_selected_identity()`; add `.syncing_global(ctx)`. Selector is gated `len>1` (:117) — single-identity case already correct. | +| 5 | `src/ui/tokens/tokens_screen/token_creator.rs` | il :148 **and** `add_identity_key_chooser` :223 (advanced) | `TokenCreatorUI.selected_identity = None` (mod.rs :1595) | operating | Seed `selected_identity` from `resolve_selected_identity()` when `None`; add `.syncing_global(ctx)` to the il (:148); in the advanced-mode chooser, write back via `set_selected_identity` on change (helper has no opt-in). | +| 6 | `src/ui/dashpay/add_contact_screen.rs` | il :257 (`.label("Identity:")`) | `None` :62 / :79 | operating (sender) | Seed in `new()`/`new_with_identity_id` from `resolve_selected_identity()`; add `.syncing_global(ctx)`. | +| 7 | `src/ui/dashpay/contacts_list.rs` | il :325 | `identities[0]` in `new()` :103 **and** `ui()` re-default :199–206 | operating (your identity) | Replace **both** `identities[0]` defaults with `resolve_selected_identity().or(first)`; add `.syncing_global(ctx)`. | +| 8 | `src/ui/dashpay/contact_requests.rs` | il :365 | `identities[0]` :101; `set_selected_identity()` :118 | operating (your identity) | Seed from `resolve_selected_identity()`; add `.syncing_global(ctx)`. Keep the change handler that clears lists + re-derives wallet (:376). | +| 9 | `src/ui/dashpay/send_payment.rs` | il :564 (Payment History) | `selected_identity` field :462 | operating (your identity) | Seed from `resolve_selected_identity()`; add `.syncing_global(ctx)`. Keep `response.changed()` → `refresh()` (:575). | +| 10 | `src/ui/dashpay/profile_screen.rs` | il :512 | `identities[0]` :152 + `ui()` re-default :240–245 | operating (edit your profile) | Replace both defaults with `resolve_selected_identity().or(first)`; add `.syncing_global(ctx)`. | +| 11 | `src/ui/dashpay/qr_code_generator.rs` | il :187 | `identities[0]` :77 | operating (share your identity) | Seed from `resolve_selected_identity()`; add `.syncing_global(ctx)`. | +| 12 | `src/ui/dashpay/qr_scanner.rs` | il :173 ("Select Your Identity" :164) | `identities[0]` :77 | operating (connect as you) | Seed from `resolve_selected_identity()`; add `.syncing_global(ctx)`. Keep the prev/new id diff at :168/:186. | + +### READ-migrate, READ-only (3) + +| # | File | Picker (line) | Current default | Mechanism | Why no sync | +|---|---|---|---|---|---| +| 13 | `src/ui/wallets/create_asset_lock_screen.rs` | il :398 ("Identity to top up") | `None` :94 | Add `.with_app_default(&self.app_context)` — it seeds **only if** the global id is one of this wallet's identities (the candidate list is wallet-scoped, :69). No `new()` pre-fill to change. | Wallet-primary: the screen is launched for a specific wallet that may not be the global wallet; topping up does not change who you operate as (K1 reconcile would mis-point). | +| 14 | `src/ui/tools/grovestark_screen.rs` | cb (`selected_identity: Option<String>` :53, ComboBox; `refresh_identities` :154) | `None` :134 | Manual seed: in `new()`/`refresh_identities`, set `selected_identity` from `resolve_selected_identity()` **iff** it is in the EdDSA-filtered list (:160), else first filtered, else `None`. Store the id string. | Capability-filtered (EdDSA-only): the global identity may be absent from the list; a developer tool should not push an EdDSA-only id as the app-wide active identity. SYNC deferred. | +| 15 | `src/ui/wallets/send_screen.rs` | cb `identity_source_selector` :1966 | `None` :424 | Manual seed: when the source list is built, default `selected_identity` from `resolve_selected_identity()` **iff** it is among this wallet's identities, else leave `None`. | Wallet-primary + transient funding source for one send; K1 reconcile of the global wallet would fight the screen's own wallet. | + +### SESSION-LOCAL (1) + +| # | File | Picker (line) | Mechanism | +|---|---|---|---| +| 16 | `src/ui/contracts_documents/group_actions_screen.rs` | il :541 | **Leave the default `IdentitySelector` untouched** (no `with_app_default`, no `syncing_global`). Keep the screen's own `selected_identity`. Add a one-line code comment citing K3, and a regression test asserting it neither seeds from nor writes the global. | + +### N/A — recipient / target / member / inherited / no input (≈13) + +| File | Picker (line) | Why N/A | +|---|---|---| +| `src/ui/tokens/mint_tokens_screen.rs` | il :238 `.label("Recipient:")` `.exclude(self)` :245 | Recipient. Operating identity is fixed `identity_token_info.identity` (row-clicked). | +| `src/ui/tokens/transfer_tokens_screen.rs` | il :183 `.label("Recipient:")` `.exclude` :190 | Recipient. | +| `src/ui/tokens/freeze_tokens_screen.rs` | il :214 "Freeze Identity ID:" | Target you act **on**. Operating identity = `identity_token_info.identity`. | +| `src/ui/tokens/unfreeze_tokens_screen.rs` | il :218 "Identity ID to unfreeze:" | Target. | +| `src/ui/tokens/destroy_frozen_funds_screen.rs` | il :225 "Frozen Identity ID:" | Target. | +| `src/ui/tokens/tokens_screen/groups.rs` | il :206 `.exclude` :212 | Control-group member (defining who controls the contract). | +| `src/ui/identities/transfer_screen.rs` | il :172 "Receiver Identity ID:" `.exclude(self)` :179 | Recipient. Operating identity `self.identity` is passed to `new()` :82 (inherited from launcher). | +| `src/ui/identities/withdraw_screen.rs` | — | No picker. `self.identity` passed to `new()` :68 (inherited). | +| `src/ui/wallets/unshield_credits_screen.rs` | — | Wallet-scoped (`new(seed_hash)` :51); no identity. | +| `src/ui/tokens/tokens_screen/mod.rs` (main TokensScreen) | — | Lists balances across all identities; per-row actions carry their own `identity_token_info` (:1493). No single operating picker. | +| `src/ui/identity/home.rs` (:274), `src/ui/identity/contacts.rs` (:166), `src/ui/identity/settings.rs` (:660) | — | **Already app-scoped** (W1): they read `resolve_selected_identity()` directly. No change. settings.rs already pulls `incoming = resolve_selected_identity()` each frame and syncs its field (:660–669). | + +Inherited-identity note: `transfer_screen` and `withdraw_screen` get their +operating identity from the launch site (the hub Home tab, `home.rs:274`, which +already uses `resolve_selected_identity()`), so they are transitively +app-scoped without their own migration. Confirm launch sites still pass the +active identity when wiring these. + +### Counts + +| Category | Count | +|---|---| +| SYNC-on-change | 12 | +| READ-migrate (READ-only) | 3 | +| SESSION-LOCAL | 1 | +| N/A (incl. 3 already-app-scoped hub tabs) | ≈13 | +| **Total identity-input sites enumerated** | **≈29** (21 `IdentitySelector` + 2 bespoke ComboBox + group/inherited/no-input) | + +`IdentitySelector` partition: 12 SYNC + 9 no-sync (7 N/A recipient/target/member ++ #13 READ-only + #16 session-local) = 21 — matches the "9 no-sync sites" +regression-test invariant. + +## 6. Domain-batched dev plan + +Ordered mechanical (`IdentitySelector` + `.syncing_global`) first, bespoke last. +Each batch is independently committable and independently testable. + +### Batch B1 — Contracts & Documents (3 SYNC) +- Files: `register_contract_screen.rs`, `update_contract_screen.rs`, + `document_action_screen.rs`. +- Change: seed `new()` (and any refresh re-default) from + `resolve_selected_identity()` (fallback `first()`); add + `.syncing_global(self.app_context.clone())` to each `IdentitySelector`; leave + the existing `response.changed()` key/wallet derivation intact. +- Tests: extend existing patterns — add a kittest (DB-seeded multi-identity, à + la `identity_hub_switcher.rs`) asserting the contract screen defaults to the + app-scoped id and that a picker change calls `set_selected_identity`. No live + network. +- Commit: `feat(contracts): obey app-scoped selected identity in register/update/document screens (W2)` + +### Batch B2 — DPNS / Identities (1 SYNC) +- Files: `register_dpns_name_screen.rs`. +- Change: seed `new()` from `resolve_selected_identity()`; add `.syncing_global`. +- Tests: **extend** `tests/kittest/register_dpns_name_screen.rs` — assert the + default identity tracks the app-scoped selection (seed two identities, set the + selection to the second, expect it pre-selected). +- Commit: `feat(identity): default DPNS registration to the app-scoped identity (W2)` + +### Batch B3 — DashPay (7 SYNC) +- Files: `add_contact_screen.rs`, `contacts_list.rs`, `contact_requests.rs`, + `send_payment.rs`, `profile_screen.rs`, `qr_code_generator.rs`, + `qr_scanner.rs`. +- Change: replace every `identities[0]` / `None` default (in both `new()` **and** + any `ui()`/refresh re-default — see contacts_list :199–206, profile_screen + :240–245) with `resolve_selected_identity().or(first)`; add `.syncing_global`. + Keep each screen's change handler (list-clear + wallet re-derive). +- Tests: **extend** `tests/kittest/dashpay_screen.rs` (+ the + `identity_hub_contacts.rs` pattern). One representative seed-and-default + assertion per screen; one write-back assertion (change picker → + `resolve_selected_identity()` reflects it) on `contacts_list` as the canary. +- Commit: `feat(dashpay): sync DashPay screens with the app-scoped selected identity (W3)` + +### Batch B4 — Tokens (1 SYNC; verify 6 N/A) +- Files: `tokens_screen/token_creator.rs` (+ `tokens_screen/mod.rs` for the + `TokenCreatorUI.selected_identity` seed at :1595). +- Change: seed `TokenCreatorUI.selected_identity` from + `resolve_selected_identity()` when `None`; add `.syncing_global(ctx)` to the + simple-mode il (:148); in advanced mode, write back via `set_selected_identity` + in the `add_identity_key_chooser` change path. +- Verify-only (no behaviour change): `mint`, `transfer_tokens`, `freeze`, + `unfreeze`, `destroy_frozen_funds`, `groups` — confirm they keep the **default** + `IdentitySelector` (no opt-in). Add a focused unit test per file is overkill; + instead add one shared regression test (see B6). +- Tests: token_creator kittest seed-and-default assertion (simple mode) — may + need a minimal token-context fixture; if unavailable, mark `#[ignore]` with a + TODO and notify (test-infra gap, §7). +- Commit: `feat(tokens): default the token creator to the app-scoped identity (W4)` + +### Batch B5 — Wallets & Tools (3 READ-only, bespoke) +- Files: `create_asset_lock_screen.rs` (add `.with_app_default(&self.app_context)` + to the il :398), `grovestark_screen.rs` (manual EdDSA-guarded seed in `new()` + / `refresh_identities`), `send_screen.rs` (manual wallet-membership-guarded + seed of the source ComboBox). +- Change: READ-only seed; **no** `syncing_global` / write-back. +- Tests: unit test the seed guards (wallet-membership for create_asset_lock / + send_screen; EdDSA-membership for grovestark) — pure model-ish, no UI harness + needed where the guard is extractable; otherwise a small kittest. +- Commit: `feat(wallets,tools): seed wallet-scoped and tool screens from the app-scoped identity (W5)` + +### Batch B6 — GroupActions guard + cross-cutting regression locks +- Files: `group_actions_screen.rs` (one-line K3 comment), plus tests. +- Change: no functional change to group_actions. +- Tests: (a) assert group_actions' selector is the default (no + `with_app_default`/`syncing_global`) and that selecting an identity there does + not call `set_selected_identity`; (b) a shared regression test asserting the + N/A token recipient/target pickers (mint/freeze/etc.) keep `sync_target == + None` — complementing the existing `default_selector_has_no_sync_target` + (identity_selector.rs:360). These lock the "9 no-sync sites" invariant against + future drift. +- Commit: `test(identity): lock session-local and no-sync identity pickers (W5)` + +## 7. Risks & test-infra gaps + +- **TI-1 — `WalletFixture` builder is the gating test-infra need.** IT-SWITCH-01/02 + (wallet dropdown + wallet-scoped identity list) are blocked because there is no + fixture for a **loaded HD `Wallet` in `AppContext::wallets` with a matching + `wallet_hash`** (documented in `tests/kittest/identity_hub_switcher.rs:6–16`; + the current path only seeds identities via + `insert_local_qualified_identity(.., &None)`). The SYNC write-back tests that + need to observe **K1 wallet reconciliation** (set identity → derived wallet + follows) cannot be fully exercised until this builder exists. Build + `WalletFixture` before, or as the first step of, B6. Until then, write-back + tests assert only `resolve_selected_identity()` movement, not wallet + reconciliation, and carry a TODO. +- **TI-2 — per-frame identity-table load in the hub.** `hub_screen.rs:209–210` + carries `TODO(IDH-003 follow-up)` to fold `landing()`'s load and the + breadcrumb switcher's per-frame `load_local_qualified_identities()` into one + shared snapshot. The SYNC migration adds **no** new per-frame DB load (the + selector seeds from in-memory `selected_identity_id()`), but each migrated + screen still calls `load_local_qualified_identities()` per frame as today — + do not regress this into extra loads; prefer seeding from already-loaded + vectors. +- **R1 — wallet-primary screens must not SYNC (K1 interaction).** `create_asset_lock` + (#13) and `send_screen` (#15) are launched for a **specific** wallet whose + identity list may differ from the global wallet. If either were given + `syncing_global`, picking an identity would call `set_selected_identity`, which + reconciles the **global** wallet to that identity's owner — fighting the + screen's own wallet and possibly clearing it to `None`. They are READ-only by + design. Do not "upgrade" them to SYNC. +- **R2 — target/recipient pickers must stay default.** Adding `syncing_global` + to a freeze/unfreeze/destroy **target** or a mint/transfer **recipient** would + let selecting another party's identity hijack the global active identity and + K1-reconcile the wallet to an identity the user does not own. The B6 regression + lock and the existing `default_selector_has_no_sync_target` test defend this. +- **R3 — seed-vs-pre-fill conflict.** `with_app_default` is inert on any screen + that pre-fills `identity_str` in `new()` (the buffer is non-empty). For all 12 + SYNC screens that pre-fill, seed via `resolve_selected_identity()` in + `new()`/refresh and rely on `syncing_global` for write-back — do **not** expect + `with_app_default` to do the reading there. +- **R4 — grovestark capability filter.** The candidate list is EdDSA-only; the + seed must guard membership or the selection silently won't take. + +## 8. Surprises vs the prior MemCan rulings + +The earlier audit (memory 5ebc3af6) named six "READ-migration" screens: +register_contract, update_contract, document_action, group_actions, +token_creator, grovestark. This plan refines that umbrella term, which predates +the W1 `syncing_global` mechanism: +- register_contract, update_contract, document_action, **token_creator** are + **SYNC-on-change**, not READ-only — they are operate-as signers, so a user + change must propagate. ("Require READ-migration" is satisfied by SYNC, which + also reads.) +- group_actions is **SESSION-LOCAL** (confirmed, K3). +- grovestark is **READ-only** (confirmed) — capability-filtered tool screen. + +The audit's "~24 READ-migrate screens" (memory b2115c58) was a loose upper +bound; the precise partition is 12 SYNC + 3 READ-only + 1 session-local, with +the token action screens' visible pickers reclassified as **N/A recipient/target +pickers** (their operating identity is row-scoped via `identity_token_info`, not +app-scoped) — a correction the earlier audit did not draw. diff --git a/docs/ai-design/2026-07-06-platform-address-signer-reconciliation/01-design.md b/docs/ai-design/2026-07-06-platform-address-signer-reconciliation/01-design.md new file mode 100644 index 000000000..3538bacc6 --- /dev/null +++ b/docs/ai-design/2026-07-06-platform-address-signer-reconciliation/01-design.md @@ -0,0 +1,99 @@ +# Platform-address signer reconciliation + +## Symptom (live mainnet, real funds) + +A user withdrew 20 DASH from a Platform address back to Core. Nothing was +broadcast (funds safe), but signing failed with: + +``` +Platform address P2pkh([…]) not found in wallet +``` + +The balance was visible in the withdraw picker, yet the signer refused it. + +## Root cause: two maps that should stay in sync, but don't + +`Wallet` (`src/model/wallet/mod.rs`) keeps two per-address maps: + +- `platform_address_info: BTreeMap<Address, PlatformAddressInfo>` — balance/nonce + per address. Feeds the balance/withdraw UI. +- `watched_addresses: BTreeMap<DerivationPath, AddressInfo>` — addresses the + wallet has a derivation path for. Feeds `PlatformPathIndex::from_wallet`, i.e. + the signer's view of "addresses I can sign for". + +The signer (`DetPlatformSigner::sign_with_address`) resolves the target address +through `PlatformPathIndex`, which is built purely from `watched_addresses`. No +path → refuse to sign. + +`WalletAddressProvider::apply_results_to_wallet` keeps the two maps in sync +correctly, because it derives every candidate itself and therefore knows each +address's index. But two other paths update `platform_address_info` from raw +`(hash160, balance, nonce)` triples pushed by the upstream `platform-wallet` +coordinator — which does its own account/gap-limit bookkeeping and never hands +DET a derivation index: + +- `AppContext::apply_platform_address_push` (`src/context/mod.rs`) — every live + coordinator push (~15 s). +- `AppContext::warm_start_platform_addresses` — cold boot. + +So an address the coordinator discovers shows a real balance in the UI while the +signer has no path for it. That is exactly what the user hit. + +## Fix: seedless reverse-derivation reconciliation + +The DIP-17 final index is a **non-hardened** child, so a platform-payment +address derives from the account-level xpub alone (no seed). We exploit that: + +1. **Cache the platform-payment account xpub on `Wallet`** + (`platform_payment_account_xpub: Option<ExtendedPubKey>`), mirroring the + existing `master_bip44_ecdsa_extended_public_key` cache. Populated eagerly in + `Wallet::new_from_seed`; `Option` for backward compatibility with wallets + persisted before the field existed (it is **not** persisted — re-derived JIT). + +2. **`Wallet::ensure_platform_payment_account_xpub(&mut self, seed, network)`** — + backfills the cache the next time the seed is borrowed through the JIT + chokepoint. A cheap no-op once cached. Solves the seedless-backfill problem + for existing wallets: the affected user's wallet self-heals on the next + unlock without re-entering a recovery phrase. + +3. **`Wallet::reconcile_platform_address(&mut self, address, network) -> bool`** — + no-ops (true) if already known; returns false (debug log) if the xpub is not + cached yet; otherwise reverse-derives candidates from the cached xpub over a + bounded window and, on a match, registers `known_addresses` + + `watched_addresses` exactly as `apply_results_to_wallet` does + (`CLEAR_FUNDS` / `PlatformPayment`). A foreign/out-of-window address returns + false with a **warn** (no silent caps). + +### Where it runs + +- `apply_platform_address_push` and `warm_start_platform_addresses` — reconcile + each pushed address. The coordinator only ever pushes OWNED addresses + (`event_bridge.rs`), so a match is found on the first push after the xpub is + cached, then all later pushes short-circuit on `known_addresses`. No re-scan, + no log spam in steady state. +- `fetch_platform_address_balances` — backfills the xpub while the seed is + already borrowed, so the seedless push path can reconcile afterward. +- `withdraw_from_platform_address` — backfills **and** reconciles the actual + input addresses, then rebuilds the path index from the reconciled wallet + before signing. This makes the reported withdrawal self-heal on the first + retry rather than depending on a later background push. + +### Bounds + +Search ceiling: `max(highest_registered_index, DEFAULT_GAP_LIMIT) + 500` +(25× the default gap limit). Owned addresses match within the first handful of +indices; the ceiling only bounds the pathological foreign-address case so the +search can never spin unbounded. Past the ceiling → warn + false. + +## Fund-safety parity + +Reverse-derivation registers the SAME address the seed path produces (the DIP-17 +index is a non-hardened child of the account xpub), so the registered +derivation path is correct and the signer derives the correct key. Covered by +`reconcile_registers_platform_address_beyond_gap_limit` and +`reconciled_address_becomes_signable_backward_compat` (index 25, past the gap +limit, no seed at reconcile time). + +Crucially, reconciliation can only register an address that actually derives +from the wallet's own xpub, so it can never mis-register a foreign/orphan +address for signing. diff --git a/docs/ai-design/2026-07-07-asset-lock-finality-retest/retest-findings.md b/docs/ai-design/2026-07-07-asset-lock-finality-retest/retest-findings.md new file mode 100644 index 000000000..0463bf528 --- /dev/null +++ b/docs/ai-design/2026-07-07-asset-lock-finality-retest/retest-findings.md @@ -0,0 +1,624 @@ +# Asset-Lock FinalityTimeout — Retest Findings (post platform-bump) + +**Date:** 2026-07-07 +**Branch:** `feat/identity-onboarding-ux` @ `866bbf5f` (merge of `cf9c16dd` — platform pin bump +`e6b508f1` → `c2135800` / rust-dashcore `5a1fdf2b` → `647fa982`, 108 upstream commits) +**Author:** Marvin (QA — verification only, no fix applied) +**Relates to:** `docs/ai-design/2026-07-01-asset-lock-finality-rootcause/investigation-findings.md` +(original investigation, PR 860 @ `637dc603`) — **not modified by this doc.** + +**Verdict: STILL BROKEN.** Same end-user symptom reproduces on current HEAD. The upstream +fix that *did* land does not cover the failure mode observed here. + +--- + +## 1. What shipped since the original investigation + +`git merge-base --is-ancestor 417d61da <current rust-dashcore pin>` → **yes**. Commit +`417d61da fix(key-wallet): don't build asset locks on unconfirmed funds (#836)` — the fix +tracked in MemCan (superseding an earlier "no funding-input finality gate" theory) for the +0-conf-UTXO coin-selection defect — **is included** in the current pin (`647fa982`). This +directly addresses one candidate mechanism from the original H1. It did not fix the bug. + +## 2. Repro mechanics and a methodology note (own error) + +Built cleanly (warm from `cf9c16dd`'s own gate, `cargo build --test backend-e2e +--all-features` finished in 5.87s, no errors/warnings). + +First attempt used the harness's default `RUST_LOG` filter (`backend_e2e=info` — see +`tests/backend-e2e/framework/harness.rs:220-221`), which **filters out all +platform_wallet/key_wallet/dash_spv logs** (`wait_for_proof`, `FinalityTimeout`, +`context=mempool`, ChainLock/InstantSend events — none of it surfaces without an explicit +`RUST_LOG`). Killed that run (~4 min in, still short of the 300s wait) rather than waste a +full run on an uninstrumented capture — **this was my own error**: killing a process +mid-broadcast raced the framework wallet's local UTXO-reservation bookkeeping against the +SIGKILL, and the next attempt (using the same shared, non-git-hash-keyed workdir — +`/tmp/dash-evo-e2e-testnet`, contradicting the harness README's documented "keyed by git +revision" behavior, itself a minor doc/behavior mismatch worth a follow-up) came up with +`spendable: 0, total: 0` and panicked in harness init (`harness.rs:477`). A third attempt +(let run to completion, un-killed) resynced fine (balance `15,082,365,339` duffs, correctly +down ~100,297 duffs from run 1's already-broadcast tx) and produced the clean, fully +instrumented repro analyzed below. No workdir/DB was wiped; all three log files retained. + +Logs (full, retained as evidence — quote only excerpts below): +- Run 2 (harness-init panic, collateral of my kill): `/data/tmp/backend-e2e-tc004-96Ggen.log` +- Run 3 (clean instrumented repro, **this is the evidence run**): `/data/tmp/backend-e2e-tc004-tqNtw6.log` + +## 3. Run 3 result + +``` +thread 'core_tasks::test_tc004_create_registration_asset_lock' (2524905) panicked at tests/backend-e2e/core_tasks.rs:140:10: +CreateRegistrationAssetLock should succeed: WalletBackend { source: FinalityTimeout(OutPoint { txid: 0x27970abaa46660e45a9e2c5c62328aa16d4ebab2e2c396edec14d417545ef013, vout: 0 }) } +test result: FAILED. 0 passed; 1 failed; ... finished in 308.04s +``` + +Identical symptom shape to the original doc: `WalletBackend { source: FinalityTimeout(OutPoint {..}) }` +after the 300s `wait_for_proof` window (`platform_wallet::wallet::asset_lock::sync::proof::wait_for_proof`). + +## 4. Hard on-chain evidence (Insight testnet) — root mechanism identified precisely + +Our broadcast asset-lock tx `27970abaa4…` and its reversed-byte form: **Not found** on +Insight (both orders) — never reached the real network, consistent with the original doc. + +But this time the *funding input* resolves cleanly, and that's the finding: + +| Tx | Insight result | +|---|---| +| `27970abaa46660e45a9e2c5c62328aa16d4ebab2e2c396edec14d417545ef013` (our new asset-lock tx) | **Not found** | +| `a16e8db4aa2676f47a8b1f93ebd6ba8b16fcda22411962ed6d603ba6277858f7:1` (the input our tx spends, value 19.99796384 DASH) | **Confirmed**, block 1479018, 31046 confirmations. `vout[1]` carries `"spentTxId":"1c6e3f9dc640ebda4d6d97d15743cc3fc0cdcb8cf6253c7b440499c57c6707a4","spentHeight":1479019` | +| `1c6e3f9dc640ebda4d6d97d15743cc3fc0cdcb8cf6253c7b440499c57c6707a4` (the *real* spender of that input, one block later) | Confirmed, block 1479019, 31045 confirmations | +| `addr/ygomtzTZPGtJ3e6xVTr7sqMDK4oLTwUB2o` (the input's address — owned by the framework wallet; DET's own coin-selection chose it) | `balance: 0`, `totalReceived == totalSent == 19.99796384`, `txApperances: 2`, `unconfirmedTxApperances: 0` | + +**The framework wallet's own local UTXO index still believes an output that Insight shows +was spent ~31,044 blocks (≈54 days at ~2.5 min/block) ago is unspent and spendable.** DET's +coin-selection picked exactly this stale phantom UTXO as the sole funding input for a brand +new asset-lock transaction. Any peer running real chain-state validation rejects that as a +double-spend (`bad-txns-inputs-missingorspent` — same rejection class independently +captured and quoted in the prior session's MemCan record for an earlier phantom chain). +Broadcasting it can never produce an IS-lock or ChainLock, so `wait_for_proof` is +**guaranteed** to exhaust its 300s budget regardless of network health. + +Corroboration that the SPV client's finality machinery itself is *not* the problem this +time (a real change from the original doc, where InstantSend showed 0 activity for 18+ +minutes): a **different**, unrelated transaction (`1435e9423af1b2be7e6f2408ab59dcc97d8c55fea080d8aa47eea89dea9db989`) +received a real IS-lock during our exact wait window (`txlock: true`, confirmed next block), +and our wallet's ChainLock height advanced three times during the 300s wait +(`1510061 → 1510062 → 1510063`, each independently `Synced`/`valid` in the log) while our +tx's own transaction-record context stayed stuck at `"Mempool"` for all four `wait_for_proof` +iterations — because it was never actually accepted anywhere to begin with. + +## 5. Which hypothesis does this confirm/refute? + +- **H1 (local spendability judged incorrectly, not from network finality)** — **confirmed, + in a sharper form.** The original doc's H1 was about 0-conf/mempool change being + misclassified `Confirmed`. Here the wallet's *persisted* index carries a UTXO that is + fully, unambiguously spent on real chain history from ~54 days ago — not a + confirmation-timing race, a **stale/never-invalidated UTXO-set entry**. The rust-dashcore + #836 fix (§1) only guards against spending *unconfirmed* funds; it has no mechanism to + detect "confirmed-according-to-local-index, but actually long-spent-on-chain," so it does + not touch this failure mode at all. +- **H2 (txs never propagate to network mempool)** — not the primary mechanism this time; + our tx never propagating is a *consequence* of H1 (peers correctly reject a double-spend), + not evidence the network/relay path itself is broken. +- **H3 (InstantSend not functioning)** — **refuted.** IS-locks are being received and + processed normally on this run (see §4); the earlier "0 valid IS-locks in 18 minutes" + observation does not reproduce. + +## 6. Scope note — is this a shared-workdir artifact or a real product defect? + +The specific stale UTXO discovered here lives in a **long-lived, never-wiped, non-git-hash-keyed +E2E workdir** (`/tmp/dash-evo-e2e-testnet`) accumulated across many past sessions over ~54 +days. It's plausible this exact address's history predates a wallet rescan checkpoint and +was never re-validated. That does not make it a non-finding: the underlying defect it +exposes — **DET/platform-wallet's coin-selection and `wait_for_proof` never validate a +candidate UTXO against current network truth before committing 300s to waiting on it, and +nothing in the wallet reconciles/invalidates a persisted UTXO once its real spend falls +outside whatever window was last rescanned** — is a real, generally applicable gap, not an +artifact confined to this test fixture. A fresh interactive-GUI wallet with a shorter +history would be less likely to hit it, but nothing prevents the same class of divergence +recurring given enough wall-clock time or SPV rescan/import edge cases. + +## 7. TC-005 (`test_tc005_create_top_up_asset_lock`) — skipped + +Not run. `fixtures::shared_identity()` (its precondition) requires a successful identity +registration, which itself funds via the same framework wallet's coin-selection path +implicated above — a second data point would very likely reproduce the identical mechanism +rather than add new information, and each attempt costs up to ~300s+ real wall-clock time. +Given §3-§6 already gives a conclusive, independently-verified mechanism, I judged the +marginal value low relative to the time cost and stopped here. + +## 8. Key evidence artifacts + +- Run 3 full log (clean, instrumented repro — 1316 lines): + `/data/tmp/backend-e2e-tc004-tqNtw6.log` +- Run 2 full log (harness-init panic, collateral damage from my kill of run 1): + `/data/tmp/backend-e2e-tc004-96Ggen.log` +- rust-dashcore pin: `647fa9820f3614090e4e5f5f2b709961d68e538b` + (cached checkout: `~/.cargo/git/checkouts/rust-dashcore-c6b13647c01f74b9/647fa98`) +- platform pin: `c2135800` (cached checkout: + `~/.cargo/git/checkouts/platform-7a21f318038a582f/c213580`) + +## 9. Minor doc/behavior mismatches noticed in passing (not the main finding) + +- `tests/backend-e2e/README.md` documents the workdir as "keyed by git revision" (e.g. + `/tmp/dash-evo-e2e-testnet-abc1234`) and a 180s spendable-balance timeout at init. Actual + code (`harness.rs`) uses a fixed base path with a numbered-slot fallback (no git-hash + component) and a 30s spendable-balance timeout. Neither is a functional bug, but the + README no longer matches the code. + +--- + +## 10. Follow-up experiment (same session, later): cold-cache wipe — headline finding + +**Prompted by a user hypothesis**: is §6's "stale UTXO" scoped to this long-lived, +never-wiped E2E workdir (cache/hygiene issue), or a real architectural gap? Tested by +backing up (renaming, not deleting) the primary workdir +(`/tmp/dash-evo-e2e-testnet` → `.bak-20260707T084128Z`, later preserved as +`.cold-synced-20260707T084959Z`) and forcing a genuine cold init. Confirmed genuine: +`BlockHeadersManager initialized at height 0` (not the cached tip), fresh +`Registered framework wallet` (not "already registered"), and a real filter/block +re-matching pass (323→399 historical blocks independently reprocessed across the session). + +**Result — far more damning than the single stale UTXO in §4.** After a full genesis +rescan, `verify_framework_funded` (`framework/funding.rs:77`) reported the framework +wallet's REAL spendable balance as **36,908,682 duffs (≈0.369 DASH)** — against the +**15,082,365,339 duffs (≈150.8 DASH)** the stale warm cache had been reporting across every +run in §1-§9. **The wallet's apparent balance was >99.9% phantom.** This isn't one bad UTXO; +essentially the entire locally-tracked balance for this wallet does not exist on the real +chain. This independently and much more broadly corroborates the self-reinforcing +phantom-chain mechanism already on record in MemCan from the 2026-07-01 session +(`dispatch_local` injecting unconfirmed self-broadcasts as spendable, with no +acceptance/rejection reconciliation) — this session's cold rescan is the first time that +mechanism's *cumulative* damage over ~54 days of unreconciled local state has been measured +directly rather than inferred from one transaction. + +Practical consequence: the harness's own 10 DASH minimum-funding gate (`MIN_BALANCE_DUFFS`) +now correctly fails fast on the truly-synced workdir — meaning further live testing needed +either faucet/manual funding or continuing against the (known-tainted) warm cache. The +cold-synced workdir was preserved (not deleted) for follow-up once real funds land; a fresh, +independently Insight-verified never-used receive address (`yYqF93Sonfe1ETRPim5vATsNdTa4qztyXf` +— `balance:0, totalReceived:0, txApperances:0`) was handed off for manual top-up. + +## 11. Addition 1 — repeat-run/restart stability: BLOCKED, not inconclusive + +Plan was 2-3 more `cargo test` invocations (each a genuine fresh process — satisfies +"restart between runs") against the now-cold-synced workdir. **Could not be completed as +scoped**: every subsequent invocation hits the same `verify_framework_funded` panic before +reaching any asset-lock code, since the real balance (0.369 DASH) is below the hard-coded +10 DASH gate. This is not "inconclusive" — see §10's headline finding and §13's synthesis +below, which make repeat-run testing on *this* wallet moot until it holds real funds: +literally any transaction it builds right now is provably phantom (§13), so "does it pass +consistently" has a deterministic answer (no, never, until re-funded for real) rather than +an intermittent one. + +## 12. Addition 2 — late-added wallet (permanent test `TC-012`) + +Added `test_tc012_create_registration_asset_lock_late_added_wallet` to +`tests/backend-e2e/core_tasks.rs` (committed; `cargo +nightly fmt` and +`cargo clippy --all-features --all-targets -- -D warnings` both clean). Uses +`create_funded_test_wallet` (registers a wallet *into* an already-running, already-synced +SPV client — the opposite of the framework wallet, which is registered *before* +`backend.start()` in `BackendTestContext::init`, see §14) then attempts +`CreateRegistrationAssetLock` from it, same pattern as TC-004. + +Three attempts, each informative in a different way: + +| Attempt | Wait strategy | Result | Time | +|---|---|---|---| +| v1 | none (relied on `create_funded_test_wallet`'s own "spendable" wait) | `AssetLockTransaction("... Coin selection error: No UTXOs available for selection")` | 12.24s | +| v2 | added explicit poll on `.confirmed` (not `.spendable()`), 180s bound | Timed out: `confirmed=0` for the full 180s | 215.11s | +| v3 | same poll, bumped to 420s bound | Timed out again: `confirmed=0` for the full 420s — longer than one average Dash block (~2.5 min) | 432.80s | + +v1's failure exposed a real, separate harness/product balance-classification mismatch (see +`DetWalletBalance::spendable()` in `src/wallet_backend/snapshot.rs`: `confirmed + +unconfirmed`, explicitly a UI-display-only heuristic per that file's "FUND-SAFETY MANDATE" +banner) — `create_funded_test_wallet`'s wait is satisfied by a plain unconfirmed mempool +deposit, but the real upstream asset-lock coin-selector requires strictly confirmed/IS-locked +funds and correctly refuses the unconfirmed ones (`Coin selection error: No UTXOs available`). +That refusal is *correct*, conservative behavior on the coin-selector's part — not itself a +bug — but it meant v1 wasn't actually testing Addition 2's real question yet, so TC-012 was +revised (v2/v3) to wait on `.confirmed` specifically before attempting the asset lock. + +v2 and v3 then hit something worse: **the funding transaction itself never confirmed, at +all, in 420 seconds — three times longer than an average Dash block.** That is not +plausible IS-lock variance; it demanded direct verification. + +## 13. Synthesis: the funding chain itself is phantom, generation after generation + +Cross-checked both `create_funded_test_wallet` funding txids (one per TC-012 attempt) against +Insight, plus each one's *own* input: + +| Tx | Role | Insight | +|---|---|---| +| `0d3447479d6782005897eb9d2bb8d104de36aaf0312c602e92e9ba23cb1b3b59` | v2's framework→test-wallet funding tx (0.02 DASH) | **Not found** | +| `1501021799c7087f0dd64a0c5b58d67dd42932d13068cda926daa04bfa1a7071` | v3's framework→test-wallet funding tx (0.02 DASH) — **spends `0d344747…:1`, v2's own change output** | **Not found** | + +**v3's funding transaction spends v2's funding transaction's change output — and neither +transaction ever reached the real network.** This is the exact self-reinforcing phantom +chain mechanism from the 2026-07-01 MemCan record, caught live, two generations deep, in a +completely different code path (`CoreTask::SendWalletPayment`, ordinary wallet-to-wallet +funding — not even the asset-lock builder) than TC-004's asset-lock-specific repro. The +framework wallet's coin-selection is currently incapable of producing a transaction that +reaches the real network, *for any purpose* — asset-lock creation, or a plain payment. +`confirmed` staying at 0 for 420s in TC-012 isn't a timing gap; it's the deterministic +consequence of funding a wallet from a transaction that never left this machine. + +**This reframes Addition 2's answer.** The late-added test wallet does *not* fail because +of its own history (it has none — a fresh 12-word mnemonic can't have a stale UTXO). It +fails because it was funded *from* the framework wallet, whose own coin-selection is already +thoroughly poisoned. The discriminating variable isn't "when was this wallet registered +relative to SPV startup" (the original framing) — it's "does this wallet's balance trace +back to a real, network-accepted transaction, or to a chain of purely-local phantom +self-broadcasts." A late-added wallet funded from a genuinely clean source (e.g. a real +faucet drip, or the user's pending manual top-up to the address in §10) would very plausibly +behave differently — that comparison is the natural next step once real funds land, and is +a cheap re-run of the already-committed TC-012 once they do. + +## 14. Init-ordering check (requested, answered from code, not requiring a live repro) + +`BackendTestContext::init` (`framework/harness.rs`) registers the framework wallet +(`register_wallet_with_retry`, ~line 344) **before** `ensure_wallet_backend` / +`backend.start()` (~line 373-393) — i.e. its addresses are part of the SPV client's very +first sync pass. `create_funded_test_wallet` (~line 520) registers a new wallet **after** +the backend is already running, requiring a live bloom-filter rebuild +(`Wallet monitor revision changed, rebuilding bloom filter` — observed in every TC-012 log) +to pick it up. This asymmetry is real, but §13 shows it isn't what's driving the current +failures — both an "early" wallet (framework) and a "late" wallet (TC-012's) are equally +unable to produce a transaction the network accepts, because the *funding source* is the +same poisoned framework wallet either way. + +Aside: `create_funded_test_wallet` always passes `WalletOrigin::Imported` (full genesis +filter-matching pass) even though every call generates a brand-new, guaranteed-empty +12-word mnemonic — `WalletOrigin::Fresh` (birth height = current tip, per +`model/wallet/birth_height.rs`'s own documented policy: "a freshly generated phrase cannot +have prior deposits") would be both correct and cheaper. Observed cost: every TC-012 attempt +paid a multi-hundred-thousand-filter re-match pass (e.g. "Filters: Syncing 1364999/1510083") +for a wallet that can, by construction, never match anything. Not a correctness bug, but an +avoidable per-test-wallet tax worth a follow-up. + +## 15. Updated verdict + +Unchanged at the headline level — **STILL BROKEN** — but the mechanism is now understood +far more precisely than the original 2026-07-01 doc or even §1-§9 of this one: it is not a +single bad UTXO or a confirmation-timing race. The framework wallet used by this E2E suite +is currently running on an entirely self-generated, self-reinforcing chain of phantom +transactions that has never been reconciled against real chain state, to the point that +>99.9% of its apparent balance does not exist on-chain, and it is currently incapable of +producing *any* transaction — asset-lock or plain payment — that the real network accepts. +Every symptom observed this session (TC-004's FinalityTimeout, TC-012's permanent +zero-confirmation) is a direct, provable consequence of that one fact, not independent bugs. + +--- + +## 16. Real-money follow-up (same session, later): a genuine, clean deposit does not fix it + +At the user's request, sent a real manual top-up (not a faucet) to the framework wallet's +verified-unused address from §10 (`yYqF93Sonfe1ETRPim5vATsNdTa4qztyXf`). Confirmed via +Insight within seconds: txid `ad9b30b831de7d2286a1bf9784d5a4e3e06e8cc53af2842376476fee2899931b`, +**20 DASH**, genuinely InstantSend-locked (`"txlock":true`; `blockheight:-1` — not yet +block-mined, but IS-lock is the same finality grade DET's coin-selector accepts). A real, +clean, node-observed deposit, unlike every transaction in §4-§13. + +Swapped the preserved cold-synced workdir (§10) back to the primary path and reran +`test_tc004_create_registration_asset_lock` — a "repeat run" per Addition 1, now against a +wallet holding real money. **Still `FinalityTimeout`**, same shape as ever: +`WalletBackend { source: FinalityTimeout(OutPoint { txid: 0xfa5b15ab…, vout: 0 }) }`, +307s total. + +The coin-selector did not even reach for the clean 20 DASH deposit — it built the new +asset-lock transaction on a *different* input entirely: +`5c43cd895754b925234950f5e94a7cdfd73b315a6d2eef9eee1b44de03d58de7:1` (value 77.99295932 +DASH). Checked on Insight: + +| Item | Insight | +|---|---| +| `5c43cd8957…:1` — the input our new asset-lock tx spent | **Confirmed**, block 1474688, 35406 confirmations (~61 days old). But: `"spentTxId":"f9ca52d513f2801a2aec9d222f3d958748254ffb7a4532c6c44b22a111b638c7","spentHeight":1474746"` — **spent for real, 58 blocks after it was created, ~61 days ago.** | +| `fa5b15ab98…` — our new asset-lock tx | **Not found** — same doomed-double-spend fate as every prior attempt. | + +**This is a materially different, and more serious, finding than §10's "the whole balance +is phantom."** §10 showed a supposedly-complete cold rescan settling on a real balance of +0.369 DASH — implying the rescan mechanism itself works and the problem was accumulated, +un-reconciled warm-cache staleness. This run shows the opposite: given more elapsed +wall-clock time to keep reconciling (the same workdir, resumed ~35 minutes later, +uninterrupted), the *same, previously-cold-synced* wallet settled on a spendable balance of +**22,998,547,073 duffs (≈230 DASH)** — logged as "sufficient" at the very first balance +check of this run, before this run broadcast anything itself — and picked at least one +UTXO for its next transaction that an independent full node shows was genuinely spent +~35,406 confirmations ago. A resync that is allowed to run longer does not converge on +truth; it reintroduces (or never actually eliminated) at least this stale entry. This +demotes the "cache/hygiene" explanation from §6/§10: the defect is not a property of an +unreconciled warm cache versus a clean cold one — it reproduces after a from-genesis rescan +that was given ample time to complete, on a different historical UTXO each time. The +underlying spend-detection/reconciliation logic in `platform-wallet`/`key-wallet` itself +does not reliably mark this wallet's own historical outputs as spent, regardless of how +"cold" or complete the resync is. + +**Practical consequence for Addition 1/2's clean-funding comparison:** routing real money +*through the existing, long-lived framework wallet* does not isolate a clean signal — +its coin-selector can always reach past the fresh deposit into the same poisoned history. +A genuinely clean comparison needs a **brand-new wallet funded directly** (bypassing the +framework wallet as an intermediary), so it has zero prior history to reconcile, correctly, +or incorrectly. That is a cheap follow-up (fund a fresh address directly, then rerun +`TC-012` pointed at that wallet) but was not completed in this session for time. + +## 17. Final updated verdict + +**STILL BROKEN — confirmed with real money, in a genuinely cold-synced context, against a +different historical UTXO than any prior attempt.** The original H1 (local spendability +diverges from network truth) is now established at three independent depths: (1) a single +stale UTXO in a warm, never-wiped cache (§4); (2) the wallet's *entire* apparent balance +being >99.9% phantom immediately after a cold rescan (§10); and (3) a *different* stale, +genuinely-spent-61-days-ago UTXO surfacing again after that same cold-synced wallet was +given more time to reconcile and received a real, clean, IS-locked deposit (§16). No amount +of waiting, re-syncing, or adding real funds to this wallet has produced a passing run. +Fixing this requires correcting the reconciliation/spend-detection defect itself — this +wallet's local UTXO index cannot currently be trusted to converge on real chain state no +matter how it is refreshed. + +--- + +## 18. Crate attribution — where does the defect actually live? + +Two upstream repos are in play (`Cargo.lock`): `key-wallet` + `dash-spv` (both v0.45.0, +`dashpay/rust-dashcore@647fa982`) and `platform-wallet` (v4.0.0, +`dashpay/platform@c213580`, branch `dash-evo-tool`). Read the actual source at both pinned +revisions (not just log-target prefixes). + +### 1. Where the Confirmed/Unconfirmed classification happens + +`key-wallet/src/wallet/balance.rs` — `WalletCoreBalance` is a plain struct; both buckets are +documented as spendable, the split is display-only (`spendable() = confirmed + unconfirmed`, +lines 52-55). The bucket a UTXO lands in is decided in +**`key-wallet/src/managed_account/managed_core_funds_account.rs::update_balance` +(~line 525-543)**: + +```rust +} else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted { + confirmed += value; +} else { + unconfirmed += value; +} +``` + +`is_confirmed` / `is_instantlocked` are set when a UTXO is first inserted, from the +transaction's `TransactionContext` (`update_utxos`, ~line 241-243, same file). `is_trusted` +is computed just above (~line 182-195) as a recursive Bitcoin-Core-`IsTrusted`-style check: +a self-send's change is only trusted if *every* input it spends is itself +confirmed/IS-locked/already-trusted. + +### 2. Does `platform-wallet` trust this as-is, or re-derive it? + +**Trusts it as-is — no independent tracking layer.** `platform-wallet`'s +`AssetLockManager::build_asset_lock_transaction` +(`packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`) calls straight into +`info.core_wallet.build_asset_lock_with_signer(...)` — `key-wallet`'s own method +(`key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs:256`), which builds via +`TransactionBuilder::set_funding(...).require_final_inputs().build_signed(...)`. +`require_final_inputs` (`transaction_builder.rs:317-318`) filters candidate UTXOs to +`is_confirmed || is_instantlocked` — key-wallet's own flags, read directly, no +re-verification. Likewise `wait_for_proof` +(`packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs`) reads +`key_wallet`'s `TransactionRecord`/`TransactionContext` off the account's own transaction map +(`a.transactions().get(&out_point.txid)`) directly. Platform-wallet has no second opinion +anywhere in this path — if key-wallet's UTXO/transaction bookkeeping is wrong, platform-wallet +has no way to notice. + +### 3. Does the landed fix (417d61da / #836) touch the same code, or a separate guard? + +**Same file, adjacent code, but a different bug than §16's.** `git show 417d61da` touches +exactly `managed_core_funds_account.rs` (the `is_trusted` computation, made recursive — +previously a flat `has_owned_input && change_addr` check that any self-send could satisfy +regardless of whether its own inputs were final) and `asset_lock_builder.rs` +(adds `.require_final_inputs()` to both asset-lock builders). This closes the loophole where +a chain of *unconfirmed* self-sends could compound trust indefinitely (the original H1/§3-§5 +symptom, and the mechanism behind the doc's early phantom-chain sightings). + +**It does not touch, and cannot fix, §16's failure mode.** §16's stale UTXO +(`5c43cd8957…:1`) was genuinely `is_confirmed = true` — a real, once-correct block +confirmation flag, not an `is_trusted` mempool inference — so it already satisfies +`require_final_inputs` and sails past 417d61da's guard entirely. The actual defect there is +in the **removal** side of the same function, `update_utxos`'s spent-outpoint loop +(same file, lines 251-267): + +```rust +self.reservations.release(tx.input.iter().map(|input| &input.previous_output)); +for input in &tx.input { + self.spent_outpoints.insert(input.previous_output); + if self.utxos.remove(&input.previous_output).is_some() { ... "Removed spent UTXO" ... } +} +``` + +This only fires when `update_utxos` is *called* for the spending transaction — i.e. only if +some upstream layer already decided that transaction was relevant to this account. The +function's own author-written comment a few lines above (lines 211-217, present in this file +both before and after 417d61da — untouched by that fix) already flags exactly this class of +risk: + +> "Check if this outpoint was already spent by a transaction we've seen. This handles +> out-of-order block processing during rescan... TODO: This is mostly needed for wallet +> rescan from storage — there is a timing issue with event processing which might lead to +> invalid UTXO set / balances. There might be a way around it." + +§16 is a live instance of exactly that acknowledged, still-open gap: a genuinely-once-real +`is_confirmed` UTXO that was later spent for real (Insight: block 1474746, 35406 +confirmations ago) never got removed from this wallet's local `utxos` map, even after a +from-genesis rescan given ample time to complete. + +### 4. Verdict + +**Primary defect: `key-wallet` (`dashpay/rust-dashcore`), file +`key-wallet/src/managed_account/managed_core_funds_account.rs`, function `update_utxos` +(spent-outpoint removal loop, lines ~251-267; the acknowledged-but-unfixed rescan/event-timing +TODO sits at lines 211-217 in the same function).** This is where a UTXO's local record +should be — but sometimes isn't — invalidated when the network genuinely spends it. + +- **`platform-wallet` is not independently at fault.** It correctly gates asset-lock funding + on `require_final_inputs` (post-417d61da) and has no separate bookkeeping to get wrong — + it fully and reasonably trusts `key-wallet`'s flags. The architecture (platform-wallet + as a thin consumer of key-wallet's UTXO/balance state) is sound; the state it consumes is + sometimes incorrect. +- **`dash-spv` is a plausible contributing factor, not conclusively pinned.** For + `update_utxos` to run at all for a given transaction, some upstream layer must first decide + that transaction is relevant to this account/wallet (address or outpoint match) and hand it + over — that relevance-matching lives either in `key-wallet`'s own `wallet_checker.rs` or + further upstream in `dash-spv`'s compact-filter/block-fetch pipeline. Tracing that boundary + conclusively (e.g. instrumenting exactly why block 1474746's spend of `5c43cd8957…:1` never + reached this account's `update_utxos`) would need live debugging beyond a source read and + wasn't done here — flagging as the natural next step for whoever picks up the upstream fix, + but not required to file the primary issue: `key-wallet`'s own code already documents the + risk class in the exact function where §16's symptom originates. + +--- + +## 19. Independent, key-wallet-bypassing ground-truth balance oracle + +Rather than trust key-wallet's own bookkeeping at all (confirmed unreliable, §10-18), built an +oracle that owes it nothing: derive every candidate address directly from the wallet's public +BIP44-account-0 xpub via a hard, brute-force index cap (no gap-limit/discovery logic — the +exact code path under suspicion), then ask the real network directly. + +**Method:** +1. Derived the account-0 xpub once via `key-wallet`'s own `Wallet::from_mnemonic` (standard, + well-tested derivation math — a different, simple code path from the buggy stateful + UTXO-tracking). The xpub itself is public/watch-only material, safe to use directly. +2. From that xpub alone, brute-force-derived the first **10,000 external + 10,000 internal + (change) addresses** (20,000 total) via `key_wallet::managed_account::address_pool::AddressPool` + with `KeySource::Public` — no wallet-side discovery, no gap limit, just raw index 0..9999 on + each chain. Sanity check: index `external/86` reproduced `yYqF93Sonfe1ETRPim5vATsNdTa4qztyXf`, + the exact address the manual 20 DASH top-up (§16) was sent to — confirms the derivation + matches the real wallet exactly. +3. Queried Insight's bulk `POST addrs/utxo` endpoint (134 batches of ≤150 addresses) for the + real, current UTXO set of all 20,000 addresses. Zero-balance addresses recorded too. +4. Summed confirmed vs unconfirmed satoshis per address, and in total. + +**Result — every one of the 20,000 addresses checked, only 10 hold any funds at all:** + +| chain | index | address | confirmed (duffs) | +|---|---|---|---| +| external | 0 | yXyzNWRRASxYzWwskmqNmb5xFjGc94bn5F | 6,000,000 | +| external | 80 | ybYG86mrDCiHDpu3489WhZTZPvWHVQo2pB | 500,000 | +| external | 82 | yNNpkn11SrLd7X5WVJnKgsY9aVMinv8ZRa | 500,000 | +| external | 84 | yeNdd18uTvXpP3L9TTYwg34E3n1EARJxqo | 2,000 | +| external | 86 | yYqF93Sonfe1ETRPim5vATsNdTa4qztyXf | 2,000,000,000 (the manual top-up, §16) | +| internal | 3 | yib81FfrYMMcLiAcsNraT8Yic5DFsGhBtZ | 447,000 | +| internal | 9 | yYXkbKJ8oRDSct9Zd66wqN1VHN8WbYYXtC | 13,493,322 | +| internal | 31 | yiwj4YDuzTRxpx8WNkP2Xp9rvjZ9D2Y8gm | 11,894,896 | +| internal | 300 | yfjZnRhJo4NVnUGW9TdNKmswbRRA911Hv4 | 2,993,488 | +| internal | 376 | yekuyJS7QNsbV2JFmPu1LgHQSjJ34LWKnV | 1,077,976 | + +**TRUE total: 2,036,908,682 duffs = 20.36908682 DASH confirmed, 0 unconfirmed.** The 9 +non-top-up addresses alone sum to exactly 0.36908682 DASH — matching §10's cold-rescan figure +(0.369 DASH) to 8 decimal places, an exact independent confirmation of that earlier reading. + +**Compared against key-wallet's own report** (§16, "real-fund run 1," logged as +`Framework wallet spendable: 22,998,547,073 duffs` before that run touched anything): + +| | duffs | DASH | +|---|---|---| +| key-wallet reported | 22,998,547,073 | 229.98547073 | +| **True (this oracle)** | **2,036,908,682** | **20.36908682** | +| Phantom discrepancy | 20,961,638,391 | 209.61638391 | + +**Key-wallet's reported balance is 11.29× the true balance** — over 209 DASH that does not +exist anywhere on the real network, across every address this wallet could plausibly hold +funds at (checked directly, not sampled). This corroborates §10's finding independently and at +a different point in time, with zero reliance on key-wallet's own state, and is by far the +strongest single piece of evidence for the upstream bug report: a third party can rerun this +exact check against the real chain and get the same answer. + +**Artifacts** (full per-address data, not reproduced inline here): +- `/data/artifacts/dash-evo-tool/2026-07-07/framework-wallet-addresses.csv` — the 20,000 + brute-force-derived addresses (chain, index, address). +- `/data/artifacts/dash-evo-tool/2026-07-07/framework-wallet-ground-truth-per-address.csv` — + full per-address result (chain, index, address, confirmed duffs, unconfirmed duffs, UTXO + count), including all zero-balance addresses. +- `/data/artifacts/dash-evo-tool/2026-07-07/ground_truth_oracle.py` — the query script. + +**Scope note:** checked BIP44 standard account 0 only — the account `CoreTask:: +CreateRegistrationAssetLock`/`CreateTopUpAssetLock` actually fund from. Other account types +the wallet may hold (CoinJoin, DashPay, identity-registration keys) were out of scope; they +don't participate in asset-lock funding and aren't relevant to the FinalityTimeout symptom +this investigation is about. + +--- + +## 20. Root mechanism, forensically traced and deterministically confirmed: out-of-order rescan processing + +Two live-network synthetic-repro attempts against `rust-dashcore`'s `repro/pr3549-rdc` branch +both came back green (a single continuous sync, then a two-phase persisted-storage reload — +see that repo's commit history, `4708d6f2`/`3b1d3117`/`0bd80444`). Per direction, stopped +guessing structures and traced the actual real failure instead. + +**Forensic trace** (`/data/tmp/backend-e2e-tc004-coldstart-JyD9Vy.log`, the cold rescan behind +§10/§16's findings): during this rescan, block heights are processed in **essentially +arbitrary order** — not just adjacent-block reordering, but jumps across thousands of blocks +(e.g. height 1,479,816 followed immediately by 1,480,137, 1,480,358, 1,485,774–1,485,793, then +back down to 1,477,157, 1,477,190, 1,477,191). This is `key-wallet-manager`'s `parallel-filters` +feature completing matches in whichever order worker threads finish, not height order. + +For the specific failing outpoint (`5c43cd8957…:1`): +- Its real spend (block **1,474,746**, txid `f9ca52d513…`) was logged as processed at + **08:45:45.155707Z**, with the spending transaction showing `sent=0 DASH` — the wallet did + **not** recognize this input as its own at that moment, because the funding UTXO did not + exist in `self.utxos` yet. +- The transaction that **created** that UTXO (block **1,474,688**, 58 blocks *earlier* on-chain) + was processed **0.87 seconds later**, at **08:45:46.028186Z** — and was inserted as a fresh, + spendable UTXO (`WalletEvent: BlockProcessed(height=1474688, …, inserted=1, …)`) despite its + own spend having already been observed moments before. +- Both blocks belong to the **same** committed batch (`1474001-1479000`, committed at + 08:45:46.735920Z) — this is an **intra-batch** ordering defect, not the cross-batch + "committed ranges never reopen" mechanism from the CoinJoin gap-limit precedent already on + `repro/pr3549-rdc` (`coinjoin_gap_discovery_tests.rs`). Related in theme (rescan reordering + breaking wallet-state invariants), confirmed to be a **different specific mechanism** by + direct log evidence, not the same bug wearing a different hat. + +**`update_utxos` (`managed_core_funds_account.rs`) already has a guard for exactly this +ordering** — `is_outpoint_spent(&outpoint)`, checked against `self.spent_outpoints`, which is +populated unconditionally for every input of every processed transaction (regardless of +whether that input is recognized as one of the account's own UTXOs). Per the log evidence, this +guard did not prevent the funding output from being inserted. + +**Deterministic, synthetic confirmation** (`key-wallet-manager/tests/ +out_of_order_spend_repro_test.rs`, committed `906fd47d` on `repro/pr3549-rdc`): built a +minimal, no-mnemonic, no-network test reproducing the *exact same order* — process the +spending block, then the funding block — via `key-wallet-manager`'s public +`WalletInterface::process_block_for_wallets`. **Result: RED, confirmed by actually running it +(`cargo test`, ~0.07s), twice (before and after `cargo +nightly fmt`).** The funding outpoint +remains in the wallet's tracked UTXO set after this exact ordering. This is the first fully +self-verified (no classifier block, no real secret, no live network) reproduction in this +entire investigation — a third party can run it immediately with zero setup. + +This settles the "is this the same bug as the CoinJoin precedent" question precisely: **no, +related theme, different mechanism** — ours is intra-batch processing-order, theirs is +cross-batch commit-pruning. Both point to the same broader class of defect (rescan/event +delivery ordering assumptions that `key-wallet`'s wallet-state bookkeeping does not correctly +handle), but are independent bugs requiring independent fixes. + +--- + +## 21. Post-merge retest: DET's display-layer fix does not touch this defect + +A separate session fixed an unrelated DET display-layer bug (balance categorization from +authoritative upstream data — `9c0f94c1`/`077bf73b`/`12ec5bce`/`7392ca47`, merged clean into +`feat/identity-onboarding-ux` at `5e902024`). Working theory: no effect on the asset-lock +finality defect (root cause is upstream in `key-wallet`'s UTXO tracking, unrelated to DET's +display/summary code) — verified rather than assumed. + +Reran `test_tc004_create_registration_asset_lock` on the merged HEAD (same shared workdir, +same instrumentation). **Still fails, same mechanism:** + +- `FinalityTimeout(OutPoint { txid: 0x4af391d7…, vout: 0 })`, 317.51s — same shape as every + prior attempt. +- Funding input `9644cccb55a8db7953b1e79d6fb59f6e3aee6b5b76d89a48e9d7de546b12fa76:1` (value + 47.99899548 DASH) — confirmed via Insight as genuinely spent for real at block **1,479,050**, + **31,106 confirmations** (~54 days) before this attempt. +- The new asset-lock tx itself (`4af391d7…`): **Not found** on Insight — phantom, same fate as + every prior attempt. +- Reported "Framework wallet spendable" at test start: **24,998,446,776 duffs (≈249.98 + DASH)** — still wildly inflated vs. the ~20.37 DASH true balance (§19); the display fix + doesn't touch the underlying reconciliation, only how already-wrong numbers are categorized + for the UI. + +**Verdict: confirmed, not assumed — the display-layer fix has zero effect on this defect.** +Same failure mode, same class of stale-already-spent-UTXO selection, a third independent +instance (different txid/height each time) of the exact mechanism traced in §20. diff --git a/docs/ai-design/2026-07-08-secret-decrypt-dedup/README.md b/docs/ai-design/2026-07-08-secret-decrypt-dedup/README.md new file mode 100644 index 000000000..c2cbdb12f --- /dev/null +++ b/docs/ai-design/2026-07-08-secret-decrypt-dedup/README.md @@ -0,0 +1,58 @@ +# Secret-decrypt dedup (audit R2, Wave 1 — CODE-003) + +**Date:** 2026-07-08 +**Scope:** `model/wallet/encryption.rs`, `wallet_backend/{secret_access,single_key_entry}.rs` + +## Decision: local dedup (route a), NOT the upstream per-secret migration (route b) + +CODE-003 flagged the AES-256-GCM decrypt sequence (derive Argon2 key → init cipher +→ checked-nonce → decrypt) as copy-pasted across three readers: + +- `wallet_backend/secret_access.rs::decrypt_hd_seed` — HD-seed migration reader +- `wallet_backend/single_key_entry.rs::SingleKeyEntry::decrypt` — imported single-key reader +- `model/wallet/encryption.rs::ClosedKeyItem::decrypt_seed` — deprecated `src/database` seed store + +The triage rationale proposed adopting the upstream platform per-secret encryption +(XChaCha20-Poly1305 Tier-2 via `SecretStore::set_secret`/`get_secret`, already wired +in `secret_seam.rs` as `put_secret_protected`/`get_secret_protected`) and deleting the +local code. + +**We did NOT do that, deliberately.** Those three AES-GCM decrypts are the **legacy +migration reader** for wallet secrets already written to users' disks in DET's own +AES-GCM envelope format. Two authoritative module docs establish this: + +- `secret_seam.rs` module doc: *"The only remaining legacy decrypt (for not-yet-migrated + AES-GCM secrets) lives in the legacy reader."* +- `wallet_seed_store.rs` module doc: the `envelope.v1` row *"is retained DECODE-ONLY as a + migration reader … rewritten to the raw label on the first load/unlock and then deleted."* + +The Tier-2 primitive already exists and the lazy legacy→Tier-2 re-wrap already runs in +`secret_access::decrypt_jit`. Route (b) is therefore a **data migration** whose write side +is already built — the readers must stay until every user's on-disk secret has been +re-wrapped. Deleting them would lock users out of existing wallets. + +## What was done (route a) + +Extracted one crate-private `decrypt_message(ciphertext, salt, nonce, password, site)` +in `model/wallet/encryption.rs`, returning `Zeroizing<Vec<u8>>` and a two-variant +`DecryptError` (`WrongPassword` = AEAD auth failure; `Malformed` = structural/corrupt). +All three readers route through it, mapping the two variants to their existing domain +errors. Behavior is preserved exactly (same `TaskError` mapping for wrong-password vs +corrupt-blob; same length validation at each caller). Structural diagnostics are logged +once inside the helper with a `site` field. + +## Consequence for Wave 16 CODE-087 + +**CODE-087 still applies, unchanged.** It targets the `(Vec<u8>, Vec<u8>, Vec<u8>)` triple +returned by `encrypt_message` (+ its `type_complexity` allows) in +`model/wallet/encryption.rs`. Route (a) keeps `encryption.rs` and `encrypt_message` +exactly as-is, so the named-struct cleanup CODE-087 asks for is neither resolved nor +mooted by this change — schedule it as originally planned. + +## If route (b) is ever scheduled + +It is a standalone migration task, not a dedup: extend the existing lazy re-wrap so the +`ClosedKeyItem` (deprecated `src/database`) path is also migrated, confirm all three +legacy formats have a re-wrap path, keep the readers until telemetry/versioning shows no +un-migrated secrets remain, then delete the readers and `encryption.rs` together. Route (a) +does not block route (b). diff --git a/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md b/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md new file mode 100644 index 000000000..a55d32e0d --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md @@ -0,0 +1,751 @@ +# Masternodes Page — Requirements + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` (based on PR #873) · **Date:** 2026-07-09 +**Phase:** Requirements + UX (planning only; no Rust changes) +**Author:** Diziet (Product Designer) + +Companion artifacts: `02-ux-spec.md` (journeys, wireframes), `wireframes.html` (visual mock). +Source of truth for the split rationale: `../identity-hub-parity-audit.md` § "Masternodes as a separate page". + +--- + +## 1. Executive Summary + +**Problem.** Masternode and evonode identity handling is buried inside the generic +*Identities → Load Identity* screen, reachable only by ticking *Show Advanced Options* and +selecting an identity type. That screen serves three audiences through one radio-plus-conditional +form. The masternode/evonode arm is a distinct job for a distinct audience — node operators whose +real payoff is **DPNS contested-name voting** — yet today these identities land in the same table, +and worse, they leak into the everyday-user **Identity Hub** picker where they are offered +user-centric actions (register a username, edit a social profile, add a contact) that are +meaningless for a collateral/voting identity. + +**Solution direction.** Add a dedicated left-nav root tab **"Masternodes"** with a **card layout** +that (a) owns a masternode/evonode-only load flow lifted from the advanced-options arm, (b) lists +loaded masternodes/evonodes as cards showing ProTxHash, type, voter-identity readiness, key status, +and DPNS-voting status, (c) opens a per-node detail/voting view, and (d) is paired with a filter +that keeps masternode/evonode identities **out of the Identity Hub / Identities pickers** so those +surfaces stay everyday-user only. + +**Key actors.** Priya (Power User / masternode operator) is the primary actor. Alex (Everyday User) +is a *contrast* actor — the design must keep the operator surface out of Alex's way and vice versa. + +**No model changes.** `IdentityType` (User/Masternode/Evonode), `associated_voter_identity`, and the +three-way `PrivateKeyTarget` already provide the seam. This is a new view + filter + card layout over +existing model and backend plumbing, not a rewrite. + +--- + +## 2. Stakeholder & Actor Analysis + +### 2.1 Primary actor — Priya (Power User / masternode operator) + +Canonical persona: `docs/personas/power-user.md`. Priya runs a Dash masternode, manages multiple +wallets, and understands ProTxHash, DIP3 owner/voting/operator keys, and derivation paths. + +| Field | Value | +|---|---| +| **Goal** | Load her masternode/evonode identities into DET and use them to vote on DPNS contested names. | +| **Pain today** | The load path is buried behind *Show Advanced Options*; her nodes then sit in the same table as everyday user identities and appear in the Identity Hub with nonsensical user actions. | +| **Success metric** | Time to check masternode key paths **under 10s** (persona success metric); load a node and reach its voting view without touching the generic identity flow. | + +### 2.2 Contrast actor — Alex (Everyday User) + +Canonical persona: `docs/personas/everyday-user.md`. Alex never operates a node. + +| Field | Value | +|---|---| +| **Goal** | Manage a personal identity (username, credits, DashPay). | +| **Relevance** | Alex must **never** be shown masternode load fields or see masternode identities in the Identity Hub picker. The Masternodes tab is a self-contained operator surface Alex can ignore. | + +### 2.3 Secondary / supporting + +- **DPNS contested-name voting** (backend `contested_names/vote_on_dpns_name.rs`, DPNS root screens): + the downstream consumer of masternode voter keys. The Masternodes page hands off to / surfaces this. +- **Wallet subsystem** — the active wallet on the Masternodes page is the **funding source for Top up** + (FR-9), not a key-derivation source. **Correction (investigated 2026-07-09):** voting/owner/payout keys + cannot be auto-derived from a wallet — `derive_keys_from_wallets` is hard-gated to `IdentityType::User` in + `backend_task/identity/load_identity.rs`; masternode keys are Core-side (tied to the node's ProRegTx), not + part of any wallet's identity-auth HD tree. The "Try to derive from loaded wallet" checkbox does NOT carry + over to the load form — see §9 note and US-6 retirement. +- **Secret seam / at-rest storage** — owner/voting/payout keys **enter unprotected (Tier-1 keyless)** at load + time because the load flow has no password field (by design; confirmed also for the `identity_masternode_load` + MCP tool). They are **not permanently plaintext**: a loaded node's keys can be sealed to **Tier-2 per-identity** + encryption afterward via the existing `IdentityTask::ProtectIdentityKeys` (Argon2id + XChaCha20-Poly1305, + per-secret object-password envelope; today reached from the Key Info screen's "Add password protection…"). + See `wallet_backend/secret_seam.rs` and `docs/ai-design/2026-06-19-secret-storage-seam/`. The design's job is + to (a) make this accurate to the user via a non-blocking awareness note, and (b) surface the "add protection" + affordance on the Masternodes page — **not** to design the crypto (which already exists). + +--- + +## 3. Domain Notes + +- **ProTxHash is the identifier.** For masternode/evonode identities the "Identity ID" is the ProTxHash, + conventionally **hex**-encoded (`IdentityType::default_encoding` → Hex for MN/Evonode vs Base58 for User). + The page must label the field **"ProTxHash"**, not "Identity ID". +- **Two node types.** `Masternode` (regular) and `Evonode` (HPMN / high-performance). Model discriminates + via `IdentityType`. Badge colours already exist: Masternode → `PLATFORM_PURPLE`, Evonode → `DASH_BLUE` + (`identity_picker_card.rs::draw_type_badge`). +- **Three key roles (DIP3).** Voting Private Key, Owner Private Key, Payout Address Private Key — the exact + three inputs in the current advanced arm (`add_existing_identity_screen.rs:420-434`). All are optional at + load time; without them the node is view-only. +- **A masternode carries a separate voter sub-identity.** `associated_voter_identity: Option<(Identity, IdentityPublicKey)>`. + Its presence is what enables voting; its absence is the `NoVotingIdentity` error at vote time. The card must + surface **voter-identity readiness** as a first-class status. +- **The real workflow is voting.** A masternode identity exists in DET primarily to vote on DPNS contested + names (and hold owner/payout keys). Vote choices are **Abstain**, **Lock**, or **vote for a candidate**. +- **Identity status** (`IdentityStatus`: Active / Unknown / PendingCreation / NotFound / FailedCreation) applies + and already has a colour mapping (green/gray/orange/red). Reuse it as a status dot on the card. +- **Key-protection tier is per-identity, opt-in.** MN/Evonode keys load as Tier-1 (unprotected/keyless) — the load + form has no password field by design — and can be upgraded to Tier-2 (per-identity password protection) later + via the existing `IdentityTask::ProtectIdentityKeys`. The Masternodes page surfaces this state and the upgrade + affordance; it does not implement encryption. Protection is gated by the vault-key scheme, not identity type. +- **Provider withdraw destination rule.** Withdrawing a provider identity's credits with the **owner** key forces + the destination to the node's **registered Core payout address**; with the **transfer/payout** key the + destination is a **free** address. Existing withdraw-flow behaviour (FR-9) — surfaced, not redesigned. +- **Add-key purpose rule (all identity types).** The add-key purpose selector **excludes OWNER and VOTING** — + Core-registered provider roles that cannot be added via Platform for any identity type. TRANSFER / AUTHENTICATION + / ENCRYPTION / DECRYPTION are addable (FR-10). +- **Evonode token rewards are Evonode-only** (protocol rule). Plain Masternodes have none, so FR-11's "Claim token + rewards" cross-link appears only for `IdentityType::Evonode`. + +--- + +## 4. Functional Requirements + +### FR-1 — Masternodes root tab *(Expert Mode gated — decision, 2026-07-09)* +A new left-nav root entry **"Masternodes"** (icon + label, matching the existing rail style), placed +adjacent to Identities / Identity Hub. Selecting it shows the Masternodes page. It persists as a root +screen and survives network switches like other root tabs. + +**Visibility gate:** the entire tab — nav item AND screen access — is shown only when **Expert Mode** is +ON (`app_context.is_developer_mode()`, user-facing label "Expert mode" per `network_chooser_screen.rs:607`). +With Expert Mode off, the nav item does not render at all (not just disabled/hidden-behind-a-click) and the +route is unreachable, matching how other expert-only surfaces are gated in this codebase (`FeatureGate::DeveloperMode`, +`model/feature_gate.rs:69`). Rationale: masternode/evonode operation is a distinct, node-operator audience +(Priya persona) — Expert Mode is the existing mechanism DET already uses to separate that audience from +Alex (Everyday User), so this reuses an established pattern rather than inventing a new one. + +### FR-2 — Empty state +When no masternode/evonode identities are loaded, show a centered card empty state (matching the +*No Identities Loaded* pattern in `03-identities-empty.png`) explaining what a masternode identity is +for (voting on DPNS contests, holding owner/payout keys) and offering a primary **"Load a masternode"** +action. Include the reassurance line about node connectivity in the existing empty-state voice. + +### FR-3 — Card list of loaded masternodes +Present loaded masternode/evonode identities as a responsive card grid (reusing the +`identity_picker_card.rs` visual language: rounded surface card, monogram, type badge pill). Each card shows: +- **Identifier** — shortened ProTxHash (heading), with alias above it when set. +- **Type badge** — Masternode (purple) / Evonode (blue). +- **Voter-identity readiness** — "Voting ready" (voter identity present) or "No voting key" (absent). +- **Key status** — which of Voting / Owner / Payout keys are loaded (compact indicator, e.g. `V O P` + with present keys emphasised). +- **DPNS-voting status** — a short line: open contests available to vote on, or scheduled/last vote state. +- **Identity status dot** — from `IdentityStatus` (Active/Unknown/NotFound…). +- The whole card is a single click target → opens the detail/voting view (FR-5). + +### FR-4 — Load a masternode/evonode +A dedicated load flow (extracted from the advanced-options arm) with fields: +- **ProTxHash** (required) — labelled and hinted as ProTxHash; accepts hex or Base58. +- **Node type** — segmented toggle Masternode / Evonode (replaces the buried combo box; no "User" option here). +- **Alias** (optional) — local-only label, explicitly "not saved to Dash Platform". +- **Voting Private Key**, **Owner Private Key**, **Payout Address Private Key** — WIF or hex, all optional, + always pasted manually (no auto-derive — see US-6 retirement in §9, these key roles cannot be derived from + any wallet). On Testnet with a `.testnet_nodes.yml` fixture present, a **"Fill Random Masternode/Evonode"** + dev-convenience button (FR-12) can autofill this section from a real test node. +- **Encryption password (optional)** — an optional password field (WIF-style show/hide eye + helper line). When + set, the entered voting/owner/payout (and identity) keys are **sealed encrypted-at-rest at load time** (Tier-2); + when left blank, keys load unprotected (Tier-1 keyless / obfuscation-only) and can be protected later from the + Key Info screen. See **FR-8** for the plumbing this requires. Copy in §7. +- **Key-storage awareness** — a non-blocking inline note (Warning tone, not a blocking gate) explaining that, + without a password, keys are stored unencrypted (obfuscation-only) and protection can be added later. Copy in §7. +- Primary **Load masternode** action, disabled until a ProTxHash is entered, with a disabled-tooltip + explaining why (per `ResponseExt::disabled_tooltip`). + +### FR-5 — Masternode detail / voting view +Opening a card shows a detail view with: +- Header: alias (if any) + shortened ProTxHash + type badge + copy-ProTxHash affordance + identity status. +- **Keys summary** — Voting / Owner / Payout presence, voter-identity ID (shortened, copyable), and the + **protection tier** (Unprotected / Password-protected). When Tier-1, offer an **"Add password protection…"** + action that dispatches the existing `IdentityTask::ProtectIdentityKeys` (no new crypto). This is the recourse + the load-form awareness note points to. Also a **"Manage keys ›"** drill-in (FR-10) into the existing + `KeyInfoScreen`. +- **DPNS voting section** — a **collapsing section, collapsed by default**, with the open-contest **count in + the header** (`DPNS name contests to vote on (3)`) so operators see there's something to act on without + expanding. Expanded, it lists active contested names this node can vote on, each with the three choices + (Abstain / Lock / vote for a candidate identity), plus scheduled and past votes where available. This surfaces + / hands off to the existing DPNS voting backend; it does not re-implement voting logic. +- **Credit actions row** — Withdraw / Top up / Transfer (FR-9), for both Masternode and Evonode. +- **Token rewards cross-link** — **Evonode only** (FR-11): "Claim token rewards ›" routing to the existing + `ClaimTokensScreen`. Hidden for plain Masternode. +- **Remove** — a destructive action (danger button, confirmation dialog) that forgets the masternode from + DET and also removes its associated voter identity (existing behaviour). + +Grouping (top → bottom, to avoid clutter): header · credit-actions row (with the Evonode-only token-rewards +link) · Keys (with "Manage keys ›") · collapsible DPNS voting · Remove. + +### FR-9 — Credit actions on the detail view (Withdraw / Top up / Transfer) *(reuse existing screens)* + +The detail view exposes the node's credit operations as an actions row/dropdown (mirroring the legacy Identities +**Actions** affordance), for **both Masternode and Evonode**: +- **Withdraw** → reuse `withdraw_screen`, scoped to the selected node's `QualifiedIdentity`. +- **Top up** → reuse `top_up_identity_screen`, scoped to the node. +- **Transfer** → reuse `transfer_screen`, scoped to the node. + +**MN/Evonode-specific withdraw behaviour (document, don't redesign):** withdrawing with the **owner** key forces +the destination to the node's **registered Core payout address**; withdrawing with the **transfer/payout** key +allows a **free** destination address. This is existing behaviour of the withdraw flow for provider identities — +surface it, don't reimplement it. + +*Reuse note (Nagatha):* no new operation screens — pass the selected node's `QualifiedIdentity` into the three +existing screens. The only new work is the entry points (row/dropdown) on the detail view. + +### FR-10 — Key-management drill-in (`KeyInfoScreen`) *(reuse existing screen)* + +The Keys section offers **"Manage keys ›"** opening the **existing** `KeyInfoScreen` for the node: view private +key / WIF, sign message, add key, remove key, protect keys. + +**Add-key constraint (not MN-specific — document it):** the add-key **purpose selector excludes OWNER and +VOTING** (those are Core-registered provider roles, un-addable via Platform for **any** identity type). +TRANSFER / AUTHENTICATION / ENCRYPTION / DECRYPTION are addable. This is an existing platform rule, surfaced here +so the operator isn't surprised. + +*Reuse note (Nagatha):* route to the existing `KeyInfoScreen` scoped to the node; no new key UI. + +### FR-11 — Evonode token-rewards cross-link (`ClaimTokensScreen`) *(Evonode only, reuse existing screen)* + +On the detail view of an **Evonode** identity (NOT a plain Masternode), show **"Claim token rewards ›"** routing +to the existing Tokens **`ClaimTokensScreen`** scoped to that identity. **Hidden for Masternode.** + +- Evonode-only is a **protocol rule** — a plain Masternode simply has no such rewards, so the action is shown + only when `identity_type == Evonode`. +- Do **not** rebuild any claim UI on the Masternodes page — it is a cross-link/route only. + +*Reuse note (Nagatha):* conditional entry point (`Evonode` only) that routes to `ClaimTokensScreen` with the +node's identity; no new claim logic. + +### FR-12 — "Fill Random Masternode / Evonode" dev convenience (Testnet only, reuse existing logic) + +Carry forward the existing dev-only quick-fill on the load form (FR-4): a single button, labelled to match the +Node-type toggle ("Fill Random Masternode" / "Fill Random Evonode"), that picks a random **real** testnet node +from a local `.testnet_nodes.yml` fixture and autofills ProTxHash + keys (**Voting + Owner for a Masternode; +Voting + Owner + Payout for an Evonode** — the regular-masternode fixture struct `MasternodeInfo` carries no payout +key, so `fill_random_masternode()` fills Voting + Owner only, verified `add_existing_identity_screen.rs:30-35,979-993`). +It does **not** fabricate a synthetic node — it is a curated-fixture quick-fill for developer testing, same as today. + +- **Source (investigated 2026-07-09):** `add_existing_identity_screen.rs:203-208` — `fill_random_masternode()` + (lines 979-993) and `fill_random_hpmn()` (lines 961-977), reading `load_testnet_nodes_from_yml(".testnet_nodes.yml")` + into a `TestnetNodes { masternodes, hp_masternodes }` fixture struct. +- **Fixture facts (verified 2026-07-09):** `.testnet_nodes.yml` is **gitignored** (`.gitignore:17`), not tracked + in git, and does **not exist** in this repo — it is not shipped, not hardcoded, and not something we currently + have. It **does contain real private keys in plaintext** (`KeyInfo.private_key` for owner/voter/payout, + `serde_yaml_ng`-parsed). It must be supplied locally by the developer; the loader returns `Ok(None)` + gracefully only when the file is **absent** (no error, no crash). A **malformed** file returns `Err(_)`, which the + *legacy* screen banners (`add_existing_identity_screen.rs:151-161`); the new Masternodes load form must map + `Err(_)` → absent button (swallow, no banner) — a deliberate divergence, not verbatim reuse (see test-spec TC-FR12-04). +- **Visibility — MUST be conditional on fixture presence (decision, 2026-07-09):** the button does not render + at all unless `load_testnet_nodes_from_yml(...)` returns `Some(_)` — never shown-but-disabled. On networks + other than Testnet, or when the file is missing/unparseable, the button and its row are simply absent from + the load form; no placeholder, no error state. +- **Gating today (current app):** visible when `show_advanced_options` is on + `network == Testnet` + the yml + fixture loaded successfully. **Not gated by `developer_mode`** on the current screen. +- **Gating on the new page — simplified by FR-1's Expert Mode gate:** because the entire Masternodes tab now + requires Expert Mode (FR-1) to even be reached, an additional per-button `developer_mode` check would be + redundant for normal navigation. The button's own remaining condition is just **Testnet + fixture present** + (dropping the now-unreachable `show_advanced_options` concept, which doesn't exist on this page — see FR-4). + Flag for Nagatha: confirm whether a defense-in-depth `developer_mode` check is still worth adding at the + button call-site (cheap, guards against any future non-nav entry point to this screen) — implementation + judgment call, not re-litigated here. + +*Reuse note (Nagatha):* reuse `fill_random_masternode()`/`fill_random_hpmn()` and the `.testnet_nodes.yml` +loader verbatim; only the entry point (one button instead of two, label follows the Node-type toggle) and the +condition (fixture-presence check controlling render, not just enabled state) are new. + +### FR-6 — Filter masternode/evonode out of user-only pickers +Masternode/evonode identities (`IdentityType != User`) must **not** appear in the Identity Hub picker or the +generic Identities user-identity surfaces. This is the paired correction that keeps the everyday-user surface +coherent (audit § "1 new latent finding"). This is a filter, not a data migration. + +**Extension (decision, §10.2, 2026-07-09):** once FR-4 ships, remove the Masternode/Evonode options from the +legacy buried arm (`add_existing_identity_screen.rs`'s Identity Type dropdown under Show Advanced Options) — +that dropdown becomes User-only. This is a removal, not just a filter, and it prevents two competing entry +points for loading the same kind of identity. + +### FR-7 — Refresh +Provide a refresh affordance (top-right, matching `01-dashpay.png` header Refresh button) that re-fetches +masternode identity + voting state. + +### FR-8 — Optional load-time key encryption password *(needs NEW plumbing — implementation scope)* + +The load form (FR-4) offers an **optional "Encryption password"** field. Its behaviour: +- **Blank (default):** keys load **Tier-1 keyless** (obfuscation-only, not confidential) — current behaviour. The + user can seal them later via the Key Info screen's "Add password protection…" (existing + `IdentityTask::ProtectIdentityKeys`), also surfaced on the Masternodes detail view (FR-5). +- **Set:** the entered voting/owner/payout (and identity) keys are **sealed Tier-2 at load time** + (`put_secret_protected` / `store_protected` — Argon2id + XChaCha20-Poly1305, per-secret object-password + envelope) instead of only post-load. + +**This is not a pure view change — it requires NEW plumbing (flag for the implementation plan / Nagatha):** +- `backend_task/identity/mod.rs::IdentityInputToLoad` (struct at `mod.rs:43`) currently has **no password field** + — it carries only `voting_private_key_input` / `owner_private_key_input` / `payout_address_private_key_input` + (`Secret`), `keys_input`, `derive_keys_from_wallets`, `selected_wallet_seed_hash`. A new optional password + field must be added. +- `backend_task/identity/load_identity.rs` currently persists loaded keys unprotected. When a password is + present it must route the persist through the **existing** seal path (`store_protected` / + `put_secret_protected`, as used by `protect_identity_keys.rs:225` and `add_key_to_identity.rs:265`) so keys + land Tier-2 at load — rather than only reachable post-load via `ProtectIdentityKeys`. +- No **new crypto**: reuse the existing protected-secret envelope. The work is threading the password param + through `IdentityInputToLoad` → `load_identity` → the seal path, and validating it (non-empty when the box is + used; the field is entirely optional). +- **MCP scope (decision, 2026-07-09):** the third `IdentityInputToLoad` constructor — the MCP + `masternode_identity_load` tool (`src/mcp/tools/masternode.rs:180`, a confirmed keyless entry point, §2.3) — + passes `encryption_password: None` this iteration (Tier-1 unchanged; FR-8 is GUI-scoped). A `TODO` records + headless password parity as a follow-up. +- **Model/backend rules:** password is a `Secret`, never logged, never stored; validation of the plaintext + password (e.g. min length, if any) belongs in `model/`, enforcement in the backend task per DET layering. + +### FR-GLOBAL-NAV — Global wallet/identity switcher on every root page *(cross-cutting app-shell)* + +> **Scope note.** This is a **cross-cutting app-shell change larger than the Masternodes page** — it touches +> every root screen's top panel, not just Masternodes. It is recorded here because it is the **foundation the +> Masternodes page sits inside**: the Masternodes tab must render the same global chrome as every other tab. +> Implementation should be scheduled as its own app-shell task; the Masternodes page consumes it. + +The wallet + identity breadcrumb switcher (`src/ui/identity/breadcrumb_switcher.rs`, IDH-003) — today rendered +**only** on the Identity Hub (`hub_screen.rs:195`) — becomes a **global top-nav rendered on every root page** +(Dashpay, Identities, Identity Hub, Masternodes, Contracts, …) through the shared top panel. This realizes the +original IDH-003 design intent, which already states the breadcrumb "is always visible in the topbar of **every +tab**" (design-spec `2026-04-22-identity-dashpay-redesign/design-spec.md` §A.3) but was only wired into the Hub. + +- **FR-GLOBAL-NAV-1 — One switcher, everywhere.** Every root screen renders the three-segment switcher + (`Identities › 💼 wallet › 👤 identity`) in the top island's left region via + `top_panel::add_top_panel_with_breadcrumb` (the seam already exists — both `add_top_panel` and the breadcrumb + variant delegate to the same `render_top_island`). Behavior, styling, tooltips, dropdowns, and placeholder + rules are identical on every page (design-spec §A.3 / §7 / §D). +- **FR-GLOBAL-NAV-2 — Selection interaction model (authoritative).** The switcher reads/writes the **app-scoped** + selection (`AppContext::selected_wallet_hash` / `selected_identity_id`; `HubSelection` holds only within-session + search buffers). Four rules govern how it behaves on every page: + 1. **Silent context change.** Selecting an object (wallet / identity / whatever the page exposes) in the top-nav + silently updates the app-global selection. **No forced navigation** to another tab. *(This supersedes the + earlier "route to Identity Home on identity selection" framing — see §9 resolved question.)* + 2. **Two-way binding where the page consumes the selection.** If the active page actively uses a selected + object, the top-nav pill and the page stay in sync **both ways**. Canonical example: on a + Send/Transfer-from-wallet page, changing the wallet in the top-nav changes the page's source wallet, and + changing the source wallet on the page updates the top-nav pill. Same for identity where a page consumes it. + 3. **Blast-radius control (rollout strategy).** The nav renders on every page immediately, but a given pill is + **interactive only on pages already wired to consume that selection**. On pages not yet wired, that pill + renders **disabled/read-only** — dimmed, no caret, **no visible text tag**; the explanation lives in a + **hover tooltip that tells the user how to change that selection** (e.g. "Change the active wallet from the + Wallets tab", "Updates when you open a masternode"). Leave a `TODO` in code to wire it later. Interactivity + rolls out page-by-page — no requirement to wire every page at once. + 4. **Per-page composition.** Show only the pills that make sense for the page. A page with no identity context + (e.g. a Wallet page) shows **only the wallet pill**, no identity pill. The switcher is composable per page: + `[wallet]`, `[wallet + identity]`, etc. +- **FR-GLOBAL-NAV-3 — The third pill is the identity/object relevant to THIS page's context (page-scoped).** + The third segment is not hard-wired to "the app-global User identity" — it is **whatever identity/object the + current page operates on**: + - **On everyday-user pages** (Dashpay / Identities / Identity Hub): the app-global **User** identity. + - **On the Masternodes page**: the **page-scoped masternode/evonode in view** (`Ⓜ mn-east-01 ▾`). Its dropdown + lists the loaded masternode/evonode identities; it is **interactive and two-way bound** with the card grid + and the detail view (opening a card sets the pill; picking from the pill opens that node). + + On the Masternodes page **both pills are interactive**: the **wallet pill** (funds Top up on this node — FR-9, + two-way bound; NOT a key-derivation source, see §9 auto-derive correction) **and** the **masternode pill** + (the node in view). + + **Why this does NOT violate FR-6 (the boundary — read carefully).** FR-6 forbids MN/Evonode identities from + appearing in the **everyday-user Identity Hub / Identities picker**. The masternode-in-view selection here is a + **separate, page-scoped selection**, distinct from the app-global User-identity selection. Picking a masternode + in the Masternodes switcher does **not** write the app-global user-identity selection and therefore **never** + makes that masternode appear in the identity pill on user pages, nor in the Hub picker. The Masternodes page's + own switcher is not the everyday-user picker FR-6 governs. Two selections, two scopes, one clean boundary. +- **FR-GLOBAL-NAV-4 — Selection filtering already respects FR-6.** Because FR-6 filters MN/Evonode out of the + identity picker/dropdown, the global identity pill's dropdown lists only User identities on every page — no new + leak is introduced by making the switcher global. +- **FR-GLOBAL-NAV-5 — Sub-screen navigation stays in the content panel.** Pushed sub-screens (the load form, a + masternode detail view) show their own lightweight back row **inside the content panel** (e.g. `‹ All masternodes`), + keeping the global switcher single-line and unchanged across navigation (design-spec §A.3 "the topbar stays + single-line"). +- **FR-GLOBAL-NAV-6 — Leftmost breadcrumb is page-aware.** Segment-1 reflects the active tab and links to its + root (`Masternodes › 💼 wallet › 👤 identity`) — confirmed answer to the earlier Q1. + +**Flag for the implementation plan (Nagatha):** the mechanism is a **per-page capability declaring which +selections it consumes**, plus a **two-way binding** between that page and the selection, with +**disabled/tooltip rendering + `TODO` markers** on pages not yet wired. Crucially, the set of selections is more +than one axis: there is the **app-global User-identity selection** (consumed by the everyday-user pages) **and a +distinct page-scoped masternode/evonode selection** (consumed by the Masternodes page). These must be kept +separate so a masternode choice never bleeds into the app-global user-identity selection (FR-6 boundary). This is +the blast-radius-limited rollout — the nav appears everywhere on day one; interactivity per pill lands +page-by-page. + +**Acceptance criteria (US-7 below).** This requirement is **Should** for the Masternodes deliverable but **Must** +as a shared prerequisite: the Masternodes page cannot ship its header without the global switcher existing. + +--- + +## 5. Non-Functional Requirements + +- **NFR-1 Reuse, do not reinvent.** Reuse `identity_picker_card.rs` (card + badge), the empty-state card + pattern, `StyledButton`/`ComponentStyles` buttons, `MessageBanner`, breadcrumb header, and the left nav + rail. Consult `src/ui/components/README.md` before adding any new widget. +- **NFR-2 No model changes.** No changes to `IdentityType`, `associated_voter_identity`, `PrivateKeyTarget`, + or the DPNS voting backend. The page is a view + filter over existing types. +- **NFR-3 Design tokens only.** All colours/spacing/typography via `DashColors` / `Spacing` / `Typography` / + `Shape` — no hardcoded values (see `docs/ux-design-patterns.md`). +- **NFR-4 Key-protection awareness (non-blocking) + reuse the existing protect path.** Surface, do not solve. + MN/Evonode owner/voting/payout keys load **unprotected (Tier-1)** — the load flow has no password field, by + design. The load form shows a one-line, actionable Warning-tone note that says protection can be added after + loading. Do **not** design the encryption (it already exists as Tier-2 `IdentityTask::ProtectIdentityKeys`); + do **not** gate the load flow on it. The detail view **surfaces the existing "Add password protection…" + affordance** for the node's keys (reuse, not new crypto) and reflects the current protection tier. +- **NFR-5 i18n-ready copy.** All proposed strings are complete sentences with named placeholders, no fragment + concatenation (per project string style). +- **NFR-6 Accessibility.** WCAG 2.1 AA: card is a single labelled click target (`WidgetInfo::labeled`), + focus order top-to-bottom, disabled controls carry disabled-tooltips, status is never colour-only (pair the + status dot with a text label). Note egui's limited screen-reader support (documented constraint). +- **NFR-7 Progressive disclosure — REVISED 2026-07-09 (supersedes the original wording).** This is a + Power-User surface; it is a sibling root tab, and **is gated behind Expert Mode** (`is_developer_mode()`) per + FR-1 — the original "not gated behind developer mode" language is corrected by explicit decision. Keep the + everyday-user surfaces (Hub/Identities) unchanged apart from the FR-6 filter. + +--- + +## 6. User Stories & Acceptance Criteria + +**US-1 — Load a masternode by keys.** +*As a masternode operator, I want to load my masternode by its ProTxHash and DIP3 keys on a dedicated page, +so that I don't have to dig through the generic identity-load advanced options.* +- **Given** I open the Masternodes tab with no nodes loaded, **When** I click "Load a masternode", **Then** I + see a form with ProTxHash, a Masternode/Evonode toggle, optional alias, and Voting/Owner/Payout key fields. +- **Given** the form, **When** the ProTxHash field is empty, **Then** the "Load masternode" button is disabled + and its tooltip explains a ProTxHash is required. +- **Given** a valid ProTxHash and (optionally) keys, **When** I click "Load masternode", **Then** the node is + loaded and appears as a card in the Masternodes list. +- **Given** I entered private keys, **When** I view the form, **Then** a non-blocking note tells me the keys are + stored unencrypted at rest on this device. + +**US-2 — See my masternodes at a glance.** +*As a masternode operator, I want a card list of my loaded masternodes showing type, voter readiness, key +status, and voting status, so that I can assess each node in seconds.* +- **Given** ≥1 loaded node, **When** I open the Masternodes tab, **Then** each node is a card showing shortened + ProTxHash, type badge (Masternode/Evonode), voter-identity readiness, key status, DPNS-voting status, and an + identity status dot with a text label. +- **Given** a node with no voter identity, **When** I read its card, **Then** it clearly shows "No voting key". +- **Given** a node with an alias, **When** I read its card, **Then** the alias is the heading and the ProTxHash + is shown beneath it. + +**US-3 — Open a masternode and vote.** +*As a masternode operator, I want to open a node and vote on the DPNS contests it can vote on, so that I can +fulfil my node's governance role.* +- **Given** a card, **When** I click it, **Then** its detail view opens showing keys summary, voter identity, + and the DPNS voting section. +- **Given** the detail view with active contested names, **When** I choose Abstain / Lock / a candidate for a + name, **Then** the vote is dispatched through the existing DPNS voting backend. +- **Given** a node whose voter identity is missing, **When** I open the voting section, **Then** I am told a + voting key is required and how to add one (rather than a raw error). + +**US-4 — Remove a masternode.** +*As a masternode operator, I want to remove a masternode from DET, so that I can stop tracking a node I no +longer operate.* +- **Given** a node's detail view, **When** I click "Remove masternode", **Then** a confirmation dialog with a + specific verb label appears. +- **Given** the confirmation, **When** I confirm, **Then** the masternode and its associated voter identity are + forgotten and the card disappears from the list. + +**US-5 — Keep the everyday surface clean.** +*As an everyday user, I want my Identity Hub to show only my personal identities, so that I'm never offered +node-operator actions that don't apply to me.* +- **Given** loaded masternode/evonode identities, **When** I open the Identity Hub or Identities picker, **Then** + those identities do **not** appear there. +- **Given** the same, **When** I open the Masternodes tab, **Then** they **do** appear there. + +**US-6 — RETIRED (auto-derive does not apply to masternode keys).** +Investigated 2026-07-09: `derive_keys_from_wallets` is hard-gated to `IdentityType::User` in +`backend_task/identity/load_identity.rs` — masternode voting/owner/payout keys are Core-side keys tied to the +node's ProRegTx, not part of any wallet's identity-auth HD tree, so none of the three can be auto-derived. This +story and its "Try to derive from loaded wallet" checkbox do NOT carry over to the load form (FR-4); keys are +always pasted manually there. The wallet pill's real purpose on this page is unrelated — it is the **funding +source for Top up** (FR-9), reflected in US-7's acceptance criteria. + +--- + +**US-7 — Switch wallet/identity from anywhere (silent + two-way, blast-radius-limited).** +*As any user, I want the same wallet/identity switcher on every page, so that I can see and change who I'm acting +as without leaving the current page.* +- **Given** I am on the Masternodes tab (or any root tab), **When** I look at the top panel, **Then** I see the + page-aware switcher `Masternodes › 💼 wallet › 👤 identity` rendered with the Identity Hub's styling. +- **Given** the switcher on the Masternodes tab, **When** I switch wallets from the wallet pill, **Then** the + app-global wallet context updates **in place (no navigation)**, and it becomes the funding source the next + time I use **Top up** on a node (FR-9) — two-way: changing the source wallet from a Top-up flow also updates + the pill. (NOT a key-derivation source — see §9 auto-derive correction; US-6 retired.) +- **Given** the Masternodes tab, **When** I open the **masternode pill** dropdown, **Then** it lists my loaded + masternode/evonode identities, and choosing one opens that node — two-way bound with the card grid and detail + view (opening a card updates the pill; picking from the pill opens the node). +- **Given** I pick a masternode in the Masternodes switcher, **When** I later open an everyday-user page + (Dashpay / Identities / Identity Hub), **Then** the identity pill there shows my app-global **User** identity — + the masternode never appears there (page-scoped selection; FR-6 boundary holds). +- **Given** a page that does not consume a given selection, **When** I hover its pill, **Then** it is dimmed with + no caret and a **tooltip tells me how to change that selection** — there is no visible "read-only" text tag. +- **Given** a page with no identity/object context (e.g. a Wallet page), **When** I look at the switcher, **Then** + it shows only the wallet pill (per-page composition), not a third pill. + +**US-8 — Encrypt my node keys at load time.** +*As a masternode operator, I want to set an optional password when I load my node, so that its private keys are +encrypted at rest immediately instead of only after a separate step.* +- **Given** the load form, **When** I leave the encryption password blank, **Then** the node loads with keys + unprotected (Tier-1) and I can protect them later from the Key Info screen / the detail view. +- **Given** the load form, **When** I enter an encryption password and load the node, **Then** the entered + voting/owner/payout keys are sealed encrypted-at-rest (Tier-2) at load time. +- **Given** the password field, **When** I toggle the show/hide eye, **Then** I can reveal the password only + while pressed (per the password-input pattern), and it is never logged or persisted in plaintext. +- **Given** a node loaded with a password, **When** I view its detail, **Then** its keys read + "password-protected" and the "Add password protection…" action is not offered (already protected). + +**US-9 — Move a node's credits.** +*As a masternode operator, I want to withdraw, top up, and transfer a node's Platform credits from its detail +view, so that I can manage its balance without leaving the Masternodes page.* +- **Given** a node's detail view (Masternode or Evonode), **When** I open the actions row, **Then** I can choose + Withdraw, Top up, or Transfer, each opening the existing screen scoped to this node. +- **Given** a withdraw with the **owner** key, **When** I set it up, **Then** the destination is forced to the + node's registered Core payout address; **Given** the transfer/payout key, **Then** I may choose a free address. + +**US-10 — Manage a node's keys.** +*As a masternode operator, I want to open the key screen for a node, so that I can view a private key/WIF, sign a +message, or add/remove a key.* +- **Given** the Keys section, **When** I click "Manage keys ›", **Then** the existing `KeyInfoScreen` opens for + this node. +- **Given** the add-key purpose selector, **When** I pick a purpose, **Then** OWNER and VOTING are not + offered (Core-registered roles), while TRANSFER / AUTH / ENCRYPTION / DECRYPTION are. + +**US-11 — Claim an evonode's token rewards.** +*As an evonode operator, I want to jump to token-reward claiming from the node's detail view, so that I can +collect rewards my evonode earned.* +- **Given** an **Evonode** detail view, **When** I look at the actions, **Then** "Claim token rewards ›" is + shown and routes to the existing `ClaimTokensScreen` for this identity. +- **Given** a plain **Masternode** detail view, **When** I look at the actions, **Then** the token-rewards + action is **not** shown. + +## 7. Proposed Copy (i18n-ready) + +- Tab label: `Masternodes` +- Empty-state heading: `No masternodes loaded` +- Empty-state body: `Load a masternode or evonode to vote on DPNS name contests and manage its owner and payout keys.` +- Empty-state primary button: `Load a masternode` +- Empty-state reassurance line *(canonical — resolves a wording drift found in test-spec review 2026-07-09; + `wireframes.html`'s wording wins as the human-approved visual mock)*: `Have your node's ProTxHash to hand. + Keys are optional — a node loads read-only without them.` +- Load form title: `Load a masternode` +- Load form subtitle: `Load a masternode or evonode that already exists on the Dash network.` +- ProTxHash label: `ProTxHash` · hint: `Enter the node's ProTxHash. You can find it in your masternode configuration.` +- Node type toggle: `Masternode` / `Evonode` +- Alias label: `Alias (optional)` · hint: `An alias helps you recognize this node inside Dash Evo Tool. It is not saved to the Dash network.` +- Key labels: `Voting private key`, `Owner private key`, `Payout address private key` · placeholder: `Private key (WIF or hex)` +- ~~Auto-derive toggle~~ / ~~No-wallet hint~~ — **removed** (leftover from a superseded pass; found and purged in + test-spec review 2026-07-09). Auto-derive does not apply to these key roles — see §9 correction. There is no + wallet-dependent copy on the load form; the wallet pill's job is unrelated (funds Top up, FR-9). +- Fill Random button *(FR-12, Testnet-only, rendered only when the fixture is present)*: `🎲 Fill Random + Masternode` / `🎲 Fill Random Evonode` (label follows the Node-type toggle) · hint: `Testnet-only dev + convenience — visible only when a local test-node fixture is found.` +- ProTxHash format error *(new, resolves test-spec gap #5, 2026-07-09; inline, on-blur, per project error-copy + rules — what happened + what to do)*: `This doesn't look like a valid ProTxHash. Enter a hex or Base58 + ProTxHash from your masternode configuration.` +- Duplicate-node error *(new, resolves test-spec gap #5; surfaced at submit, MessageBanner Error)*: `This + masternode is already loaded. Open it from the list instead of loading it again.` (Base58/hex ProTxHash or + alias included per the project's Base58-IDs-are-allowed rule, e.g. "…already loaded as `mn-east-01`.") +- Encryption password label: `Encryption password (optional)` · placeholder: `Password to encrypt these keys` · helper: `Set a password to encrypt these keys on this device. Leave it blank to store them unencrypted and add protection later.` +- Key-storage note (Warning tone, actionable): `Set an optional password to encrypt these keys on this device. Without one, they are stored unencrypted and you can add protection later from the key screen.` +- Detail protection-tier labels: `Keys: unprotected` / `Keys: password-protected` +- Add-protection action: `Add password protection…` +- Load button: `Load masternode` · disabled tooltip: `Enter a ProTxHash to continue.` +- Card voter-ready: `Voting ready` / card voter-absent: `No voting key` +- Card voting status examples: `{count} contests to vote on` · `Vote scheduled` · `No open contests` +- Detail remove button: `Remove masternode` · confirm dialog verb: `Remove masternode` +- Voting section empty: `There are no open name contests for this node to vote on right now.` +- Missing voter identity at vote time: `This node has no voting key loaded. Add its voting private key to cast votes.` + +--- + +## 8. Prioritized Backlog (MoSCoW) + +**Must** +- FR-1 Masternodes root tab · FR-2 empty state · FR-3 card list · FR-4 load flow · FR-6 Hub/Identities filter. +- NFR-2 (no model changes), NFR-3 (tokens), NFR-4 (plaintext note), NFR-6 (a11y). + +**Should** +- FR-5 detail/voting view (surfacing existing DPNS voting) · FR-7 refresh. +- ~~US-6 auto-derive parity~~ — **retired**, does not apply to masternode keys (see §9 correction). +- **FR-8 optional load-time encryption password** (needs new plumbing through `IdentityInputToLoad` → + `load_identity` → the existing `store_protected` seal path — implementation scope for Nagatha's plan). +- **FR-9 credit actions** (Withdraw / Top up / Transfer) · **FR-10 Manage-keys drill-in** (`KeyInfoScreen`) · + **FR-11 Evonode-only token-rewards cross-link** (`ClaimTokensScreen`) — all **reuse existing screens** scoped to + the selected node; only new entry points, no new operation UI. +- **FR-12 "Fill Random Masternode/Evonode" dev convenience** (Testnet-only, reuses existing + `fill_random_masternode()`/`fill_random_hpmn()` + `.testnet_nodes.yml` fixture; visible only when the + fixture is present — not shown-but-disabled; the page-level Expert Mode gate from FR-1 covers the rest). + +**Could** +- ~~Per-key "auto-derived vs pasted" provenance indicator~~ — **moot**: no key role on this page is ever + auto-derived (see §9 correction); all three are always pasted. +- Sort/filter of the card grid (by type, voter readiness, open-contest count) — aligns with Priya's + "asset lock table is too compact / no sort or filter" pain point. + +**Should** +- Surface the protection tier + **"Add password protection…"** action on the detail view (reuses the existing + `IdentityTask::ProtectIdentityKeys` — no new crypto). This is the recourse the load-form note points to (NFR-4). + +**Won't (this iteration)** +- Designing/building any **new** key-encryption mechanism — FR-8 reuses the existing Tier-2 envelope + (`store_protected` / `put_secret_protected`); it only threads a password through to it. +- Making the load-time password mandatory — it is strictly optional; blank preserves today's Tier-1 behaviour. +- **Register DPNS name for MN/Evonode — DROPPED (out of scope).** In v0.10-dev this was gated to + `identity_type = 'User'` (`database/identities.rs:344`); for MN/Evonode the button was a **silent no-op**, so + it is not real parity. Adding functional DPNS-name registration for provider identities would be a **new + feature**, not preservation — excluded here. +- Building any new operation screen — FR-9/10/11 **reuse** `withdraw_screen` / `top_up_identity_screen` / + `transfer_screen` / `KeyInfoScreen` / `ClaimTokensScreen` scoped to the node; only entry points are new. +- Registering *new* masternode identities (this page is load/manage of existing on-chain nodes). +- Full in-page DPNS contest browser — the dedicated DPNS root screens remain the canonical voting surface; + the detail view surfaces/hands off, it does not duplicate. + +--- + +## 9. Open Questions & Assumptions + +**Resolved (see §"Locked decisions"):** the four original open questions (nav placement, voting depth, filter +scope, auto-derive scope) are all locked as of 2026-07-09. **Auto-derive scope was subsequently superseded** +by a post-acceptance investigation — see decision 4's strikethrough entry in "Locked decisions" for the +correction (the load form has no auto-derive affordance at all; US-6 retired). + +**Global-nav questions — all RESOLVED:** +1. **Segment-1 label** → **page-aware** (`Masternodes › 💼 wallet › 👤 identity`); segment-1 reflects the active + tab and links to its root. (Confirmed; FR-GLOBAL-NAV-6.) +2. **Interaction on non-identity pages** → **silent context change + two-way binding, NOT route-to-Home.** + Selecting an object in the nav silently updates the app-global selection with no forced navigation; where the + page consumes that object the nav and page stay in sync both ways. On unwired pages the pill is read-only with + a `TODO`. (Confirmed; supersedes the earlier "route to Identity Home" framing — see FR-GLOBAL-NAV-2.) +3. **Third-pill scope** → **page-scoped, page-aware object.** The third pill is the identity/object the current + page operates on: the app-global **User** identity on everyday-user pages; the **page-scoped masternode/evonode + in view** on the Masternodes page (interactive, two-way bound with the card grid + detail). This supersedes the + earlier "Option (a) / read-only on Masternodes" resolution. It keeps FR-6 intact because the masternode + selection is a *separate scope* from the app-global user-identity selection and never leaks into the + everyday-user picker. Rationale in `02-ux-spec.md` §"Global nav — design question". + +**Assumptions (documented):** +- No model or backend-task changes are needed; the page is a filtered view + card layout + relabelled load form. +- ProTxHash display uses the existing `shorten_id` helper and hex encoding for MN/Evonode. +- The plaintext-at-rest note is awareness-only and does not block the flow (per brief + NFR-4). +- Masternode/Evonode badge colours reuse the existing `draw_type_badge` mapping (purple / blue). + +--- + +## 10. Requirements Quality Checklist + +- [x] Primary actor (Priya) has stories addressing her primary goal (load + vote). +- [x] Every user story has testable Given/When/Then acceptance criteria. +- [x] ≥3 real-life scenarios covered across US-1…US-11 (load, glance, vote, remove, clean-surface, global-nav + switching, encrypt-at-load, credit actions, key-mgmt drill-in, evonode token rewards). US-6 retired. +- [x] Edge/failure modes addressed: empty state, missing voter identity, no wallet loaded, disabled load button. +- [x] Priorities justified (Must = core operator flow + surface hygiene; Won't = out-of-scope crypto/registration). +- [x] No requirement without traceable justification (audit + personas + model). +- [x] Assumptions explicit; success metric tied to persona (≤10s to key paths). + +--- + +## Locked decisions (accepted 2026-07-09) + +The human reviewed the wireframes and confirmed all four open questions. Every answer matches the +wireframe as-drawn — no visual changes required. These decisions are now binding for implementation: + +1. **Voting depth = INLINE.** FR-5 / the masternode detail view casts votes **directly in-page** via the + DPNS-contest voting table + **Cast votes** button (wireframe D). It is **not** a deep-link to the DPNS + Active Contests root screen. The detail view surfaces the existing DPNS voting backend inline. +2. **Filter scope = HUB PICKER ONLY.** FR-6 filters masternode/evonode identities out of the **Identity Hub + picker only**. The legacy Identities table (`src/ui/identities/identities_screen.rs`) **keeps** showing + MN/Evonode identities for now. Stripping them from the legacy Identities table is an **explicit deferred + follow-up PR — out of scope for this iteration, and not to be treated as a regression** (supersedes + Open Question §9.3 and the "Won't" note). +3. **Nav placement = BELOW IDENTITY HUB.** Left-nav order: Dashpay / Identities / Identity Hub / **Masternodes** + / Contracts / Dash. Use a distinct node/server glyph (not the person glyph used by Identities). Resolves + Open Question §9.1. +4. ~~**Auto-derive = all three key roles**~~ — **SUPERSEDED (2026-07-09, post-acceptance investigation).** + This decision assumed "matching current behaviour" without verifying that behaviour existed for masternode + keys. It does not: `derive_keys_from_wallets` is hard-gated to `IdentityType::User`; masternode voting/ + owner/payout keys are Core-side keys tied to the ProRegTx, never part of a wallet's HD tree, so none of the + three is derivable today, for any identity type on this page. **Corrected decision: the load form has no + auto-derive affordance; all three keys are always pasted manually (FR-4).** The wallet pill remains + interactive on this page for an unrelated, verified reason — it is the funding source for **Top up** + (FR-9). US-6 is retired, not confirmed. See §4c in 02-ux-spec.md and §9 below for the full correction. + +--- + +🍬 **Findings tally** — surfaced during requirements analysis: **3** (Info severity): +(1) MN/Evonode identities leak into the everyday-user Identity Hub picker → FR-6 filter; +(2) load path is buried behind *Show Advanced Options* → FR-1/FR-4 extraction; +(3) MN/Evonode keys load unprotected (Tier-1) with no recourse shown to the user → NFR-4 actionable awareness note ++ surface the existing Tier-2 "Add password protection…" (`IdentityTask::ProtectIdentityKeys`) on the detail view. + +--- + +## 10. Resolved gaps (test-spec review, 2026-07-09) + +Marvin's Phase 1c test case specification (`03-test-case-spec.md`) surfaced 12 requirement gaps while writing +test cases against this document. Two were copy inconsistencies, fixed directly in §7 and in `02-ux-spec.md` +(empty-state reassurance line canonicalized; the retired auto-derive/no-wallet copy purged from §7). The +remaining decisions, made here so Nagatha's plan and Marvin's test cases build on settled ground rather than +open questions: + +1. **DPNS card status-line precedence (FR-3).** Three possible strings can apply simultaneously + (`{count} contests to vote on`, `Vote scheduled`, `No open contests`). **Precedence: open-contest count + first (it's actionable), then scheduled, then none** — i.e. show `{count} contests to vote on` whenever + `count > 0`, regardless of a pending scheduled vote; only show `Vote scheduled` when `count == 0` and a + vote is pending; otherwise `No open contests`. **"Vote scheduled" is not a new concept** — reuse the same + pending/scheduled-vote state the existing DPNS Scheduled Votes root screen already tracks (no new backend + state; a display-layer read of existing data). +2. **Legacy buried Masternode/Evonode arm (`add_existing_identity_screen.rs`'s Identity Type dropdown under + Show Advanced Options) — REMOVE, don't leave dangling.** Once FR-4 ships its own dedicated load flow, the + old arm's Masternode/Evonode options are removed from that dropdown (User remains). This prevents two + competing entry points for the same action, and matches the "carve masternode handling out of the generic + identity flow" framing this whole design started from. Implementation scope for Nagatha's plan; extends FR-6. +3. **FR-8 password-strength rule.** No new policy is invented. The optional load-time password reuses the + *same* validation (if any) the existing Key Info screen's "Add password protection…" flow already applies, + since FR-8 routes through the identical `store_protected`/`put_secret_protected` seal path (§ FR-8). Nagatha + confirms the existing rule at implementation time rather than this design inventing a new one. +4. **Masternode-pill state when navigating detail → list.** Already implicit in the wireframes, now stated + explicitly: the pill reflects **the current screen's context**, not "last node opened." On the card-list / + empty screens (A, B) it shows the placeholder `Choose a masternode ▾`; only the detail view (D) shows a + specific node. Navigating from D back to the list (via `‹ All masternodes` or the pill's own dropdown) + resets it to the placeholder — matches wireframe B exactly. + +5. **No-wallet-loaded behavior when attempting Top up (FR-9).** Not a new copy/state to invent: Top up is an + **existing reused screen** (`top_up_identity_screen`, per FR-9's reuse note) — whatever it already does + today when no wallet is loaded (block, prompt, or otherwise) is what happens here too, unchanged. This + design adds an entry point to that screen; it does not redefine its no-wallet behavior. +6. **Node-type toggle after Fill-Random autofill (FR-12).** Switching Masternode ↔ Evonode after using + "Fill Random…" **clears** ProTxHash, Alias, and all key fields. A real node's identity is tied to one type + — autofilled (or manually entered) data for one type is never valid for the other, so silently keeping it + would be actively misleading, not a convenience. +7. **Past/scheduled votes on the detail view (FR-5) — out of scope, by design, not a gap.** The collapsible + DPNS section covers **active, open contests only**, exactly as wireframe D draws it. Scheduled/past-vote + history is not duplicated here — it already has a home in the existing DPNS Scheduled Votes root screen. + Keeps the page focused; consistent with "reuse existing screens" rather than re-showing the same data twice. +8. **"Add voting key" (US-3, missing-voter-identity affordance) is a targeted action, not a re-run of FR-4's + load form.** It opens a small, scoped key-input prompt that adds/updates the voter identity on the + **already-loaded** node in place. It is a different flow from FR-4's load form and is therefore exempt from + the duplicate-ProTxHash rejection below (that rejection guards *new* loads, not fixing up an existing one). +9. **Duplicate-ProTxHash load (FR-4) — reject, don't merge or duplicate.** Submitting a ProTxHash that's + already loaded shows the duplicate-node error (§7 copy, added above) and does not create a second card or + silently update the existing one. **Malformed-ProTxHash (FR-4)** — validated inline/on-blur (client-side + shape check, hex or Base58), not only gated on emptiness; error copy added to §7 above. +10. **Network switch mid-sub-screen (load form or detail).** Matches TC-EDGE-05's existing rule ("card list + scoped per active network"): switching network while on the load form or a node's detail view returns to + the Masternodes **list** for the new network, rather than leaving a stale sub-screen referencing an + identity that may not exist there. Consistent with root screens surviving network switches while + identity-scoped sub-screens do not carry a now-foreign identity forward. +11. **Live de-gating fallback (FR-1).** If Expert Mode is turned off while the Masternodes tab is the active + screen (no existing DET precedent found for a dev-gated *root tab* specifically — checked `app.rs` and + found none to reuse), the app falls back to the **Identities** root screen — the nearest neutral, + always-available screen, rather than leaving the user stranded on a tab that just disappeared from the nav. + +No open items remain from Marvin's gap list that require a return to Phase 1a/1b (UX Design) — all eleven are +implementation-detail-level and are resolved above without changing any wireframe screen. diff --git a/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md b/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md new file mode 100644 index 000000000..21d218def --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md @@ -0,0 +1,430 @@ +# Masternodes Page — UX Specification + +**Repo:** `dash-evo-tool` · **Date:** 2026-07-09 · **Author:** Diziet +Companion: `01-requirements.md`, `wireframes.html`. Design tokens: `src/ui/theme.rs`, `docs/ux-design-patterns.md`. + +All wireframes are annotated with the **existing component / token** they reuse. Nothing here invents a new +widget where an existing one serves. + +--- + +## 1. Information Architecture + +``` +Left nav (root screens) +├── Dashpay +├── Identities +├── Identity Hub +├── Masternodes ◀── NEW root tab (this design) +├── Contracts +└── … (Dash / network) +``` + +The Masternodes tab is a **sibling root screen** (persists in `AppState.main_screens`, survives network +switch). **Corrected 2026-07-09 (supersedes the original NFR-7):** it IS gated behind **Expert Mode** +(`is_developer_mode()`, user-facing label "Expert mode") — nav item and route both absent when Expert Mode +is off. Node-operator work is a distinct audience (Priya), and Expert Mode is DET's existing mechanism for +separating that audience from Alex (Everyday User) — see FR-1. + +Internal screen stack within Masternodes: +``` +Masternodes (list / empty state) ──"Load a masternode"──▶ Load form ──success──▶ back to list + │ + └──click a card──▶ Masternode detail / voting ──"Remove"──▶ confirm ──▶ back to list +``` +Navigation uses the standard breadcrumb header + `PushScreen`/`PopScreen` for the load form and detail view. + +--- + +## 2. User Journeys + +### 2.1 First-time (empty → load a node) +Persona: Priya. Entry: clicks **Masternodes** in the left nav for the first time. +1. Sees the **empty state** card (§ wireframe A): what a masternode identity is for + a primary + **Load a masternode** button. +2. Clicks it → **Load form** (wireframe C): enters ProTxHash, picks Masternode/Evonode, pastes + Voting/Owner/Payout keys (manual paste only — see §4c, auto-derive does not apply to these key roles). + Reads the plaintext-at-rest note. +3. Clicks **Load masternode** → returns to the list, now showing **one card** (wireframe B). +Success state: a card representing her node, with voter readiness + key status visible. +Failure paths: empty ProTxHash → button disabled with tooltip; load error → `MessageBanner` (Error) with a +user-friendly message and technical detail attached (never a raw error string). + +### 2.2 Returning (list → open → vote) +Persona: Priya, on a later session with nodes already loaded. +1. Opens **Masternodes** → **card list** (wireframe B). Scans type badges, voter readiness, and + "N contests to vote on". +2. Clicks a card → **detail/voting view** (wireframe D). Reviews keys summary + voter identity. +3. In the DPNS voting section, for each open contested name chooses **Abstain / Lock / a candidate**; vote is + dispatched via the existing DPNS voting backend. +Success state: vote recorded (Success `MessageBanner`, auto-dismiss). +Edge case: node has no voter identity → voting section shows an actionable message ("Add its voting private +key to cast votes"), not a raw `NoVotingIdentity` error. + +### 2.3 Housekeeping (remove a node) +1. Detail view → **Remove masternode** (danger button) → confirmation dialog (specific verb, Cancel left / + Remove right, Escape cancels). +2. Confirm → node **and its associated voter identity** are forgotten; back to the list. + +--- + +## 3. Interaction Patterns + +- **Cards** — reuse `IdentityPickerCard` visual language (`src/ui/identity/identity_picker_card.rs`): rounded + `surface` card (`RADIUS_LG`=16), monogram, type-badge pill, single click target with hover elevation and + `WidgetInfo::labeled` a11y. Extend content to masternode-specific rows (voter readiness / key status / + voting status) — same frame, different body. +- **Type badge** — reuse `draw_type_badge`: Masternode → `PLATFORM_PURPLE`, Evonode → `DASH_BLUE`, white text. +- **Node-type toggle** (load form) — segmented control using `unselected_fill(dark_mode)` for the inactive + segment and `DASH_BLUE` for the active, matching the existing "Identity ID & private key / From my wallet / + My username" tab styling in `04-load-masternode-keys.png`. +- **Key inputs** — reuse the existing private-key input widget (WIF-or-hex placeholder, reveal control) already + used by the advanced arm. Password-input reveal rules per `docs/ux-design-patterns.md` §5. +- **Buttons** — `ComponentStyles`: `add_primary_button` (Dash-blue) for Load; `add_secondary_button` (outline) + for Cancel; `add_danger_button` for Remove. `add_primary_button_enabled(false, …)` + `disabled_tooltip` for + the disabled Load state. Top-right **Refresh** uses `add_toolbar_button` on the network accent (as in + `01-dashpay.png`). +- **Collapsing sections** — the DPNS voting section on the detail view is an egui **collapsing header**, + **collapsed by default**, with the open contest **count in the header** (`▸ DPNS name contests to vote on (3)`) + so operators still see there's something to act on without expanding. Expanding (`▾`) reveals the voting table + + Cast-votes button. Use the standard egui `CollapsingHeader` pattern. +- **Status** — identity status dot uses the `IdentityStatus → Color32` mapping (green/gray/orange/red) always + paired with a text label (never colour-only — NFR-6). +- **Global-nav pills** — the existing `BreadcrumbPillMode` already provides the exact three renderings this model + needs: `Interactive` (caret + dropdown, for a consumed selection), `Subdued` (dimmed, no caret + hover tooltip — + the disabled fallback for unwired pills; no visible text tag), `Placeholder` (no value yet, e.g. the empty-state + masternode pill). On Masternodes **both** the wallet pill and the masternode pill are `Interactive` and two-way + bound. A page declares which selections it consumes; consumed pills bind two-way, others render `Subdued` with a + how-to-change tooltip. +- **Messages** — `MessageBanner::set_global`; errors persistent + `.with_details(e)`, success auto-dismiss. +- **Confirmation** — `ConfirmationDialog` with `danger_mode(true)` for Remove. + +--- + +## 4. Navigation / Breadcrumb — global wallet/identity switcher + +**Change (FR-GLOBAL-NAV):** the top island's left region now hosts the **global wallet/identity switcher** on +every root page, not just the Identity Hub. It is the exact three-segment breadcrumb switcher from IDH-003 +(`breadcrumb_switcher.rs`) — `Identities › 💼 wallet › 👤 identity` — rendered via +`top_panel::add_top_panel_with_breadcrumb`. The connection dot sits to its left; Refresh/action buttons stay +top-right. The switcher looks and behaves identically to the Identity Hub (`05-identity-hub-landing.png`). + +- **Which page am I on?** Conveyed by the **left-nav rail highlight** (Masternodes active), not by the header. +- **Sub-screen navigation** (load form, detail) uses a **content-panel back row** (`‹ All masternodes`), keeping + the global switcher single-line and unchanged across navigation (design-spec §A.3). +- The status/connection dot reflects network colour (orange on Testnet, as in the reference screenshots) and + reuses `top_panel::add_connection_indicator`. + +Header on every Masternodes screen: `[●dot] Masternodes › 💼 Main Wallet ▾ › Ⓜ mn-east-01 ▾` — **page-aware** +leftmost crumb (confirmed Q1). On Masternodes **both pills are interactive** (caret ▾): the **wallet pill** +(funds Top up on this node, two-way bound — see §4c, NOT a key-derivation source) **and** the **masternode pill** +(the node in view — its dropdown lists loaded masternodes/evonodes, two-way bound with the card grid + detail +view; see §4b). The third pill is **page-scoped**: it is the masternode-in-view here, and the app-global User +identity on everyday-user pages. + +## 4b. Global nav — design question & resolution + +**Question (from the coordinator):** on non-identity pages (Masternodes, Contracts), what does the wallet/identity +selector scope to? +- **(a)** It always reflects the same app-global wallet + (User) identity context, independent of page. +- **(b)** On the Masternodes page the identity pill instead reflects the selected **masternode/evonode** identity + (page-aware), since masternode identities are `IdentityType != User`. + +**Resolution → the third pill is a page-scoped, page-aware object** (updated; supersedes the earlier +"Option (a) / read-only on Masternodes"). The third segment shows the identity/object the **current page** +operates on: +- **Everyday-user pages** (Dashpay / Identities / Identity Hub): the app-global **User** identity. +- **Masternodes page**: the **masternode/evonode in view** (`Ⓜ mn-east-01 ▾`), interactive and two-way bound with + the card grid and the detail view. Its dropdown lists the loaded masternodes/evonodes. + +**Why this does NOT violate FR-6 (the boundary).** FR-6 governs the **everyday-user Identity Hub / Identities +picker** — it must not list MN/Evonode identities. The masternode-in-view here is a **separate, page-scoped +selection**, distinct from the app-global User-identity selection. Choosing a masternode in the Masternodes +switcher does not touch the app-global user-identity selection, so it **never** appears in the identity pill on +user pages nor in the Hub picker. Two selections, two scopes — the Masternodes page's own switcher is simply not +the everyday-user picker FR-6 constrains. (The masternode pill's dropdown is also *not* the Hub picker; it is the +operator surface, which is exactly where MN/Evonode identities belong.) + +**Two-way binding on Masternodes.** The masternode pill mirrors the page's own selection: opening a card sets the +pill; picking a node from the pill opens that node's detail. The detail view additionally carries a +`‹ All masternodes` content-panel back row to return to the grid. + +### Authoritative selection interaction model (resolved) + +The global nav follows four rules on every page (full text in requirements FR-GLOBAL-NAV-2): + +1. **Silent context change** — selecting an object updates the app-global selection; **no forced navigation**. +2. **Two-way binding where the page consumes the selection** — nav pill and page stay in sync both ways. +3. **Blast-radius control** — a pill is interactive only on pages already wired to consume it; elsewhere it is + **dimmed, no caret, no visible tag**, with a **hover tooltip telling the user how to change that selection** + (+ a `TODO` in code). Interactivity rolls out page-by-page. +4. **Per-page composition** — show only the pills that make sense (a Wallet page shows only the wallet pill). + +**On Masternodes:** **both pills are interactive** — wallet pill = two-way bound (funds Top up on this node, §4c); +masternode pill = the node in view, two-way bound with the card grid + detail (its dropdown lists loaded +masternodes/evonodes). The third pill is page-scoped, so it never leaks a masternode into the user pages (FR-6). + +**Two-way-binding example to carry into implementation (Send/Transfer-from-wallet):** changing the wallet in the +top-nav changes the page's *source wallet*; changing the *source wallet* on the page updates the top-nav wallet +pill. This is the canonical shape for any page that consumes a selection — on Masternodes the source wallet feeds +**Top up** (FR-9), not key derivation (§4c). + +**Confirmed Q1:** leftmost crumb is **page-aware** (`Masternodes › …`), linking to the active tab's root. + +## 4c. Auto-derive finding — corrected (investigation, 2026-07-09) + +**Auto-deriving Voting/Owner/Payout private keys from a loaded wallet does NOT work and was never wired for +Masternode/Evonode identities.** Verified against code: `backend_task/identity/load_identity.rs` gates +`derive_keys_from_wallets` to `IdentityType::User` only; for Masternode/Evonode the three key fields are always +manual paste, verified (not discovered) against the identity's on-chain public keys. This is architectural, not a +missing feature: masternode owner/voting/payout keys are Core-side keys tied to the node's ProRegTx, not part of +any wallet's identity-auth HD derivation tree. Consequence: +- **Load form (wireframe C):** the "Try to derive these keys from a loaded wallet" checkbox is **removed** — it + would be misleading UI chrome (a no-op) on a page that is masternode-only (no User option here). +- **Wallet pill rationale corrected:** the wallet pill on the Masternodes page is NOT a key-derivation source. + Its real, verified purpose is as the **funding source for Top up** (FR-9) — Top up moves DASH from the active + wallet to the node's identity balance, a genuine use of "active wallet" context. The pill stays interactive and + two-way bound for that reason. +- Superseded: **US-6 ("auto-derive parity")** is retired — see 01-requirements.md backlog note. + +--- + +## 5. Accessibility (WCAG 2.1 AA) + +- Card is one labelled click target (`WidgetInfo::labeled(Button, …, "Open {node}")`); Enter activates. +- Focus order: header actions → cards (reading order) / form fields top-to-bottom → primary action last. +- Focus indicator: `BORDER_WIDTH_THICK`, ≥3:1 contrast (theme default). +- No colour-only status: every status dot and badge carries a text label. +- Disabled Load button uses `disabled_tooltip` (NotAllowed cursor) explaining the blocker. +- Contrast: Dash-blue `#008de4` on white for primary buttons; secondary text `#64788c` meets AA on white. +- Known constraint (documented): egui offers no screen-reader annotations beyond `WidgetInfo`. + +--- + +## 6. Responsive Behavior + +- Card grid: `minmax(260px, 1fr)` columns (matches `CARD_MIN_WIDTH`=260), wrapping to 1 column on narrow + widths via `ui.available_width()`; `ScrollArea` for overflow. Empty-state and forms sit inside + `island_central_panel()` responsive margins. +- Load form: single-column, labels above inputs on narrow widths. + +--- + +## 7. ASCII Wireframes + +> **Erratum (PROJ-013, 2026-07-09):** `wireframes.html` still draws the legacy **two** Fill-Random buttons +> ("Fill Random HPMN" / "Fill Random Masternode") and omits the missing-voter "Add voting key" affordance. +> FR-12/§7 (one button, label follows the Node-type toggle) and wireframe D below are canonical; the HTML mock is +> stale for these two details only. + +Legend: `[●]` status dot · `[ Button ]` primary · `( Button )` secondary/outline · `‹ Button ›` danger · +`{MN}`/`{EVO}` type badge pill. + +### (A) Masternodes tab — empty state +Reuses: **global switcher** header (`breadcrumb_switcher.rs` via `add_top_panel_with_breadcrumb`, styled per +`05-identity-hub-landing.png`), empty-state card pattern (`03-identities-empty.png`), `add_primary_button`, +`island_central_panel`. Nav rail highlights **Masternodes**. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ (no masternode yet) [ Refresh ] │ ← both pills interactive; MN pill is a placeholder (none loaded yet) +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ ▽ Dash │ │ +│ ⦿ Ident. │ ┌───────────────────────────────────────────────────┐ │ +│ ⦿ IdHub │ │ No masternodes loaded │ │ ← empty-state card (surface, RADIUS_LG) +│ ▶Masterno.│ ├───────────────────────────────────────────────────┤ │ +│ ⦿ Contr. │ │ Load a masternode or evonode to vote on DPNS name │ │ +│ ~Dash~ │ │ contests and manage its owner and payout keys. │ │ +│ │ │ │ │ +│ │ │ [ Load a masternode ] │ │ ← primary (DASH_BLUE) +│ │ │ │ │ +│ │ │ Have your node's ProTxHash to hand. Keys are │ │ ← canonical wording, §7 +│ │ │ optional — a node loads read-only without them. │ │ +│ │ └───────────────────────────────────────────────────┘ │ +│ │ │ +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` +Note: **both pills are interactive** on Masternodes. The **wallet pill** (▾) opens the wallet dropdown (two-way +bound — funds Top up on this node, §4c; not a key-derivation source). The **masternode pill** (▾) is the +page-scoped node-in-view selector; here it is a +placeholder because none is loaded yet. This third pill is page-scoped — it shows the app-global User identity on +everyday-user pages, never a masternode there (§4b, FR-6 boundary). + +### (B) Masternodes tab — card list (2–3 cards) +Reuses: `IdentityPickerCard` frame + `draw_type_badge` + monogram + hover elevation; `IdentityStatus` colour +dot; responsive `minmax(260,1fr)` grid. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ Choose a masternode ▾ [ + Load ][⟳] │ ← both pills interactive; MN pill dropdown mirrors the cards below +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ ▽ Dash │ ┌────────────────────────┐ ┌────────────────────────┐ │ +│ ⦿ Ident. │ │ (M) {MN} │ │ (E) {EVO} │ │ ← monogram + type badge +│ ⦿ IdHub │ │ mn-east-01 │ │ 6f2a…c19b │ │ ← alias (or shortened ProTxHash) +│ ▶Masterno.│ │ 9a3f…d7e2 ·ProTxHash │ │ Evonode │ │ ← sub-line +│ ⦿ Contr. │ │ │ │ │ │ +│ ~Dash~ │ │ ● Voting ready │ │ ▲ No voting key │ │ ← voter readiness (green / warning) +│ │ │ Keys: V O P │ │ Keys: · O · │ │ ← key status (present emphasised) +│ │ │ 3 contests to vote on │ │ No open contests │ │ ← DPNS voting status +│ │ │ ● Active │ │ ● Unknown │ │ ← IdentityStatus dot + label +│ │ └────────────────────────┘ └────────────────────────┘ │ +│ │ ┌────────────────────────┐ │ +│ │ │ (M) {MN} │ │ +│ │ │ mn-west-02 │ │ +│ │ │ b71c…40aa ·ProTxHash │ │ +│ │ │ ● Voting ready │ │ +│ │ │ Keys: V O · │ │ +│ │ │ Vote scheduled │ │ +│ │ │ ● Active │ │ +│ │ └────────────────────────┘ │ +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` + +### (C) Load a masternode form +Reuses: global switcher header; segmented toggle styled like existing tab row (`04-load-masternode-keys.png`); +existing private-key input widget; the password-input pattern (`wallet_unlock.rs` hold-to-reveal, see +`docs/ux-design-patterns.md` §5) for the optional encryption password; `add_primary_button_enabled` + +`disabled_tooltip`; Warning-tone inline note. **New (FR-8):** the optional encryption-password field needs new +plumbing (password threaded through `IdentityInputToLoad` → `load_identity` → existing `store_protected`). +**FR-12 (new, investigated 2026-07-09):** carries forward the "Fill Random Masternode" / "Fill Random HPMN" +dev convenience from `add_existing_identity_screen.rs:203-208` (`fill_random_masternode()` / `fill_random_hpmn()`, +lines 961-993) — picks a random REAL testnet node from a local `.testnet_nodes.yml` fixture and autofills +ProTxHash + keys; it does not fabricate a synthetic node. One button, labelled to match the Node-type toggle +above ("Fill Random Masternode" / "Fill Random Evonode"). **Fixture facts:** `.testnet_nodes.yml` is gitignored, +not tracked, and does not exist in this repo — it must be supplied locally and **does contain real private keys +in plaintext**. **Visibility is conditional, not disabled-state:** the button renders only when +`load_testnet_nodes_from_yml(...)` returns `Some(_)` on Testnet; otherwise it is absent entirely, no placeholder. +Gating is simpler than first assumed: the whole Masternodes tab now requires **Expert Mode** (FR-1), so this +button's own remaining condition is just Testnet + fixture-present — no separate `developer_mode` re-check +needed for normal navigation (Nagatha may still add one at the call-site as defense-in-depth; judgment call). + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ Choose a masternode ▾ │ ← both pills interactive; unchanged during sub-nav +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ nav … │ ‹ All masternodes │ ← content-panel back row (§4b), not in header +│ │ Load a masternode │ +│ │ Load a masternode or evonode that already exists on the network. │ +│ │ │ +│ │ Node type: [ Masternode ] ( Evonode ) │ ← segmented toggle (active=DASH_BLUE) +│ │ │ +│ │ ( 🎲 Fill Random Masternode ) Testnet-only dev convenience — │ ← FR-12, testnet-only; carried over from +│ │ fills a real test node's ProTxHash and keys below. │ add_existing_identity_screen.rs +│ │ │ +│ │ ProTxHash: [___________________________________] (i) │ ← required +│ │ Alias (optional): [___________________________________] (i) │ +│ │ Voting private key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Owner private key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Payout addr. key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Encryption password [ Password to encrypt these keys ] 👁 │ ← OPTIONAL (FR-8); reuses password-input pattern +│ │ (optional): Set a password to encrypt these keys on this │ ← helper line +│ │ device. Leave it blank to store them │ +│ │ unencrypted and add protection later. │ +│ │ │ +│ │ ⚠ Set an optional password to encrypt these keys on this device. │ ← Warning-tone, non-blocking, ACCURATE (FR-8/NFR-4) +│ │ Without one, they are stored unencrypted and you can add │ +│ │ protection later from the key screen. │ +│ │ │ +│ │ [ Load masternode ] ( Cancel ) │ ← primary disabled until ProTxHash set +│ │ Enter a ProTxHash to continue. │ ← disabled tooltip +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` + +### (D) Masternode detail / voting view +Reuses: breadcrumb header; type badge; `shorten_id` + copy affordance; DPNS voting backend (surfaced, not +re-implemented); `add_danger_button` + `ConfirmationDialog`. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ mn-east-01 ▾ [ Refresh ]│ ← MN pill shows the node in view; two-way bound with the list + this detail +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ nav … │ ‹ All masternodes │ ← content-panel back row (§4b) +│ │ mn-east-01 {MN} 9a3f…d7e2 ⧉ ● Active │ ← alias + badge + ProTxHash(copy) + status +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ Actions: [ Withdraw ] [ Top up ] [ Transfer ] │ ← reuse withdraw / top_up / transfer screens, scoped to this node +│ │ ⓔ Evonode only: ( Claim token rewards › ) │ ← Evonode-only cross-link → ClaimTokensScreen (hidden for Masternode) +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ Keys Keys: unprotected ( Add password │ +│ │ Voting: loaded ✓ Owner: loaded ✓ Payout: loaded ✓ protection…)│ ← surfaces existing IdentityTask::ProtectIdentityKeys +│ │ Voter identity: 4c8e…1b70 ⧉ ( Manage keys › ) │ ← opens existing KeyInfoScreen (view WIF, sign, add/remove key) +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ ▸ DPNS name contests to vote on (3) │ ← COLLAPSIBLE, collapsed by default; count stays visible +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ … expanded (▾), the same header reveals the voting table: │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ │ alice ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ │ cooltoken ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ │ dashfan ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ +│ │ [ Cast votes ] │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ ‹ Remove masternode › │ ← danger + confirmation dialog +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` +**Evonode detail differs by:** the `( Claim token rewards › )` cross-link is shown **only** for Evonode +identities (routes to the existing `ClaimTokensScreen`); a plain Masternode hides it. Everything else is identical +across the two node types. + +**Withdraw destination rule (FR-9):** with the owner key the destination is forced to the node's registered Core +payout address; with the transfer/payout key it is a free address. **Add-key rule (FR-10):** the purpose selector +excludes OWNER/VOTING for all identity types. + +Empty voting state (no voter identity): the contests block is replaced by — +`This node has no voting key loaded. Add its voting private key to cast votes.` with a `( Add voting key )` +secondary action routing back to the load form pre-filled with this ProTxHash. + +--- + +## 8. Component Reuse Summary + +| Screen element | Existing asset reused | +|---|---| +| Global wallet/identity switcher (header) | `breadcrumb_switcher.rs` + `breadcrumb_pill.rs` (`Interactive`/`Subdued`/`Placeholder` modes already exist) + `identity_pill.rs`, via `top_panel::add_top_panel_with_breadcrumb`; app-scoped + page-scoped selection. **On Masternodes:** wallet pill `Interactive` (two-way) **and** masternode pill `Interactive` (two-way with card grid + detail). **New:** a page-scoped masternode selection distinct from the app-global user-identity selection (Nagatha) | +| Root-tab header + status dot + toolbar action | `top_panel::render_top_island` + `add_connection_indicator` + `add_toolbar_button` | +| Sub-screen back row (content panel) | lightweight label/link inside `island_central_panel` (no new widget) | +| Left nav rail (icon + label) | existing nav rail | +| Empty-state card | `03-identities-empty.png` pattern, `surface`/`RADIUS_LG`/`Shadow::medium` | +| Node cards | `IdentityPickerCard` frame, monogram, hover elevation, `WidgetInfo::labeled` | +| Type badge pill | `draw_type_badge` (PLATFORM_PURPLE / DASH_BLUE) | +| Node-type toggle | existing segmented tab styling | +| Key inputs | existing WIF-or-hex private-key input widget | +| Optional encryption password (load form) | password-input pattern (`wallet_unlock.rs` hold-to-reveal). **Backend NEW:** thread password through `IdentityInputToLoad` → `load_identity` → existing `store_protected`/`put_secret_protected` (Nagatha's scope) | +| Buttons | `ComponentStyles` primary / secondary / danger / toolbar | +| Status dot | `IdentityStatus → Color32` mapping (+ text label) | +| Messages / errors | `MessageBanner::set_global` + `.with_details` | +| Add-password-protection action | existing `IdentityTask::ProtectIdentityKeys` (Key Info screen's "Add password protection…"); Masternodes detail view reuses it — no new crypto | +| Credit actions (Withdraw / Top up / Transfer) | existing `withdraw_screen` / `top_up_identity_screen` / `transfer_screen`, scoped to the node's `QualifiedIdentity` (FR-9). Only the entry points are new | +| Manage keys drill-in | existing `KeyInfoScreen` scoped to the node (FR-10): view WIF, sign, add/remove key. Add-key purpose selector excludes OWNER/VOTING | +| Evonode token rewards | existing `ClaimTokensScreen` cross-link, Evonode-only (FR-11). Route, not rebuild | +| Remove confirmation | `ConfirmationDialog` `danger_mode(true)` | +| DPNS voting | existing `contested_names/vote_on_dpns_name.rs` backend + DPNS root screens | + +--- + +## Locked decisions (accepted 2026-07-09) + +Human accepted the wireframes; all four answers match the mock as-drawn, so `wireframes.html` is unchanged. +Binding for implementation: + +1. **Voting depth = INLINE** — wireframe (D) stands: cast votes in the detail view via the DPNS-contest table + + **Cast votes** button. No deep-link to the DPNS Active Contests root screen. +2. **Filter scope = HUB PICKER ONLY** — remove MN/Evonode from the Identity Hub picker only. The legacy + Identities table keeps them; stripping it is a **deferred follow-up PR** (out of scope, not a regression). +3. **Nav placement = BELOW IDENTITY HUB** — order Dashpay / Identities / Identity Hub / Masternodes / Contracts + / Dash, with a distinct node/server glyph (as drawn in the wireframes' rail). +4. ~~**Auto-derive = all three key roles** (Voting / Owner / Payout), matching current behaviour.~~ — + **SUPERSEDED 2026-07-09** (post-acceptance investigation; matches `01-requirements.md` Locked-decisions #4). The + load form has **no auto-derive affordance**: `derive_keys_from_wallets` is hard-gated to `IdentityType::User`, so + masternode Voting/Owner/Payout keys are always pasted manually. See §4c. US-6 retired. + +--- + +**Correction folded in (2026-07-09, per CLAUDE.md update):** MN/Evonode keys are not permanently plaintext — they +load Tier-1 (unprotected, no password field by design) and can be sealed to Tier-2 per-identity afterward via the +existing `IdentityTask::ProtectIdentityKeys`. The load-form note is now actionable ("you can protect this node's +keys after loading it") and the detail view surfaces the protection tier + an "Add password protection…" action +(reuse, no new crypto). + +🍬 **Findings tally (UX)** — **1** usability improvement confirmed (Info): the missing-voter-identity path +must present an actionable "Add voting key" affordance instead of surfacing the raw `NoVotingIdentity` error +(carried into wireframe D empty state and US-3 acceptance criteria). diff --git a/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md b/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md new file mode 100644 index 000000000..e5b3e0e05 --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md @@ -0,0 +1,366 @@ +# Masternodes Page — Test Case Specification + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` · **Date:** 2026-07-09 +**Author:** Marvin (QA) · **Phase:** 1c (Test Case Specification) — spec only, no test code, no Rust changes. + +Derived from `01-requirements.md` (FR-1…FR-12, NFR-1…NFR-7, US-1…US-11) and `02-ux-spec.md` + +`wireframes.html` (final, human-accepted 2026-07-09). Every case cites its traceability so +Nagatha's Phase 1d plan can reference "this task satisfies TC-X, TC-Y, TC-Z." + +Legend: `[AMBIGUOUS]` = requirement as written cannot be reduced to a deterministic pass/fail +assertion; case is recorded for traceability but flagged back to the coordinator, not silently +dropped. Colour mapping for `IdentityStatus` was verified against +`src/model/qualified_identity/mod.rs:147-155` (not left as a doc-only assumption): +Active→Green(0,128,0), Unknown→Gray(128,128,128), PendingCreation→Orange(255,165,0), +NotFound→Red(255,0,0), FailedCreation→Red(255,0,0) — two statuses legitimately share Red, this is +not a spec gap. + +--- + +## US-6 — RETIRED, no test cases + +Auto-derive of Voting/Owner/Payout keys from a loaded wallet is architecturally impossible for +Masternode/Evonode identities (`derive_keys_from_wallets` hard-gated to `IdentityType::User` in +`backend_task/identity/load_identity.rs`). No test cases are written against auto-derive on the +load form. Its *absence* is instead asserted positively under **TC-FR4-01**. + +--- + +## FR-1 — Masternodes root tab (Expert-Mode gated) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR1-01 | Nav item absent with Expert Mode off | Expert Mode = OFF | Inspect left-nav rail | No "Masternodes" entry exists in the nav item list (assert absence, not merely disabled/hidden-behind-click) | FR-1, NFR-7 | +| TC-FR1-02 | Route unreachable with Expert Mode off | Expert Mode = OFF | Attempt to activate/select the Masternodes root screen via any non-nav path available to the app (e.g. programmatic `AppState` screen switch) | Screen is not reachable / request is a no-op; app remains on prior screen | FR-1 | +| TC-FR1-03 | Nav item present with Expert Mode on | Expert Mode = ON | Inspect left-nav rail | "Masternodes" entry renders, positioned between "Identity Hub" and "Contracts" (locked decision #3) | FR-1, ux-spec §Locked decisions #3 | +| TC-FR1-04 | Nav item functional with Expert Mode on | Expert Mode = ON | Click "Masternodes" nav item | Masternodes root screen (list or empty state) is shown | FR-1, US-1 | +| TC-FR1-05 | Toggling Expert Mode off while tab is active | Expert Mode = ON, Masternodes tab currently visible | Toggle Expert Mode to OFF via Network/Settings screen | Nav item disappears | FR-1 | +| TC-FR1-05b | Falls back to Identities on live de-gating *(RESOLVED — was `[AMBIGUOUS]`; found outside Marvin's original 12-item list, closed 2026-07-09)* | Same as TC-FR1-05 | Same | Active screen falls back to the **Identities** root tab — the nearest neutral, always-available screen (§10.11; no existing DET precedent for a dev-gated root tab was found to reuse instead) | FR-1 | +| TC-FR1-06 | Root screen persists across network switch | Expert Mode = ON, on Masternodes tab | Switch network (Mainnet↔Testnet) | Masternodes remains the active root tab (same persistence behaviour as other root screens in `AppState.main_screens`) | FR-1, ux-spec §1 | +| TC-FR1-07 | Distinct glyph, not the Identities person-glyph | Expert Mode = ON | Compare nav icon for "Masternodes" vs "Identities" | Icons are visually distinct SVG/glyph identifiers (node/server glyph vs person glyph) | FR-1, wireframes.html rail markup | + +--- + +## FR-2 — Empty state + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR2-01 | Empty state renders with zero nodes loaded | Expert Mode ON, 0 MN/Evonode identities loaded | Open Masternodes tab | Centered empty-state card renders (surface, `RADIUS_LG`), matching `03-identities-empty.png` pattern | FR-2, NFR-1 | +| TC-FR2-02 | Empty-state heading exact copy | Same | Read heading text | Text is exactly `No masternodes loaded` | FR-2, §7 copy | +| TC-FR2-03 | Empty-state body exact copy | Same | Read body text | Text is exactly `Load a masternode or evonode to vote on DPNS name contests and manage its owner and payout keys.` | FR-2, §7 copy | +| TC-FR2-04 | Primary CTA present and enabled | Same | Inspect primary button | Button labeled `Load a masternode`, enabled (not disabled) | FR-2, §7 copy | +| TC-FR2-05 | Reassurance-line exact copy *(RESOLVED 2026-07-09 — was `[AMBIGUOUS]`)* | Same | Read the reassurance line below the primary CTA | Text is exactly `Have your node's ProTxHash to hand. Keys are optional — a node loads read-only without them.` — now canonical in 01-requirements.md §7; `02-ux-spec.md`'s ASCII wireframe A corrected to match | FR-2, §7 copy | +| TC-FR2-06 | CTA navigates to load form | Same | Click "Load a masternode" | Load form (FR-4) opens | FR-2, US-1 | +| TC-FR2-07 | Empty state does not render once ≥1 node loaded | ≥1 MN/Evonode identity loaded | Open Masternodes tab | Card grid (FR-3) renders instead of the empty-state card; empty-state card is entirely absent (regression boundary) | FR-2, FR-3 | + +--- + +## FR-3 — Card list of loaded masternodes + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR3-01 | Card grid renders with ≥1 node | ≥1 node loaded | Open tab | Card grid renders, empty state absent | FR-3, US-2 | +| TC-FR3-02 | Heading = shortened ProTxHash when alias unset | Node loaded, no alias | View card | Heading shows `shorten_id`-formatted ProTxHash | FR-3, US-2 | +| TC-FR3-03 | Heading = alias, ProTxHash beneath, when alias set | Node loaded with alias `mn-east-01` | View card | Heading = `mn-east-01`; shortened ProTxHash shown as sub-line beneath | FR-3, US-2 bullet 3 | +| TC-FR3-04 | Masternode badge colour/text | Node type = Masternode | View card | Badge text `Masternode`, fill = `PLATFORM_PURPLE` | FR-3, Domain Notes §3 | +| TC-FR3-05 | Evonode badge colour/text | Node type = Evonode | View card | Badge text `Evonode`, fill = `DASH_BLUE` | FR-3, Domain Notes §3 | +| TC-FR3-06 | Voter-ready indicator | `associated_voter_identity` present | View card | Shows `Voting ready` with green status dot | FR-3, US-2 bullet 2 | +| TC-FR3-07 | No-voting-key indicator | `associated_voter_identity` absent | View card | Shows `No voting key` text (not colour-only — NFR-6), warning/orange dot | FR-3, US-2 bullet 2, NFR-6 | +| TC-FR3-08 | Key-status indicator across all V/O/P combinations | 8 nodes, one per bit-combination of {Voting, Owner, Payout} present/absent | View each card | Compact indicator (e.g. `V O P`) correctly emphasises exactly the present keys per node; all-off and all-on are both rendered distinctly from partial states | FR-3, US-2 | +| TC-FR3-09 | DPNS status: open contests | Node has N>0 open contests it can vote on | View card | Shows `{N} contests to vote on` with correct N | FR-3, §7 copy | +| TC-FR3-10 | DPNS status: no open contests | Node has 0 open contests, no scheduled vote | View card | Shows `No open contests` | FR-3, §7 copy | +| TC-FR3-11 | DPNS status precedence, count-first *(RESOLVED — was `[AMBIGUOUS]`)* | Node simultaneously has ≥1 open contest AND a scheduled vote | View card | Shows `{count} contests to vote on` (open-contest count takes precedence whenever `count > 0`, regardless of a pending scheduled vote); `Vote scheduled` only shown when `count == 0` and a vote is pending, reusing the existing DPNS Scheduled Votes screen's state — no new backend concept (§10.1) | FR-3, §7 copy | +| TC-FR3-12 | IdentityStatus dot + label, all 5 states | One card per `IdentityStatus` value | View each card | Active→green+"Active"; Unknown→gray+"Unknown"; PendingCreation→orange+"Pending Creation"; NotFound→red+"Not Found"; FailedCreation→red+"Creation Failed" (verified mapping, `qualified_identity/mod.rs:147-155`) | FR-3, US-2 bullet 1 | +| TC-FR3-13 | Whole card is a single click target | ≥1 node loaded | Click anywhere on a card body (not just heading) | Detail view for that node opens | FR-3 bullet "whole card...single click target", NFR-6 | +| TC-FR3-14 | Responsive wrap to 1 column | Narrow viewport width | Resize app window narrow | Grid collapses from multi-column (`minmax(260,1fr)`) to a single column; `ScrollArea` handles overflow | ux-spec §6 | +| TC-FR3-15 | Card count matches loaded-identity count | N nodes loaded (N=1, N=5) | Open tab | Exactly N cards render, no duplicates, no omissions (assert count equality against DB row count for MN/Evonode identities) | FR-3, data integrity | + +--- + +## FR-4 — Load a masternode/evonode + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR4-01 | No auto-derive affordance present | Load form open | Inspect full field set | Fields present: ProTxHash, Node-type toggle, Alias, Voting/Owner/Payout key inputs, Encryption password. **No** "Try to derive from wallet" checkbox anywhere on the form (explicit negative assertion — corrects the superseded ux-spec §Locked-decisions #4) | FR-4, ux-spec §4c, US-6 retirement | +| TC-FR4-02 | Node-type toggle defaults to Masternode | Load form freshly opened | Inspect toggle | `Masternode` segment is active/selected by default | FR-4, wireframe C | +| TC-FR4-03 | Selecting Evonode switches active segment and affects submit type | Load form open | Click `Evonode` segment, fill ProTxHash, submit | `Evonode` segment shows active styling; loaded identity has `IdentityType::Evonode` | FR-4 | +| TC-FR4-04 | No "User" option on toggle | Load form open | Inspect toggle | Exactly two segments: Masternode, Evonode — no third "User" option | FR-4 | +| TC-FR4-05 | Load button disabled when ProTxHash empty | Load form open, ProTxHash field empty | Inspect Load button | Button is disabled | FR-4, US-1 bullet 2 | +| TC-FR4-06 | Disabled-tooltip exact text | Same as TC-FR4-05 | Hover disabled Load button | Tooltip text exactly `Enter a ProTxHash to continue.` | FR-4, §7 copy | +| TC-FR4-07 | Load button enables once ProTxHash populated | Load form open | Type a valid ProTxHash | Button becomes enabled (regardless of key fields) | FR-4 | +| TC-FR4-08 | ProTxHash accepts hex | Load form open | Enter a valid hex ProTxHash, submit | Node loads with the entered ProTxHash | FR-4, Domain Notes §3 | +| TC-FR4-09 | ProTxHash accepts Base58 | Load form open | Enter a valid Base58 ProTxHash, submit | Node loads with the entered ProTxHash resolved correctly | FR-4 | +| TC-FR4-10 | Load with ProTxHash only → view-only node | Load form open | Enter ProTxHash, leave all 3 key fields blank, submit | Node loads; detail view Keys section shows Voting/Owner/Payout all absent (read-only per empty-state copy) | FR-4, FR-2 copy "loads read-only without them" | +| TC-FR4-11 | Load with all three keys present | Load form open | Enter ProTxHash + Voting + Owner + Payout keys, submit | Detail view Keys section shows all three `loaded ✓` | FR-4, FR-5 | +| TC-FR4-12 | Load with only Voting key | Load form open | Enter ProTxHash + Voting key only, submit | Detail view shows Voting loaded, Owner/Payout absent | FR-4 | +| TC-FR4-13 | Alias becomes card heading when set | Load form open | Enter ProTxHash + alias `mn-east-01`, submit | Card list shows `mn-east-01` as heading (cross-ref TC-FR3-03) | FR-4, FR-3 | +| TC-FR4-14 | Alias is local-only, not sent to Platform | Load form open | Enter ProTxHash + alias, submit | Alias persisted only in local DB; no outbound state-transition/network call includes the alias value | FR-4, §7 copy "not saved to the Dash network" | +| TC-FR4-15 | Key inputs accept WIF | Load form open | Paste a WIF-formatted private key into Voting field, submit | Key accepted and loaded | FR-4, Domain Notes §3 | +| TC-FR4-16 | Key inputs accept hex | Load form open | Paste a hex-formatted private key into Owner field, submit | Key accepted and loaded | FR-4 | +| TC-FR4-17 | Reveal control is hold/press semantics | Load form open, key entered | Press-and-hold the eye icon on a key field, then release | Plaintext visible while pressed; masked again on release (per password-input hold-to-reveal pattern, not a persistent toggle) | ux-spec §3, docs/ux-design-patterns.md §5 | +| TC-FR4-18 | Warning-tone note always visible | Load form open, regardless of field state | Inspect form | Note text exactly `Set an optional password to encrypt these keys on this device. Without one, they are stored unencrypted and you can add protection later from the key screen.` is always rendered (non-blocking, unconditional) | FR-4, NFR-4, §7 copy | +| TC-FR4-19 | Load-error path shows friendly MessageBanner | Backend load task returns a `TaskError` (e.g. network failure resolving ProTxHash) | Submit a ProTxHash that triggers a backend error | Error `MessageBanner` shown with a user-friendly message; technical detail attached via `.with_details(e)`; no raw error string/stack trace visible in the banner text itself | FR-4, US-1 "Failure paths", CLAUDE.md error-message rules | +| TC-FR4-20 | Successful load returns to list with fresh form state | Load form open | Submit a valid load, later reopen "Load a masternode" | New card appears in list; reopened form has no residual data from the previous submission | FR-4, US-1 bullet 3 | +| TC-FR4-21 | Cancel discards without loading | Load form open, fields populated | Click Cancel | Returns to list; no new card created; no backend load task dispatched | FR-4, wireframe C | +| TC-FR4-22 | Old advanced-options arm is removed *(RESOLVED — was `[AMBIGUOUS]`)* | Legacy `add_existing_identity_screen.rs` Identity Type dropdown | Open Show Advanced Options on the legacy Add Existing Identity screen | Masternode/Evonode options are **removed** from the dropdown (User-only remains) — no duplicate entry point for loading node identities (§10.2, extends FR-6) | FR-4, FR-6 | + +--- + +## FR-5 — Masternode detail / voting view composition + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR5-01 | **Section order — Actions ABOVE Keys (explicit late correction)** | Detail view open for a fully-keyed node | Read section order top→bottom | Order is exactly: Header → Credit-actions row (incl. Evonode-only cross-link) → Keys section (with "Manage keys ›") → collapsible DPNS voting section → Remove. **Actions row must render before the Keys section**, not after. | FR-5 "Grouping (top→bottom)", explicit human-requested change | +| TC-FR5-02 | Header alias line conditional | Node with alias vs. node without alias | Open both detail views | Aliased node shows alias in header; unaliased node's header omits the alias line entirely (no empty placeholder) | FR-5 | +| TC-FR5-03 | Header ProTxHash + copy affordance | Detail view open | Click the copy icon next to the shortened ProTxHash | ProTxHash is written to clipboard (full value, not the shortened display string) | FR-5 | +| TC-FR5-04 | Header badge matches card badge | Same node, compare card vs detail | Open card then detail | Badge colour/text identical between card and detail header | FR-5, FR-3 | +| TC-FR5-05 | Header status + label | Detail view open | Read status | `IdentityStatus` dot + text label shown (never colour-only) | FR-5, NFR-6 | +| TC-FR5-06 | Detail reachable via card click and via masternode pill | ≥1 node loaded | (a) click card (b) select node from masternode-pill dropdown | Both paths open the same detail view for the chosen node | FR-5, FR-GLOBAL-NAV-3 | +| TC-FR5-07 | Back row returns to list | Detail view open | Click `‹ All masternodes` | Returns to card list (content-panel back row, header/global switcher unchanged) | FR-5, FR-GLOBAL-NAV-5 | + +--- + +## FR-6 — Filter masternode/evonode out of user-only pickers (Hub-picker-only scope) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR6-01 | Masternode excluded from Hub picker | 1 Masternode identity loaded | Open Identity Hub picker | Masternode identity is absent from the picker list | FR-6, US-5 bullet 1 | +| TC-FR6-02 | Evonode excluded from Hub picker | 1 Evonode identity loaded | Open Identity Hub picker | Evonode identity is absent from the picker list | FR-6, US-5 bullet 1 | +| TC-FR6-03 | Masternode still visible in legacy Identities table | 1 Masternode identity loaded | Open legacy `identities_screen.rs` table | Masternode identity IS listed (locked decision #2 — Hub-picker-only scope, not a full removal) | FR-6, ux-spec §Locked decisions #2 | +| TC-FR6-04 | Evonode still visible in legacy Identities table | 1 Evonode identity loaded | Open legacy Identities table | Evonode identity IS listed | FR-6, ux-spec §Locked decisions #2 | +| TC-FR6-05 | User identity unaffected (control case) | 1 User identity loaded alongside MN/Evonode | Open both Hub picker and legacy table | User identity appears in BOTH surfaces (confirms filter is type-scoped, not a blanket removal) | FR-6, US-5 | +| TC-FR6-06 | MN/Evonode present on Masternodes tab | Same identities loaded | Open Masternodes tab | Both MN and Evonode identities appear as cards | FR-6, US-5 bullet 2 | +| TC-FR6-07 | Masternode-pill selection never leaks into app-global user-identity selection | Masternode selected via Masternodes-page pill | Navigate to Dashpay/Identities/Identity Hub | The app-global identity pill/selection there shows the User identity (or none), never the masternode just selected | FR-6 boundary, FR-GLOBAL-NAV-3, US-7 bullet 4 (duplicated for emphasis; full nav coverage under TC-NAV-12) | + +--- + +## FR-7 — Refresh + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR7-01 | Refresh button present, styled per `01-dashpay.png` | Card list open | Inspect top-right toolbar | `Refresh` button present, `add_toolbar_button` styling | FR-7 | +| TC-FR7-02 | Refresh dispatches re-fetch task | Card list open | Click Refresh | A backend task re-fetching masternode identity + voting state is dispatched (assert task variant, not merely a UI repaint) | FR-7 | +| TC-FR7-03 | Refresh reflects updated state | Underlying vote/identity state changed externally since last load | Click Refresh | Card list content updates to reflect the new state (data freshness assertion, not just "no crash") | FR-7 | +| TC-FR7-04 | Refresh also present on detail view | Detail view open | Inspect header | Refresh button present and functional on the detail screen too | FR-7, wireframe D | + +--- + +## FR-8 / US-8 — Optional load-time key encryption password + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR8-01 | Blank password → Tier-1 keyless persistence | Load form open, password field left blank, ≥1 key entered | Submit | Keys persisted via the unprotected path (`put_secret`/`store` — not `put_secret_protected`) | FR-8, US-8 bullet 1 | +| TC-FR8-02 | Non-blank password → Tier-2 sealed persistence at load | Load form open, password set, ≥1 key entered | Submit | Keys persisted via `put_secret_protected`/`store_protected` (Argon2id + XChaCha20-Poly1305) at load time, not only reachable post-load | FR-8, US-8 bullet 2 | +| TC-FR8-03 | Password reveal is hold-to-reveal, not toggle | Password field has a value | Press-and-hold eye icon, then release | Plaintext shown while held; masked again immediately on release | FR-8, ux-spec §3 "password-input pattern" | +| TC-FR8-04 | Password never logged | Load with a distinctive password value, e.g. `Tr0ub4dor&3-QA` | Submit, then inspect application logs (`RUST_LOG` output) | The literal password string never appears in logs | FR-8, "never logged, never stored" | +| TC-FR8-05 | Password/plaintext keys never stored unencrypted when Tier-2 chosen | Same as TC-FR8-02 | Inspect DB/secret-vault contents directly | Only the Argon2id-derived / XChaCha20-Poly1305-sealed envelope is present; no plaintext key material or password readable at rest | FR-8, security | +| TC-FR8-06 | Detail view reflects Tier-1 unprotected state | Node loaded with blank password | Open detail view | Shows `Keys: unprotected`; "Add password protection…" action IS offered | FR-8, FR-5, US-8 bullet 1 | +| TC-FR8-07 | Detail view reflects Tier-2 protected state, no redundant action | Node loaded with a password set | Open detail view | Shows `Keys: password-protected`; "Add password protection…" action is NOT offered (already protected) | FR-8, US-8 bullet 4 | +| TC-FR8-08 | "Add password protection…" reuses existing task | Detail view for a Tier-1 node | Click "Add password protection…" | Dispatches the existing `IdentityTask::ProtectIdentityKeys` (no new crypto path introduced) | FR-8, FR-5, NFR-4 | +| TC-FR8-09 | Password validation matches existing Add-password-protection rule *(RESOLVED — was `[AMBIGUOUS]`)* | Load form open | Enter a password, submit | No new policy is invented: validation is identical to whatever the existing Key Info screen's "Add password protection…" flow already enforces, since FR-8 reuses the same `store_protected`/`put_secret_protected` seal path (§10.3) — confirm the existing rule at implementation time | FR-8 | +| TC-FR8-10 | Identity key also sealed when password set (not just V/O/P) | Load form open, password set, load produces an identity key too | Submit, inspect secret store | The identity key (per FR-8 "(and identity) keys") is sealed Tier-2 alongside voting/owner/payout, not left unprotected | FR-8 | + +--- + +## FR-9 / US-9 — Credit actions (Withdraw / Top up / Transfer) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR9-01 | Actions row present for Masternode | Masternode detail view | Inspect Actions row | `Withdraw`, `Top up`, `Transfer` all present | FR-9, US-9 bullet 1 | +| TC-FR9-02 | Actions row present for Evonode | Evonode detail view | Inspect Actions row | Same three actions present (FR-9 explicit "both Masternode and Evonode") | FR-9, US-9 bullet 1 | +| TC-FR9-03 | Withdraw scoped to correct identity | Detail view for node X | Click Withdraw | `withdraw_screen` opens with `QualifiedIdentity` == node X (not the app-global User identity or any other node) | FR-9 | +| TC-FR9-04 | Top up scoped to node, wallet = current wallet pill | Detail view for node X, wallet pill = Wallet A | Click Top up | `top_up_identity_screen` opens scoped to node X with source wallet = Wallet A | FR-9, FR-GLOBAL-NAV-3 | +| TC-FR9-05 | Transfer scoped to node | Detail view for node X | Click Transfer | `transfer_screen` opens scoped to node X | FR-9 | +| TC-FR9-06 | Owner-key withdraw forces payout-address destination | Withdraw flow initiated with the node's owner key | Attempt to set a custom destination address | Destination field is fixed to the node's registered Core payout address; not user-editable | FR-9, US-9 bullet 2, Domain Notes §3 | +| TC-FR9-07 | Transfer/payout-key withdraw allows free destination | Withdraw flow initiated with the transfer/payout key | Enter a custom destination address | Destination field accepts any user-chosen address | FR-9, US-9 bullet 2 | +| TC-FR9-08 | Reuse, not reimplementation | All three actions | Trigger each from the Masternodes detail view and independently from wherever else in the app they're already reachable | Same screen struct/type is pushed in both cases (structural reuse assertion — no parallel MN-specific implementation) | FR-9 "reuse existing screens", NFR-1 | + +--- + +## FR-10 / US-10 — Manage-keys drill-in (KeyInfoScreen) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR10-01 | Drill-in opens existing KeyInfoScreen | Detail view, Keys section | Click "Manage keys ›" | Existing `KeyInfoScreen` opens scoped to this node's identity | FR-10, US-10 bullet 1 | +| TC-FR10-02 | View private key/WIF | KeyInfoScreen open for node | Select a loaded key | Private key/WIF viewable | FR-10 | +| TC-FR10-03 | Sign message | KeyInfoScreen open for node | Use sign-message action | Message signed using node's key | FR-10 | +| TC-FR10-04 | Add-key selector excludes OWNER (Masternode) | KeyInfoScreen for a Masternode | Open add-key purpose selector | `OWNER` not offered | FR-10, US-10 bullet 2, Domain Notes §3 | +| TC-FR10-05 | Add-key selector excludes VOTING (Masternode) | Same | Same | `VOTING` not offered | FR-10, US-10 bullet 2 | +| TC-FR10-06 | Add-key selector excludes OWNER (Evonode) | KeyInfoScreen for an Evonode | Open add-key purpose selector | `OWNER` not offered | FR-10 | +| TC-FR10-07 | Add-key selector excludes VOTING (Evonode) | Same | Same | `VOTING` not offered | FR-10 | +| TC-FR10-08 | Rule applies to User identities too (not MN-specific) | KeyInfoScreen for a User identity | Open add-key purpose selector | `OWNER`/`VOTING` also excluded here (regression check confirming this is a platform-wide rule, not new MN-only logic) | FR-10 "not MN-specific — document it" | +| TC-FR10-09 | TRANSFER offered | Any identity type's KeyInfoScreen | Open selector | `TRANSFER` present | FR-10 | +| TC-FR10-10 | AUTHENTICATION offered | Same | Same | `AUTHENTICATION` present | FR-10 | +| TC-FR10-11 | ENCRYPTION offered | Same | Same | `ENCRYPTION` present | FR-10 | +| TC-FR10-12 | DECRYPTION offered | Same | Same | `DECRYPTION` present | FR-10 | +| TC-FR10-13 | Remove-key reachable | KeyInfoScreen for node with ≥2 keys | Use remove-key action on a non-critical key | Key removed (existing capability, unchanged) | FR-10 | + +--- + +## FR-11 / US-11 — Evonode token-rewards cross-link + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR11-01 | Cross-link shown for Evonode | Evonode detail view | Inspect Actions row | `Claim token rewards ›` shown | FR-11, US-11 bullet 1 | +| TC-FR11-02 | Cross-link hidden for plain Masternode (explicit negative case) | Masternode detail view | Inspect Actions row | `Claim token rewards ›` is **absent** — not disabled, not present-but-greyed | FR-11, US-11 bullet 2 | +| TC-FR11-03 | Cross-link routes to existing ClaimTokensScreen | Evonode detail view | Click `Claim token rewards ›` | Existing `ClaimTokensScreen` opens scoped to this Evonode identity | FR-11 | +| TC-FR11-04 | No new claim UI on Masternodes page | Same | Same | Screen pushed is the same `ClaimTokensScreen` type used elsewhere in the app (structural reuse, no MN-page-local reimplementation) | FR-11, NFR-1 | + +--- + +## FR-12 — "Fill Random Masternode/Evonode" (Testnet-only, fixture-conditional) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR12-01 | Button renders: Testnet + fixture present + Masternode toggle | Network=Testnet, `.testnet_nodes.yml` present and parses, toggle=Masternode | Open load form | `🎲 Fill Random Masternode` button + hint row render | FR-12 | +| TC-FR12-02 | Button label follows toggle: Evonode | Same fixture conditions, toggle=Evonode | Open load form | Button label reads `Fill Random Evonode` (matches toggle, per FR-12 "label follows Node-type toggle") | FR-12 | +| TC-FR12-03 | Button absent when fixture file missing (not disabled) | Network=Testnet, `.testnet_nodes.yml` absent | Open load form | The entire button+hint row is absent — assert widget/element count for that row is 0, not `disabled=true`; form flows directly from Node-type to ProTxHash | FR-12 "MUST be conditional... never shown-but-disabled" | +| TC-FR12-04 | Button absent when fixture unparseable, no crash/no error banner | Network=Testnet, `.testnet_nodes.yml` present but malformed YAML | Open load form | Button absent; no panic; no `MessageBanner` error surfaced for this case. **Note (PROJ-004):** the loader returns `Ok(None)` only for a *missing* file; a *malformed* file returns `Err(_)` which the legacy screen banners — so the new form must **swallow `Err(_)` → absent** (a deliberate divergence from legacy, `tracing::debug!` the parse error), not verbatim reuse | FR-12; loader `Ok(None)` on absent, `Err` on malformed (`add_existing_identity_screen.rs:58-71,151-161`) | +| TC-FR12-05 | Button absent on Mainnet regardless of fixture | Network=Mainnet, fixture present | Open load form | Button absent (Testnet-only gate) | FR-12 | +| TC-FR12-06 | Button absent on Devnet | Network=Devnet, fixture present | Open load form | Button absent | FR-12 | +| TC-FR12-07 | Clicking Fill Random autofills from a real fixture entry *(CORRECTED 2026-07-09, PROJ-003 — Masternode fixture has no payout key)* | Testnet + fixture present, toggle=Masternode | Click `Fill Random Masternode` | ProTxHash + **Voting + Owner** fields populate from one of the fixture's `masternodes` entries (`MasternodeInfo` has no payout field, so `fill_random_masternode()` fills V+O only — Payout stays blank; the Evonode/`hp_masternodes` path fills all three incl. Payout, TC-FR12-08) — not blank, not synthetic | FR-12, `add_existing_identity_screen.rs:30-35,979-993` | +| TC-FR12-08 | Correct fixture list consulted per node type | Testnet + fixture present | Click Fill Random with toggle=Masternode, then separately with toggle=Evonode | Masternode toggle pulls from fixture's `masternodes` list; Evonode toggle pulls from `hp_masternodes` list (verified against `fill_random_masternode()`/`fill_random_hpmn()`) | FR-12 | +| TC-FR12-09 | `[DEFERRED, not ambiguous]` Defense-in-depth `developer_mode` re-check at button call-site | Load form reachable only via Expert-Mode-gated nav (FR-1) | N/A | FR-12 explicitly defers this as an implementation judgment call for Nagatha's plan, not an open requirements question — Nagatha's plan should record its own decision, not this spec | FR-12 "Flag for Nagatha" | +| TC-FR12-10 | Node type toggle clears autofilled fields *(RESOLVED — was `[AMBIGUOUS]`)* | Fields already autofilled via Fill Random for Masternode | Switch toggle to Evonode | ProTxHash, Alias, and all key fields are **cleared** — a real node's identity is tied to one type; data for one is never valid for the other (§10.6) | FR-12, wireframe C | + +--- + +## Global Nav — US-7 / FR-GLOBAL-NAV-1…6 + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-NAV-01 | Page-aware leftmost crumb | On Masternodes tab | Read header crumb | Leftmost segment reads `Masternodes` (not `Identities`), links to the Masternodes root | FR-GLOBAL-NAV-6, US-7 bullet 1 | +| TC-NAV-02 | Same switcher component as Identity Hub | Masternodes tab open | Inspect rendered widget | Same `breadcrumb_switcher.rs` component via `add_top_panel_with_breadcrumb` used, not a bespoke reimplementation | FR-GLOBAL-NAV-1, NFR-1 | +| TC-NAV-03 | Wallet pill Interactive on Masternodes | Any Masternodes screen (list/load/detail) | Inspect wallet pill | Renders `Interactive` mode: caret, clickable | FR-GLOBAL-NAV-3, US-7 | +| TC-NAV-04 | Masternode pill Placeholder when 0 nodes | Empty state | Inspect masternode pill | Renders `Placeholder`: `(no masternode yet)`, italic, no caret | FR-GLOBAL-NAV-3, wireframe A | +| TC-NAV-05 | Masternode pill "Choose a masternode" on list with no selection | ≥1 node loaded, list view, no node opened yet | Inspect masternode pill | `Interactive`, text `Choose a masternode ▾` | wireframe B | +| TC-NAV-06 | Wallet selection is silent, no forced navigation | On Masternodes tab | Select a different wallet from the wallet-pill dropdown | `AppContext::selected_wallet_hash` updates; page remains on Masternodes tab (no navigation away) | FR-GLOBAL-NAV-2 rule 1, US-7 bullet 2 | +| TC-NAV-07 | Wallet-pill → Top-up binding (pill drives page) | Wallet pill changed to Wallet B | Open Top up on a node | Top-up screen's source wallet = Wallet B | FR-GLOBAL-NAV-2 rule 2, US-7 bullet 2 | +| TC-NAV-08 | Top-up → wallet-pill binding (page drives pill) | Top-up screen open with source wallet initially = Wallet A | Change source wallet inside the Top-up flow to Wallet C | Top-nav wallet pill updates to show Wallet C | FR-GLOBAL-NAV-2 rule 2 ("two-way"), US-7 bullet 2 | +| TC-NAV-09 | Card click → masternode-pill binding | List view, ≥2 nodes | Click card for node X | Masternode pill updates to show node X | FR-GLOBAL-NAV-3, US-7 bullet 3 | +| TC-NAV-10 | Masternode-pill selection → detail navigation | List or detail view, ≥2 nodes | Pick node Y from masternode-pill dropdown | Detail view for node Y opens | FR-GLOBAL-NAV-3, US-7 bullet 3 | +| TC-NAV-11 | Masternode-pill dropdown content correctness | 3 nodes loaded | Open masternode-pill dropdown | Exactly the 3 loaded MN/Evonode identities are listed, no others, no duplicates | FR-GLOBAL-NAV-3 | +| TC-NAV-12 | **FR-6 boundary — masternode selection never leaks to user pages (critical)** | Masternode selected via Masternodes-page pill | Navigate to Dashpay, then Identities, then Identity Hub | On every one of these pages, the identity pill/selection reflects the app-global **User** identity (or none) — never the masternode | FR-GLOBAL-NAV-3, FR-6, US-7 bullet 4 | +| TC-NAV-12b | **FR-6 boundary — first-loaded fallback never resolves a masternode** *(added 2026-07-09, PROJ-001)* | Exactly one identity loaded and it is a Masternode/Evonode; nothing explicitly selected | Open Dashpay/Identities/Identity Hub | `resolve_selected_identity()` returns the User identity or `None`, **never** the masternode via the first-loaded fallback (`context/mod.rs:1099-1105` filters MN/Evonode at the resolution layer, not just display) | FR-6, PROJ-001a | +| TC-NAV-12c | **FR-6 boundary — stale persisted MN selection sanitized on load** *(added 2026-07-09, PROJ-001)* | A masternode was persisted as `selected_identity_id` in a prior session (masternodes were Hub-pickable then) | Launch/build with the new FR-6 filter and open an everyday-user page | The stale MN/Evonode selection is cleared on context load; the identity pill shows a User identity or none, and operate-as reads never resolve the masternode | FR-6, PROJ-001b | +| TC-NAV-13 | Unwired-page pill renders Subdued | A page not yet wired to consume a given selection | Inspect its pill | Dimmed, no caret, no visible text tag | FR-GLOBAL-NAV-2 rule 3, US-7 bullet 5 | +| TC-NAV-14 | Subdued pill tooltip explains how to change selection | Same | Hover the Subdued pill | Tooltip text present, non-empty, page-specific (e.g. "Change the active wallet from the Wallets tab") | FR-GLOBAL-NAV-2 rule 3 | +| TC-NAV-15 | Per-page composition: wallet-only page shows one pill | A page with no identity/object context (e.g. a Wallet page) | Inspect switcher | Only the wallet pill renders; no third segment at all | FR-GLOBAL-NAV-2 rule 4, US-7 bullet 6 | +| TC-NAV-16 | Sub-screen nav doesn't disturb the global switcher | On list view, open load form or a node's detail | Compare header before/after | Global switcher stays single-line/unchanged; a separate `‹ All masternodes` back row appears in the content panel instead | FR-GLOBAL-NAV-5 | +| TC-NAV-17 | Everyday-page identity-pill dropdown never lists MN/Evonode | On Dashpay/Identities/Identity Hub, MN+Evonode+User identities all loaded | Open the identity pill's dropdown | Only User identities listed | FR-GLOBAL-NAV-4 | +| TC-NAV-18 | Masternode-pill resets to placeholder on list return *(RESOLVED — was `[AMBIGUOUS]`)* | Detail view for node X open | Click `‹ All masternodes` | Pill resets to `Choose a masternode ▾` placeholder — it reflects the current screen's context (specific node only on the detail view), not "last node opened" (§10.4, matches wireframe B as drawn) | FR-GLOBAL-NAV-3, §4b | + +--- + +## DPNS voting section (FR-5 collapsible + US-3) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-DPNS-01 | Collapsed by default | Detail view freshly opened | Observe section state | Collapsing section starts collapsed (`▸`) | FR-5, ux-spec §3 | +| TC-DPNS-02 | Count visible in collapsed header | Node has 3 open contests | Read collapsed header | Reads `DPNS name contests to vote on (3)` — count matches actual open-contest count | FR-5 | +| TC-DPNS-03 | Expand reveals per-contest choices | Collapsed section | Click header | Expands (`▾`); each contested name shows Abstain / Lock / Vote-for-candidate | FR-5, US-3 bullet 1 | +| TC-DPNS-04 | Candidate dropdown scoped per contest | Section expanded | Open the "Vote for" dropdown on one contested name | Only candidates registered for that specific name appear (not a global candidate list) | FR-5 | +| TC-DPNS-05 | Cast votes dispatches real backend | Section expanded, choice selected for ≥1 name | Click "Cast votes" | Vote is dispatched through the existing `contested_names/vote_on_dpns_name.rs` backend with the correct name/choice/identity parameters | FR-5, US-3 bullet 2 | +| TC-DPNS-06 | Success feedback auto-dismisses | Vote cast successfully | Observe banner | Success `MessageBanner` shown, auto-dismisses (per journey 2.2) | US-3 | +| TC-DPNS-07 | Scheduled/past votes are out of scope for this section *(RESOLVED — was `[AMBIGUOUS]`)* | Node has both a scheduled vote and past votes | Expand section | Only **active, open contests** render here (exactly as wireframe D draws it); scheduled/past-vote history is not duplicated on this page — it already lives on the existing DPNS Scheduled Votes root screen (§10.7) | FR-5 | +| TC-DPNS-08 | Zero-open-contests empty copy | Node has 0 open contests | Expand section | Body text exactly `There are no open name contests for this node to vote on right now.`, no contest table | §7 copy | +| TC-DPNS-09 | **Missing voter identity → actionable message, not raw error (critical)** | `associated_voter_identity` is `None` | Open/expand voting section | Shows exactly `This node has no voting key loaded. Add its voting private key to cast votes.` plus a secondary `( Add voting key )` action — the raw `NoVotingIdentity` error type/string is never surfaced to the user | US-3 bullet 3, §7 copy, CLAUDE.md error-message rules | +| TC-DPNS-10 | "Add voting key" opens the scoped in-place prompt *(CORRECTED 2026-07-09, PROJ-002 — was "load form opens pre-filled", which contradicted TC-DPNS-11/§10.8)* | Voting section showing the missing-voter-identity state | Click `( Add voting key )` | A **scoped voter-key-input prompt** opens with this node's context pre-bound — it is **not** FR-4's load form (consistent with §10.8 / TC-DPNS-11); no ProTxHash re-entry | ux-spec wireframe D note, §10.8 | +| TC-DPNS-11 | "Add voting key" is a scoped in-place action, not a load-form resubmission *(RESOLVED — was `[AMBIGUOUS]`)* | Load form pre-filled per TC-DPNS-10 | Submit with a new voting key entered | "Add voting key" (US-3) opens a small, scoped key-input prompt that updates the voter identity on the already-loaded node in place — it is a different flow from FR-4's load form, so the duplicate-ProTxHash rejection (TC-EDGE-07) does not apply here (§10.8) | FR-4, wireframe D | + +--- + +## US-4 — Remove a masternode + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-US4-01 | Remove button present | Detail view open | Inspect footer | `Remove masternode` danger button present | US-4 bullet 1, FR-5 | +| TC-US4-02 | Confirmation dialog with specific verb | Detail view open | Click Remove | `ConfirmationDialog` opens, `danger_mode(true)`, verb label `Remove masternode` | US-4 bullet 1, §7 copy | +| TC-US4-03 | Cancel/Remove button placement | Confirmation open | Inspect dialog | Cancel positioned left, Remove positioned right | ux-spec §3 | +| TC-US4-04 | Escape cancels | Confirmation open | Press Escape | Dialog closes, node NOT removed | ux-spec §3 | +| TC-US4-05 | Confirm removes node AND its voter identity | Node with an associated voter identity, confirmation open | Confirm | Both the masternode/evonode identity row AND its associated voter identity row are deleted from the DB (assert both, not just the card disappearing) | US-4 bullet 2, journey 2.3 | +| TC-US4-06 | Returns to list, card gone | Same, post-confirm | Observe screen | Back on card list; removed node's card no longer present | US-4 bullet 2 | +| TC-US4-07 | Isolation — other nodes unaffected | ≥2 nodes loaded, remove one | Confirm removal of node X | Node Y's card, keys, and voter identity remain fully intact (no over-deletion) | US-4, data integrity | + +--- + +## Edge / failure cases + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-EDGE-01 | Enter-key submit bypass with empty ProTxHash | Load form open, ProTxHash empty, focus in another field | Press Enter | No backend load task is dispatched (disabled state cannot be bypassed via keyboard submit) | FR-4, US-1 bullet 2 | +| TC-EDGE-02 | Load error is friendly + non-leaking | Backend load fails (e.g. simulated network error) | Submit a ProTxHash that triggers the failure | `MessageBanner` (Error), persistent (not auto-dismiss), `.with_details(e)` attached, visible message contains no raw SDK/DB error text or stack trace | FR-4, journey 2.1 "Failure paths" | +| TC-EDGE-03 | Missing voter identity at vote time is actionable | Voting section, no voter identity | Attempt to vote | Actionable message shown (cross-ref TC-DPNS-09), never the raw `NoVotingIdentity` error | US-3 bullet 3 | +| TC-EDGE-04 | No wallet loaded, attempt Top up *(RESOLVED 2026-07-09 — was `[AMBIGUOUS]`)* | 0 wallets loaded, Masternodes tab open | Inspect wallet pill and attempt Top up | Behavior matches whatever the existing, reused `top_up_identity_screen` already does with 0 wallets loaded — this design adds an entry point only and does not redefine that screen's no-wallet handling (§10.5) | FR-9 | +| TC-EDGE-05 | Card list scoped per active network | Node A loaded under Testnet, network switched to Mainnet | Switch network, open Masternodes tab | Card list shows only Mainnet-scoped nodes; node A (Testnet) is not shown until switching back | FR-1 "survives network switches", NFR consistency with other root tabs | +| TC-EDGE-06 | Network switch mid-sub-screen returns to the list *(RESOLVED — was `[AMBIGUOUS]`)* | Load form (with Fill Random visible, Testnet) or detail view open | Switch network away from Testnet mid-screen | App returns to the Masternodes **list** for the new network, matching TC-EDGE-05's "card list scoped per active network" rule — no stale sub-screen referencing a now-foreign identity (§10.10) | FR-12, FR-1 | +| TC-EDGE-07 | Duplicate-ProTxHash load is rejected *(RESOLVED — was `[AMBIGUOUS]`)* | Node with ProTxHash P already loaded | Submit the load form again with the same ProTxHash P | Duplicate-node error shown (§7 copy: "This masternode is already loaded…"); no second card created, existing node not silently updated (§10.9) | FR-4 | +| TC-EDGE-08 | Malformed ProTxHash validated inline *(RESOLVED — was `[AMBIGUOUS]`)* | Load form open | Enter a syntactically invalid ProTxHash (wrong length/charset), attempt submit | Client-side format validation fires inline/on-blur (not only gated on emptiness); error copy per §7: "This doesn't look like a valid ProTxHash…" (§10.9) | FR-4 | + +--- + +## NFR checks (execution-verifiable subset) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-NFR4-01 | Warning note is non-blocking | Load form open | Fill only ProTxHash, leave password/note area untouched | Load button enabled purely on ProTxHash presence; note's existence does not gate submission | NFR-4, FR-4 | +| TC-NFR6-01 | Card has a single accessible label | Card list, ≥1 card | Query accessibility tree (kittest) | Card exposes `WidgetInfo::labeled(Button, ..., "Open {node}")`, single click target | NFR-6 | +| TC-NFR6-02 | Focus order top-to-bottom on load form | Load form open | Tab through fields | Order follows visual top-to-bottom layout, ending on the primary action (Load masternode) last | NFR-6 | +| TC-NFR6-03 | No colour-only status anywhere in the feature | Card list + detail view, all status indicators (voter readiness, identity status, key protection tier) | Inspect each | Every status indicator pairs its colour with a text label | NFR-6 (regression across FR-3/FR-5/FR-8) | +| TC-NFR6-04 | Disabled Load button carries disabled-tooltip semantics | Load form, ProTxHash empty | Inspect button state via kittest | `enabled() == false`; tooltip text retrievable and matches TC-FR4-06 | NFR-6, FR-4 | + +--- + +## Coverage summary + +| Group | Count | +|---|---| +| FR-1 (Expert Mode gating) | 8 | +| FR-2 (Empty state) | 7 | +| FR-3 (Card list) | 15 | +| FR-4 (Load flow) | 22 | +| FR-5 (Detail composition) | 7 | +| FR-6 (Hub-picker filter) | 7 | +| FR-7 (Refresh) | 4 | +| FR-8 / US-8 (Load-time password) | 10 | +| FR-9 / US-9 (Credit actions) | 8 | +| FR-10 / US-10 (Manage-keys drill-in) | 13 | +| FR-11 / US-11 (Evonode cross-link) | 4 | +| FR-12 (Fill Random) | 10 (1 intentionally deferred, see below) | +| Global Nav (US-7 / FR-GLOBAL-NAV) | 20 | +| DPNS voting section (FR-5 / US-3) | 11 | +| US-4 (Remove) | 7 | +| Edge/failure cases | 8 | +| NFR (execution-verifiable subset) | 5 | +| **Total** | **166** (0 open ambiguities remain; +2 = TC-NAV-12b/12c added 2026-07-09 for the PROJ-001 FR-6 resolution-layer boundary) | + +US-6 retired — 0 test cases, documented above. + +--- + +## Ambiguities/gaps surfaced — RESOLVED 2026-07-09 (folded back before Nagatha's plan) + +All 12 gaps originally surfaced in this closing section were resolved by the coordinator in `01-requirements.md` +§10 and reflected in the corresponding test-case rows above (each row now reads "RESOLVED" inline with a +§10.N citation). One item (TC-FR12-09) was reclassified `[DEFERRED, not ambiguous]` — it was always an +intentional implementation judgment call for Nagatha's plan, not an open requirements question. A **13th gap, +TC-FR1-05b** (live de-gating fallback screen), existed in the FR-1 test cases but was omitted from this +closing list in the original pass — found by the coordinator during a direct sweep and resolved in §10.11. +Original list, kept for traceability: + +1. ~~TC-FR2-05~~ — empty-state reassurance-line copy → canonicalized in 01-requirements.md §7 (wireframes.html's wording wins). +2. ~~TC-FR3-11~~ — DPNS status precedence → §10.1 (count-first, then scheduled, then none; reuses existing Scheduled Votes state). +3. ~~TC-FR4-22~~ — legacy advanced-options arm → §10.2 (removed, extends FR-6). +4. ~~TC-FR8-09~~ — password strength rule → §10.3 (reuses existing Add-password-protection validation, no new policy). +5. TC-FR12-09 → reclassified `[DEFERRED, not ambiguous]`; TC-FR12-10 → §10.6 (node-type toggle clears autofilled fields). +6. ~~TC-NAV-18~~ — masternode-pill state on list return → §10.4 (resets to placeholder). +7. ~~TC-DPNS-07~~ — scheduled/past votes → §10.7 (out of scope by design; already covered by the existing Scheduled Votes screen). +8. ~~TC-DPNS-11~~ — "Add voting key" resubmission → §10.8 (scoped in-place action, not a load-form resubmission). +9. ~~TC-EDGE-04~~ — no-wallet Top-up behavior → §10.5 (inherits the existing reused Top-up screen's behavior, unchanged). +10. ~~TC-EDGE-06~~ — network switch mid-sub-screen → §10.10 (returns to the list, matches TC-EDGE-05). +11. ~~TC-EDGE-07~~ — duplicate-ProTxHash load → §10.9 (rejected with a friendly error, §7 copy). +12. ~~TC-EDGE-08~~ — malformed-ProTxHash validation → §10.9 (inline/on-blur, §7 copy). +13. ~~TC-FR1-05b~~ *(found outside this list)* — live de-gating fallback → §10.11 (falls back to Identities). + +None of these were silently dropped — each had a recorded, traceable test-case ID, and each now has a decision ++ an updated expected-outcome to assert against. diff --git a/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md b/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md new file mode 100644 index 000000000..bf4b12b68 --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md @@ -0,0 +1,388 @@ +# Masternodes Page — Development Plan (Phase 1d) + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` (base `v1.0-dev`) · **Date:** 2026-07-09 +**Author:** Nagatha (Software Architect) · **Phase:** 1d — implementation decomposition. Planning only; no Rust written. + +Inputs (all final, human-accepted 2026-07-09): `01-requirements.md` (FR-1…FR-12, FR-GLOBAL-NAV, +NFR-1…NFR-7, US-1…US-11, §10 Resolved gaps), `02-ux-spec.md`, `wireframes.html`, `03-test-case-spec.md` +(164 cases), `v010-masternode-features.md`. Grounded against the live tree: `src/ui/mod.rs`, +`src/ui/components/{top_panel,left_panel}.rs`, `src/ui/identity/breadcrumb_switcher.rs`, +`src/ui/state/hub_selection.rs`, `src/backend_task/identity/{mod,load_identity,protect_identity_keys}.rs`, +`src/model/feature_gate.rs`, `src/app.rs`. + +> **Review fixes folded in (2026-07-09):** Fable/Adams reviewed this plan — APPROVE WITH FIXES, 0 CRITICAL/HIGH, +> 4 MEDIUM + 9 LOW (`fable-review.md`). PROJ-001, -002, -003, -005 (MEDIUM) and PROJ-004, -006, -007, -008, -009, +> -010, -011, -012, -013 (LOW) are folded into the affected tasks below and into `01-requirements.md` / +> `03-test-case-spec.md` / `02-ux-spec.md`. Changes are spec-level; the plan's architecture is unchanged. + +Two structural facts from the live tree that shape the whole plan: +1. **The nav rail already accepts a per-entry `FeatureGate`** (`left_panel.rs:237-243` skips entries whose gate is + unavailable; runtime-gate precedent is the `DashPay` entry). FR-1's Expert-Mode gate is therefore a *data* + change (one gated entry), not new machinery. +2. **The top-panel breadcrumb seam already exists** (`top_panel::add_top_panel_with_breadcrumb` → + `render_top_island`, `top_panel.rs:378-385`). The switcher itself (`breadcrumb_switcher::render`) is, however, + **hub-hardwired**: a literal `"Identities"` segment-1, hub-only pill semantics, and effect application living + inside `hub_screen.rs::apply_breadcrumb_effect`. Phase A generalizes exactly this. + +--- + +## 1. System Layers & Responsibilities + +Per DET's Module Placement Policy. Every task below names its layer(s); this is the map. + +| Layer | This feature's responsibilities | Files (new *n* / modified *m*) | +|---|---|---| +| **model/** (pure, no IO) | ProTxHash shape validation (hex/Base58) as a stateless validator; **relocation** of the existing password-format validator into `model/` (see B0/PROJ-006 — it currently lives, private, in `backend_task/identity/protect_identity_keys.rs:156`, contrary to DET's Validation-placement rule). Global-nav page-scope enum lives in `ui/state`, not here (it is view state, per the discriminator). | `model/masternode_input.rs` *n* (ProTxHash validator); relocate `validate_protection_password` → `model/` *m* | +| **backend_task/** (async, authoritative enforcement) | Thread the optional load-time password through load; authoritative duplicate-ProTxHash rejection; masternode+voting refresh; reuse withdraw/top-up/transfer/protect/vote tasks unchanged. `TaskError` variants for duplicate/malformed. | `backend_task/identity/{mod.rs,load_identity.rs}` *m*; `backend_task/error.rs` *m* | +| **database/** | No new module. MN/Evonode rows already persist as `QualifiedIdentity`. Read paths reuse existing accessors; the per-node contest read (B1/PROJ-012) composes from the existing contested-names store. | — | +| **context/** (glue) | Wrapper read methods: masternode-only identity list for the active network; **User-only accessor consumed at the resolution layer** so `resolve_selected_identity()` and wallet-reconciliation can never resolve a masternode as the everyday-page identity (FR-6, PROJ-001); one-time sanitization of a stale persisted MN/Evonode selection; live-de-gating fallback helper. | `context/mod.rs` (or `context/identity_db.rs`) *m* | +| **ui/state/** (renders nothing) | The page-scoped nav model: which pills a page composes, which selections it consumes, and the **page-scoped masternode selection kept distinct from the app-global user-identity selection** (FR-6 boundary). Masternodes-screen view state. | `ui/state/global_nav.rs` *n*; `ui/state/masternodes_view.rs` *n* | +| **ui/components/** (renders egui) | Generalized global switcher built on the existing `BreadcrumbPill` modes (`Interactive`/`Subdued`/`Placeholder` — no new pill widget). | `ui/components/global_nav_switcher.rs` *n* (extracted from `breadcrumb_switcher.rs`) | +| **ui/masternodes/** (new screen domain) | The Masternodes root screen, empty state, card grid + card body, load form, detail/voting view, Fill-Random, entry points into reused screens. No business logic, no own validation. | `ui/masternodes/{mod,list_screen,load_form,detail_screen,card}.rs` *n* | +| **ui/mod.rs, app.rs** (shell) | New `RootScreenType::RootScreenMasternodes`, `ScreenType::Masternodes`, `Screen::MasternodesScreen`; root-screen registration; nav entry (gated); global-switcher render on every root screen. | `ui/mod.rs` *m*, `app.rs` *m*, `left_panel.rs` *m* | + +**Reuse-first ledger (no new operation screens).** FR-9 → `withdraw_screen` / `top_up_identity_screen` / +`transfer_screen`; FR-10 → `KeyInfoScreen`; FR-11 → `ClaimTokensScreen`; FR-5 protect → `IdentityTask::ProtectIdentityKeys`; +FR-5 voting → `contested_names/vote_on_dpns_name.rs`; FR-12 → `fill_random_masternode()`/`fill_random_hpmn()` + +`.testnet_nodes.yml` loader; FR-3 card → `IdentityPickerCard` frame + `draw_type_badge`; header → `add_top_panel_with_breadcrumb`. +Each is an **entry point**, not a reimplementation — this is the QA dedup contract (TC-FR9-08, TC-FR11-04, TC-NAV-02). + +--- + +## 2. Two-Phase Sequencing (coordinator decision) + +- **Phase A — Global wallet/identity switcher on every root page (app-shell foundation).** Larger than this + feature; lands and is reviewed as its own commit sequence *first*. The nav renders on every root page day one; + interactivity is blast-radius-limited (Subdued + tooltip + `TODO` on unwired pages). The Masternodes page then + *consumes* this, it does not build it. +- **Phase B — The Masternodes page**, built on top, inheriting the global nav. + +Phase B B7 (page nav wiring) has a hard dependency on Phase A. **B1 now also depends on A2** (PROJ-005): the FR-6 +filter lands in the durable context accessor consumed by both switcher generations, so B1 does not patch the file +A2 rewrites. All other Phase B tasks can proceed against a Subdued/placeholder header until B7 lands. + +--- + +## 3. Phase A — Global Nav (app-shell) + +### A1 — Page-nav model & the two-scope selection abstraction *(ui/state)* +**Files:** `ui/state/global_nav.rs` *n* (+ unit tests inline). +**Work:** Define `PageNavSpec` describing, per root page: segment-1 `(label, RootScreenType target)` +(page-aware, FR-GLOBAL-NAV-6); pill composition (`wallet?`, `identity/object?` — FR-GLOBAL-NAV-2 rule 4); and +per-pill consumption mode (`Consumed{two-way}` vs `Unwired{how-to-change tooltip}`, rule 3). Define +`IdentityPillScope` = `AppGlobalUser` **|** `PageScopedObject{ label, dropdown items, selected }` — this enum is +the structural guarantee behind the FR-6 boundary: the page-scoped variant **never** writes +`AppContext::selected_identity_id`. Pure state; renders nothing (placement discriminator → `ui/state`). +**Tests (TDD):** spec resolves correct pill set per page; `PageScopedObject` selection is isolated from app-global +identity; unwired-pill mode carries a non-empty tooltip. +**Satisfies:** TC-NAV-13, TC-NAV-14, TC-NAV-15 (composition/subdued/tooltip logic); foundation for TC-NAV-12, TC-FR6-07. + +### A2 — Generalize `breadcrumb_switcher` into a page-aware global switcher *(ui/components)* +**Files:** `ui/components/global_nav_switcher.rs` *n* (extract/rewrite of `ui/identity/breadcrumb_switcher.rs`); +keep a thin hub-facing shim so `hub_screen.rs` compiles unchanged in behavior. +**Work:** `render(ui, ctx, spec: &PageNavSpec, view_state) -> GlobalNavEffect`. Segment-1 renders `spec` label + +links to `spec` root (replaces the literal `"Identities"`). Pills compose per `spec`: consumed pills render +`Interactive` (caret + dropdown) and emit two-way effects; unwired pills render `Subdued` (dimmed, **no caret, no +visible text tag**) with the how-to-change tooltip and a `// TODO: wire <selection> on this page` marker. Reuse +`BreadcrumbPill`/`IdentityPill` and the existing `BreadcrumbPillMode` verbatim — **no new pill widget** (NFR-1). +Generalize `BreadcrumbEffect` → `GlobalNavEffect` (adds `SelectPageObject(...)` for the page-scoped pill, kept +distinct from `SelectIdentity`). The identity-list source for the pill dropdown reads through the **User-only +context accessor** (B1), so the FR-6 filter lives in the accessor, not this component (PROJ-005). +**Tests:** segment-1 label is page-driven; a page-scoped-object selection produces `SelectPageObject`, never +`SelectIdentity`; subdued pill emits no effect on click. +**Satisfies:** TC-NAV-01, TC-NAV-02, TC-NAV-03, TC-NAV-13, TC-NAV-16. + +### A3 — Render the switcher on every root screen + centralize effect application *(app-shell)* +**Files:** each root screen's top-panel call site → `add_top_panel_with_breadcrumb` with its `PageNavSpec`; a shared +`apply_global_nav_effect` (lift `hub_screen.rs::apply_breadcrumb_effect` to a shared helper) so wallet/identity +selection updates the **app-global** selection *silently, with no forced navigation* (rule 1). Every root screen not +yet wired supplies a Subdued spec (+ `TODO`). Hub keeps its existing interactive wallet/identity pills (regression). +**Work:** wiring + one shared effect applier. `SwitchWallet`→`set_selected_hd_wallet` (silent); +`SelectIdentity`→`set_selected_identity` (silent); `SelectPageObject`→ handled by the owning page (B7). +**Reconciliation semantics are not a pure wallet write (PROJ-010):** `set_selected_hd_wallet` +(`context/mod.rs:1152-1175`) reconciles the app-global *identity* to the new wallet's identities as a side effect +(keep-if-owned → first → `None`); on non-Hub pages this cross-axis mutation is real and intentional — A3 must +document it, and combined with B1's resolution-layer filter it must **never** reconcile onto an MN/Evonode. +Blast radius: only Hub (and, after B7, Masternodes) are interactive; all else Subdued. +**Tests:** kittest — switcher present on ≥2 non-Hub root screens; wallet selection does not navigate; +**wallet switch on a non-Hub page reconciles identity per existing rules and never onto a MN/Evonode**; unwired +identity pill on an everyday page never lists MN/Evonode (re-asserted in TC-NAV-17). +**Satisfies:** TC-NAV-06 (silent, no-nav), TC-NAV-15, TC-NAV-16; enables TC-NAV-12/17. + +**Phase A rollout/TODO placement:** every root screen that does not yet consume a selection gets a `Subdued` pill and +an explicit `// TODO: wire wallet/identity selection consumption for <page>` at its `PageNavSpec` construction — the +page-by-page wiring backlog. Interactivity is opt-in per page; this bounds the app-shell blast radius. + +--- + +## 4. Phase B — Masternodes Page (dependency order) + +### B0 — FR-8 load-time key encryption plumbing *(model + backend_task)* — **no UI** +**Files:** `backend_task/identity/mod.rs` (`IdentityInputToLoad`, add `encryption_password: Option<Secret>` at +`mod.rs:43`); `backend_task/identity/load_identity.rs` (route persistence); `backend_task/identity/protect_identity_keys.rs` +(the seal path + `validate_protection_password:156`); **`src/mcp/tools/masternode.rs:180`** (the third +`IdentityInputToLoad` constructor — the MCP `masternode_identity_load` tool). +**Work:** when `encryption_password` is `Some`, seal the voting/owner/payout **and identity** keys via the existing +`store_protected`/`put_secret_protected` envelope (Argon2id + XChaCha20-Poly1305) at load time, through the +`wallet_backend/secret_seam.rs` chokepoint — **no new crypto, no second persistence path**. When `None`, current +Tier-1 keyless path is unchanged. Validate the password in the backend (enforcement) reusing the *existing* rule +(§10.3 — do not invent one); password is a `Secret`, never logged, never stored. Add typed `TaskError` variants for +duplicate/malformed ProTxHash here (used by B1/B4) rather than string parsing. +- **Password-validator placement (PROJ-006).** `validate_protection_password` is today a **private fn in + `protect_identity_keys.rs:156`**, not a `model/` validator. Per DET's Validation-placement rule, pure + password-format validation belongs in `model/`: **relocate it to `model/` (or expose `pub(crate)` if relocation + is deferred)** so `load_identity` can call it — small, mechanical, in-scope for B0. +- **MCP constructor decision (PROJ-007).** Adding the field breaks `mcp/tools/masternode.rs:180` at compile time. + **Decision: MCP passes `encryption_password: None` this iteration** (Tier-1 unchanged — matches FR-8's GUI-only + scope; the tool is a confirmed keyless entry point, requirements §2.3). Leave a `TODO` for headless password + parity as a follow-up; do not silently invent MCP password handling. +**Tests (RED-first):** blank→unprotected path; set→`put_secret_protected`; identity key also sealed (TC-FR8-10); +password never appears in logs; no plaintext at rest; MCP path compiles and stays Tier-1. +**Satisfies:** TC-FR8-01, TC-FR8-02, TC-FR8-04, TC-FR8-05, TC-FR8-09, TC-FR8-10. +**Depends on:** nothing. Start immediately (parallel to Phase A). + +### B1 — Context read paths + FR-6 filter at the resolution layer + FR-7 refresh *(context + backend_task)* +**Files:** `context/mod.rs` (or `identity_db.rs`); `backend_task/identity/` (refresh). +**Work:** +- **New accessors:** `load_local_masternode_identities()` (active-network MN/Evonode — card-list + masternode-pill + source) and a **User-only accessor** for the Hub picker + global identity pill. +- **FR-6 filter at the *resolution* layer, not the display call sites (PROJ-001 / PROJ-005 — R1-critical).** The leak + is not confined to the two display sources (`breadcrumb_switcher.rs:148`, `hub_screen.rs:91,215`): + `resolve_selected_identity()` (`context/mod.rs:1099-1105`) falls back to the **first loaded identity over ALL + types** via `model::selected_identity::resolve_selected`, and `set_selected_hd_wallet` (`:1152-1175`) reconciles + the app-global identity the same way. An operator whose only/first loaded identity is a masternode — exactly the + Priya profile — would get it as the everyday-page operate-as identity with `IdentityPillScope` never involved. + **Filter MN/Evonode inside the resolution path** (both the keep-if-loaded check and the first-loaded fallback, over + all-loaded and over the per-wallet reconciliation source) via the User-only accessor. This is the durable seam and + also feeds the two display sources; the legacy `identities_screen.rs` table stays untouched (locked decision #2). +- **One-time stale-selection sanitization (PROJ-001b).** Masternodes are pickable in the Hub *today*, so a persisted + `selected_identity_id` may already point at an MN/Evonode. On context load, if the persisted selection resolves to + `IdentityType != User`, clear it — otherwise the User-filtered pill and the MN-valued selection disagree and + operate-as reads still resolve the masternode. +- **FR-7 refresh** (backend_task): re-fetch MN identity + **voting** state — compose from existing `refresh_identity` + + the contested-names query; **do not** add a parallel fetcher. +- **Card DPNS read accessor (PROJ-012).** The card's per-node open-contest count + scheduled-vote state + (TC-FR3-09/-10/-11, TC-DPNS-02) needs a **read** join over the existing contested-names store — name it here and + compose from that store; if no existing query serves it, this is the second thin new piece alongside the refresh + task (R4). +**Tests:** MN/Evonode absent from the picker/pill list **and** from `resolve_selected_identity()` even when it is the +only/first loaded identity; present in legacy table (control); User identity present in both; **stale MN persisted +selection cleared on load**; MN present in the masternode accessor. +**Satisfies:** TC-FR6-01…06, TC-NAV-12 (new preconditions TC-NAV-12b/12c), TC-NAV-17, TC-FR7-02, TC-FR7-03. +**Depends on:** **A2** (FR-6 filter lands in the durable context accessor both switcher generations consume; sequence +B1 after A2 to avoid the same-file collision — PROJ-005). + +### B2 — Root tab: registration, Expert-Mode gate, nav, de-gating fallback *(ui/mod.rs + app.rs + left_panel)* +**Files:** `ui/mod.rs` (`RootScreenType::RootScreenMasternodes` + `to_int`/`from_int` round-trip with a fresh stable +integer + test, mirroring the IdentityHub precedent at `ui/mod.rs:161,194,205-215`; `ScreenType::Masternodes`; +`Screen::MasternodesScreen`; `create_screen`; `change_context` arm); `app.rs` root-screen registration (mirror the +hub chain at `app.rs:805-821`); `left_panel.rs` nav entry **gated `FeatureGate::DeveloperMode`**, positioned +**below Identity Hub** (locked decision #3), distinct node/server glyph (not `identity.png`). +**Work:** the gate reuses the existing per-entry `gate.is_available()` skip (`left_panel.rs:237-243`) — nav item and +route both absent when Expert Mode is off. **Live de-gating fallback (§10.11):** when Expert Mode flips off while +Masternodes is active, fall back to `RootScreenIdentities` (nearest neutral tab — no existing precedent to reuse, so +this is a small explicit guard in the screen-resolution path, analogous to the persisted-selection fallback at +`app.rs:829-833`). Network switch keeps the tab selected but resets any pushed sub-screen to the list (§10.10). +**Tests:** round-trip of the new variant; nav absent Expert-off / present + positioned Expert-on; distinct glyph id; +survives network switch; de-gating falls back to Identities. +**Satisfies:** TC-FR1-01…07, TC-FR1-05b, TC-EDGE-05, TC-EDGE-06. +**Depends on:** none structurally; do before B3–B7 (they need the screen to exist). + +### B3 — Empty state + card grid + card body *(ui/masternodes)* +**Files:** `ui/masternodes/{list_screen,card}.rs`. +**Work:** Empty state (FR-2) reusing the `03-identities-empty.png` card pattern + exact §7 copy incl. the canonical +reassurance line. Card grid (FR-3) reusing `IdentityPickerCard` frame + `draw_type_badge` (purple/blue), extended +body rows: shortened ProTxHash / alias-as-heading, voter readiness, `V O P` key status across all 8 combinations, +DPNS status line with **count-first precedence** (§10.1 — `{count} contests` when `count>0`, else `Vote scheduled` +when pending, else `No open contests`; reads the per-node contest accessor from B1, reusing the existing +Scheduled-Votes state — no new backend concept), and the `IdentityStatus` dot+label (verified mapping, five states). +Whole card is one labelled click target (`WidgetInfo::labeled`, NFR-6). Responsive `minmax(260,1fr)`. Add the +top-right toolbar **Refresh** button here (FR-7). +**Tests (kittest):** empty↔grid boundary; badge colour/text per type; voter-ready vs no-voting-key; all 8 key combos; +precedence rows; five status states; single-click-target label; count == DB rows; Refresh button present/styled. +**Satisfies:** TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01, TC-NFR6-03. +**Depends on:** B1 (list + contest read source), B2 (screen). + +### B4 — Load form + validation + legacy-arm removal *(ui/masternodes + model)* +**Files:** `ui/masternodes/load_form.rs` *n*; `model/masternode_input.rs` *n* (ProTxHash validator); +`ui/identities/add_existing_identity_screen.rs` *m* (remove MN/Evonode options from the Advanced-Options Identity-Type +dropdown — User-only remains, §10.2 / TC-FR4-22). +**Work:** MN/Evonode-only form (FR-4): ProTxHash (required), Masternode/Evonode segmented toggle (default Masternode, +**no User option**), optional alias, V/O/P key inputs (reuse existing WIF-or-hex widget, hold-to-reveal), optional +encryption-password field (reuse `wallet_unlock.rs` hold-to-reveal; drives B0), always-visible Warning-tone note +(§7 copy), Load button disabled until ProTxHash present + disabled tooltip. **Validation delegates to model** +(NFR/layer rule): inline/on-blur ProTxHash shape check (hex or Base58) via the new `model/` validator; duplicate +detection is authoritative in the backend (B0/B1 typed error), surfaced as the §7 duplicate copy. Node-type toggle +**clears** ProTxHash/alias/keys (§10.6). **Explicitly no auto-derive affordance** (US-6 retired; assert absence). +**Tests:** field set incl. negative no-auto-derive assertion; toggle default/switch/clear; disabled+tooltip; +hex/Base58 accept; malformed inline; duplicate reject; friendly error banner + `.with_details`; cancel discards; +fresh form on reopen; legacy dropdown MN/Evonode removed. +**Satisfies:** TC-FR4-01…22, TC-EDGE-01, TC-EDGE-02, TC-EDGE-07, TC-EDGE-08, TC-FR8-03, TC-NFR4-01, TC-NFR6-02, TC-NFR6-04. +**Depends on:** B0 (password field target), B2. + +### B5a — Detail view scaffold: header, actions row, keys, remove *(ui/masternodes, reuse-heavy)* +**Files:** `ui/masternodes/detail_screen.rs` *n*. +**Work:** Detail composition in the **corrected order (TC-FR5-01): Header → Actions row → Keys → DPNS → Remove.** +Header: alias (conditional) + shortened ProTxHash + copy-full-value + type badge + status. **Actions row (FR-9):** +entry points pushing the existing `WithdrawalScreen`/`TopUpIdentityScreen`/`TransferScreen` scoped to the node's +`QualifiedIdentity` (both MN and Evonode) — reuse, not reimplementation; Top up sources the wallet-pill wallet. +**Evonode-only** `Claim token rewards ›` cross-link → existing `ClaimTokensScreen` (hidden for Masternode, FR-11). +**Keys section:** V/O/P presence + voter-identity id (copyable) + protection tier (`unprotected`/`password-protected`); +`Add password protection…` → existing `IdentityTask::ProtectIdentityKeys` (offered only Tier-1); `Manage keys ›` → +existing `KeyInfoScreen` (FR-10, add-key selector already excludes OWNER/VOTING for all types — verify, don't add +logic). **Remove:** `ConfirmationDialog danger_mode(true)`, removes node + associated voter identity. +Content-panel `‹ All masternodes` back row (not in the global header). Add the detail-view **Refresh** button (FR-7). +**Tests:** section order (actions above keys); reuse identity scoping for all three credit actions + structural-reuse +assertion; Evonode-only cross-link present/absent; protection-tier display + conditional Add-protection; Manage-keys +opens `KeyInfoScreen`; Remove confirm deletes node+voter, isolation, back row; Refresh present on detail. +**Satisfies:** TC-FR5-01…05, TC-FR5-07, TC-FR7-04, TC-FR9-01…08, TC-FR10-01…13, TC-FR11-01…04, TC-FR8-06, TC-FR8-08, +TC-US4-01…07, TC-EDGE-04. +**Depends on:** B2, B3. **(PROJ-011)** For **TC-FR8-07** (detail reflects a *load-time-sealed* node, no redundant +Add-protection), the Tier-2-at-load precondition requires B0+B4 — that one case is verified in **B8's integration +pass**; B5a covers only the tier-display + conditional-action logic (reachable via a post-load `ProtectIdentityKeys` +seal), so TC-FR8-07 is listed under B8, not here. + +### B5b — Detail view: inline DPNS voting + missing-voter path *(ui/masternodes, reuse voting backend)* +**Files:** `ui/masternodes/detail_screen.rs` (voting section). +**Work:** Collapsible section (**collapsed by default**, open-contest **count in header**). Expanded: per-contest +Abstain/Lock/Vote-for-candidate table + **Cast votes**, dispatching the existing +`contested_names/vote_on_dpns_name.rs` backend inline (locked decision #1 — **not** a deep-link). Candidate dropdown +scoped per contest. Active/open contests only (§10.7 — scheduled/past live on the existing Scheduled Votes screen, +not duplicated). **Missing-voter-identity (US-3/§10.8):** show the actionable §7 copy (never raw `NoVotingIdentity`) ++ `( Add voting key )` → a **scoped, in-place voter-key-input prompt** that updates the voter identity on the +already-loaded node (distinct from B4's load form; exempt from the duplicate-ProTxHash rejection). This is the +**§10.8-resolved design; TC-DPNS-10 is corrected in `03-test-case-spec.md`** from "load form opens pre-filled" to +"scoped prompt opens, node context pre-bound" so the pair TC-DPNS-10 / TC-DPNS-11 no longer contradict (PROJ-002). +Success banner auto-dismisses. +**Tests:** collapsed default; count in header; expand reveals choices; per-contest candidate scoping; Cast votes hits +the real backend with correct params; zero-open empty copy; missing-voter actionable message + scoped in-place prompt +(node pre-bound, NOT a load-form resubmission). +**Satisfies:** TC-DPNS-01…11, TC-EDGE-03. +**Depends on:** B5a. + +### B6 — FR-12 Fill Random (Testnet-only, fixture-conditional) *(ui/masternodes, reuse loader)* +**Files:** `ui/masternodes/load_form.rs`. +**Work:** Reuse `fill_random_masternode()`/`fill_random_hpmn()` + `load_testnet_nodes_from_yml(".testnet_nodes.yml")` +(`add_existing_identity_screen.rs:961-993`). **One** button, label follows the toggle. **Render-conditional, not +disabled:** button+hint row present only when network == Testnet **and** the loader returns `Some(_)`; absent (0 +widgets) otherwise. +- **Autofilled key set differs by node type (PROJ-003 — TC-FR12-07 corrected).** The regular-masternode fixture + struct `MasternodeInfo { pro_tx_hash, owner, voter }` has **no payout field**, so `fill_random_masternode()` fills + **ProTxHash + Voting + Owner only** (Payout stays blank). Only `fill_random_hpmn()` (Evonode / `hp_masternodes`) + fills all three including Payout. This is the honest, low-cost option matching the fixture operators actually have; + do **not** claim three-key autofill for the Masternode toggle. (Requirements FR-12 §7 and TC-FR12-07 corrected to + match.) +- **Malformed-YAML is a deliberate behavior change, not verbatim reuse (PROJ-004 — TC-FR12-04).** The loader returns + `Ok(None)` only for a **missing** file; a **malformed** file returns `Err(_)`, and the *legacy* screen banners it + (`add_existing_identity_screen.rs:151-161`). The new form must **map `Err(_)` → absent button** (swallow; no + banner, no panic) — a conscious divergence from the legacy screen. Add a `tracing::debug!` on the swallowed parse + error so a broken fixture is diagnosable. +- Masternode toggle pulls `masternodes`; Evonode pulls `hp_masternodes`. Autofill respects the node-type clear rule + (§10.6, shared with B4). **TC-FR12-09 decision — see §6:** add the defense-in-depth `is_developer_mode()` check. +**Tests:** render matrix (Testnet+fixture±toggle / missing / malformed→absent+no-banner / Mainnet / Devnet); +Masternode autofill = V+O (Payout blank); Evonode autofill = V+O+P; label follows toggle. +**Satisfies:** TC-FR12-01…08 (with the -07 correction), TC-FR12-09 (recorded decision), TC-FR12-10. +**Depends on:** B4. + +### B7 — Wire the Masternodes page into the global nav *(ui/masternodes + ui/state)* +**Files:** `ui/state/masternodes_view.rs` *n* (page-scoped masternode selection); Masternodes screens' `PageNavSpec`. +**Work:** Provide the page's `PageNavSpec`: page-aware segment-1 `Masternodes`; **wallet pill Interactive + two-way** +(funds Top up — FR-9; changing it on a Top-up flow updates the pill and vice-versa); **masternode pill Interactive + +two-way** using `IdentityPillScope::PageScopedObject` — dropdown lists loaded MN/Evonode (B1 source), opening a card +sets the pill, picking from the pill opens that node's detail; placeholder `(no masternode yet)` on empty, `Choose a +masternode ▾` on list, specific node on detail, **reset to placeholder on `‹ All masternodes`** (§10.4). The +page-scoped selection lives in `masternodes_view.rs`, **never** in `AppContext::selected_identity_id` — this is the +FR-6 boundary in code (complementing B1's resolution-layer filter). +**Tests (critical):** card-click↔pill and pill↔detail two-way; dropdown content correctness; wallet-pill→Top-up and +Top-up→wallet-pill two-way; **masternode selection never leaks to app-global user-identity pill across Dashpay/ +Identities/Hub (TC-NAV-12)**; pill placeholder/choose/reset states. +**Satisfies:** TC-NAV-04, TC-NAV-05, TC-NAV-07, TC-NAV-08, TC-NAV-09, TC-NAV-10, TC-NAV-11, TC-NAV-12, TC-NAV-18, +TC-FR5-06, TC-FR6-07, TC-FR9-04. +**Depends on:** **Phase A (A1–A3)**, B1, B3, B5a. + +### B8 — Cross-cutting integration coverage & QA handoff *(tests/)* +**Files:** `tests/kittest/…`, optionally `tests/e2e/…`. +**Work:** Assemble the kittest/e2e suite mapping the remaining execution-verifiable cases not covered by unit tests +in B0–B7 (a11y sweep, network-switch edge cases, the FR-6 boundary end-to-end, and **TC-FR8-07** load-time-sealed +detail display which needs the full B0+B4+B5a chain — PROJ-011). No production code. QA-facing traceability closure. +**Satisfies:** TC-FR8-07, TC-NFR6-01…04 (sweep), TC-EDGE-05/06 end-to-end, regression net over TC-NAV-12. +**Depends on:** B2–B7. + +--- + +## 5. Task → Test-Case Traceability + +| Task | Layer(s) | Test cases satisfied | +|---|---|---| +| **A1** | ui/state | TC-NAV-13, -14, -15 | +| **A2** | ui/components | TC-NAV-01, -02, -03, -13, -16 | +| **A3** | app-shell | TC-NAV-06, -15, -16 | +| **B0** | model + backend_task | TC-FR8-01, -02, -04, -05, -09, -10 | +| **B1** | context + backend_task | TC-FR6-01…06, TC-NAV-12 (12b/12c), TC-NAV-17, TC-FR7-02, -03 | +| **B2** | ui/mod + app.rs + left_panel | TC-FR1-01…07, TC-FR1-05b, TC-EDGE-05, -06 | +| **B3** | ui/masternodes | TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01, -03 | +| **B4** | ui/masternodes + model | TC-FR4-01…22, TC-EDGE-01, -02, -07, -08, TC-FR8-03, TC-NFR4-01, TC-NFR6-02, -04 | +| **B5a** | ui/masternodes (reuse) | TC-FR5-01…05, -07, TC-FR7-04, TC-FR9-01…08, TC-FR10-01…13, TC-FR11-01…04, TC-FR8-06, -08, TC-US4-01…07, TC-EDGE-04 | +| **B5b** | ui/masternodes (reuse voting) | TC-DPNS-01…11, TC-EDGE-03 | +| **B6** | ui/masternodes (reuse loader) | TC-FR12-01…10 | +| **B7** | ui/masternodes + ui/state | TC-NAV-04, -05, -07, -08, -09, -10, -11, -12, -18, TC-FR5-06, TC-FR6-07, TC-FR9-04 | +| **B8** | tests/ | TC-FR8-07, TC-NFR6 sweep, TC-EDGE-05/06 e2e, TC-NAV-12 regression | + +All 164 cases map to at least one task. US-6 retired (0 cases). TC-FR7-01 (list Refresh) is now owned by B3 and +TC-FR7-04 (detail Refresh) by B5a — no longer footnote-only (PROJ-009). TC-FR8-07 moved from B5a to B8 (PROJ-011). + +--- + +## 6. Risks & Open Implementation Decisions + +**My decision on TC-FR12-09 (deferred to me by design).** *Add* the defense-in-depth `is_developer_mode()` check at +the Fill-Random button call-site. The whole tab is Expert-gated (FR-1), so under normal navigation the check is +redundant — **the decision therefore stands on future-proofing grounds** (a plaintext-private-key-reading dev tool +should be contained inside the Expert-Mode envelope regardless of any future non-nav entry point into this screen). +*(Corrected premise, PROJ-002: an earlier draft justified this by claiming the DPNS "Add voting key" affordance opens +the load form — under the §10.8-resolved design that affordance opens a **scoped in-place prompt**, not the load form, +so no second path into the load form / Fill-Random exists today. The check is still worth adding on future-proofing +alone; the recorded rationale is corrected.)* + +**Risks to flag before implementation:** + +- **R1 — FR-6 boundary is the highest-severity correctness item (critical, release-blocking).** The page-scoped + masternode selection must never write `AppContext::selected_identity_id` (B7 / A1's `IdentityPillScope`), **and** — + the gap Fable caught (PROJ-001) — the **resolution layer** must not resolve a masternode as the everyday-page + identity via the first-loaded fallback or wallet reconciliation, and a stale MN selection persisted from a prior + session must be sanitized on load. B1 now filters at `resolve_selected_identity()` + the reconciliation source and + clears stale persisted MN selections; TC-NAV-12 gains preconditions 12b ("only a masternode loaded, nothing + selected") and 12c ("masternode persisted as selection from a prior session"). Treat any failure here as blocking. +- **R2 — Phase A blast radius.** Making the switcher global touches every root screen's top panel; regression risk to + the Hub and all tabs. Mitigation: Phase A lands and is reviewed independently; Subdued+`TODO` is the inert default, + so unwired pages cannot misbehave. Do not begin B7 until A1–A3 are merged/green. **Also (PROJ-010):** a wallet + switch reconciles the app-global identity as a side effect — A3 documents and tests this. +- **R3 — FR-8 secret path discipline (security).** The password must seal through the existing + `secret_seam.rs` chokepoint / `protect_loaded_identity_keys` — **no second persistence path, no new crypto**. + Password is a `Secret`; TC-FR8-04/05 (never logged, no plaintext at rest) are security assertions. Reuse the + existing `validate_protection_password` rule (relocated to `model/`, PROJ-006); do not invent a policy (§10.3). +- **R4 — FR-7 refresh + the card contest-read may hide a genuine gap.** The rest of the plan is reuse; the two places + a *new* thin piece may be required are re-fetching **voting** state (refresh) and the **per-node open-contest read** + the card displays (PROJ-012). B1 must verify whether existing tasks/queries cover both; if not, add minimal + composed pieces — do not reimplement contest fetching. +- **R5 — Inline DPNS voting reuse (B5b).** Casting votes inline (locked decision #1) reuses the vote backend, but the + existing DPNS root screen currently *owns* contest fetching/rendering. Extracting a reusable voting-table widget + may be needed; guard against a partial reimplementation that would fail the TC-FR9-08-style structural-reuse intent. +- **R6 — `.testnet_nodes.yml` handles real plaintext private keys.** FR-12's fixture is gitignored, absent from the + repo, and Testnet-only. The render-conditional (not disabled) gate + Testnet + Expert-Mode + (my) dev-mode check + keep it contained; never ship or hardcode the fixture, never log its contents. + +**Artifact erratum (PROJ-013, non-blocking).** `wireframes.html` still draws the legacy **two** Fill-Random buttons +("Fill Random HPMN" / "Fill Random Masternode") and omits the missing-voter "Add voting key" affordance. FR-12/§7 +(one button, label follows toggle) and ux-spec wireframe D are canonical; the HTML mock is stale for these two +details only. Recorded here so no implementer treats the mock as the source of truth for them. + +--- + +🍬 **Findings tally (architecture, Phase 1d + review fold-in):** **6** architectural risks (Info/decision severity) — +R1 (scope-leak boundary, now covering the resolution layer per PROJ-001), R2 (app-shell blast radius + wallet +reconciliation), R3 (secret-path discipline), R4 (refresh + card-contest read gap), R5 (voting-widget reuse seam), +R6 (fixture secret containment). Plus one recorded decision (TC-FR12-09, premise corrected) and **13 Fable findings +folded in** (4 MEDIUM: PROJ-001/-002/-003/-005; 9 LOW: PROJ-004/-006…-013). + +**Task count:** Phase A = 3 · Phase B = 9 (B0, B1, B2, B3, B4, B5a, B5b, B6, B7) + B8 integration = 10 · **13 total.** diff --git a/docs/kv-keys.md b/docs/kv-keys.md new file mode 100644 index 000000000..1fb38945b --- /dev/null +++ b/docs/kv-keys.md @@ -0,0 +1,209 @@ +# DET k/v key reference + +`DetKv` wraps the upstream `platform_wallet_storage::KvStore`. Values are encoded as `[ schema_version (1 byte) | bincode(payload) ]` using `bincode::config::standard()`. Keys are colon-separated namespaces. Every `DetKv` call takes a `DetScope` argument: `DetScope::Global` = global slot, `DetScope::Wallet(&seed_hash)` = per-wallet slot (cascades on wallet delete), `DetScope::Identity(&id)` = per-identity slot (active — used for identities, top-ups, scheduled votes, and DashPay `private`/`address_index` overlays), `DetScope::Token { identity_id, token_id }` = per-token slot (defined and mapped, currently unused — token balances are read live from upstream). `DetScope::Identity` and `DetScope::Token` map to the upstream `meta_identity` / `meta_token` tables; their metadata is reaped by an upstream `AFTER DELETE` soft-cascade when the parent object row is removed. `DetScope` is the DET-side seam over the upstream `ObjectId` enum — the upstream scope type never crosses the wallet-backend boundary. + +Three backing stores exist: + +| Store | Path | Contents | +|-------|------|----------| +| `det-app.sqlite` | `<data_dir>/det-app.sqlite` | Cross-network settings, wallet metadata, migration sentinel, single-key metadata | +| `platform-wallet.sqlite` | `<data_dir>/spv/<net>/platform-wallet.sqlite` | Per-network identities, tokens, contracts, DashPay overlays, platform addresses, selected wallet | +| `SecretStore` | `<data_dir>/secrets/det-secrets.*` | Encrypted HD-wallet seed envelopes and imported single-key private bytes | + +In the per-domain tables below, a `Scope` of `None` denotes `DetScope::Global`. + +--- + +## Settings + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `det:settings:v1` | `None` | `det-app.sqlite` | `AppSettings` via `AppSettingsWire` | `network`, `root_screen_type`, `dash_qt_path`, `overwrite_dash_conf`, `disable_zmq`, `theme_mode`, `_reserved_core_backend_mode` (retired; reserved byte), `onboarding_completed`, `show_evonode_tools`, `user_mode`, `close_dash_qt_on_exit`, `auto_start_spv` | + +Source: `src/model/settings.rs`, `src/context/settings_db.rs` + +--- + +## Wallet metadata sidecar + +DET-owned per-wallet display fields (alias, main flag, Dash Core wallet name link, pre-computed xpub). Stored in the cross-network `det-app.sqlite` so the wallet picker can enumerate every known wallet at cold boot without touching the per-network persister. + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `<network>:wallet_meta:<seed_hash_base58>` | `None` | `det-app.sqlite` | `WalletMeta` | `alias: String`, `is_main: bool`, `core_wallet_name: Option<String>`, `xpub_encoded: Vec<u8>` | + +`<network>` is one of `mainnet`, `testnet`, `devnet`, `regtest`. `<seed_hash_base58>` is the 32-byte `WalletSeedHash` base58-encoded. Global (`DetScope::Global`) scope is used instead of per-wallet scope because the upstream `WalletId` does not exist until a wallet is registered with `PlatformWalletManager`. + +Source: `src/wallet_backend/wallet_meta.rs`, `src/model/wallet/meta.rs` + +--- + +## Single-key metadata sidecar + +Enumerable index of imported single-key wallets. The private bytes live in `SecretStore`; this sidecar holds only the display-facing metadata so cold-boot hydration can reconstruct the in-memory index without enumerating the (non-enumerable) vault. + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `<network>:single_key_meta:<base58_addr>` | `None` | `det-app.sqlite` | `ImportedKey` | `address: String`, `alias: Option<String>`, `network: Network`, `has_passphrase: bool`, `passphrase_hint: Option<String>` | + +`<network>` is the same vocabulary as wallet metadata. Global scope for the same reason: the sidecar must be listable independently of any per-wallet `WalletId`. + +Source: `src/wallet_backend/single_key.rs`, `src/model/single_key.rs` + +--- + +## Migration sentinel + +Completion record written by the finish-unwire migration (`BackendTask::MigrationTask::FinishUnwire`). Read on every cold-start to short-circuit re-migration. Written once per network — idempotent. + +The sentinel is **per-network**: each network gets its own key so an upgrade completed on mainnet does not suppress the migration for testnet after a network switch. + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `det:migration:finish_unwire:<network>:v1` | `None` | `det-app.sqlite` | `MigrationCompletion` | `completed_at: i64` (Unix seconds), `sha: String` (build SHA), `network_count: u32` | + +`<network>` is one of `mainnet`, `testnet`, `devnet`, `regtest`. Example: `det:migration:finish_unwire:mainnet:v1`. The key is produced by `sentinel_key_for(network)` using the constants `SENTINEL_KEY_PREFIX` (`"det:migration:finish_unwire"`) and `SENTINEL_KEY_VERSION` (`"v1"`). + +Source: `src/backend_task/migration/finish_unwire.rs` (`sentinel_key_for`, `SENTINEL_KEY_PREFIX`, `SENTINEL_KEY_VERSION`) + +--- + +## Wallet selection + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `det:selected_wallet:v1` | `None` | `platform-wallet.sqlite` | `SelectedWallet` | `hd_wallet_hash: Option<[u8;32]>`, `single_key_hash: Option<[u8;32]>` | + +Source: `src/model/selected_wallet.rs`, `src/wallet_backend/mod.rs` + +--- + +## Identities + +The identity blob and top-up history are **identity-scoped** (`DetScope::Identity(&id)`) so the upstream soft-cascade reaps them when the identity row is deleted. `DetScope::Identity` has no cross-identity listing, so a Global `det:identity_index:v1` slot holds the complete id roster the load-all paths iterate. `det:identity_order:v1` is a separate user-ordering view (may lag the full set) and stays Global. + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:identity:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode, redacted in `Debug`), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option<u32>` | +| `det:identity_index:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Complete enumeration index of stored identity ids | +| `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | User-chosen display ordering of identity ID raw bytes | +| `det:top_ups:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `BTreeMap<u32, u64>` | Top-up history: account index → credits | + +Source: `src/context/identity_db.rs` + +--- + +## Scheduled votes + +Scheduled votes are **voter-scoped** (`DetScope::Identity(&voter_id)`); the contested name is the key suffix. A Global `det:scheduled_vote_voters:v1` slot holds the complete set of voter ids that have at least one scheduled vote, driving the network-wide read / clear paths (Identity scope has no cross-voter listing). + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:scheduled_vote:<contested_name>` | `DetScope::Identity(&voter_id)` | `platform-wallet.sqlite` | `StoredScheduledVote` | Fields: `voter_id: [u8;32]`, `contested_name: String`, `choice: StoredVoteChoice`, `unix_timestamp: u64`, `executed_successfully: bool` | +| `det:scheduled_vote_voters:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Enumeration index of voter ids with scheduled votes | + +Source: `src/context/identity_db.rs` + +--- + +## Contested names (DPNS) + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:contested_name:<normalized_name>` | `None` | `platform-wallet.sqlite` | `StoredContestedName` | Fields: `normalized_contested_name`, `locked_votes`, `abstain_votes`, `awarded_to`, `end_time`, `locked`, `last_updated`, `contestants: Vec<StoredContestant>` | + +`StoredContestant` fields: `id: [u8;32]`, `name`, `info`, `votes: u32`, `created_at`, `created_at_block_height`, `created_at_core_block_height`, `document_id: [u8;32]`. + +Source: `src/context/contested_names_db.rs` + +--- + +## Contracts + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:contract:<base58_contract_id>` | `None` | `platform-wallet.sqlite` | `StoredContract` | Fields: `contract_bytes: Vec<u8>` (platform-serialized), `alias: Option<String>` | + +Source: `src/context/contract_token_db.rs` + +--- + +## Tokens + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:token:<base58_token_id>` | `None` | `platform-wallet.sqlite` | `StoredToken` | Fields: `config_bytes: Vec<u8>` (bincode `TokenConfiguration`), `alias: String`, `data_contract_id: [u8;32]`, `position: u16` | +| `det:token_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<([u8;32],[u8;32])>` | Ordered `(token_id, identity_id)` pairs for My Tokens screen | + +Per-`(identity, token)` balances are no longer cached by DET. They are read live from the upstream `IdentitySyncManager` through the `TokenBalanceView` seam (`src/wallet_backend/token_balance.rs`), which is fed a lock-free snapshot refreshed off the UI thread. + +Source: `src/context/contract_token_db.rs` + +--- + +## Platform addresses + +Both keys use **per-wallet scope** (`DetScope::Wallet(&seed_hash)`) so entries cascade on wallet removal. Reads/writes route through the `PlatformAddressView` seam (`src/wallet_backend/platform_address.rs`); the cache stays active because upstream's public per-address reader exposes balance but not the DET-tracked nonce. + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:platform_addr:<canonical_address>` | `DetScope::Wallet(&seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformAddressInfo` | Fields: `balance: u64`, `nonce: u32` | +| `det:platform_sync:v1` | `DetScope::Wallet(&seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformSyncInfo` | Fields: `last_sync_timestamp: u64`, `sync_height: u64` | + +Source: `src/context/platform_address_db.rs`, `src/wallet_backend/platform_address.rs` + +--- + +## DashPay sidecar + +Most sidecar keys use **global scope** (`DetScope::Global`); the per-network `platform-wallet.sqlite` already partitions by network, so no `<network>:` prefix is needed within the key. The two **owner-scoped** overlays (`private`, `address_index`) moved to `DetScope::Identity(&owner)` in Wave 2 — the owner id is carried by the scope, so the key drops the `<owner>:` prefix and the upstream soft-cascade reaps them when the owner identity row is deleted. + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:dashpay:blocked:<base58_contact_id>` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact is blocked | +| `det:dashpay:rejected:<base58_counterparty_id>` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact request rejected | +| `det:dashpay:timestamps:<base58_entity_id>` | `None` | `platform-wallet.sqlite` | `(i64, i64)` | DET-local `(created_at_ms, updated_at_ms)` | +| `det:dashpay:private:<base58_contact>` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `ContactPrivateInfo` | Fields: `nickname: String`, `notes: String`, `is_hidden: bool` | +| `det:dashpay:address_index:<base58_contact>` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `ContactAddressIndex` | Fields: `owner_identity_id: Vec<u8>`, `contact_identity_id: Vec<u8>`, `next_send_index: u32`, `highest_receive_index: u32`, `bloom_registered_count: u32` | +| `det:dashpay:addr_map:<base58_owner>:<address>` | `None` | `platform-wallet.sqlite` | `([u8;32], u32)` | Reverse map: wallet address → `(contact_id_bytes, index)` | + +Source: `src/wallet_backend/dashpay.rs`, `src/model/dashpay.rs` + +--- + +## SecretStore entries + +The `SecretStore` file backend (`<data_dir>/secrets/det-secrets.*`) stores opaque encrypted blobs. It is **not** a `KvStore` and does not use the `DetKv` bincode-plus-version-byte envelope. Entries are addressed by `(WalletId scope, label)` pairs. + +### HD wallet seed envelopes + +| Service (scope) | Label | Value encoding | Struct | Fields | +|-----------------|-------|----------------|--------|--------| +| `WalletId(seed_hash)` | `envelope.v1` | `[ STORED_SEED_ENVELOPE_VERSION (1 byte) \| bincode::serde(payload) ]` (no DetKv wrapper; leading version byte prepended by the storage layer) | `StoredSeedEnvelope` | `encrypted_seed: Vec<u8>`, `salt: Vec<u8>`, `nonce: Vec<u8>`, `password_hint: Option<String>`, `uses_password: bool`, `xpub_encoded: Vec<u8>` | + +One entry per HD wallet. `seed_hash` is the 32-byte `WalletSeedHash` reused as the upstream `SecretWalletId`. The outer vault adds Argon2id + XChaCha20-Poly1305 at-rest encryption on top of DET's own AES-GCM per-wallet password layer. + +Source: `src/wallet_backend/wallet_seed_store.rs` (`ENVELOPE_LABEL`), `src/model/wallet/seed_envelope.rs` + +### Imported single-key private bytes + +| Service (scope) | Label | Value encoding | Notes | +|-----------------|-------|----------------|-------| +| `SINGLE_KEY_NAMESPACE_ID` (fixed constant) | `single_key_priv.<base58_addr>` | 32 raw key bytes | One entry per imported WIF address | + +`SINGLE_KEY_NAMESPACE_ID` is a fixed `[u8; 32]` (SHA-256 of `"det-single-key-namespace"`) shared by all imported keys — single-key entries are not per-HD-wallet. The label uses a dot separator because the upstream label allowlist (`^[A-Za-z0-9._-]{1,64}$`) rejects colons. + +Source: `src/wallet_backend/single_key.rs` (`SINGLE_KEY_PRIV_LABEL_PREFIX`, `SINGLE_KEY_NAMESPACE_BYTES`) + +--- + +## Summary counts + +| Store | Key count | +|-------|-----------| +| `det-app.sqlite` | 4 (settings, wallet-meta sidecar, single-key-meta sidecar, migration sentinel) | +| `platform-wallet.sqlite` | 19 (across 8 domains) | +| `SecretStore` | 2 label patterns (seed envelopes, imported-key private bytes) | +| **Total** | **25** | + +Prefixed/templated keys (e.g. `det:identity:<id>`) are counted once per prefix, not per instance. `SecretStore` entries are counted as label-pattern families, not per-wallet instances. diff --git a/docs/personas/platform-developer.md b/docs/personas/platform-developer.md index 0ab6e1c56..43819c496 100644 --- a/docs/personas/platform-developer.md +++ b/docs/personas/platform-developer.md @@ -24,7 +24,7 @@ Jordan is the user who genuinely benefits from seeing raw protocol details: cred 1. **Rapid identity creation and funding** -- Create test identities quickly, fund them with specific credit amounts, and top them up as needed during development. 2. **Platform address operations** -- Fund Platform addresses from asset locks or wallet UTXOs; transfer credits between addresses; withdraw back to Core. 3. **Network flexibility** -- Switch between Testnet and Devnet easily; possibly run against a local regtest network. -4. **Contract and state transition inspection** -- Use the Tools screens (transition visualizer, proof log, document query) alongside the wallet to verify dApp behavior. +4. **Contract and state transition inspection** -- Use the Tools screens (transition visualizer, document query) alongside the wallet to verify dApp behavior. 5. **Disposable wallet workflows** -- Create temporary wallets, use them for a testing session, and remove them without ceremony. ## Secondary Goals @@ -72,7 +72,7 @@ Jordan is the user who genuinely benefits from seeing raw protocol details: cred - Minimal-friction wallet creation for throwaway wallets. - Bulk operations (create N identities, fund N addresses). - State transition error details with protocol-level context. -- Quick access to Tools screens (transition visualizer, proof log) from wallet context. +- Quick access to Tools screens (transition visualizer) from wallet context. ## Quotes (Illustrative) diff --git a/docs/user-stories.md b/docs/user-stories.md index 7e689123a..598a01679 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -12,11 +12,14 @@ See [docs/personas/](personas/) for full persona descriptions. - [Identity Operations (IDN)](#identity-operations-idn) - [DPNS (DPN)](#dpns-dpn) - [DashPay (DPY)](#dashpay-dpy) +- [Identities Hub (IDH)](#identities-hub-idh) - [Token Operations (TOK)](#token-operations-tok) - [Contracts and Documents (DOC)](#contracts-and-documents-doc) - [Developer and Power Tools (DEV)](#developer-and-power-tools-dev) - [Network and Settings (NET)](#network-and-settings-net) - [Programmatic Access (MCP)](#programmatic-access-mcp) +- [User Experience (UX)](#user-experience-ux) +- [Masternodes (MN)](#masternodes-mn) --- @@ -67,10 +70,13 @@ As a power user, I want to rename wallets so that I can identify them by purpose ### WAL-006: Lock and unlock wallet [Implemented] **Persona:** Alex, Priya -As a user, I want to lock my wallet with a password so that others cannot access my funds if I leave the app open. +As a user, I want my wallet protected by a passphrase so that others cannot access my funds if I leave the app open. -- Locked wallet requires password to unlock. -- Sensitive operations are blocked while locked. +- The passphrase is requested just-in-time, when an operation actually needs the secret (sending funds, registering an identity, signing). +- The prompt offers a "Keep this wallet unlocked until I close the app" option so a busy session is asked only once. +- That option defaults to off: unless the user actively ticks it, every secret access re-prompts, and the seed is not cached. +- The seed is never held in memory between operations: it is decrypted on demand and wiped as soon as the operation finishes. +- After the storage-seam migration, a previously password-protected wallet's secret is re-sealed in the on-device vault under the same password (Tier-2 per-secret encryption: Argon2id + XChaCha20-Poly1305). The wallet continues to prompt just-in-time; the migration is silent (no disclosure notice). ### WAL-007: Remove a wallet [Implemented] **Persona:** Priya, Jordan @@ -87,6 +93,7 @@ As a user, I want to see my wallet balance so that I know how much Dash I hold. - Displays Core balance and Platform balance. - Alex sees a simplified view; Priya sees per-account breakdown. +- Received funds remain visible after restarting the app, including funds deposited before the wallet was last opened (the wallet re-registers with the network watcher on launch). ### WAL-009: View fiat equivalent of balances [Gap] **Persona:** Alex @@ -104,6 +111,7 @@ As a user, I want to generate a new receive address so that I can share it with - Address displayed with QR code. - Alex sees a single address by default. +- A generated address is always within the SPV-watched pool, so deposits to it are seen. ### WAL-011: View address table [Implemented] **Persona:** Priya, Jordan @@ -223,6 +231,37 @@ As a power user, I want the balance breakdown and address table to be collapsibl - Asset locks section has a collapsible header. - Sections are expanded by default for quick access. +### WAL-025: Restore a password-protected imported key after an update [Implemented] +**Persona:** Priya, Jordan + +As a power user who imported a private key under an old per-key password, I want to restore that key after the storage update so that I do not lose access to the address. + +- A banner on the wallets screen counts the imported keys still waiting to be restored and offers to restore them. +- A per-key dialog takes the old password, decrypts the preserved key, and re-saves it in the on-device secret vault (optionally under a new passphrase the user chooses). +- A wrong password fails with a calm, generic message and leaves the key restorable — the old data is never corrupted. +- After restore the key appears in the wallet list at the same address; a note explains that balance and sending for single-key wallets arrive in a future update. + +### WAL-026: Unlock a passphrase-protected vault at startup [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user whose saved keys were sealed with a passphrase by an earlier version, I want the app to ask me for that passphrase at startup so that it opens normally instead of failing to launch. + +- When the app cannot open its saved-keys vault because it was sealed with a passphrase, it shows a masked unlock prompt at startup instead of closing. +- Entering the correct passphrase opens the existing vault in place and the app continues to its normal screen; nothing is deleted, recreated, or re-encrypted. +- A wrong passphrase re-asks with a calm message and no hint; the vault is never altered, so a later correct passphrase still works. +- Choosing to quit closes the app cleanly and leaves the vault untouched, so the user can try again next time. +- The headless command-line and automation paths never show a dialog; they report a calm, actionable message instead. + +### WAL-027: Balance health check after syncing [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, I want the app to tell me when a wallet's totals don't add up after syncing so that I know about a display glitch and that my funds are still safe. + +- When a sync finishes, the app checks every loaded wallet's overall Core and Platform balance against the amounts its account tabs add up to. +- If they disagree by more than a rounding amount, a single calm warning banner appears explaining that funds are safe, it's a known display issue, and a refresh or reopen usually resolves it. +- The banner is not repeated on every later sync while the same difference persists, and it clears on its own once the totals agree again. +- The check runs for all loaded wallets, not just the one currently on screen. + --- ## Send and Receive (SND) @@ -235,12 +274,12 @@ As a user, I want to send Dash to a recipient address so that I can make payment - Enter destination address and amount. - Confirmation dialog before broadcast. -### SND-002: Send Dash from single-key wallet [Implemented] +### SND-002: Send Dash from single-key wallet [Gap] **Persona:** Priya, Jordan 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. -- Send flow works the same as for HD wallets. +- 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. ### SND-003: Receive Dash with QR code [Implemented] **Persona:** Alex, Priya @@ -339,6 +378,15 @@ As a user, I want to transfer identity credits to a Platform address from the Se - Select Identity as source and enter a Platform address (bech32m) as destination. - Credits arrive at the Platform address. +### SND-014: Send maximum from a Core wallet [Implemented] +**Persona:** Alex, Priya + +As a user, I want a "Max" button on a Core-to-Core send that fills in the largest amount I can actually send so that I can empty a wallet in one go without the transaction failing on fees. + +- "Max" sets the amount to the wallet balance minus the estimated network fee, so the send leaves enough to pay the fee and succeeds. +- 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. + --- ## Asset Locks (ALK) @@ -383,8 +431,8 @@ As a developer, I want a one-click "fund this identity with X credits" button so As a user, I want to register a new identity on Dash Platform so that I can use Platform features like DPNS and DashPay. +- Fund-first wizard: choose a funding method — from your wallet (recommended, pre-selected by default when available), recover an unfinished funding, or use a Platform address — then optionally set a local alias before creating. - Multi-stage confirmation flow. -- Identity funded from an asset lock. ### IDN-002: Load existing identity by ID [Implemented] **Persona:** Priya, Jordan @@ -394,12 +442,12 @@ As a power user, I want to load an existing identity by its ID and owner private - Enter identity ID and private key. - Identity details are fetched and displayed. -### IDN-003: Load evonode/masternode identity [Implemented] +### IDN-003: Load evonode/masternode identity [Superseded by MN-001] **Persona:** Priya As a masternode operator, I want to load my evonode identity via protx hash so that I can manage it through the GUI. -- Enter protx hash to load the associated identity. +- Loading now happens on the dedicated [Masternodes tab](#masternodes-mn) (see MN-001); the generic "Load Existing Identity" screen's Identity Type selector offers User only, so this story's original path — loading a Masternode/Evonode from that generic screen — no longer exists. ### IDN-004: Top up identity credits [Implemented] **Persona:** Priya, Jordan @@ -441,6 +489,19 @@ As a user, I want to view all keys associated with my identity so that I can aud - Lists all keys with type, purpose, and status. - View individual key details. +### IDN-013: Password-protect an identity's signing keys (SEC-001) [Implemented] +**Persona:** Priya, Jordan + +As a power user, I want to add a password to an identity's signing keys so that they cannot be used to sign on this device without that password. + +- Identity keys default to keyless: they sign automatically and headless/MCP signing keeps working — this is unchanged for any identity the user does not opt in. +- From the Key Info screen, a collapsible "Key Protection" section (closed by default) shows whether this identity's keys are protected and offers "Add password protection…" or "Remove password protection…". +- Opting in shows a danger warning (a forgotten password makes the keys unrecoverable for standalone-imported identities; automatic tools can no longer sign this identity), then asks for a new password, a confirmation, and an optional plain-text hint. +- Once protected, every signing operation for that identity asks for the password just-in-time, with an optional "keep unlocked until I close the app". A wrong password re-asks with no oracle. +- Headless / MCP signing of a protected identity fails with a calm, actionable message telling the user to unlock it in the app or remove the protection — no environment-variable or flag password fallback exists. +- Opting out asks for the current password and reverts the keys to keyless; signing is prompt-free again, including headless. +- One password protects all of the identity's keys; it is separate from any wallet password (per-secret isolation). The encryption reuses the shipped Tier-2 seam (Argon2id + XChaCha20-Poly1305) — no new crypto, no plaintext written to disk. + ### IDN-009: Refresh identity state [Implemented] **Persona:** Priya, Jordan @@ -481,6 +542,26 @@ As a user, I want to top up identity credits from a Platform address so that I c - Available as funding method in top-up screen. - Uses Platform address credits directly. +### IDN-014: Fund identity directly from scanned external payment [Removed — upstream-only funding] +**Persona:** Priya, Jordan + +As a user, I want to register or top up an identity by scanning a QR code or supplying an external outpoint directly, so that I can fund an identity without first receiving the payment into my wallet. + +- `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` variants removed. +- QR-direct-fund UI removed. + +**Rationale:** No upstream funding-outpoint API exists in `platform-wallet` at PR #3625 head. The capability cannot be preserved or emulated; all asset-lock funding is upstream-authoritative wallet-managed selection. Superseded by funding from wallet balance (`WalletBackend::create_asset_lock_proof`). Disclosed via the one-time post-migration informational notice shown to all migrated users. + +### IDN-015: Automatic identity discovery after sync [Implemented] +**Persona:** Alex, Priya + +As a user, I want my wallet's identities to be found and loaded automatically once the app finishes connecting, so that I do not have to open the "Load Identity → By Wallet" screen and search manually. + +- After the network is ready, every unlocked wallet is searched automatically once per session. +- The search uses a rolling five-index lookahead, going deeper each time an identity is found, so identities at non-contiguous indices are discovered. +- Already-loaded identities are refreshed (new keys, new DPNS names) while any alias the user assigned is preserved. +- Locked, password-protected wallets are skipped without prompting; they are searched after the user unlocks them. + --- ## DPNS (DPN) @@ -492,6 +573,7 @@ As a user, I want to register a human-readable username on DPNS so that others c - Choose identity, enter desired name. - Cost estimate displayed before confirmation. +- While registration runs, a full-window blocking overlay (UX-001) is shown so the same name cannot be submitted twice; it lowers automatically on success or error. ### DPN-002: View owned usernames [Implemented] **Persona:** Alex, Priya @@ -628,6 +710,24 @@ As a power user, I want to generate an auto-accept proof so that incoming contac - HD derivation and proof signing for automatic acceptance. - QR code generation for sharing auto-accept proof. +### DPY-012: Detect payments received from contacts [Implemented] +**Persona:** Alex, Priya + +As a user, I want payments sent to me by a DashPay contact to be detected and recorded automatically so that they appear in my payment history without any manual action. + +- Incoming on-chain transactions are matched against my contacts' receiving addresses. +- Matched payments are recorded and surfaced in payment history. +- Re-scanning the same transaction does not duplicate or double-count it. + +### DPY-013: View contacts and avatars offline [Implemented] +**Persona:** Alex, Priya + +As a user, I want my contact list, their profiles, and their avatars to show instantly without a network round-trip so that I can view my contacts even when offline or on a slow connection. + +- Contacts and private notes are read from already-synced local state. +- 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. + --- ## Token Operations (TOK) @@ -766,6 +866,15 @@ As a developer, I want to pay for document operations (create, replace, delete, - Optional `TokenPaymentInfo` parameter on all document actions. - Token-based payment as alternative to credit-based payment. +### TOK-018: Stop tracking a token balance [Implemented] +**Persona:** Alex, Priya + +As a user, I want to stop tracking a token balance for one of my identities so that the "My Tokens" screen stays focused on the tokens I care about. + +- "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. + --- ## Contracts and Documents (DOC) @@ -853,12 +962,12 @@ As a developer, I want to paste and decode raw state transitions so that I can d - Transition visualizer parses and displays state transition contents. -### DEV-002: View proof request log [Implemented] +### DEV-002: View proof request log [Gap] **Persona:** Jordan As a developer, I want to review the history of proof requests made by the app so that I can debug query behavior and performance. -- Proof log lists all requests with timestamps and results. +- Not available in this version: the proof log screen was removed. Proof requests are no longer recorded or listed in the app. ### DEV-003: Inspect ZK proofs [Implemented] **Persona:** Jordan @@ -883,7 +992,7 @@ As a user, I want to see Platform status (epoch info, total credits, validators, - Displays epoch info, validator list, withdrawal queue, and version voting status. -### DEV-006: View masternode list diff [Implemented] +### DEV-006: View masternode list diff [Removed] **Persona:** Priya As a masternode operator, I want to view changes to the masternode list so that I can monitor network composition. @@ -963,12 +1072,15 @@ As a power user, I want to choose whether to refresh Core Only, Platform Only, o - Refresh mode selector available in detailed/developer view. -### NET-008: Select Core backend mode [Implemented] +### NET-008: Select Core backend mode [Removed] **Persona:** Priya, Jordan -As a user, I want to choose between SPV, RPC, or Auto mode for the Core backend so that I can control how the app connects to the Dash Core network. - -- SPV for light sync, RPC for full node, Auto for app-selected. +Withdrawn in the platform-wallet migration. Chain sync is owned entirely by +the upstream platform-wallet engine, which is SPV-only — there is no RPC or +full-node wallet backend, so the SPV/RPC/Auto mode selector, the +"Use local Dash Core node" toggle, and the related settings have been +removed. Single-key wallet send is consequently degraded in this version +(receive still works); see the one-time post-migration notice. ### NET-009: Toggle ZMQ [Implemented] **Persona:** Priya, Jordan @@ -1048,3 +1160,200 @@ As an AI agent, I want MCP server access so that I can assist users with wallet - Bearer token authentication for HTTP mode. - Network verification guard prevents cross-network mistakes. - Tools expose wallet, identity, and platform operations. + +### MCP-003: Load a masternode/evonode identity via CLI [Implemented] +**Persona:** Priya, Jordan + +As a masternode operator, I want to load my masternode or evonode identity headlessly via det-cli — by ProTxHash plus owner/voting/payout private keys — so that I can manage it in scripts and automation without opening the GUI. + +- Identity is fetched by ProTxHash over the network and persisted locally. +- Private keys are accepted as WIF or hex, never echoed back, and redacted in logs. +- Output reports which keys loaded, the available withdrawal modes, and the registered payout address. +- The 'network' parameter is required and must match the active network. + +### MCP-004: Withdraw masternode/evonode credits via CLI [Implemented] +**Persona:** Priya, Jordan + +As a masternode operator, I want to withdraw my node's Platform credits to Core headlessly via det-cli, in both key modes, so that I can automate payouts. + +- With the owner key, the destination is forced to the registered payout address; supplying a different address is rejected. +- With the payout/transfer key, I can withdraw to any Core address. +- The withdrawal is queued on Platform and settles after confirmation; the result reports the destination used and the estimated and actual fees. +- The 'network' parameter is required and must match the active network. + +--- + +## User Experience (UX) + +### UX-001: Blocking progress overlay for unsafe-to-interrupt operations [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, while a long operation that is unsafe to interrupt is running (broadcasting a state transition, signing, key import, a multi-step registration, a network migration), I want to see a clear please-wait block over the whole window so that I understand the app is busy and cannot accidentally fire a conflicting second action. + +- A full-window dimming overlay with an indeterminate spinner and an optional "Step N of M" counter and description appears while the operation runs, and lowers automatically when it finishes (success or error). +- All interaction beneath the block is suppressed: pointer clicks hit a sink, and keyboard/text input is claimed at frame start so nothing reaches a focused field beneath (FR-8 / QA-001). The block is never dismissable by Esc, Enter, Space, or Tab. +- The block yields to a passphrase prompt: when a secret prompt is shown above the overlay it keeps the keyboard (Enter/Esc/Tab) so the user can still authenticate or cancel (SEC-004). +- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer error. For these unsafe-to-interrupt operations there is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. _(Exception: the startup/Connect SPV-sync block of UX-002 is unbounded but read-only, so it ships an always-visible "Continue in the background" escape instead.)_ + +### UX-002: Blocking SPV-sync overlay with a "continue in the background" escape [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, while the app connects to and syncs the Dash chain on startup or after I press Connect, I want a clear please-wait block so I know it is working — and because that sync can wait indefinitely for peers, I want an always-visible "Continue in the background" button so I am never trapped behind it. + +- While that startup/Connect sync is getting connected, a full-window block appears with a plain please-wait sentence ("Connecting to the Dash network." / "Syncing with the Dash network.") and a friendly progress indicator ("Step N of 5") — no blockchain jargon, raw heights, or percentages. +- The block always offers a secondary "Continue in the background" button. Clicking it lowers the block; sync keeps running in the background (it is read-only and strands nothing), and the block is not re-raised for the rest of that sync episode. +- The "Continue in the background" escape is reachable by **keyboard**, not just the mouse: it is the one designated keyboard escape on this otherwise keyboard-blocked block, so a keyboard-only or assistive-technology user can activate it with Enter or Space and is never trapped behind the unbounded sync. Focus is pinned to that button, so Enter/Space (and Tab/clicks) can never reach a widget beneath the block. +- The block is scoped to *user-initiated* sync (startup auto-start / Connect): it lowers on its own when the chain becomes usable (Synced) or fails (Error), and an **ambient** reconnect or per-block catch-up afterward does not block a working user. Pressing Connect (or a fresh startup) blocks again. +- This is the overlay's first real adopter (PR #863). Unlike the unsafe-to-interrupt operations in UX-001, SPV sync is **unbounded but safe to background** — so its C2 "never trap the user" guarantee is met by the always-on escape, not by operation boundedness. + +### UX-003: Global wallet/identity switcher across all tabs [Implemented] +**Persona:** Alex, Priya, Jordan + +As any user, I want the same wallet/identity switcher on every page, so that I can see and change who I'm acting as without leaving the current page. + +- Every root screen renders a page-aware three-segment switcher (e.g. `Masternodes › wallet › identity`) in the top panel; segment 1 reflects and links to the active tab. +- Selecting a wallet or identity updates the app-global selection in place, with no forced navigation; pages that already consume that selection stay in sync both ways. +- The third segment is page-scoped: the app-global User identity on everyday-user pages (Dashpay, Identities, Identity Hub), or the masternode/evonode in view on the Masternodes tab. Picking a masternode there never changes the identity shown on the everyday-user pages (see MN-005's Identity Hub filter). +- 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. + +## Identities Hub (IDH) + +### IDH-001: First-time identity setup [Implemented] +**Persona:** Alex + +As Alex, I want to open the Identities section on a fresh device and be offered a single-step path to create my first identity, so I can start using Dash Platform without understanding what an identity is first. + +- Onboarding empty state shows an abstract avatar silhouette on a soft Dash-blue glow, a heading, a plain-language explanation, and two primary CTAs: `Create my first identity` and `I already have an identity — load it`. +- Dev-mode footer adds `Create multiple test identities` / `Load identity by ID` tertiary links. + +### IDH-002: Identity home at a glance [Implemented] +**Persona:** Alex + +As Alex, when I have one identity, opening Identities shows me my balance, username, quick actions, and recent activity without jargon. + +- Home tab renders the full layout: `IdentityHeroCard`, quick actions (Send · Receive · Add contact), secondary actions (Add funds · Send to wallet · Send to another identity), `OnboardingChecklist`, and a recent-activity preview. +- "See all activity" link on Home hops directly to the Activity tab via `HomeOutcome::GoToActivity`. + +### IDH-003: Multi-identity switching [Implemented] +**Persona:** Priya + +As Priya, with multiple wallets and identities, I can switch between them from the breadcrumb pill on any tab in under two clicks, and every screen I then open operates as the identity I picked. + +- Reusable `BreadcrumbPill` and `IdentityPill` components shipped, including the label priority rule (Local nickname → DPNS handle → shortened Identity ID). +- Identity picker grid lands with `IdentityPickerCard` + `IdentityPickerAddCard`, so a multi-identity account sees a picker landing. +- The three-segment breadcrumb switcher (Identities link › wallet pill › identity pill, each with a dropdown) composes the full top-of-hub switcher. +- The selected identity is app-scoped and persisted per network: every operate-as screen (contracts, documents, DPNS registration, the token creator, and DashPay) defaults to it and writes a change back, so switching once changes who I operate as everywhere. Recipient and target pickers (sending, freezing, transferring to someone else) deliberately leave my active identity unchanged. + +### IDH-004: Opt in to DashPay social profile [Implemented] +**Persona:** Alex + +As Alex, setting up a social profile to unlock DashPay contacts is clearly optional and I can keep using payments and usernames without doing it. + +- Contacts tab shows `SocialProfileGateCard` when the active identity has no DashPay profile; the primary CTA deep-links to Settings via `AppAction::SwitchIdentityHubTab(Settings)`. +- Settings tab hosts the social-profile block where display name and avatar can be edited; identities without a profile continue to use payments and usernames untouched. +- Home tab renders a `Set up your social profile` entry in the onboarding checklist with a skip affordance — opting in is never forced. + +### IDH-005: Developer bulk identity creation [Gap] +**Persona:** Jordan + +As Jordan in Developer Mode, I have a single entry point to create many test identities without leaving the Identities section. + +- Onboarding screen surfaces a Developer-mode footer mentioning `Create multiple test identities` / `Load identity by ID` as plain text. +- Planned (follow-up): wire those footer items to the existing `AddNewIdentityScreen` bulk path and dev-mode identity-picker dropdown entries. + +### IDH-006: Unified activity timeline [Gap] +**Persona:** All + +As any persona, my payments, funding movements, and platform actions all live in one Activity tab with filters, not in separate screens. + +- 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. + +## Masternodes (MN) + +### MN-001: Load a masternode by keys [Implemented] +**Persona:** Priya + +As a masternode operator, I want to load my masternode by its ProTxHash and DIP3 keys on a dedicated Masternodes page, so that I don't have to dig through the generic identity-load advanced options. + +- Load form collects a ProTxHash (required, hex or Base58), a Masternode/Evonode toggle, an optional local-only alias, and optional Voting/Owner/Payout private keys. +- The "Load masternode" button is disabled with an explanatory tooltip until a ProTxHash is entered; a malformed or already-loaded ProTxHash is rejected with a specific message. +- A non-blocking note explains that entered keys are stored unencrypted at rest unless an encryption password is set (see MN-006). +- On Testnet, when a local test-node fixture is present, a "Fill Random Masternode/Evonode" button autofills the form for developer testing. + +### MN-002: See my masternodes at a glance [Implemented] +**Persona:** Priya + +As a masternode operator, I want a card list of my loaded masternodes showing type, voter readiness, key status, and voting status, so that I can assess each node in seconds. + +- Each card shows a shortened ProTxHash (or alias as heading), a Masternode/Evonode type badge, voter-identity readiness ("Voting ready" / "No voting key"), a compact Voting/Owner/Payout key-status indicator, a DPNS-voting status line, and an identity status dot with a text label. +- An empty state explains what a masternode identity is for and offers a primary "Load a masternode" action when none are loaded. +- The Masternodes tab and its nav entry are visible only with Expert Mode enabled; turning Expert Mode off while the tab is active falls back to the Identities screen. + +### MN-003: Open a masternode and vote [Implemented] +**Persona:** Priya + +As a masternode operator, I want to open a node and vote on the DPNS contests it can vote on, so that I can fulfil my node's governance role. + +- Clicking a card opens a detail view with a keys summary, the voter identity, and a collapsible DPNS-voting section (collapsed by default, open-contest count shown in its header). +- Votes (Abstain, Lock, or a candidate) are cast inline through the existing DPNS voting backend. +- A node with no voter identity is told a voting key is required, with a way to add one, instead of a raw error. + +### MN-004: Remove a masternode [Implemented] +**Persona:** Priya + +As a masternode operator, I want to remove a masternode from DET, so that I can stop tracking a node I no longer operate. + +- The detail view's "Remove masternode" action shows a confirmation dialog before proceeding. +- Confirming forgets the masternode and its associated voter identity, and the card disappears from the list. + +### MN-005: Keep the everyday surface clean [Implemented] +**Persona:** Alex, Priya + +As an everyday user, I want my Identity Hub to show only my personal identities, so that I'm never offered node-operator actions that don't apply to me. + +- Masternode/Evonode identities are filtered out of the Identity Hub picker; they still appear on the Masternodes tab. +- The legacy "Load Existing Identity" screen's Identity Type selector now offers User only — Masternode/Evonode loading lives solely on the Masternodes tab (MN-001), removing the earlier duplicate entry point. + +### MN-006: Encrypt my node keys at load time [Implemented] +**Persona:** Priya + +As a masternode operator, I want to set an optional password when I load my node, so that its private keys are encrypted at rest immediately instead of only after a separate step. + +- Leaving the load form's "Encryption password" field blank loads the node's keys unprotected (Tier-1), same as before; a password can be added later from the Key Info screen or the node's detail view. +- Entering a password seals the entered voting/owner/payout keys encrypted-at-rest (Tier-2) at load time. +- The detail view's Keys section shows the current protection tier ("Unprotected" / "Password-protected") and offers "Add password protection…" only while unprotected. + +### MN-007: Move a node's credits [Implemented] +**Persona:** Priya + +As a masternode operator, I want to withdraw, top up, and transfer a node's Platform credits from its detail view, so that I can manage its balance without leaving the Masternodes page. + +- The detail view's actions row opens the existing Withdraw, Top Up, and Transfer screens scoped to the selected node (Masternode or Evonode). +- Withdrawing with the owner key forces the destination to the node's registered Core payout address; withdrawing with the transfer/payout key allows any address. + +### MN-008: Manage a node's keys [Implemented] +**Persona:** Priya + +As a masternode operator, I want to open the key screen for a node, so that I can view a private key/WIF, sign a message, or add/remove a key. + +- The detail view's "Manage keys ›" opens the existing Key Info screen scoped to the node. +- The add-key purpose selector excludes OWNER and VOTING (Core-registered roles that cannot be added via Platform); TRANSFER/AUTHENTICATION/ENCRYPTION/DECRYPTION remain available. + +### MN-009: Claim an evonode's token rewards [Implemented] +**Persona:** Priya + +As an evonode operator, I want to jump to token-reward claiming from the node's detail view, so that I can collect rewards my evonode earned. + +- An Evonode's detail view shows "Claim token rewards ›", routing to the existing Claim Tokens screen for that identity. +- The action is hidden entirely on a plain Masternode's detail view. + +### MN-010: Keep the Masternodes tab consistent across a network switch [Implemented] +**Persona:** Priya + +As a masternode operator, I want the Masternodes tab to reset to a clean state when I switch the active network, so that I never act on a node, form, or error that belonged to the network I just left. + +- 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. diff --git a/src/app.rs b/src/app.rs index 434e7f233..d080297be 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,25 +1,26 @@ +mod reconcilers; +use reconcilers::{ + AccessibilityActivator, ConnectionBanner, MigrationReconciler, SpvBlockReconciler, +}; + #[cfg(not(feature = "testing"))] use crate::app_dir::data_file_path; +#[cfg(feature = "testing")] use crate::app_dir::{app_user_data_dir_path, ensure_data_dir_exists, ensure_env_file}; use crate::backend_task::contested_names::ContestedResourceTask; -use crate::backend_task::core::CoreItem; +use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::components::core_zmq_listener::{CoreZMQListener, ZMQMessage}; use crate::context::AppContext; use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; +use crate::context::migration_status::MigrationStep; use crate::database::Database; -#[cfg(not(feature = "testing"))] -use crate::logging::initialize_logger; -use crate::model::feature_gate::FeatureGate; -use crate::model::settings::Settings; -use crate::spv::CoreBackendMode; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::model::settings::AppSettings; +use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ProgressOverlay}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; -use crate::ui::dpns::dpns_contested_names_screen::{ - DPNSScreen, DPNSSubscreen, ScheduledVoteCastingStatus, -}; +use crate::ui::dpns::dpns_contested_names_screen::{DPNSScreen, DPNSSubscreen}; use crate::ui::identities::identities_screen::IdentitiesScreen; use crate::ui::network_chooser_screen::NetworkChooserScreen; use crate::ui::theme::ThemeMode; @@ -28,28 +29,150 @@ use crate::ui::tools::address_balance_screen::AddressBalanceScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; use crate::ui::tools::grovestark_screen::GroveSTARKScreen; -use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; -use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::tools::transition_visualizer_screen::TransitionVisualizerScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use crate::ui::welcome_screen::WelcomeScreen; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; -use crate::utils::egui_mpsc::{self, EguiMpscAsync, EguiMpscSync}; +use crate::utils::egui_mpsc::{self, EguiMpscAsync}; use crate::utils::tasks::TaskManager; +use crate::wallet_backend::DetScope; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use derive_more::From; use eframe::{App, egui}; +use platform_wallet_storage::secrets::SecretStore; use std::collections::BTreeMap; use std::ops::BitOrAssign; use std::path::PathBuf; -use std::sync::{Arc, mpsc}; -use std::time::{Duration, Instant, SystemTime}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use std::vec; use tokio::sync::mpsc as tokiompsc; +/// Banner action id pushed when the user clicks "Retry now" on the +/// migration-failure banner. The app loop matches this id and +/// re-dispatches the FinishUnwire backend task. Kept as a `const` so a +/// future second migration variant can pick a distinct id without +/// risking a typo collision. Exposed for kittest coverage. +pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; + +/// 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 +/// (violating the overlay's C1/C2 contract). This escape lowers the block while +/// sync continues safely in the background — a read-only operation that strands +/// nothing if backgrounded. It is also designated the block's single +/// keyboard-reachable escape (`with_keyboard_escape`), so a keyboard-only / +/// assistive-tech user can activate it with Enter or Space. +/// Colon-namespaced per the overlay action-id convention. Exposed for kittest +/// coverage. +pub const SPV_CONTINUE_BACKGROUND_ACTION: &str = "spv:sync:continue_background"; + +/// Plain, jargon-free descriptions for the SPV-sync block (Everyday-User rule: +/// no "SPV"/"headers"/"masternodes"/raw heights/percentages — the jargon-free +/// "Step N of 5" counter carries the granularity). Complete sentences (NFR-2). +const SPV_CONNECTING_DESCRIPTION: &str = "Connecting to the Dash network."; +const SPV_SYNCING_DESCRIPTION: &str = "Syncing with the Dash network."; + +/// What the per-frame SPV-sync block driver should do with the overlay this +/// frame, given whether a startup/Connect sync is **armed**, whether the user +/// chose to continue in the background, and the current connection state. Pure so +/// the policy is unit-testable in isolation from `AppState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpvBlockStep { + /// Armed, not dismissed, still connecting/syncing: raise (or keep + update). + Block, + /// Armed episode reached a terminal state (Synced/Error): lower the block and + /// DISARM, so subsequent ambient Connecting/Syncing (reconnect, per-block + /// catch-up) never re-blocks (F-SPV-A). + Disarm, + /// Armed but the user chose to continue in the background: keep the block + /// lowered without ending the episode (C2 escape). + Stand, + /// Not armed (ambient sync, or already disarmed): ensure no block is shown. + Idle, +} + +/// Pure SPV-sync block policy (F-SPV-A scope gate + C1/C2). The block is **scoped +/// to user-initiated sync** — armed only on startup auto-start and the Connect +/// button — so an ambient reconnect or the SPV engine flipping Synced→Syncing on +/// each new block never hard-blocks a working user. Once an armed episode reaches +/// a terminal state it disarms and stays disarmed until the next Connect/startup. +fn spv_block_step(armed: bool, dismissed: bool, state: OverallConnectionState) -> SpvBlockStep { + use OverallConnectionState as S; + if !armed { + return SpvBlockStep::Idle; + } + match state { + // Terminal for an armed episode: lower and disarm (banner surfaces Error). + S::Synced | S::Error => SpvBlockStep::Disarm, + // Still getting connected/synced for this episode: block unless the user + // is waiting in the background. Disconnected stays blocking while armed — + // it just means we are still trying to connect. + S::Connecting | S::Syncing | S::Disconnected => { + if dismissed { + SpvBlockStep::Stand + } else { + SpvBlockStep::Block + } + } + } +} + +/// One-sentence user-facing label for an in-progress migration step. +/// Mirrors Diziet §2.2 D-1 banner copy — single complete sentence per +/// variant so i18n extraction is trivial. Exposed for kittest +/// coverage so a regression in the label table fails the test suite. +pub fn migration_running_text(step: MigrationStep) -> &'static str { + match step { + MigrationStep::Detecting => "Checking your wallet data.", + 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::Finalize => "Finishing storage update.", + } +} + +/// 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. +/// +/// Wiring is a local, non-network operation (open the SQLite sidecar, hydrate +/// wallets, bootstrap addresses) that normally completes within a few frames of +/// boot / a network switch — sub-second in the common case. 30 seconds is ~two +/// orders of magnitude past the expected completion, generous enough never to +/// false-positive on a slow disk or a large wallet set, yet short enough that a +/// genuinely wedged backend surfaces within half a minute instead of never. It +/// sits well below the network-bound waits (the 120 s SPV no-progress watchdog, +/// the 10 min MCP sync gate), matching that this wait is local, not on the wire. +const COLD_START_BACKEND_READY_TIMEOUT: Duration = Duration::from_secs(30); + +/// User-facing banner shown when the wallet backend never finishes wiring within +/// [`COLD_START_BACKEND_READY_TIMEOUT`], so the cold-start migration can never +/// run. Everyday-User copy (no "backend"/"wiring"/"SPV" jargon): what happened + +/// a self-serviceable action. Complete sentences so i18n extracts it as one unit. +const COLD_START_STUCK_MESSAGE: &str = + "We couldn't finish preparing your wallet. Try restarting the app."; + +/// Decide whether to dispatch the cold-start migration for the active network +/// this frame: only when it has not already been dispatched AND its wallet +/// backend is wired. The migration's first step needs a wired backend; firing +/// before it is wired aborts with a transient `WalletBackendUnavailable` and +/// burns the per-network dispatch guard, so the readiness gate keeps the guard +/// pending and retries on a later frame once the backend wires. +fn should_dispatch_cold_start(already_dispatched: bool, backend_ready: bool) -> bool { + !already_dispatched && backend_ready +} + +/// Whether the readiness gate has been waiting on an unwired wallet backend long +/// enough to surface the stuck-preparation banner. Pure so the timeout is +/// unit-testable with synthetic durations. `waited == None` means we are not (or +/// no longer) waiting — that never times out. +fn cold_start_backend_wait_timed_out(waited: Option<Duration>, timeout: Duration) -> bool { + waited.is_some_and(|elapsed| elapsed >= timeout) +} + #[derive(Debug, From)] pub enum TaskResult { Refresh, @@ -139,10 +262,6 @@ pub struct AppState { network_switch_pending: Option<Network>, /// Progress banner displayed while a network switch is in progress. network_switch_banner: Option<BannerHandle>, - #[allow(dead_code)] // Kept alive for the lifetime of the app - zmq_listeners: BTreeMap<Network, CoreZMQListener>, - core_message_sender: egui_mpsc::SenderSync<(ZMQMessage, Network)>, - pub core_message_receiver: mpsc::Receiver<(ZMQMessage, Network)>, pub task_result_sender: egui_mpsc::SenderAsync<TaskResult>, // Channel sender for sending task results pub task_result_receiver: tokiompsc::Receiver<TaskResult>, // Channel receiver for receiving task results theme: ThemeState, @@ -153,32 +272,42 @@ pub struct AppState { pub show_welcome_screen: bool, /// The welcome screen instance (only created if needed) pub welcome_screen: Option<WelcomeScreen>, - /// Previous connection state, used to detect transitions and update banners. - /// `None` on startup / after network switch to force the first evaluation. - previous_connection_state: Option<OverallConnectionState>, - /// Handle to the current connection status banner, if one is displayed - connection_banner_handle: Option<BannerHandle>, + /// Connection-status banner reconciler (state-transition driven). + connection_banner: ConnectionBanner, + /// Blocking SPV-sync overlay reconciler. Hard-blocks the UI while a + /// **user-initiated** sync (startup auto-start / Connect) connects, until + /// the chain becomes usable (Synced) or fails (Error), or the user chooses + /// to continue in the background. Ambient reconnects are never armed, so + /// they never hard-block a working user (F-SPV-A). + spv_block: SpvBlockReconciler, + /// Data-migration banner + cold-start `FinishUnwire` dispatch reconciler. + migration: MigrationReconciler, /// Async shutdown receiver. `Some` while a graceful shutdown is in progress; /// the viewport is closed once the receiver resolves. shutdown_receiver: Option<tokio::sync::oneshot::Receiver<()>>, /// Timestamp when the async shutdown was initiated, used as a hard deadline /// to force-close the viewport if the shutdown task stalls. shutdown_started: Option<std::time::Instant>, - /// Whether accessibility is force-enabled (DASH_EVO_TOOL_ACCESSIBILITY=1). When unset, accessibility still works normally via VoiceOver or other assistive technology — this flag forces it on unconditionally. - accessibility_enforced: bool, - /// Whether we have already triggered platform-level accessibility activation. - accessibility_activated: bool, - /// How many frames we have attempted accessibility activation. - accessibility_retries: u32, + /// Platform-level accessibility (AccessKit) activation reconciler. + accessibility: AccessibilityActivator, /// Shared MCP context -- follows network switches via `ArcSwap`. #[cfg(feature = "mcp")] pub mcp_app_context: Option<Arc<arc_swap::ArcSwap<AppContext>>>, + /// The egui secret prompt host, kept so newly-created (on-demand) network + /// contexts can have it installed before their backend is wired. + secret_prompt_host: Arc<dyn crate::wallet_backend::SecretPrompt>, + /// Receives just-in-time passphrase requests enqueued by the egui secret + /// prompt host. Drained once per frame in [`Self::update`]; the active + /// request becomes [`Self::active_secret_prompt`]. + secret_prompt_receiver: tokiompsc::UnboundedReceiver<QueuedPrompt>, + /// The passphrase prompt currently shown, if any. Exactly one is active at + /// a time; further requests wait in `secret_prompt_receiver` (FIFO). + active_secret_prompt: Option<ActivePrompt>, } #[derive(Debug, Clone, PartialEq)] pub enum DesiredAppAction { None, - #[allow(dead_code)] // May be used in future for explicit refresh actions Refresh, AddScreenType(Box<ScreenType>), BackendTask(Box<BackendTask>), @@ -226,6 +355,16 @@ pub enum AppAction { PopThenAddScreenToMainScreen(RootScreenType, Screen), BackendTask(BackendTask), BackendTasks(Vec<BackendTask>, BackendTasksExecutionMode), + /// Wire the wallet backend (if needed) and start chain sync for the active + /// context. Handled by the update loop, which owns the `TaskResult` sender + /// the wiring step requires. Used by the manual Connect button so a click + /// during the brief not-yet-wired window lazily wires-then-starts instead + /// of silently fast-failing. + StartSpv, + /// Stop chain sync and unwire the wallet backend for the active context. + /// Handled by the update loop because the teardown is async. Used by the + /// manual Disconnect button; the next Connect rebuilds the backend. + StopSpv, Custom(String), /// Mark onboarding as complete, hide welcome screen, and optionally navigate OnboardingComplete { @@ -234,6 +373,11 @@ pub enum AppAction { /// Optional sub-screen to push onto the stack add_screen: Option<Box<crate::ui::ScreenType>>, }, + /// Switch the active sub-tab inside the Identity Hub root screen. Emitted + /// by in-hub deep links (e.g. the Home tab's "See all activity" link, the + /// Contacts gate's "Add a display name" CTA). Handled by `AppState::update` + /// which looks up the visible `IdentityHubScreen` and calls `select_tab`. + SwitchIdentityHubTab(crate::ui::identity::IdentityHubTab), } impl BitOrAssign for AppAction { @@ -247,31 +391,137 @@ impl BitOrAssign for AppAction { *self = rhs; } } + +/// Why the wallet backend is being wired, selecting the spawned task's label +/// and its log/banner wording. The single shape behind boot, network switch, +/// post-onboarding auto-start, and the manual Connect button (see +/// [`AppState::spawn_backend_init`]). +#[derive(Debug, Clone, Copy)] +enum BackendInitReason { + /// Eager per-network wiring at process start. + Boot, + /// Eager wiring after a network switch. + NetworkSwitch, + /// Post-onboarding chain-sync opt-in. + OnboardingAutoStart, + /// The manual Connect button. + ManualConnect, +} + +impl BackendInitReason { + /// Label for the spawned subtask. + fn task_name(self) -> &'static str { + match self { + BackendInitReason::Boot | BackendInitReason::NetworkSwitch => { + "wallet-backend-eager-init" + } + BackendInitReason::OnboardingAutoStart => "spv_auto_start", + BackendInitReason::ManualConnect => "spv_manual_start", + } + } + + /// Log a successful chain-sync start. + fn log_spv_started(self, app_ctx: &AppContext, already_running: bool) { + let network = app_ctx.network; + match self { + BackendInitReason::Boot => { + tracing::info!(?network, "SPV sync started automatically at boot"); + } + BackendInitReason::NetworkSwitch if already_running => { + tracing::info!( + ?network, + "Chain sync already running on the switched-to context" + ); + } + BackendInitReason::NetworkSwitch => { + tracing::info!(?network, "Chain sync started after network switch"); + } + BackendInitReason::OnboardingAutoStart => { + tracing::info!(?network, "SPV sync started automatically after onboarding"); + } + // The manual Connect button is silent on success — the SPV-sync + // block conveys progress and the indicator flips to connected. + BackendInitReason::ManualConnect => {} + } + } + + /// Handle a failed wire+start. Every path except `ManualConnect` warns and + /// relies on the lazy backend-task fallback to retry; the manual Connect + /// surfaces an actionable error banner because the user is waiting. + fn on_spv_start_error(self, egui_ctx: &egui::Context, error: &TaskError) { + match self { + BackendInitReason::Boot => { + tracing::warn!(error = %error, "eager wallet-backend init + SPV auto-start failed; SDK proof verification will retry once the lazy backend-task fallback fires"); + } + BackendInitReason::NetworkSwitch => { + tracing::warn!(error = %error, "eager wallet-backend init + SPV auto-start after network switch failed; lazy fallback will retry"); + } + BackendInitReason::OnboardingAutoStart => { + tracing::warn!(error = %error, "Failed to auto-start SPV sync after onboarding"); + } + BackendInitReason::ManualConnect => { + // The chokepoint already flipped the SPV indicator to Error; + // the user pressed Connect and is waiting, so also surface an + // actionable error banner here. + let handle = MessageBanner::set_global( + egui_ctx, + "Could not start network sync. Check your connection and try again.", + MessageType::Error, + ); + handle.disable_auto_dismiss(); + handle.with_details(error); + } + } + } + + /// Handle a failed wire-only init (no chain-sync start requested). + fn on_wire_error(self, error: &TaskError) { + match self { + BackendInitReason::Boot => { + tracing::warn!(error = %error, "eager wallet-backend init failed; SDK proof verification will retry once the lazy backend-task fallback fires"); + } + // Only Boot / NetworkSwitch ever wire without starting SPV; the + // opt-in paths always start. + _ => { + tracing::warn!(error = %error, "eager wallet-backend init after network switch failed; lazy fallback will retry"); + } + } + } +} + impl AppState { - /// Creates a new `AppState` using the production database. + /// Creates a new `AppState`, opening the seed vault keyless. /// - /// This constructor is hidden when the `testing` feature is active to prevent - /// tests from accidentally using the production database. Use the `testing` - /// feature-gated `new()` variant instead. - #[cfg(not(feature = "testing"))] + /// Database selection is delegated to [`Self::boot_inputs`], which is + /// feature-gated so that the `testing` build can never touch the + /// production database. The keyless open aborts on a passphrase-protected + /// legacy vault; the GUI binary boots through + /// [`BootApp`](crate::boot::BootApp) instead, which prompts for the + /// passphrase rather than aborting. pub fn new(ctx: egui::Context) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { - let data_dir = app_user_data_dir_path()?; - ensure_data_dir_exists(&data_dir)?; - ensure_env_file(&data_dir); + let (data_dir, db) = Self::boot_inputs()?; + let secret_store = AppContext::open_secret_store(&data_dir)?; + Self::new_inner(ctx, db, data_dir, secret_store) + } - initialize_logger(); + /// Prepare the boot inputs (data dir, env file, logging, database). + /// + /// The non-testing build opens and initializes the on-disk production + /// database; the `testing` build substitutes an in-memory database so + /// tests never read or write production data. + #[cfg(not(feature = "testing"))] + pub(crate) fn boot_inputs() + -> Result<(PathBuf, Arc<Database>), Box<dyn std::error::Error + Send + Sync>> { + let data_dir = crate::boot::prepare_environment()?; let db_file_path = data_file_path(&data_dir, "data.db")?; let db = Arc::new(Database::new(&db_file_path)?); db.initialize(&db_file_path)?; - Self::new_inner(ctx, db, data_dir) + Ok((data_dir, db)) } - /// Creates a new `AppState` using an in-memory database for testing. - /// - /// Available only when the `testing` feature is active. This prevents tests - /// from reading or writing the production database. #[cfg(feature = "testing")] - pub fn new(ctx: egui::Context) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { + pub(crate) fn boot_inputs() + -> Result<(PathBuf, Arc<Database>), Box<dyn std::error::Error + Send + Sync>> { let data_dir = app_user_data_dir_path()?; ensure_data_dir_exists(&data_dir)?; ensure_env_file(&data_dir); @@ -280,18 +530,32 @@ impl AppState { crate::database::test_helpers::create_test_database() .map_err(|e| format!("Failed to create test database: {}", e))?, ); - Self::new_inner(ctx, db, data_dir) + Ok((data_dir, db)) } - fn new_inner( + pub(crate) fn new_inner( ctx: egui::Context, db: Arc<Database>, data_dir: PathBuf, + secret_store: Arc<SecretStore>, ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { - let settings = db.get_settings()?.map(Settings::from).unwrap_or_default(); - let password_info = settings.password_info; + // Boot path now reads preferences from the shared app k/v store + // (`<data_dir>/det-app.sqlite`). The store is opened once here and + // 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)?; + let settings = match app_kv.get::<AppSettings>(DetScope::Global, AppSettings::KV_KEY) { + Ok(Some(s)) => s, + Ok(None) => AppSettings::default(), + Err(e) => { + tracing::warn!( + error = ?e, + "Failed to read AppSettings at boot — using defaults" + ); + AppSettings::default() + } + }; let theme_preference = settings.theme_mode; - let overwrite_dash_conf = settings.overwrite_dash_conf; let onboarding_completed = settings.onboarding_completed; let subtasks = Arc::new(TaskManager::new()); @@ -299,16 +563,28 @@ impl AppState { let saved_network = settings.network; + // App-global Expert Mode flag: read once from config and shared into + // every per-network context (including any created later by a network + // switch), so a live toggle is observed everywhere without a restart. + let developer_mode = Arc::new(std::sync::atomic::AtomicBool::new( + crate::config::Config::load_from(&data_dir) + .ok() + .and_then(|c| c.developer_mode) + .unwrap_or(false), + )); + // Build a helper to create AppContext for a given network. let make_context = |network: Network| -> Option<Arc<AppContext>> { AppContext::new( data_dir.clone(), network, db.clone(), - password_info.clone(), subtasks.clone(), connection_status.clone(), ctx.clone(), + Arc::clone(&app_kv), + Arc::clone(&secret_store), + Arc::clone(&developer_mode), ) }; @@ -351,8 +627,14 @@ impl AppState { .into(), ); } - let chosen_network = *network_contexts.keys().next().unwrap(); - let active_context = network_contexts.get(&chosen_network).unwrap().clone(); + let chosen_network = *network_contexts + .keys() + .next() + .expect("invariant: network_contexts is non-empty after the emptiness check above"); + let active_context = network_contexts + .get(&chosen_network) + .expect("invariant: chosen_network was just taken from network_contexts") + .clone(); // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -381,7 +663,6 @@ impl AppState { let proof_visualizer_screen = ProofVisualizerScreen::new(&active_context); let document_visualizer_screen = DocumentVisualizerScreen::new(&active_context); let contract_visualizer_screen = ContractVisualizerScreen::new(&active_context); - let proof_log_screen = ProofLogScreen::new(&active_context); let platform_info_screen = PlatformInfoScreen::new(&active_context); let address_balance_screen = AddressBalanceScreen::new(&active_context); let grovestark_screen = GroveSTARKScreen::new(&active_context); @@ -399,30 +680,58 @@ impl AppState { DashPayScreen::new(&active_context, DashPaySubscreen::Payments); let dashpay_profile_search_screen = ProfileSearchScreen::new(active_context.clone()); - let network_chooser_screen = - NetworkChooserScreen::new(&network_contexts, chosen_network, overwrite_dash_conf); - - let masternode_list_diff_screen = MasternodeListDiffScreen::new(&active_context); + let network_chooser_screen = NetworkChooserScreen::new(&network_contexts, chosen_network); let wallets_balances_screen = WalletsBalancesScreen::new(&active_context); - let selected_main_screen = settings.root_screen_type; + // Persisted setting; the effective `selected_main_screen` is computed + // after the screen map is built (below) so we can fall back to a + // known-registered screen if the persisted value is no longer + // registered. + let persisted_main_screen = settings.root_screen_type; // // Create a channel with a buffer size of 32 (adjust as needed) let (task_result_sender, task_result_receiver) = tokiompsc::channel(256).with_egui_ctx(ctx.clone()); - // Create a channel for communication with the InstantSendListener - let (core_message_sender, core_message_receiver) = - mpsc::channel().with_egui_ctx(ctx.clone()); + // Build the egui just-in-time secret prompt host and install it on + // every network context BEFORE the eager wallet-backend init below + // reads it into each backend's `SecretAccess`. The host enqueues + // passphrase requests onto `secret_prompt_receiver`, which the frame + // loop drains. One host serves every network (the request carries the + // scope; the active network's backend prompts through it). + let (secret_prompt_host, secret_prompt_receiver) = EguiSecretPromptHost::new(ctx.clone()); + let secret_prompt_host: Arc<dyn crate::wallet_backend::SecretPrompt> = + Arc::new(secret_prompt_host); + for app_ctx in network_contexts.values() { + app_ctx.install_secret_prompt(Arc::clone(&secret_prompt_host)); + } - let zmq_listeners: BTreeMap<Network, CoreZMQListener> = network_contexts - .iter() - .filter_map(|(&network, ctx)| { - Self::spawn_zmq_listener(ctx, network, &core_message_sender) - .map(|listener| (network, listener)) - }) - .collect(); + // Eagerly build the wallet seam for every pre-created network context + // (typically just the active one) so the SpvProvider can serve + // chain-only lookups (e.g. `get_quorum_public_key`) before any + // wallet is unlocked. Without this, the SDK retry loop tight-loops + // at 10ms on `WalletBackendNotYetWired`. `PlatformWalletManager` is + // wallet-independent at construction (Case B); persisted wallets + // load watch-only via `load_from_persistor_seedless`, no unlock required + // to display funds — the seed enters memory only on unlock. + // + // Auto-start of chain sync rides on wiring completion: for the active + // network, when onboarding is done and the user opted in, the same + // task that wires the backend goes on to start SPV. Folding the start + // into the spawned init closes the boot race where a synchronous + // `start_spv()` fired before the fire-and-forget wiring could finish. + let boot_auto_start_spv = onboarding_completed && settings.auto_start_spv; + for (&net, app_ctx) in network_contexts.iter() { + let auto_start = boot_auto_start_spv && net == chosen_network; + Self::spawn_backend_init( + &subtasks, + task_result_sender.clone(), + app_ctx.clone(), + BackendInitReason::Boot, + auto_start, + ); + } // MCP server (feature-gated, opt-in via MCP_API_KEY env var) #[cfg(feature = "mcp")] @@ -451,110 +760,132 @@ impl AppState { } }; + let main_screens: BTreeMap<RootScreenType, Screen> = [ + ( + RootScreenType::RootScreenIdentities, + Screen::IdentitiesScreen(identities_screen), + ), + ( + RootScreenType::RootScreenDPNSActiveContests, + Screen::DPNSScreen(dpns_active_contests_screen), + ), + ( + RootScreenType::RootScreenDPNSPastContests, + Screen::DPNSScreen(dpns_past_contests_screen), + ), + ( + RootScreenType::RootScreenDPNSOwnedNames, + Screen::DPNSScreen(dpns_my_usernames_screen), + ), + ( + RootScreenType::RootScreenDPNSScheduledVotes, + Screen::DPNSScreen(dpns_scheduled_votes_screen), + ), + ( + RootScreenType::RootScreenWalletsBalances, + Screen::WalletsBalancesScreen(wallets_balances_screen), + ), + ( + RootScreenType::RootScreenToolsTransitionVisualizerScreen, + Screen::TransitionVisualizerScreen(transition_visualizer_screen), + ), + ( + RootScreenType::RootScreenToolsProofVisualizerScreen, + Screen::ProofVisualizerScreen(proof_visualizer_screen), + ), + ( + RootScreenType::RootScreenToolsDocumentVisualizerScreen, + Screen::DocumentVisualizerScreen(document_visualizer_screen), + ), + ( + RootScreenType::RootScreenToolsContractVisualizerScreen, + Screen::ContractVisualizerScreen(contract_visualizer_screen), + ), + ( + RootScreenType::RootScreenToolsPlatformInfoScreen, + Screen::PlatformInfoScreen(platform_info_screen), + ), + ( + RootScreenType::RootScreenToolsAddressBalanceScreen, + Screen::AddressBalanceScreen(address_balance_screen), + ), + ( + RootScreenType::RootScreenToolsGroveSTARKScreen, + Screen::GroveSTARKScreen(grovestark_screen), + ), + ( + RootScreenType::RootScreenDocumentQuery, + Screen::DocumentQueryScreen(document_query_screen), + ), + ( + RootScreenType::RootScreenDashpay, + Screen::DashPayScreen(contracts_dashpay_screen), + ), + ( + RootScreenType::RootScreenNetworkChooser, + Screen::NetworkChooserScreen(network_chooser_screen), + ), + ( + RootScreenType::RootScreenMyTokenBalances, + Screen::TokensScreen(Box::new(tokens_balances_screen)), + ), + ( + RootScreenType::RootScreenTokenSearch, + Screen::TokensScreen(Box::new(token_search_screen)), + ), + ( + RootScreenType::RootScreenTokenCreator, + Screen::TokensScreen(Box::new(token_creator_screen)), + ), + ( + RootScreenType::RootScreenDashPayContacts, + Screen::DashPayScreen(dashpay_contacts_screen), + ), + ( + RootScreenType::RootScreenDashPayProfile, + Screen::DashPayScreen(dashpay_profile_screen), + ), + ( + RootScreenType::RootScreenDashPayPayments, + Screen::DashPayScreen(dashpay_payments_screen), + ), + ( + RootScreenType::RootScreenDashPayProfileSearch, + Screen::DashPayProfileSearchScreen(dashpay_profile_search_screen), + ), + ( + // Always registered — the Masternodes tab is gated at runtime by + // Expert Mode (the nav entry + route), not by a Cargo feature, so + // the screen must always exist to switch into when Expert Mode + // is on. Live de-gating falls back to Identities (see below). + RootScreenType::RootScreenMasternodes, + Screen::MasternodesScreen(crate::ui::masternodes::MasternodesScreen::new( + &active_context, + )), + ), + ] + .into_iter() + .chain({ + // Register the unified Identities hub screen. + let hub = crate::ui::identity::IdentityHubScreen::new(&active_context); + [( + RootScreenType::RootScreenIdentityHub, + Screen::IdentityHubScreen(hub), + )] + }) + .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) { + persisted_main_screen + } else { + RootScreenType::RootScreenIdentities + }; + let mut app_state = Self { - main_screens: [ - ( - RootScreenType::RootScreenIdentities, - Screen::IdentitiesScreen(identities_screen), - ), - ( - RootScreenType::RootScreenDPNSActiveContests, - Screen::DPNSScreen(dpns_active_contests_screen), - ), - ( - RootScreenType::RootScreenDPNSPastContests, - Screen::DPNSScreen(dpns_past_contests_screen), - ), - ( - RootScreenType::RootScreenDPNSOwnedNames, - Screen::DPNSScreen(dpns_my_usernames_screen), - ), - ( - RootScreenType::RootScreenDPNSScheduledVotes, - Screen::DPNSScreen(dpns_scheduled_votes_screen), - ), - ( - RootScreenType::RootScreenWalletsBalances, - Screen::WalletsBalancesScreen(wallets_balances_screen), - ), - ( - RootScreenType::RootScreenToolsTransitionVisualizerScreen, - Screen::TransitionVisualizerScreen(transition_visualizer_screen), - ), - ( - RootScreenType::RootScreenToolsProofVisualizerScreen, - Screen::ProofVisualizerScreen(proof_visualizer_screen), - ), - ( - RootScreenType::RootScreenToolsDocumentVisualizerScreen, - Screen::DocumentVisualizerScreen(document_visualizer_screen), - ), - ( - RootScreenType::RootScreenToolsContractVisualizerScreen, - Screen::ContractVisualizerScreen(contract_visualizer_screen), - ), - ( - RootScreenType::RootScreenToolsProofLogScreen, - Screen::ProofLogScreen(proof_log_screen), - ), - ( - RootScreenType::RootScreenToolsPlatformInfoScreen, - Screen::PlatformInfoScreen(platform_info_screen), - ), - ( - RootScreenType::RootScreenToolsAddressBalanceScreen, - Screen::AddressBalanceScreen(address_balance_screen), - ), - ( - RootScreenType::RootScreenToolsGroveSTARKScreen, - Screen::GroveSTARKScreen(grovestark_screen), - ), - ( - RootScreenType::RootScreenDocumentQuery, - Screen::DocumentQueryScreen(document_query_screen), - ), - ( - RootScreenType::RootScreenDashpay, - Screen::DashPayScreen(contracts_dashpay_screen), - ), - ( - RootScreenType::RootScreenNetworkChooser, - Screen::NetworkChooserScreen(network_chooser_screen), - ), - ( - RootScreenType::RootScreenToolsMasternodeListDiffScreen, - Screen::MasternodeListDiffScreen(masternode_list_diff_screen), - ), - ( - RootScreenType::RootScreenMyTokenBalances, - Screen::TokensScreen(Box::new(tokens_balances_screen)), - ), - ( - RootScreenType::RootScreenTokenSearch, - Screen::TokensScreen(Box::new(token_search_screen)), - ), - ( - RootScreenType::RootScreenTokenCreator, - Screen::TokensScreen(Box::new(token_creator_screen)), - ), - ( - RootScreenType::RootScreenDashPayContacts, - Screen::DashPayScreen(dashpay_contacts_screen), - ), - ( - RootScreenType::RootScreenDashPayProfile, - Screen::DashPayScreen(dashpay_profile_screen), - ), - ( - RootScreenType::RootScreenDashPayPayments, - Screen::DashPayScreen(dashpay_payments_screen), - ), - ( - RootScreenType::RootScreenDashPayProfileSearch, - Screen::DashPayProfileSearchScreen(dashpay_profile_search_screen), - ), - ] - .into(), + main_screens, selected_main_screen, screen_stack: vec![], chosen_network, @@ -562,9 +893,6 @@ impl AppState { network_contexts, network_switch_pending: None, network_switch_banner: None, - zmq_listeners, - core_message_sender, - core_message_receiver, task_result_sender, task_result_receiver, theme: ThemeState::new(theme_preference), @@ -573,15 +901,19 @@ impl AppState { subtasks, show_welcome_screen: !onboarding_completed, welcome_screen: None, - previous_connection_state: None, - connection_banner_handle: None, + connection_banner: ConnectionBanner::new(), + // Arm the block for the boot SPV sync when it auto-starts (F-SPV-A: + // scoped to user-initiated sync, not ambient reconnect). + spv_block: SpvBlockReconciler::new(boot_auto_start_spv), + migration: MigrationReconciler::new(), shutdown_receiver: None, shutdown_started: None, - accessibility_enforced, - accessibility_activated: false, - accessibility_retries: 0, + accessibility: AccessibilityActivator::new(accessibility_enforced), #[cfg(feature = "mcp")] mcp_app_context, + secret_prompt_host, + secret_prompt_receiver, + active_secret_prompt: None, }; // Initialize welcome screen if needed (uses whichever context is active) @@ -589,7 +921,8 @@ impl AppState { app_state.welcome_screen = Some(WelcomeScreen::new(app_state.current_app_context().clone())); } else { - app_state.try_auto_start_spv(); + // Boot-time SPV auto-start is folded into the eager wallet-backend + // init above (so it cannot fire before the backend is wired). // Refresh ALL main screens so they load data properly // This ensures screens like DashPay Profile have identities loaded @@ -599,13 +932,9 @@ impl AppState { } } - // Warm up the Halo 2 ProvingKey in a background thread (~30s build). - // This ensures the key is ready for the user's first shielded operation. - #[cfg(not(feature = "testing"))] - std::thread::spawn(|| { - let _ = crate::context::shielded::get_proving_key(); - tracing::info!("Halo 2 ProvingKey built and cached"); - }); + // The Orchard proving key is now owned by the upstream shielded + // coordinator (`CachedOrchardProver`), warmed lazily on the first + // shielded operation — DET no longer builds or caches it here. Ok(app_state) } @@ -646,6 +975,39 @@ impl AppState { ); } + /// Spawn wallet-backend wiring for `app_ctx` and, when `start_spv`, chain + /// sync — the single shape behind every eager-init site (boot, network + /// switch, post-onboarding auto-start, manual Connect). Folding the start + /// into the same spawned task closes the boot race where a synchronous + /// `start_spv()` could fire before the fire-and-forget wiring finished. + /// `reason` selects the task label and the log/banner wording. + /// + /// Associated (not `&mut self`) so the constructor's per-network loop can + /// call it before `AppState` exists; the block-arming that user-initiated + /// starts need stays at those callsites. + fn spawn_backend_init( + subtasks: &Arc<TaskManager>, + sender: egui_mpsc::SenderAsync<TaskResult>, + app_ctx: Arc<AppContext>, + reason: BackendInitReason, + start_spv: bool, + ) { + subtasks.spawn_sync(reason.task_name(), async move { + if start_spv { + let already_running = app_ctx + .wallet_backend() + .map(|b| b.is_started()) + .unwrap_or(false); + match app_ctx.ensure_wallet_backend_and_start_spv(sender).await { + Ok(()) => reason.log_spv_started(&app_ctx, already_running), + Err(e) => reason.on_spv_start_error(app_ctx.egui_ctx(), &e), + } + } else if let Err(e) = app_ctx.ensure_wallet_backend(sender).await { + reason.on_wire_error(&e); + } + }); + } + // Handle the backend task and send the result through the channel. // // Uses spawn_blocking + block_on to avoid Send bound issues with platform @@ -694,50 +1056,16 @@ impl AppState { }); } - fn spawn_zmq_listener( - ctx: &Arc<AppContext>, - network: Network, - sender: &egui_mpsc::SenderSync<(ZMQMessage, Network)>, - ) -> Option<CoreZMQListener> { - let default_endpoint = match network { - Network::Mainnet => "tcp://127.0.0.1:23708", - Network::Testnet => "tcp://127.0.0.1:23709", - Network::Devnet => "tcp://127.0.0.1:23710", - Network::Regtest => "tcp://127.0.0.1:20302", - }; - let endpoint = ctx - .config - .read() - .unwrap() - .core_zmq_endpoint - .clone() - .unwrap_or_else(|| default_endpoint.to_string()); - let disable = ctx - .get_settings() - .ok() - .flatten() - .map(|s| s.disable_zmq) - .unwrap_or(false); - if disable { - return None; - } - // SPV mode has no local Dash Core node to talk to over ZMQ. Gate the - // listener spawn through FeatureGate so the common (SPV) path doesn't - // burn a socket + retry loop waiting for a node that isn't there. - if !FeatureGate::RpcBackend.is_available(ctx) { - return None; - } - CoreZMQListener::spawn_listener( - network, - &endpoint, - sender.clone(), - Some(ctx.sx_zmq_status.clone()), - ) - .inspect_err(|e| tracing::error!("Failed to create {network:?} ZMQ listener: {e}")) - .ok() - } - pub fn active_root_screen_mut(&mut self) -> &mut Screen { + // Live de-gating (§10.11): if Expert Mode flipped off while the + // Masternodes tab was active, fall back to the neutral Identities tab so + // the Expert-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 + && !self.current_app_context().is_developer_mode() + { + self.selected_main_screen = RootScreenType::RootScreenIdentities; + } self.main_screens .get_mut(&self.selected_main_screen) .expect("expected to get screen") @@ -769,20 +1097,43 @@ impl AppState { format!("Connecting to {network:?}..."), MessageType::Info, )); - let start_spv = self - .current_app_context() - .db - .get_auto_start_spv() - .unwrap_or(false); + let start_spv = self.current_app_context().get_app_settings().auto_start_spv; self.handle_backend_task(BackendTask::SwitchNetwork { network, start_spv }); } /// Complete the network switch after the context is available. fn finalize_network_switch(&mut self, network: Network) { + // Forget any session-cached secrets on the outgoing context before we + // leave it. The outgoing per-network context stays cached in + // `network_contexts` (its `WalletBackend` is NOT dropped on switch), so + // this explicit, eager zeroize is what the JIT design relies on to keep + // secrets from lingering across a network change — not a drop. + if let Ok(backend) = self.current_app_context().wallet_backend() { + backend.forget_all_secrets(); + } + self.chosen_network = network; let app_context = self.current_app_context().clone(); + // Same eager wallet-backend init as at app start (Case B): chain- + // only SDK lookups must work pre-unlock on the freshly-switched + // context too, otherwise the SDK tight-loops on WalletBackendNotYetWired. + // + // Chain sync auto-starts on wiring completion (mirrors boot). The slow + // path already started SPV inside the `SwitchNetwork` task, but the fast + // path (cached context) reaches here without ever having started it — so + // the auto-start must live here to cover both. All steps are idempotent: + // re-wiring is a no-op and the backend's start latch prevents a second + // run loop. + Self::spawn_backend_init( + &self.subtasks, + self.task_result_sender.clone(), + app_context.clone(), + BackendInitReason::NetworkSwitch, + app_context.get_app_settings().auto_start_spv, + ); + // Update MCP server's context to follow network switch #[cfg(feature = "mcp")] if let Some(ref mcp_ctx) = self.mcp_app_context { @@ -790,32 +1141,31 @@ impl AppState { tracing::debug!("MCP context switched to {:?}", network); } - // INTENTIONAL(SEC-004): Clear stale banners from the previous network context. + // Deliberately clear stale banners from the previous network context. // A backend task completing after the switch could set a new banner in the new // network context — accepted risk for a local desktop app (cosmetic only). MessageBanner::clear_all_global(app_context.egui_ctx()); + // Drop any blocking overlay from the previous context so the new network + // is never left behind a stale block. Also drop the SPV-sync overlay + // bookkeeping so its handle never goes stale against the cleared `ctx.data`. + ProgressOverlay::clear_all_global(app_context.egui_ctx()); + self.spv_block.reset(); for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } - self.connection_status - .reset(app_context.core_backend_mode()); + self.connection_status.reset(); // Reset connection banner tracking so the next frame re-evaluates // the new network's state (even if it matches the old state). - if let Some(handle) = self.connection_banner_handle.take() { - handle.clear(); - } - self.previous_connection_state = None; + self.connection_banner.reset(); - // Spawn a ZMQ listener for the newly created network context. - if !self.zmq_listeners.contains_key(&network) - && let Some(listener) = - Self::spawn_zmq_listener(&app_context, network, &self.core_message_sender) - { - self.zmq_listeners.insert(network, listener); - } + // Reset the migration banner reconciler too: the new network's + // `MigrationStatus` lives on the new `AppContext`, so the reconciler + // must re-evaluate from scratch (otherwise a stale `Success` from the + // previous network would suppress the new network's `Running` banner). + self.migration.reset_for_switch(); // Persist the network choice. app_context @@ -823,96 +1173,111 @@ impl AppState { .ok(); } - /// Update the connection status banner when the overall connection state - /// transitions between Disconnected, Connecting, Syncing, and Synced. - /// - /// Also re-evaluates the banner text while in `Connecting` state each frame - /// because the degraded-peer timeout can fire without a state transition. - fn update_connection_banner(&mut self, ctx: &egui::Context, app_context: &Arc<AppContext>) { - let connection_status = app_context.connection_status(); - let current_state = connection_status.overall_state(); - let state_changed = self.previous_connection_state != Some(current_state); - - // In Connecting state the banner text can change (normal → degraded) - // without a state transition, so we must re-evaluate every frame. - // For all other states, skip if nothing changed. - if !state_changed && current_state != OverallConnectionState::Connecting { - return; + /// Claim all keyboard + text input for an active blocking overlay at frame + /// start — UNLESS a secret prompt is active above it. The prompt + /// renders above the overlay and needs the keyboard (Enter to submit, Esc to + /// cancel, Tab to navigate), so the overlay must yield to it. + /// Extracted from `update` so the gate is exercised by a kittest (RQ-1): + /// removing the `active_secret_prompt.is_none()` guard must fail that test. + fn claim_overlay_input(&self, ctx: &egui::Context) { + if self.active_secret_prompt.is_none() { + ProgressOverlay::claim_input(ctx); } + } - // Clear old banner on state transitions - if state_changed && let Some(handle) = self.connection_banner_handle.take() { - handle.clear(); - } + /// Test seam (RQ-1): force a secret prompt to be active (or not) so a kittest + /// can drive the REAL `update()` loop — including the + /// [`claim_overlay_input`](Self::claim_overlay_input) gate and + /// `render_secret_prompt` — and assert that the prompt above an overlay stays + /// focusable/typeable. Compiled only under the `testing` feature. + #[cfg(feature = "testing")] + pub fn test_set_secret_prompt_active(&mut self, active: bool) { + self.active_secret_prompt = active.then(ActivePrompt::test_stub); + } - // Display new banner based on current state - let backend_mode = connection_status.backend_mode(); - match current_state { - OverallConnectionState::Disconnected => { - let msg = match backend_mode { - CoreBackendMode::Rpc => "Disconnected — check that Dash Core is running", - CoreBackendMode::Spv => "Disconnected — check your internet connection", - }; - self.connection_banner_handle = - Some(MessageBanner::set_global(ctx, msg, MessageType::Error)); - } - OverallConnectionState::Connecting => { - // SPV active but no peers connected yet. The degraded flag - // flips after 30 s — `set_global` is idempotent for same text, - // so calling it every frame while Connecting is cheap. - let msg = if connection_status.spv_peer_degraded() { - "Having trouble finding peers. Check your connection." - } else { - "Looking for peers…" - }; - // Replace the banner when the text changes (normal → degraded). - if let Some(handle) = &self.connection_banner_handle { - handle.set_message(msg); - } else { - self.connection_banner_handle = - Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); - } - } - OverallConnectionState::Syncing => { - let msg = match backend_mode { - CoreBackendMode::Rpc => "Syncing with Dash Core…", - CoreBackendMode::Spv => "SPV sync in progress…", - }; - self.connection_banner_handle = - Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); - } - OverallConnectionState::Error => { - let handle = MessageBanner::set_global( - ctx, - "SPV sync failed. Go to Settings for connection details.", - MessageType::Error, - ); - if let Some(detail) = connection_status.spv_last_error() { - handle.with_details(detail); - } - self.connection_banner_handle = Some(handle); - } - OverallConnectionState::Synced => { - // No banner needed for fully synced state. - // Fetch epoch info on first sync to populate protocol version - // and fee multiplier — needed for feature gating (e.g., shielded - // tab requires protocol version >= 12). - if state_changed { - let task = BackendTask::PlatformInfo( - crate::backend_task::platform_info::PlatformInfoTaskRequestType::CurrentEpochInfo, - ); - self.handle_backend_task(task); - } - } + /// Test seam (Task 9 / F-SPV-A): arm a user-initiated SPV-sync block episode, + /// as the boot auto-start and the Connect button do. + #[cfg(feature = "testing")] + pub fn test_arm_spv_block(&mut self) { + self.spv_block.arm(); + } + + /// Test seam (Task 9): run the REAL SPV-sync block driver once against the + /// active context's (forced) connection state, in isolation from the + /// throttled frame loop. Lets a kittest assert that an armed episode blocks, + /// disarms on a terminal state, and that ambient (un-armed) sync never blocks. + #[cfg(feature = "testing")] + pub fn test_drive_spv_overlay(&mut self, ctx: &egui::Context) { + let app_context = self.current_app_context().clone(); + self.spv_block.update(ctx, &app_context); + } + + /// Test seam (F-SPV-A): run the REAL post-onboarding auto-start path + /// ([`Self::try_auto_start_spv`], the method `AppAction::OnboardingComplete` + /// invokes) so a kittest can lock that it arms the SPV-sync block. + #[cfg(feature = "testing")] + pub fn test_run_auto_start_spv(&mut self) { + self.try_auto_start_spv(); + } + + /// Test seam (F-SPV-A): observe the SPV-sync block's armed flag. + #[cfg(feature = "testing")] + pub fn test_spv_block_armed(&self) -> bool { + self.spv_block.armed() + } + + /// Sweep orphaned overlay action ids whose owning overlay is gone. Screens own + /// dispatch and cancellation today — they drain their own clicks via + /// [`OverlayHandle::take_actions`](crate::ui::components::OverlayHandle::take_actions); + /// this loop only reclaims orphans so they cannot accumulate in `ctx.data`. + // + // TODO(T7): the BackendTask system has no cooperative cancellation, so an + // overlay button can only stop waiting, never abort a running operation. When + // T7 lands (thread a per-operation CancellationToken through run_backend_task + // and retain the abort handle in handle_backend_task), a screen can wire a + // generic overlay button — e.g. one it labels "Cancel" — to a real abort. + // Until then no production overlay attaches a button to a running task, and + // this loop has no live cancellation role; the 120s watchdog + // (see progress_overlay.rs) bounds every block in the meantime. + fn drain_overlay_actions(&mut self, ctx: &egui::Context) { + for action_id in ProgressOverlay::sweep_orphan_actions(ctx) { + tracing::warn!( + target = "ui::overlay", + action_id = %action_id, + "Overlay action received for an overlay that is no longer active — dropping" + ); } - self.previous_connection_state = Some(current_state); } pub fn visible_screen_mut(&mut self) -> &mut Screen { if self.screen_stack.is_empty() { self.active_root_screen_mut() } else { - self.screen_stack.last_mut().unwrap() + self.screen_stack + .last_mut() + .expect("invariant: screen_stack is non-empty in this branch") + } + } + + /// Drain at most one pending passphrase request and render the active + /// prompt modal. Exactly one prompt is shown at a time; on submit/cancel + /// the host's one-shot is answered (inside [`ActivePrompt`]) and the slot + /// frees for the next queued request next frame. + fn render_secret_prompt(&mut self, ctx: &egui::Context) { + if self.active_secret_prompt.is_none() + && let Ok(queued) = self.secret_prompt_receiver.try_recv() + { + self.active_secret_prompt = Some(ActivePrompt::new(queued)); + } + + if let Some(prompt) = &mut self.active_secret_prompt { + let resolved = prompt.show(ctx); + if resolved { + self.active_secret_prompt = None; + // A second request may be queued — repaint so it surfaces + // without waiting for an idle wakeup. + ctx.request_repaint(); + } } } @@ -924,23 +1289,35 @@ impl AppState { .ok(); } - /// Auto-start SPV sync if the conditions are met: auto-start enabled and - /// backend mode is SPV. - fn try_auto_start_spv(&self) { - let ctx = self.current_app_context(); - let auto_start = ctx.db.get_auto_start_spv().unwrap_or(false); - if auto_start && FeatureGate::SpvBackend.is_available(ctx) { - if let Err(e) = ctx.start_spv() { - tracing::warn!("Failed to auto-start SPV sync: {e}"); - } else { - tracing::info!("SPV sync started automatically for {:?}", ctx.network); - } + /// Auto-start chain sync for the active context when the user opted in. + /// + /// Wires the wallet backend first (via the async chokepoint) so the start + /// cannot race ahead of backend wiring. Used after onboarding completes; + /// boot-time auto-start is handled inline by the eager wallet-backend init. + /// + /// Arms the SPV-sync block (F-SPV-A) when the start actually fires — this is + /// a user-initiated sync just like the Connect button, so the blocking + /// overlay must cover it. Boot auto-start arms via the constructor instead. + fn try_auto_start_spv(&mut self) { + if self.current_app_context().get_app_settings().auto_start_spv { + // Fresh user-initiated episode: arm the block and re-arm the escape, + // mirroring AppAction::StartSpv. + self.spv_block.arm(); + Self::spawn_backend_init( + &self.subtasks, + self.task_result_sender.clone(), + self.current_app_context().clone(), + BackendInitReason::OnboardingAutoStart, + true, + ); } } } impl App for AppState { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // ── Graceful shutdown: intercept window close so the UI stays responsive ── // When the user closes the window we cancel the native close, show a banner, // and start an async shutdown. Once all tasks have finished (or timed out) @@ -982,7 +1359,7 @@ impl App for AppState { } // Render a minimal UI that shows the shutdown banner. self.theme.poll_and_apply(ctx); - crate::ui::components::styled::island_central_panel(ctx, |_ui| {}); + crate::ui::components::styled::island_central_panel(ui, |_ui| {}); return; } @@ -1001,28 +1378,9 @@ impl App for AppState { return; } - // On the first frame, trigger platform-level accessibility activation + // On the first frames, trigger platform-level accessibility activation // so tools like Peekaboo can see the AccessKit tree without VoiceOver. - // Retries up to 60 frames, then gives up to avoid indefinite repaints. - const MAX_ACCESSIBILITY_RETRIES: u32 = 60; - if self.accessibility_enforced - && !self.accessibility_activated - && self.accessibility_retries < MAX_ACCESSIBILITY_RETRIES - { - self.accessibility_retries += 1; - self.accessibility_activated = crate::platform::force_accessibility_activation(); - if !self.accessibility_activated { - if self.accessibility_retries >= MAX_ACCESSIBILITY_RETRIES { - tracing::warn!( - "Accessibility activation failed after {} frames, giving up", - MAX_ACCESSIBILITY_RETRIES - ); - } else { - // Ensure we get another frame to retry, even if egui would otherwise go idle. - ctx.request_repaint(); - } - } - } + self.accessibility.update(ctx); self.theme.poll_and_apply(ctx); @@ -1044,8 +1402,24 @@ impl App for AppState { BackendTaskSuccessResult::Refresh => { self.visible_screen_mut().refresh(); } + BackendTaskSuccessResult::DashPayIncomingDetected(outputs) => { + // The EventBridge surfaced received outputs on a + // freshly-seen wallet transaction. Run the owner- + // scoped detect-match-record off the frame thread; + // matches come back as a `Refresh` to repaint the + // payment history, misses as `None`. + self.handle_backend_task(BackendTask::DashPayTask(Box::new( + DashPayTask::DetectIncomingContactPayments { outputs }, + ))); + } + BackendTaskSuccessResult::PlatformReadyDiscoverIdentities => { + // Platform is reachable: run the automatic all-wallets + // identity discovery sweep. The latch inside makes it a + // no-op if it already ran this session. + active_context.queue_all_wallets_identity_discovery(); + } BackendTaskSuccessResult::Message(ref msg) => { - // TODO(RUST-002): Some screens inspect Message text for error + // TODO: Some screens inspect Message text for error // keywords and may override with an Error banner, causing a // brief green-then-red flash. Refactor to pass structured error // types through task results instead of string messages. @@ -1054,6 +1428,42 @@ impl App for AppState { self.visible_screen_mut() .display_task_result(unboxed_message); } + BackendTaskSuccessResult::AssetLockBroadcast { ref txid } => { + let msg = format!( + "Asset lock transaction broadcast successfully. Transaction ID: {txid}" + ); + MessageBanner::set_global(ctx, &msg, MessageType::Success); + self.visible_screen_mut() + .display_task_result(unboxed_message); + } + BackendTaskSuccessResult::DashPayAddressesRegistered { + addresses, + contacts, + errors, + } => { + let msg = if errors == 0 { + format!( + "Registered {addresses} DashPay addresses for {contacts} contacts." + ) + } else { + format!( + "Registered {addresses} DashPay addresses for {contacts} contacts. {errors} addresses could not be registered." + ) + }; + MessageBanner::set_global(ctx, &msg, MessageType::Success); + self.visible_screen_mut() + .display_task_result(unboxed_message); + } + BackendTaskSuccessResult::IdentitiesLoaded { count } => { + let msg = if count == 1 { + "Successfully loaded 1 identity from your wallet.".to_string() + } else { + format!("Successfully loaded {count} identities from your wallet.") + }; + MessageBanner::set_global(ctx, &msg, MessageType::Success); + self.visible_screen_mut() + .display_task_result(unboxed_message); + } BackendTaskSuccessResult::Progress { .. } => { // Progress updates only go to the screen — no global banner. // The screen updates its existing banner handle in-place. @@ -1105,11 +1515,22 @@ impl App for AppState { context, .. } => { + // Install the egui prompt host before the new + // context's backend is wired, so its `SecretAccess` + // gets the interactive host rather than the headless + // default. + context.install_secret_prompt(Arc::clone(&self.secret_prompt_host)); self.network_contexts.insert(network, context); self.network_switch_pending = None; self.network_switch_banner.take_and_clear(); self.finalize_network_switch(network); } + BackendTaskSuccessResult::PlatformAddressSyncPushed { updates } => { + // Coordinator push: populate per-address platform_address_info + // for all loaded wallets so the per-address tab stays current + // without a manual Refresh. No banner — this fires every 15 s. + active_context.apply_platform_address_push(updates); + } _ => { // For all other success results, let the screen decide how to display // the outcome without showing a generic global success banner. @@ -1118,7 +1539,8 @@ impl App for AppState { } } } - TaskResult::Error(TaskError::MustRetry(msg)) => { + TaskResult::Error(err @ TaskError::CoreWalletAutoDetected { .. }) => { + let msg = err.to_string(); MessageBanner::set_global(ctx, &msg, MessageType::Success); self.visible_screen_mut() .display_message(&msg, MessageType::Success); @@ -1127,7 +1549,14 @@ impl App for AppState { TaskResult::Error(err @ TaskError::NetworkContextCreationFailed { .. }) => { self.network_switch_pending = None; self.network_switch_banner.take_and_clear(); - MessageBanner::set_global(ctx, err.to_string(), MessageType::Error); + MessageBanner::set_global(ctx, err.to_string(), MessageType::Error) + .disable_auto_dismiss(); + } + TaskResult::Error(TaskError::MigrationFailed { .. }) => { + // The migration task already published `MigrationState::Failed`, + // which the migration reconciler surfaces with the typed + // details and a "Retry now" action. Suppress the generic + // error banner here so the user sees one banner, not two. } TaskResult::Error(err) => { // Let the screen handle specific error types first. @@ -1137,7 +1566,8 @@ impl App for AppState { if !handled { let msg = err.to_string(); let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); - // INTENTIONAL(SEC-003): TaskError Debug output is shown to users. + handle.disable_auto_dismiss(); + // TaskError Debug output is shown to users, deliberately. // Ensure inner error types don't expose secrets. handle.with_details(&err); self.visible_screen_mut() @@ -1158,129 +1588,55 @@ impl App for AppState { self.last_repaint_request = Instant::now(); } - // **Poll the instant_send_receiver for any new InstantSend messages** - while let Ok((message, network)) = self.core_message_receiver.try_recv() { - let Some(app_context) = self.network_contexts.get(&network) else { - tracing::error!("No app context available for {:?}", network); - continue; - }; - match message { - ZMQMessage::ISLockedTransaction(tx, is_lock) => { - // Store the asset lock transaction in the database - match app_context.received_transaction_finality( - &tx, - Some(is_lock.clone()), - None, - ) { - Ok(utxos) => { - let core_item = - CoreItem::InstantLockedTransaction(tx.clone(), utxos, is_lock); - self.visible_screen_mut() - .display_task_result(BackendTaskSuccessResult::CoreItem(core_item)); - } - Err(e) => { - tracing::error!("Failed to store asset lock: {}", e); - } - } - } - ZMQMessage::ChainLockedLockedTransaction(tx, height) => { - if let Err(e) = - app_context.received_transaction_finality(&tx, None, Some(height)) - { - tracing::error!("Failed to store asset lock: {}", e); - } - } - ZMQMessage::ChainLockedBlock(block, chain_lock) => { - self.visible_screen_mut().display_task_result( - BackendTaskSuccessResult::CoreItem(CoreItem::ChainLockedBlock( - block, chain_lock, - )), - ); - } - } - } - - // Check if there are scheduled masternode votes to cast and if so, cast them + // Periodically cast any scheduled masternode votes that have come due. + // The poll itself — the DB query, local-identity load, and per-vote + // casting — runs off the UI thread in the `CastDueScheduledVotes` + // backend task; this tick only dispatches it. The DPNS Scheduled Votes + // screen learns which votes are in progress / cast via + // `display_task_result`, so a slow or failing query never stalls a frame. let now = Instant::now(); if now.duration_since(self.last_scheduled_vote_check) > Duration::from_secs(60) { self.last_scheduled_vote_check = now; - let app_context = self.current_app_context(); - - // Query the database - let db_votes = match app_context.get_scheduled_votes() { - Ok(votes) => votes, - Err(e) => { - tracing::error!("Error querying scheduled votes: {}", e); - return; - } - }; + self.handle_backend_task(BackendTask::ContestedResourceTask( + ContestedResourceTask::CastDueScheduledVotes, + )); + } - // Filter due votes - let current_time = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - let due_votes: Vec<_> = db_votes - .into_iter() - .filter(|v| { - v.unix_timestamp <= current_time - && !v.executed_successfully - && (v.unix_timestamp + 120000 >= current_time) // Don't cast votes more than 2 minutes behind current time - }) - .collect(); - - // For each due vote, construct a BackendTask and handle it - if !due_votes.is_empty() { - let local_identities = match app_context.load_local_voting_identities() { - Ok(identities) => identities, - Err(e) => { - tracing::error!("Error querying local voting identities: {}", e); - return; - } - }; + // Drive the SPV-sync block BEFORE claiming input and running the screen, so + // a freshly-armed episode RAISES the overlay in time for THIS frame's input + // claim + global render. Otherwise (raising after the claim + screen) the + // frame right after Connect/arming is fully interactive and the block only + // takes effect a frame later — the one-frame interactive gap. The connection + // banner still reads the block state afterwards (it suppresses its redundant + // Connecting/Syncing copy while the block is up). + self.spv_block.update(ctx, &active_context); - for vote in due_votes { - if let Some(voter) = local_identities - .iter() - .find(|i| i.identity.id() == vote.voter_id) - { - let dpns_screen = self - .main_screens - .get_mut(&RootScreenType::RootScreenDPNSScheduledVotes) - .unwrap(); - if let Screen::DPNSScreen(screen) = dpns_screen { - screen.scheduled_vote_cast_in_progress = true; - if let Some((_, s)) = screen - .scheduled_votes - .lock() - .unwrap() - .iter_mut() - .find(|(v, _)| v == &vote) - { - *s = ScheduledVoteCastingStatus::InProgress; - } - } - let task = BackendTask::ContestedResourceTask( - ContestedResourceTask::CastScheduledVote(vote, Box::new(voter.clone())), - ); - self.handle_backend_task(task); - } else { - tracing::warn!("Voter not found for scheduled vote: {:?}", vote); - } - } - } - } + // Total input block at frame start: while a blocking overlay is up, claim + // all keyboard + text input BEFORE the panels run — unless a + // secret prompt is active above the overlay (it needs the keyboard). + self.claim_overlay_input(ctx); // Show welcome screen if onboarding not completed let mut actions = Vec::new(); if self.show_welcome_screen && let Some(welcome_screen) = &mut self.welcome_screen { - actions.push(welcome_screen.ui(ctx)); + actions.push(welcome_screen.ui(ui)); } else { - actions.push(self.visible_screen_mut().ui(ctx)); + actions.push(self.visible_screen_mut().ui(ui)); }; + // Blocking progress overlay: above banners, below the secret prompt. + // It consumes Esc/Tab/Enter while active, so it must render before the + // secret prompt (which is focus-raised and stays interactive above it) + // and before the migration banner's Esc handling so the overlay wins Esc. + // The secret-prompt flag (mirroring the `claim_overlay_input` gate) tells + // the block to suppress its focus management so the prompt keeps the keyboard. + ProgressOverlay::render_global(ctx, self.active_secret_prompt.is_some()); + + // Render any just-in-time passphrase prompt on top of the screen. + self.render_secret_prompt(ctx); + // Schedule connection status refresh actions.push( active_context @@ -1288,7 +1644,26 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); - self.update_connection_banner(ctx, &active_context); + // The SPV-sync block was already driven at frame start (above), before the + // input claim + screen, to close the one-frame interactive gap. It still + // runs before the connection banner, which suppresses its redundant + // Connecting/Syncing text while the overlay is up. + let spv_overlaying = self.spv_block.is_overlaying(); + if let Some(task) = self + .connection_banner + .update(ctx, &active_context, spv_overlaying) + { + self.handle_backend_task(task); + } + if let Some(task) = self.migration.dispatch_cold_start(&active_context) { + self.handle_backend_task(task); + } + self.migration.update_banner(ctx, &active_context); + self.migration.handle_esc(ctx); + if let Some(task) = self.migration.drain_actions(ctx, self.chosen_network) { + self.handle_backend_task(task); + } + self.drain_overlay_actions(ctx); for action in actions { match action { @@ -1340,6 +1715,34 @@ impl App for AppState { self.screen_stack = vec![screen]; self.set_main_screen(root_screen_type); } + AppAction::StartSpv => { + // Arm the SPV-sync block for this user-initiated Connect (a + // fresh episode — re-arm the escape). The block conveys the + // "connecting" state, so no separate Info banner is set here + // (F-SPV-E: a dropped Info-banner handle could not be cleared + // by the overlay's banner suppression). + self.spv_block.arm(); + Self::spawn_backend_init( + &self.subtasks, + self.task_result_sender.clone(), + self.current_app_context().clone(), + BackendInitReason::ManualConnect, + true, + ); + } + AppAction::StopSpv => { + let app_ctx = self.current_app_context().clone(); + // Claim the disconnect synchronously: this flips the + // indicator to Stopping on this frame (so the button + // disables immediately) and dedupes a fast second click — + // only the winner spawns the async teardown. No banner is + // needed for a user-initiated stop. + if app_ctx.connection_status().begin_spv_stop() { + self.subtasks.spawn_sync("spv_manual_stop", async move { + app_ctx.stop_spv().await; + }); + } + } AppAction::Custom(_) => {} AppAction::OnboardingComplete { main_screen, @@ -1354,11 +1757,21 @@ impl App for AppState { } self.try_auto_start_spv(); } + AppAction::SwitchIdentityHubTab(tab) => { + // Resolve the visible screen. In-hub deep links are only + // meaningful when the user is actually on the hub, so we + // silently drop the action otherwise rather than hijack + // navigation. + if let crate::ui::Screen::IdentityHubScreen(hub) = self.visible_screen_mut() { + hub.select_tab(tab); + hub.refresh(); + } + } } } } - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + fn on_exit(&mut self) { // On macOS, order windows out before winit tears down the event // handler. This lets AppKit properly clean up display-related KVO // observers (TouchBar, etc.) while views are still alive. @@ -1379,3 +1792,215 @@ impl App for AppState { tracing::debug!("App shutdown complete"); } } + +#[cfg(test)] +mod migration_banner_tests { + use super::*; + + /// TC-MIG-014 — every `MigrationStep` exposes a non-empty, + /// sentence-shaped label so i18n extraction picks it up as one + /// translation unit (no concatenation). + #[test] + fn migration_running_text_is_sentence_for_every_step() { + for step in [ + MigrationStep::Detecting, + MigrationStep::SingleKey, + MigrationStep::Shielded, + MigrationStep::WalletSeeds, + MigrationStep::WalletMeta, + MigrationStep::Finalize, + ] { + let text = migration_running_text(step); + assert!(!text.is_empty(), "{step:?} has empty banner text"); + assert!( + text.ends_with('.'), + "{step:?} text `{text}` is not a complete sentence", + ); + } + } + + /// TC-MIG-001 / TC-MIG-013 — every step has a distinct sentence so + /// the per-frame reconciler can detect transitions by text equality + /// alone (`set_global` is idempotent for matching text). + #[test] + fn migration_running_text_distinct_per_step() { + let labels = [ + migration_running_text(MigrationStep::Detecting), + migration_running_text(MigrationStep::SingleKey), + migration_running_text(MigrationStep::Shielded), + migration_running_text(MigrationStep::WalletSeeds), + migration_running_text(MigrationStep::WalletMeta), + migration_running_text(MigrationStep::Finalize), + ]; + let unique: std::collections::HashSet<&str> = labels.iter().copied().collect(); + assert_eq!( + unique.len(), + labels.len(), + "duplicate banner text across MigrationStep variants", + ); + } + + /// Retry action id is stable — kittest + production both match on + /// this constant, so a typo would mean the click silently drops on + /// the floor. + #[test] + fn migration_retry_action_id_is_stable() { + assert_eq!(MIGRATION_RETRY_ACTION_ID, "migration:retry:finish_unwire"); + } + + /// Cold-start dispatch gate (the startup-race fix): dispatch only when the + /// network has NOT already been dispatched AND its wallet backend is wired. + /// The not-ready row is the regression guard — a switched-to network whose + /// backend is still wiring must NOT dispatch (and so must not burn its + /// per-network guard), so a later frame retries once the backend wires. + #[test] + fn cold_start_dispatch_gate_truth_table() { + assert!( + should_dispatch_cold_start(false, true), + "fresh network with a wired backend must dispatch", + ); + assert!( + !should_dispatch_cold_start(false, false), + "fresh network whose backend is still wiring must wait, not dispatch", + ); + assert!( + !should_dispatch_cold_start(true, true), + "an already-dispatched network must not re-dispatch", + ); + assert!( + !should_dispatch_cold_start(true, false), + "already-dispatched and not-ready must not dispatch", + ); + } + + /// Readiness-timeout watchdog: the gate surfaces the stuck-preparation + /// banner only after the backend has been unwired for at least the timeout, + /// never before (premature firing would flash the banner on a normal boot, + /// where wiring lags dispatch by a few frames). Synthetic durations so the + /// test needs no real clock. + #[test] + fn cold_start_backend_wait_timeout_fires_only_after_grace() { + let timeout = COLD_START_BACKEND_READY_TIMEOUT; + + // Not waiting at all never times out. + assert!( + !cold_start_backend_wait_timed_out(None, timeout), + "a network that is not waiting must never time out", + ); + + // Inside the grace window: keep waiting silently. + assert!( + !cold_start_backend_wait_timed_out(Some(Duration::ZERO), timeout), + "a just-started wait must not fire immediately", + ); + assert!( + !cold_start_backend_wait_timed_out(Some(timeout - Duration::from_millis(1)), timeout), + "a wait one tick short of the timeout must not fire prematurely", + ); + + // At or past the window: fire. + assert!( + cold_start_backend_wait_timed_out(Some(timeout), timeout), + "a wait that reaches the timeout must fire", + ); + assert!( + cold_start_backend_wait_timed_out(Some(timeout * 4), timeout), + "a wait well past the timeout must fire", + ); + } +} + +#[cfg(test)] +mod spv_overlay_tests { + use super::*; + + const ALL_STATES: [OverallConnectionState; 5] = [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + OverallConnectionState::Synced, + OverallConnectionState::Error, + ]; + + /// F-SPV-A — UN-armed (ambient sync, or already disarmed): NEVER block, for + /// every state and dismissal. This is the regression guard: a mid-session + /// reconnect or per-block Synced→Syncing flip must not hard-block. + #[test] + fn unarmed_never_blocks() { + for dismissed in [false, true] { + for state in ALL_STATES { + assert_eq!( + spv_block_step(false, dismissed, state), + SpvBlockStep::Idle, + "un-armed {state:?} (dismissed={dismissed}) must not block" + ); + } + } + } + + /// Armed + getting-connected (Connecting/Syncing/Disconnected) + not dismissed + /// → hard block. + #[test] + fn armed_blocks_while_getting_connected() { + for state in [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ] { + assert_eq!(spv_block_step(true, false, state), SpvBlockStep::Block); + } + } + + /// C2 escape — armed + dismissed + getting-connected → Stand (no block, episode + /// kept armed so sync keeps running and the user is just not trapped). + #[test] + fn armed_dismissed_stands_down_without_disarming() { + for state in [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ] { + assert_eq!(spv_block_step(true, true, state), SpvBlockStep::Stand); + } + } + + /// C1 / F-SPV-A — armed + terminal (Synced/Error) → Disarm, regardless of + /// dismissal: lower and disarm so ambient sync afterwards never re-blocks. + #[test] + fn armed_terminal_state_disarms() { + for dismissed in [false, true] { + for state in [ + OverallConnectionState::Synced, + OverallConnectionState::Error, + ] { + assert_eq!(spv_block_step(true, dismissed, state), SpvBlockStep::Disarm); + } + } + } + + /// The escape action id is stable — production raises it and the SPV-sync + /// block reconciler matches on it; a typo would drop the click. + #[test] + fn continue_background_action_id_is_stable() { + assert_eq!( + SPV_CONTINUE_BACKGROUND_ACTION, + "spv:sync:continue_background" + ); + } + + /// F-SPV-B — the block descriptions are jargon-free complete sentences (no + /// "SPV"/"headers"/"masternodes"/raw heights/percentages). + #[test] + fn descriptions_are_jargon_free_sentences() { + for desc in [SPV_CONNECTING_DESCRIPTION, SPV_SYNCING_DESCRIPTION] { + assert!(desc.ends_with('.'), "`{desc}` must be a complete sentence"); + let lower = desc.to_lowercase(); + for jargon in ["header", "masternode", "filter", "spv", "rpc", "%", "/"] { + assert!( + !lower.contains(jargon), + "`{desc}` leaks blockchain jargon `{jargon}` to the Everyday User" + ); + } + } + } +} diff --git a/src/app/reconcilers.rs b/src/app/reconcilers.rs new file mode 100644 index 000000000..f3dbab683 --- /dev/null +++ b/src/app/reconcilers.rs @@ -0,0 +1,555 @@ +//! Per-frame reconcilers extracted from [`AppState`](super::AppState). +//! +//! Each reconciler owns the fields it needs and exposes an `update()`/action +//! method the frame loop calls once per frame, mirroring the `ThemeState` +//! precedent. Reconcilers that must dispatch async work return the +//! [`BackendTask`] for `AppState` to run through its channel, keeping the +//! dispatch chokepoint in one place. +//! +//! The pure decision helpers and copy constants stay in [`super`]; a child +//! module can read its parent's private items, so no visibility widening is +//! needed. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Instant; + +use dash_sdk::dpp::dashcore::Network; +use eframe::egui; + +use crate::backend_task::migration::MigrationTask; +use crate::backend_task::{BackendTask, platform_info}; +use crate::context::AppContext; +use crate::context::connection_status::{ + OverallConnectionState, SPV_SYNC_PHASE_COUNT, spv_phase_step, spv_progress_token, +}; +use crate::context::migration_status::MigrationState; +use crate::ui::MessageType; +use crate::ui::components::{ + BannerHandle, MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, +}; + +use super::{ + COLD_START_BACKEND_READY_TIMEOUT, COLD_START_STUCK_MESSAGE, MIGRATION_RETRY_ACTION_ID, + SPV_CONNECTING_DESCRIPTION, SPV_CONTINUE_BACKGROUND_ACTION, SPV_SYNCING_DESCRIPTION, + SpvBlockStep, cold_start_backend_wait_timed_out, migration_running_text, + should_dispatch_cold_start, spv_block_step, +}; + +/// Drives platform-level accessibility (AccessKit) activation on the first +/// frames so tooling can see the tree without a live assistive client. +pub(super) struct AccessibilityActivator { + /// Force-enable requested via `DASH_EVO_TOOL_ACCESSIBILITY=1`. + enforced: bool, + /// Whether activation has already succeeded. + activated: bool, + /// Frames spent attempting activation. + retries: u32, +} + +impl AccessibilityActivator { + /// Give up after this many frames to avoid indefinite repaints. + const MAX_RETRIES: u32 = 60; + + pub(super) fn new(enforced: bool) -> Self { + Self { + enforced, + activated: false, + retries: 0, + } + } + + /// Attempt activation this frame (no-op once enforced-off, activated, or + /// out of retries). Requests another frame while still retrying. + pub(super) fn update(&mut self, ctx: &egui::Context) { + if !self.enforced || self.activated || self.retries >= Self::MAX_RETRIES { + return; + } + self.retries += 1; + self.activated = crate::platform::force_accessibility_activation(); + if self.activated { + return; + } + if self.retries >= Self::MAX_RETRIES { + tracing::warn!( + "Accessibility activation failed after {} frames, giving up", + Self::MAX_RETRIES + ); + } else { + // Ensure another frame to retry, even if egui would go idle. + ctx.request_repaint(); + } + } +} + +/// Drives the blocking SPV-sync overlay (F-SPV-A). Owns the overlay handle and +/// the armed/dismissed episode flags so an ambient reconnect never hard-blocks +/// a working user. +pub(super) struct SpvBlockReconciler { + /// The blocking overlay raised while an armed sync is connecting. + overlay: Option<OverlayHandle>, + /// Whether a user-initiated sync episode is armed for blocking. + armed: bool, + /// Whether the user chose "Continue in the background" for this episode. + dismissed: bool, +} + +impl SpvBlockReconciler { + pub(super) fn new(armed: bool) -> Self { + Self { + overlay: None, + armed, + dismissed: false, + } + } + + /// Arm a fresh user-initiated episode (boot auto-start, Connect button, + /// post-onboarding auto-start), re-arming the background escape. + pub(super) fn arm(&mut self) { + self.armed = true; + self.dismissed = false; + } + + /// Whether an episode is currently armed (test seam / observation). + pub(super) fn armed(&self) -> bool { + self.armed + } + + /// Whether the block overlay is currently raised — the connection banner + /// suppresses its redundant Connecting/Syncing copy while it is. + pub(super) fn is_overlaying(&self) -> bool { + self.overlay.is_some() + } + + /// Drop the overlay and disarm the episode (used on network switch). + pub(super) fn reset(&mut self) { + self.overlay = None; + self.armed = false; + self.dismissed = false; + } + + /// Drive the blocking SPV-sync overlay for one frame (see the field docs on + /// [`AppState`](super::AppState) for the F-SPV-A / C1-C2 contract). Raises + /// at most once per episode, then updates content in place. + pub(super) fn update(&mut self, ctx: &egui::Context, app_context: &Arc<AppContext>) { + let cs = app_context.connection_status(); + let state = cs.overall_state(); + match spv_block_step(self.armed, self.dismissed, state) { + SpvBlockStep::Block => { + // F-SPV-B: plain, jargon-free copy — the determinate granularity + // is the "Step N of 5" counter, NOT raw phase names / heights. + let progress = cs.spv_sync_progress(); + let step = progress.as_ref().and_then(spv_phase_step); + // A-1 (Item B): the hidden liveness token tracks the advancing + // height so a slow-but-advancing phase never trips the + // no-progress watchdog. It is never rendered. + let token = progress.as_ref().and_then(spv_progress_token); + let description = if step.is_some() { + SPV_SYNCING_DESCRIPTION + } else { + SPV_CONNECTING_DESCRIPTION + }; + if self.overlay.is_none() { + // The escape is the single keyboard-reachable exit: the + // overlay focus-pins this button and lets Enter/Space + // activate it, so a keyboard-only / assistive-tech user is + // never stranded behind the UNBOUNDED SPV block. + let mut config = OverlayConfig::new() + .with_description(description) + .with_secondary_action( + "Continue in the background", + SPV_CONTINUE_BACKGROUND_ACTION, + ) + .with_keyboard_escape(SPV_CONTINUE_BACKGROUND_ACTION); + if let Some(n) = step { + config = config.with_step(n, SPV_SYNC_PHASE_COUNT); + } + if let Some(t) = token { + config = config.with_progress_token(t); + } + self.overlay.raise(ctx, "", config); + } else if let Some(handle) = &self.overlay { + handle.set_description(description); + match step { + Some(n) => { + handle.set_step(n, SPV_SYNC_PHASE_COUNT); + } + None => { + handle.clear_step(); + } + } + if let Some(t) = token { + handle.set_progress_token(t); + } + } + } + SpvBlockStep::Disarm => { + // Armed episode ended (Synced/Error): lower and disarm so + // ambient Connecting/Syncing never re-blocks (F-SPV-A). Re-arm + // the escape for the next user-initiated sync. + self.overlay.take_and_clear(); + self.armed = false; + self.dismissed = false; + } + SpvBlockStep::Stand => { + // User chose to continue in the background: stay lowered, but + // keep the episode armed + dismissed so we don't re-raise (C2). + self.overlay.take_and_clear(); + } + SpvBlockStep::Idle => { + // Not armed (ambient sync, or already disarmed): never block. + self.overlay.take_and_clear(); + } + } + + // Drain this overlay's own clicks: the "Continue in the background" + // escape lowers the block for the rest of this episode. + let actions = self + .overlay + .as_ref() + .map(|handle| handle.take_actions()) + .unwrap_or_default(); + if actions + .iter() + .any(|id| id == SPV_CONTINUE_BACKGROUND_ACTION) + { + self.dismissed = true; + self.overlay.take_and_clear(); + } + } +} + +/// Reconciles the connection-status banner with the overall connection state. +pub(super) struct ConnectionBanner { + /// Previous state, to detect transitions. `None` forces re-evaluation. + previous_state: Option<OverallConnectionState>, + /// Handle to the current connection banner, if displayed. + handle: Option<BannerHandle>, +} + +impl ConnectionBanner { + pub(super) fn new() -> Self { + Self { + previous_state: None, + handle: None, + } + } + + /// Clear the banner and force re-evaluation next frame (network switch). + pub(super) fn reset(&mut self) { + if let Some(handle) = self.handle.take() { + handle.clear(); + } + self.previous_state = None; + } + + /// Update the banner for the current connection state. `spv_overlaying` + /// suppresses the redundant Connecting/Syncing copy while the SPV block is + /// up. Returns a [`BackendTask`] to dispatch on the first `Synced`. + pub(super) fn update( + &mut self, + ctx: &egui::Context, + app_context: &Arc<AppContext>, + spv_overlaying: bool, + ) -> Option<BackendTask> { + let connection_status = app_context.connection_status(); + let current_state = connection_status.overall_state(); + let state_changed = self.previous_state != Some(current_state); + + // In Connecting state the banner text can change (normal → degraded) + // without a transition, so re-evaluate every frame; skip otherwise. + if !state_changed && current_state != OverallConnectionState::Connecting { + return None; + } + + // While the SPV-sync block is up it already conveys Connecting/Syncing + // with live progress, so suppress the redundant banner text. + if spv_overlaying + && matches!( + current_state, + OverallConnectionState::Connecting | OverallConnectionState::Syncing + ) + { + if let Some(handle) = self.handle.take() { + handle.clear(); + } + self.previous_state = Some(current_state); + return None; + } + + // Clear old banner on state transitions. + if state_changed && let Some(handle) = self.handle.take() { + handle.clear(); + } + + let mut task = None; + match current_state { + OverallConnectionState::Disconnected => { + let msg = "Disconnected — check your internet connection"; + let handle = MessageBanner::set_global(ctx, msg, MessageType::Error); + handle.disable_auto_dismiss(); + self.handle = Some(handle); + } + OverallConnectionState::Connecting => { + // SPV active but no peers yet. The degraded flag flips after + // 30 s — `set_global` is idempotent for same text. + let msg = if connection_status.spv_peer_degraded() { + "Having trouble finding peers. Check your connection." + } else { + "Looking for peers…" + }; + if let Some(handle) = &self.handle { + handle.set_message(msg); + } else { + self.handle = Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + } + OverallConnectionState::Syncing => { + let msg = "SPV sync in progress…"; + self.handle = Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + OverallConnectionState::Error => { + let handle = MessageBanner::set_global( + ctx, + "SPV sync failed. Go to Settings for connection details.", + MessageType::Error, + ); + handle.disable_auto_dismiss(); + if let Some(detail) = connection_status.spv_last_error() { + handle.with_details(detail); + } + self.handle = Some(handle); + } + OverallConnectionState::Synced => { + // No banner. Fetch epoch info on first sync to populate protocol + // version and fee multiplier (needed for feature gating). + if state_changed { + task = Some(BackendTask::PlatformInfo( + platform_info::PlatformInfoTaskRequestType::CurrentEpochInfo, + )); + } + } + } + self.previous_state = Some(current_state); + task + } +} + +/// Reconciles the data-migration banner and drives the cold-start +/// `FinishUnwire` dispatch per network. +pub(super) struct MigrationReconciler { + /// Handle to the current migration banner, if displayed. + banner_handle: Option<BannerHandle>, + /// Last-seen migration state so reconciliation fires only on change. + last_state: Option<MigrationState>, + /// Networks whose cold-start `FinishUnwire` has been dispatched this process. + dispatched: BTreeSet<Network>, + /// Per network, when the readiness gate first observed an unwired backend. + backend_wait_since: BTreeMap<Network, Instant>, + /// Networks whose stuck-preparation timeout was already logged (dedupe). + timeout_signaled: BTreeSet<Network>, +} + +impl MigrationReconciler { + pub(super) fn new() -> Self { + Self { + banner_handle: None, + last_state: None, + dispatched: BTreeSet::new(), + backend_wait_since: BTreeMap::new(), + timeout_signaled: BTreeSet::new(), + } + } + + /// Clear the migration banner and force re-evaluation (network switch). The + /// per-network dispatch guard is intentionally NOT reset — it is scoped per + /// network so a return to a seen network never re-drains. + pub(super) fn reset_for_switch(&mut self) { + if let Some(handle) = self.banner_handle.take() { + handle.clear(); + } + self.last_state = None; + } + + /// Dispatch the cold-start migration once per network, gated on the wallet + /// backend being wired. Returns the `FinishUnwire` task when it fires; + /// otherwise surfaces the stuck-preparation banner past the readiness + /// timeout. See `finish_unwire` for the per-network scoping rationale. + pub(super) fn dispatch_cold_start( + &mut self, + app_context: &Arc<AppContext>, + ) -> Option<BackendTask> { + let network = app_context.network; + let already_dispatched = self.dispatched.contains(&network); + // Readiness gate: after a network SWITCH the switched-to backend wires + // a few frames later; dispatching before it is ready aborts the first + // step with a transient error AND burns the per-network guard. Poll + // readiness (a cheap ArcSwap load) and only dispatch once wired. + let backend_ready = app_context.wallet_backend().is_ok(); + if should_dispatch_cold_start(already_dispatched, backend_ready) { + // Backend wired: retire any stuck-preparation watchdog before + // burning the guard and dispatching. + self.clear_backend_wait(app_context); + self.dispatched.insert(network); + tracing::info!( + target = "migration::cold_start", + ?network, + "Dispatching FinishUnwire migration at cold start", + ); + return Some(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); + } + + if already_dispatched { + return None; + } + + // Not dispatched because the wallet backend has not wired yet. Record + // when the wait began and, once it exceeds the readiness timeout, + // surface a visible, actionable banner. Recovery stays automatic: if the + // backend wires later, the dispatch branch above clears the banner. + let now = Instant::now(); + let waited = now.duration_since(*self.backend_wait_since.entry(network).or_insert(now)); + if cold_start_backend_wait_timed_out(Some(waited), COLD_START_BACKEND_READY_TIMEOUT) { + let handle = MessageBanner::set_global( + app_context.egui_ctx(), + COLD_START_STUCK_MESSAGE, + MessageType::Error, + ); + handle.disable_auto_dismiss(); + // Log + attach the last wiring error once per network; the banner is + // re-asserted every frame (idempotent) so it survives a switch. + if self.timeout_signaled.insert(network) { + if let Some(detail) = app_context.connection_status().spv_last_error() { + handle.with_details(detail); + } + tracing::warn!( + target = "migration::cold_start", + ?network, + waited_secs = waited.as_secs(), + "Wallet backend did not finish wiring within the readiness timeout; showing the wallet-preparation banner. Restart the app if this persists.", + ); + } + } + None + } + + /// Retire the stuck-preparation watchdog for the active network: drop the + /// wait timer and, if the timeout banner was raised, remove it. + fn clear_backend_wait(&mut self, app_context: &Arc<AppContext>) { + let network = app_context.network; + self.backend_wait_since.remove(&network); + if self.timeout_signaled.remove(&network) { + MessageBanner::clear_global_message(app_context.egui_ctx(), COLD_START_STUCK_MESSAGE); + } + } + + /// Update the migration banner to reflect the current [`MigrationState`]. + /// Each step / outcome surfaces a single i18n-ready sentence; `Failed` gets + /// a "Retry now" action button. + pub(super) fn update_banner(&mut self, ctx: &egui::Context, app_context: &Arc<AppContext>) { + let state = (*app_context.migration_status().state()).clone(); + if self.last_state.as_ref() == Some(&state) { + return; + } + self.last_state = Some(state.clone()); + + // Clear the previous banner — text changes between steps must not stack. + if let Some(handle) = self.banner_handle.take() { + handle.clear(); + } + + match state { + MigrationState::Idle => {} + MigrationState::Running { step } => { + let text = migration_running_text(step); + let handle = MessageBanner::set_global(ctx, text, MessageType::Info); + handle.with_elapsed(); + self.banner_handle = Some(handle); + } + MigrationState::Success => { + let handle = MessageBanner::set_global( + ctx, + "Storage update complete — your wallet is ready.", + MessageType::Success, + ); + self.banner_handle = Some(handle); + } + MigrationState::Failed { error } => { + if error.is_backend_not_ready() { + // Transient: the wallet backend had not finished wiring when + // this run fired. Drop the per-network dispatch guard so the + // frame loop re-dispatches once ready, and reset to Idle so + // no failure banner flashes — the retry is automatic. + self.dispatched.remove(&app_context.network); + app_context + .migration_status() + .set_state(MigrationState::Idle); + self.last_state = Some(MigrationState::Idle); + return; + } + let handle = MessageBanner::set_global( + ctx, + "Storage update could not complete. Your data is safe.", + MessageType::Error, + ); + handle.disable_auto_dismiss(); + // The collapsed details panel + log line get the full typed + // `MigrationError` chain rather than a lossy `to_string()`. + handle.with_details(error.as_ref()); + handle.with_action("Retry now", MIGRATION_RETRY_ACTION_ID); + self.banner_handle = Some(handle); + } + } + } + + /// Dismiss the migration banner on Escape, unless the migration is still + /// running (kept sticky so ongoing progress is not hidden). + pub(super) fn handle_esc(&mut self, ctx: &egui::Context) { + let esc_pressed = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if !esc_pressed { + return; + } + if matches!( + self.last_state.as_ref(), + Some(MigrationState::Running { .. }) + ) { + return; + } + if let Some(handle) = self.banner_handle.take() { + handle.clear(); + } + } + + /// Drain pending banner-action clicks. The only registered action is the + /// migration Retry, which re-dispatches `FinishUnwire` after resetting the + /// cold-start guard — returned for `AppState` to dispatch. + pub(super) fn drain_actions( + &mut self, + ctx: &egui::Context, + network: Network, + ) -> Option<BackendTask> { + let mut task = None; + while let Some(action_id) = MessageBanner::take_action(ctx) { + if action_id == MIGRATION_RETRY_ACTION_ID { + tracing::info!( + target = "migration::cold_start", + ?network, + "User clicked migration Retry — re-dispatching FinishUnwire", + ); + // Reset the reconciler so the new run's Running banner overwrites + // the stale Failed one, and drop the per-network dispatch guard + // so a future `dispatch_cold_start` for the same network re-fires. + self.last_state = None; + self.dispatched.remove(&network); + task = Some(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); + } else { + tracing::warn!( + target = "ui::banner", + action_id = %action_id, + "Unknown banner action id — dropping", + ); + } + } + task + } +} diff --git a/src/app_dir.rs b/src/app_dir.rs index adf318630..ecf9b0b45 100644 --- a/src/app_dir.rs +++ b/src/app_dir.rs @@ -11,7 +11,7 @@ const QUALIFIER: &str = ""; // Typically empty on macOS and Linux const ORGANIZATION: &str = ""; const APPLICATION: &str = "Dash-Evo-Tool"; -#[allow(dead_code)] +#[cfg(not(target_os = "linux"))] const CORE_APPLICATION: &str = "DashCore"; fn user_data_dir_path(app: &str) -> Result<PathBuf, std::io::Error> { diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 8929f7a34..f25b2d37d 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -8,11 +8,19 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use futures::future::join_all; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Widest window past a scheduled vote's time that the sweep will still cast it. +/// Beyond this a due vote is considered stale and left for the user to reschedule +/// rather than cast late (mirrors the original 2-minute UI-poll grace). +const SCHEDULED_VOTE_MAX_LATENESS_MS: u64 = 120_000; #[derive(Debug, Clone, PartialEq)] pub enum ContestedResourceTask { @@ -20,6 +28,10 @@ pub enum ContestedResourceTask { VoteOnDPNSNames(Vec<(String, ResourceVoteChoice)>, Vec<QualifiedIdentity>), ScheduleDPNSVotes(Vec<ScheduledDPNSVote>), CastScheduledVote(ScheduledDPNSVote, Box<QualifiedIdentity>), + /// Sweep the scheduled-vote table and cast every vote that is now due. The + /// periodic UI tick dispatches this so the DB query, identity load, and + /// casting all run off the frame thread. + CastDueScheduledVotes, ClearAllScheduledVotes, ClearExecutedScheduledVotes, DeleteScheduledVote(Identifier, String), @@ -34,6 +46,32 @@ pub struct ScheduledDPNSVote { pub executed_successfully: bool, } +/// Logs a Drive proof-verification failure raised by a contested-resource query. +/// +/// No-op unless `e` is a [`dash_sdk::Error::Proof`] carrying a GroveDB proof +/// failure — the shape these read-only queries surface (distinct from the +/// `DriveProofError` shape handled by `AppContext::log_drive_proof_error`). +pub(super) fn log_contested_proof_error(e: &dash_sdk::Error, request_type: RequestType) { + if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes, + height, + time_ms, + error, + .. + }) = e + { + tracing::error!( + target: "proof_log", + request_type = ?request_type, + height = *height, + time_ms = *time_ms, + proof_bytes_len = proof_bytes.len(), + error = %error, + "drive proof verification failed during contested-resource query", + ); + } +} + impl AppContext { pub async fn run_contested_resource_task( self: &Arc<Self>, @@ -41,12 +79,13 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { - match &task { + match task { ContestedResourceTask::QueryDPNSContests => self .query_dpns_contested_resources(sdk, sender) .await .map(|_| BackendTaskSuccessResult::None), ContestedResourceTask::VoteOnDPNSNames(votes, all_voters) => { + let all_voters = &all_voters; let futures = votes .iter() .map(|(name, choice)| { @@ -73,7 +112,11 @@ impl AppContext { platform_results } Err(det_err) => { - vec![(name.clone(), *vote_choice, Err(det_err.to_string()))] + vec![( + name.clone(), + *vote_choice, + Err(std::sync::Arc::new(det_err)), + )] } Ok(_) => { vec![(name.clone(), *vote_choice, Ok(()))] @@ -85,21 +128,22 @@ impl AppContext { Ok(BackendTaskSuccessResult::DPNSVoteResults(final_results)) } ContestedResourceTask::ScheduleDPNSVotes(scheduled_votes) => { - self.insert_scheduled_votes(scheduled_votes)?; + self.insert_scheduled_votes(&scheduled_votes)?; Ok(BackendTaskSuccessResult::ScheduledVotes) } ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => { self.vote_on_dpns_name( &scheduled_vote.contested_name, scheduled_vote.choice, - &[(**voter).clone()], + &[*voter], sdk, sender, ) .await?; - Ok(BackendTaskSuccessResult::CastScheduledVote( - scheduled_vote.clone(), - )) + Ok(BackendTaskSuccessResult::CastScheduledVote(scheduled_vote)) + } + ContestedResourceTask::CastDueScheduledVotes => { + self.cast_due_scheduled_votes(sdk, sender).await } ContestedResourceTask::ClearAllScheduledVotes => { self.clear_all_scheduled_votes()?; @@ -110,9 +154,98 @@ impl AppContext { Ok(BackendTaskSuccessResult::Refresh) } ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) => { - self.delete_scheduled_vote(voter_id.as_slice(), contested_name)?; + self.delete_scheduled_vote(voter_id.as_slice(), &contested_name)?; Ok(BackendTaskSuccessResult::Refresh) } } } + + /// Cast every scheduled vote that is now due, off the UI thread. + /// + /// Queries the scheduled-vote table, keeps the votes whose time has arrived + /// (and are not already executed or stale beyond + /// [`SCHEDULED_VOTE_MAX_LATENESS_MS`]), pairs each with its local voting + /// identity, and casts them independently so one failure cannot abort the + /// rest. Emits [`ScheduledVotesInProgress`] before casting and a + /// [`CastScheduledVote`] per success so the Scheduled Votes screen can + /// reflect progress via `display_task_result`. + /// + /// [`ScheduledVotesInProgress`]: BackendTaskSuccessResult::ScheduledVotesInProgress + /// [`CastScheduledVote`]: BackendTaskSuccessResult::CastScheduledVote + async fn cast_due_scheduled_votes( + self: &Arc<Self>, + sdk: &Sdk, + sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let due: Vec<ScheduledDPNSVote> = self + .get_scheduled_votes()? + .into_iter() + .filter(|v| { + !v.executed_successfully + && v.unix_timestamp <= now_ms + && v.unix_timestamp + SCHEDULED_VOTE_MAX_LATENESS_MS >= now_ms + }) + .collect(); + if due.is_empty() { + return Ok(BackendTaskSuccessResult::None); + } + + let voters = self.load_local_voting_identities()?; + let mut castable: Vec<(ScheduledDPNSVote, QualifiedIdentity)> = Vec::new(); + for vote in due { + match voters.iter().find(|i| i.identity.id() == vote.voter_id) { + Some(voter) => castable.push((vote, voter.clone())), + None => { + tracing::warn!( + contested_name = %vote.contested_name, + "No local voting identity for a scheduled vote; skipping it" + ); + } + } + } + if castable.is_empty() { + return Ok(BackendTaskSuccessResult::None); + } + + // Tell the Scheduled Votes screen which votes are now in flight. + let in_progress = castable.iter().map(|(v, _)| v.clone()).collect(); + let _ = sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::ScheduledVotesInProgress(in_progress), + ))) + .await; + + for (vote, voter) in castable { + match self + .vote_on_dpns_name( + &vote.contested_name, + vote.choice, + &[voter], + sdk, + sender.clone(), + ) + .await + { + Ok(_) => { + let _ = sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::CastScheduledVote(vote), + ))) + .await; + } + Err(e) => { + tracing::error!( + error = %e, + contested_name = %vote.contested_name, + "Failed to cast a due scheduled vote; leaving it for the next sweep" + ); + } + } + } + Ok(BackendTaskSuccessResult::None) + } } diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index ce08fde80..e197370de 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -2,7 +2,7 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::{ProofLogItem, RequestType}; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -52,50 +52,9 @@ impl AppContext { Ok(contested_resources) => contested_resources, Err(e) => { tracing::error!("Error fetching contested resources: {}", e); - if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { - proof_bytes, - path_query, - height, - time_ms, - error, - }) = &e - { - let encoded_query = - bincode::encode_to_vec(&query, bincode::config::standard()).map_err( - |encode_err| { - tracing::error!("Error encoding query: {}", encode_err); - TaskError::SerializationError { - detail: format!("Error encoding query: {}", encode_err), - } - }, - )?; - - let verification_path_query_bytes = - bincode::encode_to_vec(path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - TaskError::SerializationError { - detail: format!( - "Error encoding path_query: {}", - encode_err - ), - } - })?; - - if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { - request_type: RequestType::GetContestedResources, - request_bytes: encoded_query, - verification_path_query_bytes, - height: *height, - time_ms: *time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(error.clone()), - }) { - return Err(TaskError::from(e)); - } - } - // TODO: Replace the "contract not found" string match with a - // structural SDK variant when one is available. + super::log_contested_proof_error(&e, RequestType::GetContestedResources); + // TODO(#875): replace substring match once the SDK exposes a + // structural "contract not found" variant. if matches!(e, dash_sdk::Error::StaleNode(_)) || e.to_string().contains( "contract not found when querying from value with contract info", @@ -138,9 +97,8 @@ impl AppContext { break; }; - let new_names_to_be_updated = self - .db - .insert_name_contests_as_normalized_names(contested_resources_as_strings, self)?; + let new_names_to_be_updated = + self.insert_name_contests_as_normalized_names(contested_resources_as_strings)?; names_to_be_updated.extend(new_names_to_be_updated); diff --git a/src/backend_task/contested_names/query_dpns_vote_contenders.rs b/src/backend_task/contested_names/query_dpns_vote_contenders.rs index efc65d86a..52760f99b 100644 --- a/src/backend_task/contested_names/query_dpns_vote_contenders.rs +++ b/src/backend_task/contested_names/query_dpns_vote_contenders.rs @@ -1,7 +1,7 @@ use crate::app::TaskResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::{ProofLogItem, RequestType}; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -55,54 +55,16 @@ impl AppContext { loop { match ContenderWithSerializedDocument::fetch_many(sdk, contenders_query.clone()).await { Ok(contenders) => { - return self - .db - .insert_or_update_contenders(name, &contenders, document_type, self) - .map_err(TaskError::from); + return self.insert_or_update_contenders(name, &contenders, document_type); } Err(e) => { tracing::error!("Error fetching vote contenders: {}", e); - if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { - proof_bytes, - path_query, - height, - time_ms, - error, - }) = &e - { - let encoded_query = - bincode::encode_to_vec(&contenders_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding query: {}", encode_err); - TaskError::SerializationError { - detail: format!("Error encoding query: {}", encode_err), - } - })?; - - let verification_path_query_bytes = - bincode::encode_to_vec(path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - TaskError::SerializationError { - detail: format!( - "Error encoding path_query: {}", - encode_err - ), - } - })?; - - self.db.insert_proof_log_item(ProofLogItem { - request_type: RequestType::GetContestedResourceIdentityVotes, - request_bytes: encoded_query, - verification_path_query_bytes, - height: *height, - time_ms: *time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(error.clone()), - })? - } - // TODO: Replace the "contract not found" string match with a - // structural SDK variant when one is available. + super::log_contested_proof_error( + &e, + RequestType::GetContestedResourceIdentityVotes, + ); + // TODO(#875): replace substring match once the SDK exposes a + // structural "contract not found" variant. if matches!(e, dash_sdk::Error::StaleNode(_)) || e.to_string().contains( "contract not found when querying from value with contract info", diff --git a/src/backend_task/contested_names/query_ending_times.rs b/src/backend_task/contested_names/query_ending_times.rs index eaa598cfe..294896099 100644 --- a/src/backend_task/contested_names/query_ending_times.rs +++ b/src/backend_task/contested_names/query_ending_times.rs @@ -1,6 +1,6 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::{ProofLogItem, RequestType}; +use crate::model::request_type::RequestType; use chrono::{DateTime, Duration, Utc}; use dash_sdk::dpp::voting::vote_polls::VotePoll; use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; @@ -61,47 +61,9 @@ impl AppContext { } Err(e) => { tracing::error!("Error fetching vote polls: {}", e); - if let dash_sdk::Error::Proof(dash_sdk::ProofVerifierError::GroveDBError { - proof_bytes, - path_query, - height, - time_ms, - error, - }) = &e - { - let encoded_query = - bincode::encode_to_vec(&end_time_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding query: {}", encode_err); - TaskError::SerializationError { - detail: format!("Error encoding query: {}", encode_err), - } - })?; - - let verification_path_query_bytes = - bincode::encode_to_vec(path_query, bincode::config::standard()) - .map_err(|encode_err| { - tracing::error!("Error encoding path_query: {}", encode_err); - TaskError::SerializationError { - detail: format!( - "Error encoding path_query: {}", - encode_err - ), - } - })?; - - self.db.insert_proof_log_item(ProofLogItem { - request_type: RequestType::GetVotePollsByEndDate, - request_bytes: encoded_query, - verification_path_query_bytes, - height: *height, - time_ms: *time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(error.clone()), - })? - } - // TODO: Replace the "contract not found" string match with a - // structural SDK variant when one is available. + super::log_contested_proof_error(&e, RequestType::GetVotePollsByEndDate); + // TODO(#875): replace substring match once the SDK exposes a + // structural "contract not found" variant. if matches!(e, dash_sdk::Error::StaleNode(_)) || e.to_string().contains( "contract not found when querying from value with contract info", @@ -134,7 +96,7 @@ impl AppContext { } } - self.db.update_ending_time(contests_end_times, self)?; + self.update_contested_name_ending_times(contests_end_times)?; Ok(()) } } diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 7a670eda0..23ac03f5a 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -115,7 +115,7 @@ impl AppContext { ) .await .map(|_| ()) - .map_err(|e| TaskError::from(e).to_string()); + .map_err(|e| std::sync::Arc::new(TaskError::from(e))); vote_results.push((name.to_owned(), vote_choice, result)); } else { diff --git a/src/backend_task/contract.rs b/src/backend_task/contract.rs index 36ec190a7..91b56e4fe 100644 --- a/src/backend_task/contract.rs +++ b/src/backend_task/contract.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use super::BackendTaskSuccessResult; use crate::app::TaskResult; use crate::context::AppContext; -use crate::database::contracts::InsertTokensToo; -use crate::database::contracts::InsertTokensToo::NoTokensShouldBeAdded; +use crate::model::qualified_contract::InsertTokensToo; +use crate::model::qualified_contract::InsertTokensToo::NoTokensShouldBeAdded; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::tokens::tokens_screen::{ContractDescriptionInfo, TokenInfo}; @@ -20,7 +20,7 @@ use dash_sdk::dpp::group::group_action::GroupAction; use dash_sdk::dpp::group::group_action_status::GroupActionStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::group_actions::GroupActionsQuery; use dash_sdk::platform::{ DataContract, Document, DocumentQuery, Fetch, FetchMany, Identifier, IdentityPublicKey, @@ -52,11 +52,10 @@ impl AppContext { let mut results = vec![]; for data_contract in data_contracts { if let Some(contract) = &data_contract.1 { - self.db.insert_contract_if_not_exists( + self.insert_contract_if_not_exists( contract, None, NoTokensShouldBeAdded, - self, )?; results.push(Some(contract.clone())); } else { @@ -79,6 +78,7 @@ impl AppContext { // Fetch the contract description from the Search Contract let search_contract = &self.keyword_search_contract; let document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: search_contract.clone(), document_type_name: "fullDescription".to_string(), limit: 1, @@ -88,6 +88,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(contract.id().into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], }; let document_option = Document::fetch(sdk, document_query) @@ -208,14 +210,12 @@ impl AppContext { } ContractTask::RemoveContract(identifier) => self .remove_contract(&identifier) - .map(|_| BackendTaskSuccessResult::RemovedContract) - .map_err(crate::backend_task::error::TaskError::from), + .map(|_| BackendTaskSuccessResult::RemovedContract), ContractTask::SaveDataContract(data_contract, alias, insert_tokens_too) => { - self.db.insert_contract_if_not_exists( + self.insert_contract_if_not_exists( &data_contract, alias.as_deref(), insert_tokens_too, - self, )?; Ok(BackendTaskSuccessResult::SavedContract) } diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs deleted file mode 100644 index ff200672e..000000000 --- a/src/backend_task/core/create_asset_lock.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::Wallet; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::fee::Credits; -use std::sync::{Arc, RwLock}; - -impl AppContext { - pub async fn create_registration_asset_lock( - &self, - wallet: Arc<RwLock<Wallet>>, - amount: Credits, - allow_take_fee_from_amount: bool, - identity_index: u32, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let amount_duffs = amount / CREDITS_PER_DUFF; - - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { - let mut wallet_guard = wallet.write()?; - - wallet_guard - .registration_asset_lock_transaction( - self, - self.network, - amount_duffs, - allow_take_fee_from_amount, - identity_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? - }; - - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); - } - - Ok(BackendTaskSuccessResult::Message(format!( - "Asset lock transaction broadcast successfully. TX ID: {}", - tx_id - ))) - } - - pub async fn create_top_up_asset_lock( - &self, - wallet: Arc<RwLock<Wallet>>, - amount: Credits, - allow_take_fee_from_amount: bool, - identity_index: u32, - top_up_index: u32, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let amount_duffs = amount / CREDITS_PER_DUFF; - - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { - let mut wallet_guard = wallet.write()?; - - wallet_guard - .top_up_asset_lock_transaction( - self, - self.network, - amount_duffs, - allow_take_fee_from_amount, - identity_index, - top_up_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? - }; - - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); - } - - Ok(BackendTaskSuccessResult::Message(format!( - "Asset lock transaction broadcast successfully. TX ID: {}", - tx_id - ))) - } -} diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 78afd6e33..89b7455df 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -1,55 +1,28 @@ -mod create_asset_lock; -mod recover_asset_locks; -mod refresh_single_key_wallet_info; -mod refresh_wallet_info; -mod send_single_key_wallet_payment; -mod start_dash_qt; - use crate::app_dir::core_cookie_path; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::{TaskError, is_rpc_auth_error, is_rpc_connection_error}; use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; -use crate::model::wallet::networks_address_compatible; use crate::model::wallet::single_key::SingleKeyWallet; -use crate::spv::CoreBackendMode; -use dash_sdk::dash_spv::sync::ProgressPercentage; use dash_sdk::dashcore_rpc; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::{Auth, Client}; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; -use dash_sdk::dpp::dashcore::sighash::SighashCache; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::dashcore::{ - Address, Block, ChainLock, InstantLock, Network, OutPoint, PrivateKey, Transaction, TxOut, + Address, Block, ChainLock, InstantLock, Network, OutPoint, Transaction, TxOut, }; use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::key_wallet::Network as WalletNetwork; -use dash_sdk::dpp::key_wallet::account::ECDSAAddressDerivation; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::{ - BuilderError, TransactionBuilder, -}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use dash_sdk::dpp::key_wallet_manager::{WalletError, WalletId, WalletInterface, WalletManager}; -use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; -const DEFAULT_BIP44_ACCOUNT_INDEX: u32 = 0; - #[derive(Debug, Clone)] pub enum CoreTask { - #[allow(dead_code)] // May be used for getting single chain lock - GetBestChainLock, GetBestChainLocks, /// Refresh wallet info from Core. The bool controls whether to also sync /// Platform address balances (true = sync Platform, false = Core only). RefreshWalletInfo(Arc<RwLock<Wallet>>, bool), RefreshSingleKeyWalletInfo(Arc<RwLock<SingleKeyWallet>>), - StartDashQT(Network, PathBuf, bool), CreateRegistrationAssetLock(Arc<RwLock<Wallet>>, Credits, u32), // wallet, amount in credits, identity index CreateTopUpAssetLock(Arc<RwLock<Wallet>>, Credits, u32, u32), // wallet, amount in credits, identity index, top up index SendWalletPayment { @@ -60,20 +33,17 @@ pub enum CoreTask { wallet: Arc<RwLock<SingleKeyWallet>>, request: WalletPaymentRequest, }, - RecoverAssetLocks(Arc<RwLock<Wallet>>), MineBlocks { block_count: u64, address: Address, wallet: Arc<RwLock<Wallet>>, }, - ListCoreWallets, } impl PartialEq for CoreTask { fn eq(&self, other: &Self) -> bool { matches!( (self, other), - (CoreTask::GetBestChainLock, CoreTask::GetBestChainLock) - | (CoreTask::GetBestChainLocks, CoreTask::GetBestChainLocks) + (CoreTask::GetBestChainLocks, CoreTask::GetBestChainLocks) | ( CoreTask::RefreshWalletInfo(_, _), CoreTask::RefreshWalletInfo(_, _) @@ -82,10 +52,6 @@ impl PartialEq for CoreTask { CoreTask::RefreshSingleKeyWalletInfo(_), CoreTask::RefreshSingleKeyWalletInfo(_) ) - | ( - CoreTask::StartDashQT(_, _, _), - CoreTask::StartDashQT(_, _, _) - ) | ( CoreTask::CreateRegistrationAssetLock(_, _, _), CoreTask::CreateRegistrationAssetLock(_, _, _) @@ -102,12 +68,7 @@ impl PartialEq for CoreTask { CoreTask::SendSingleKeyWalletPayment { .. }, CoreTask::SendSingleKeyWalletPayment { .. }, ) - | ( - CoreTask::RecoverAssetLocks(_), - CoreTask::RecoverAssetLocks(_), - ) | (CoreTask::MineBlocks { .. }, CoreTask::MineBlocks { .. }) - | (CoreTask::ListCoreWallets, CoreTask::ListCoreWallets) ) } } @@ -122,8 +83,6 @@ pub struct PaymentRecipient { #[derive(Debug, Clone)] pub struct WalletPaymentRequest { pub recipients: Vec<PaymentRecipient>, - pub subtract_fee_from_amount: bool, - pub memo: Option<String>, /// Override fee to use instead of calculated fee (for retry after min relay fee error) pub override_fee: Option<u64>, } @@ -144,30 +103,11 @@ pub enum CoreItem { } impl AppContext { - /// Extract the seed hash and first known address from an HD wallet. - fn core_wallet_first_address( - wallet: &Arc<RwLock<Wallet>>, - ) -> Result<([u8; 32], Option<Address>), TaskError> { - let g = wallet.read()?; - Ok((g.seed_hash(), g.known_addresses.keys().next().cloned())) - } - pub async fn run_core_task( self: &Arc<Self>, task: CoreTask, ) -> Result<BackendTaskSuccessResult, TaskError> { match task { - CoreTask::GetBestChainLock => self - .core_client - .read()? - .get_best_chain_lock() - .map(|chain_lock| { - BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock( - chain_lock, - self.network, - )) - }) - .map_err(|e| self.rpc_error_with_url(e)), CoreTask::GetBestChainLocks => { // Load configs let config = Config::load_from(&self.data_dir)?; @@ -203,9 +143,6 @@ impl AppContext { tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); Some(format!("RPC error: {e}")) } else { - // Successful chain lock fetch — clear any lingering RPC error - // so the connection status recovers after a transient outage. - self.connection_status.set_rpc_last_error(None); None }; @@ -225,108 +162,139 @@ impl AppContext { ))) } CoreTask::RefreshWalletInfo(wallet, sync_platform) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; - - if self.core_backend_mode() == crate::spv::CoreBackendMode::Spv { - self.reconcile_spv_wallets().await?; - } else { - let ctx = self.clone(); - let wallet_for_retry = wallet.clone(); - let result = - tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) - .await?; - match self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) - { - Err(TaskError::MustRetry(_)) => { - // Wallet was auto-configured; retry the refresh. - let ctx = self.clone(); - tokio::task::spawn_blocking(move || { - ctx.refresh_wallet_info(wallet_for_retry) - }) - .await??; - } - other => { - other?; - } - } - } - + // Core wallet state (balances/UTXOs/transactions) is kept + // current continuously by the upstream runtime and pushed via + // the EventBridge — there is no explicit Core reconcile to + // run. Ensure the backend exists, then optionally refresh the + // DAPI-sourced Platform-address balances (retained DET path). + self.wallet_backend()?; + let seed_hash = { + let guard = wallet.read()?; + guard.seed_hash() + }; let warning = if sync_platform { match self.fetch_platform_address_balances(seed_hash).await { Ok(_) => None, Err(e) => { tracing::warn!("Failed to fetch Platform address balances: {}", e); - Some(format!("Platform sync failed: {}", e)) + Some(Arc::new(e)) } } } else { None }; - Ok(BackendTaskSuccessResult::RefreshedWallet { warning }) } - CoreTask::RefreshSingleKeyWalletInfo(wallet) => { - let (key_hash, address) = { - let g = wallet.read()?; - (g.key_hash, g.address.clone()) - }; - let wallet_for_retry = wallet.clone(); - let ctx = self.clone(); - let result = - tokio::task::spawn_blocking(move || ctx.refresh_single_key_wallet_info(wallet)) - .await? - .map(|()| BackendTaskSuccessResult::RefreshedWallet { warning: None }); - match self.with_wallet_recovery(&key_hash, Some(&address), true, result) { - Err(TaskError::MustRetry(_)) => { - // Wallet was auto-configured; retry the refresh. - let ctx = self.clone(); - tokio::task::spawn_blocking(move || { - ctx.refresh_single_key_wallet_info(wallet_for_retry) - }) - .await??; - Ok(BackendTaskSuccessResult::RefreshedWallet { warning: None }) - } - other => other, - } + // 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. + CoreTask::RefreshSingleKeyWalletInfo(_wallet) => { + Err(TaskError::SingleKeyWalletsUnsupported) } - CoreTask::StartDashQT(network, custom_dash_qt, overwrite_dash_conf) => self - .start_dash_qt(network, custom_dash_qt, overwrite_dash_conf) - .map_err(|e| TaskError::DashCoreStartError { source: e }) - .map(|_| BackendTaskSuccessResult::None), CoreTask::CreateRegistrationAssetLock(wallet, amount, identity_index) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; - let result = self - .create_registration_asset_lock(wallet, amount, true, identity_index) - .await; - self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) + let backend = self.wallet_backend()?; + let seed_hash = wallet.read()?.seed_hash(); + let amount_duffs = amount / CREDITS_PER_DUFF; + let (_, _, txid) = backend + .create_asset_lock_proof( + &seed_hash, + amount_duffs, + platform_wallet::AssetLockFundingType::IdentityRegistration, + identity_index, + ) + .await?; + Ok(BackendTaskSuccessResult::AssetLockBroadcast { + txid: txid.to_string(), + }) } - CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, top_up_index) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; - let result = self - .create_top_up_asset_lock(wallet, amount, true, identity_index, top_up_index) - .await; - self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) + CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, _top_up_index) => { + let backend = self.wallet_backend()?; + let seed_hash = wallet.read()?.seed_hash(); + let amount_duffs = amount / CREDITS_PER_DUFF; + let (_, _, txid) = backend + .create_asset_lock_proof( + &seed_hash, + amount_duffs, + platform_wallet::AssetLockFundingType::IdentityTopUp, + identity_index, + ) + .await?; + Ok(BackendTaskSuccessResult::AssetLockBroadcast { + txid: txid.to_string(), + }) } CoreTask::SendWalletPayment { wallet, request } => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; - let result = self.send_wallet_payment(wallet, request).await; - self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) - } - CoreTask::SendSingleKeyWalletPayment { wallet, request } => { - let (key_hash, address) = { - let g = wallet.read()?; - (g.key_hash, g.address.clone()) + // `WalletBackend::send_payment` builds via the upstream + // key-wallet `TransactionBuilder` with an internally-computed fee + // and a fixed coin-selection strategy. It exposes only a fee + // *rate*, not the absolute `override_fee` DET passes for a + // min-relay retry. Rather than silently ignore that option — + // which would send at a different fee than requested — reject it + // with a typed error. + if request.override_fee.is_some() { + return Err(TaskError::WalletPaymentOptionUnsupported); + } + // Backend-authoritative input validation: reject an empty + // recipient list and any zero-amount recipient before building + // a transaction (model validator is the single source of truth). + let amounts: Vec<u64> = request.recipients.iter().map(|r| r.amount_duffs).collect(); + crate::model::wallet::validate_payment_recipients(&amounts)?; + let backend = self.wallet_backend()?; + let seed_hash = { + let guard = wallet.read()?; + guard.seed_hash() }; - let result = self.send_single_key_wallet_payment(wallet, request).await; - self.with_wallet_recovery(&key_hash, Some(&address), true, result) - } - CoreTask::RecoverAssetLocks(wallet) => { - let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; - let ctx = self.clone(); - let result = - tokio::task::spawn_blocking(move || ctx.recover_asset_locks(wallet)).await?; - self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) + let mut recipients = Vec::with_capacity(request.recipients.len()); + for r in &request.recipients { + let parsed = Address::from_str(&r.address).map_err(|source| { + TaskError::InvalidRecipientAddress { + address: r.address.clone(), + source, + } + })?; + let addr = parsed + .require_network(self.network) + .map_err(|source| TaskError::AddressNetworkMismatch { source })?; + recipients.push((addr, r.amount_duffs)); + } + let total_amount: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); + let result_recipients: Vec<(String, u64)> = request + .recipients + .iter() + .map(|r| (r.address.clone(), r.amount_duffs)) + .collect(); + let txid = backend.send_payment(&seed_hash, recipients).await?; + Ok(BackendTaskSuccessResult::WalletPayment { + txid: txid.to_string(), + recipients: result_recipients, + 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. + CoreTask::SendSingleKeyWalletPayment { + wallet: _, + request: _, + } => Err(TaskError::SingleKeyWalletsUnsupported), CoreTask::MineBlocks { block_count, address, @@ -348,91 +316,14 @@ impl AppContext { }) .await??; + let _ = wallet; let mined_count = mined.len() as u64; - - // Refresh wallet balances via RPC so the UI reflects the new coins - let refresh_ctx = self.clone(); - tokio::task::spawn_blocking(move || refresh_ctx.refresh_wallet_info(wallet)) - .await??; - + // Balances refresh via the EventBridge once sync observes the + // mined block. Ok(BackendTaskSuccessResult::MineBlocksSuccess(mined_count)) } - CoreTask::ListCoreWallets => { - let wallets = self.list_core_wallets()?; - Ok(BackendTaskSuccessResult::CoreWalletsList(wallets)) - } - } - } - - /// If `result` is `Err(CoreWalletNotConfigured)`, attempt auto-detection - /// of the correct Core wallet by address. On success returns - /// `Err(MustRetry)` so callers can retry the original operation with - /// the newly configured wallet. On failure returns the original - /// `Err(CoreWalletNotConfigured)` so the wallets screen can show a - /// selection dialog. Non-wallet errors pass through unchanged. - fn with_wallet_recovery( - &self, - wallet_id: &[u8; 32], - address: Option<&Address>, - is_single_key: bool, - result: Result<BackendTaskSuccessResult, TaskError>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let Err(TaskError::CoreWalletNotConfigured) = &result else { - return result; - }; - - tracing::debug!( - "RPC error -19{}: wallet not specified, attempting auto-detection", - if is_single_key { " (single-key)" } else { "" } - ); - - if let Some(addr) = address { - let detection_result = - tokio::task::block_in_place(|| self.try_detect_core_wallet_for_address(addr)); - match detection_result { - Ok(Some(wallet_name)) => { - if is_single_key { - if !self - .db - .set_single_key_wallet_core_wallet_name(wallet_id, Some(&wallet_name))? - { - return Err(TaskError::WalletDatabasePersistError); - } - if let Ok(skw) = self.single_key_wallets.read() - && let Some(w) = skw.get(wallet_id) - && let Ok(mut g) = w.write() - { - g.core_wallet_name = Some(wallet_name.clone()); - } - } else { - if !self - .db - .set_wallet_core_wallet_name(wallet_id, Some(&wallet_name))? - { - return Err(TaskError::WalletDatabasePersistError); - } - if let Ok(wallets) = self.wallets.read() - && let Some(w) = wallets.get(wallet_id) - && let Ok(mut g) = w.write() - { - g.core_wallet_name = Some(wallet_name.clone()); - } - } - tracing::info!("Auto-detected Core wallet '{}'", wallet_name); - return Err(TaskError::MustRetry(format!( - "Auto-detected Core wallet '{wallet_name}'" - ))); - } - Ok(None) => { - tracing::debug!("Auto-detection inconclusive, manual selection needed"); - } - Err(e) => tracing::warn!("Auto-detection failed: {}", e), - } } - - Err(TaskError::CoreWalletNotConfigured) } - fn get_best_chain_lock( config: &Option<NetworkConfig>, network: Network, @@ -501,515 +392,74 @@ impl AppContext { } None } - - async fn send_wallet_payment( - &self, - wallet: Arc<RwLock<Wallet>>, - request: WalletPaymentRequest, - ) -> Result<BackendTaskSuccessResult, TaskError> { - match self.core_backend_mode() { - CoreBackendMode::Spv => self.send_wallet_payment_via_spv(wallet, request).await, - CoreBackendMode::Rpc => self.send_wallet_payment_via_rpc(wallet, request).await, - } - } } -impl AppContext { - async fn send_wallet_payment_via_rpc( - &self, - wallet: Arc<RwLock<Wallet>>, - request: WalletPaymentRequest, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let parsed_recipients = self.parse_recipients(&request)?; - - const DEFAULT_TX_FEE: u64 = 1_000; - - let tx = { - let mut wallet_guard = wallet.write()?; - if !wallet_guard.is_open() { - return Err(TaskError::WalletLocked); - } - wallet_guard - .build_multi_recipient_payment_transaction( - self, - self.network, - &parsed_recipients, - DEFAULT_TX_FEE, - request.subtract_fee_from_amount, - ) - .map_err(|e| TaskError::WalletPaymentFailed { detail: e })? - }; - - let txid = self - .core_client - .read()? - .send_raw_transaction(&tx) - .map_err(TaskError::from)?; - - let total_amount: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); - let recipients_result: Vec<(String, u64)> = request - .recipients - .iter() - .map(|r| (r.address.clone(), r.amount_duffs)) - .collect(); - - Ok(BackendTaskSuccessResult::WalletPayment { - txid: txid.to_string(), - recipients: recipients_result, - total_amount, - }) - } - - async fn send_wallet_payment_via_spv( - &self, - wallet: Arc<RwLock<Wallet>>, - request: WalletPaymentRequest, - ) -> Result<BackendTaskSuccessResult, TaskError> { - self.reconcile_spv_wallets().await?; - - let parsed_recipients = self.parse_recipients(&request)?; - let seed_hash = { - let guard = wallet.read()?; - if !guard.is_open() { - return Err(TaskError::WalletLocked); - } - guard.seed_hash() - }; - - let wallet_id = self - .spv_manager - .wallet_id_for_seed(seed_hash) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet not loaded into SPV".to_string(), - })?; - - let tx = { - let wm_arc = self.spv_manager.wallet(); - let mut wm = wm_arc.write().await; - let unsigned = self.build_spv_unsigned_transaction_multi( - &mut wm, - &wallet_id, - &parsed_recipients, - &request, - )?; - let signed = self.sign_spv_transaction(&mut wm, &wallet_id, unsigned)?; - - // Notify the wallet about the outgoing tx while still holding the - // write lock. This marks spent UTXOs immediately so concurrent - // callers don't select the same inputs (double-spend prevention). - let _ = wm.process_mempool_transaction(&signed, None).await; - signed - }; - - self.spv_manager - .broadcast_transaction(&tx) - .await - .map_err(|e| TaskError::SpvBroadcastFailed { detail: e })?; - - self.reconcile_spv_wallets().await?; - - // Calculate actual amounts sent from the transaction outputs - let recipients_result: Vec<(String, u64)> = request - .recipients - .iter() - .zip(parsed_recipients.iter()) - .map(|(req, (addr, _))| { - let actual_amount = Self::sum_outputs_to_script(&tx, &addr.script_pubkey()) - .unwrap_or(req.amount_duffs); - (req.address.clone(), actual_amount) - }) - .collect(); - - let total_amount: u64 = recipients_result.iter().map(|(_, amt)| *amt).sum(); - - Ok(BackendTaskSuccessResult::WalletPayment { - txid: tx.txid().to_string(), - recipients: recipients_result, - total_amount, - }) - } - - fn parse_recipients( - &self, - request: &WalletPaymentRequest, - ) -> Result<Vec<(Address, u64)>, TaskError> { - if request.recipients.is_empty() { - return Err(TaskError::WalletPaymentFailed { - detail: "No recipients specified".to_string(), - }); - } - - let mut parsed = Vec::with_capacity(request.recipients.len()); - for recipient in &request.recipients { - if recipient.amount_duffs == 0 { - return Err(TaskError::WalletPaymentFailed { - detail: format!( - "Amount must be greater than zero for address {}", - recipient.address - ), - }); - } - - let addr = Address::from_str(&recipient.address) - .map_err(|source| TaskError::InvalidRecipientAddress { - address: recipient.address.clone(), - source, - })? - .assume_checked(); - - if !networks_address_compatible(addr.network(), &self.network) { - return Err(TaskError::WalletPaymentFailed { - detail: format!( - "Recipient address {} uses {} but wallet network is {}", - recipient.address, - addr.network(), - self.network - ), - }); - } - - parsed.push((addr, recipient.amount_duffs)); - } - - Ok(parsed) - } - - fn build_spv_unsigned_transaction_multi( - &self, - wm: &mut WalletManager<ManagedWalletInfo>, - wallet_id: &WalletId, - recipients: &[(Address, u64)], - request: &WalletPaymentRequest, - ) -> Result<Transaction, TaskError> { - const FALLBACK_STEP: u64 = 100; - - let _network = self.wallet_network_key(); - let current_height = self - .spv_manager() - .status() - .sync_progress - .and_then(|p| { - p.headers() - .inspect_err(|e| { - tracing::debug!("SPV headers progress unavailable: {e}"); - }) - .ok() - .map(|h| h.current_height()) - }) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Cannot build transaction: SPV sync height is not yet known".to_string(), - })?; - - let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); - let mut scale_factor = 1.0f64; - let mut attempted_fallback = false; - - // Get UTXOs and change address from the wallet account - let (utxos, change_index) = { - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; - let account = managed_info - .accounts() - .standard_bip44_accounts - .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), - })?; - - let utxos: Vec<_> = account.utxos.values().cloned().collect(); - let change_index = account.get_next_change_address_index().unwrap_or(0); - (utxos, change_index) - }; - - let wallet = wm - .get_wallet(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet object not found".to_string(), - })?; - let wallet_account = wallet - .accounts - .standard_bip44_accounts - .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 wallet account missing".to_string(), - })?; - let change_addr = wallet_account - .derive_change_address(change_index) - .map_err(|e| TaskError::WalletPaymentFailed { - detail: format!("Failed to derive change address: {e}"), - })?; - - loop { - let scaled_recipients: Vec<(Address, u64)> = recipients - .iter() - .map(|(addr, amt)| (addr.clone(), (*amt as f64 * scale_factor) as u64)) - .collect(); - - let build_result = (|| -> Result<Transaction, BuilderError> { - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_addr.clone()); - - for (addr, amt) in &scaled_recipients { - builder = builder.add_output(addr, *amt)?; - } - - builder = builder.select_inputs( - &utxos, - SelectionStrategy::LargestFirst, - current_height, - |_| None, // No private keys for unsigned tx - )?; - - builder.build() - })(); - - match build_result { - Ok(tx) => return Ok(tx), - Err(BuilderError::InsufficientFunds { .. }) if request.subtract_fee_from_amount => { - let next_scale = if !attempted_fallback { - attempted_fallback = true; - let fallback_amount = self.estimate_fallback_amount( - wm, - wallet_id, - _network, - DEFAULT_BIP44_ACCOUNT_INDEX, - current_height, - )?; - fallback_amount as f64 / total_amount as f64 - } else { - let current_total = (total_amount as f64 * scale_factor) as u64; - let reduced = current_total.saturating_sub(FALLBACK_STEP); - reduced as f64 / total_amount as f64 - }; - - if next_scale <= 0.0 || (next_scale - scale_factor).abs() < 0.0001 { - return Err(TaskError::WalletPaymentFailed { - detail: "Insufficient funds".to_string(), - }); - } - scale_factor = next_scale; - } - Err(err) => { - return Err(TaskError::WalletPaymentFailed { - detail: format!("Failed to build transaction: {err}"), - }); - } - } - } +/// `override_fee` must NOT be silently ignored on `SendWalletPayment`. +/// Upstream `WalletBackend::send_payment` cannot express an absolute fee +/// override (only a fee rate), so the handler rejects it with a typed error +/// rather than sending at a different fee than requested. This lane proves +/// the rejection happens (no silent ignore). +#[cfg(test)] +mod send_payment_unsupported_options { + use super::*; + use crate::context::AppContext; + use crate::model::wallet::Wallet; + use dash_sdk::dpp::dashcore::Network; + + fn network_free_ctx(tmp: &std::path::Path) -> std::sync::Arc<AppContext> { + crate::app_dir::ensure_env_file(tmp); + let db_file = tmp.join("data.db"); + let db = std::sync::Arc::new(crate::database::Database::new(&db_file).expect("db")); + // Force legacy wallet-family schema for tests — `initialize` + // gates these out for truly-fresh installs post-T-DEV-01. + db.create_tables(true).expect("create tables"); + db.set_default_version().expect("set version"); + let app_kv = AppContext::open_app_kv(tmp).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(tmp).expect("open secret store"); + AppContext::new( + tmp.to_path_buf(), + Network::Testnet, + db, + Default::default(), + Default::default(), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("AppContext") } - fn estimate_fallback_amount( - &self, - wm: &mut WalletManager<ManagedWalletInfo>, - wallet_id: &WalletId, - _network: WalletNetwork, - account_index: u32, - current_height: u32, - ) -> Result<u64, TaskError> { - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; - let collection = managed_info.accounts(); - let account = collection - .standard_bip44_accounts - .get(&account_index) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), - })?; - - let mut spendable_total = 0u64; - let mut spendable_inputs = 0usize; - for utxo in account.utxos.values() { - if (*utxo).is_spendable(current_height) { - spendable_total = spendable_total.saturating_add(utxo.value()); - spendable_inputs += 1; - } - } - - if spendable_total == 0 || spendable_inputs == 0 { - return Err(TaskError::WalletPaymentFailed { - detail: "No spendable funds available".to_string(), - }); - } - - let estimated_size = Self::estimate_p2pkh_tx_size(spendable_inputs, 1); - let fee = FeeRate::normal().calculate_fee(estimated_size); - Ok(spendable_total.saturating_sub(fee)) + fn wallet_arc() -> Arc<RwLock<Wallet>> { + let w = Wallet::new_from_seed([5u8; 64], Network::Testnet, Some("send-opts".into()), None) + .expect("wallet"); + Arc::new(RwLock::new(w)) } - /// Build an unsigned payment transaction using TransactionBuilder. - #[allow(dead_code)] - fn build_unsigned_payment_tx( - wm: &mut WalletManager<ManagedWalletInfo>, - wallet_id: &WalletId, - account_index: u32, - recipients: Vec<(Address, u64)>, - current_height: u32, - change_address: &Address, - ) -> Result<Transaction, WalletError> { - // Get spendable UTXOs from the managed wallet info - let managed_info = wm - .get_wallet_info(wallet_id) - .ok_or(WalletError::WalletNotFound(*wallet_id))?; - let collection = managed_info.accounts(); - let account = collection - .standard_bip44_accounts - .get(&account_index) - .ok_or(WalletError::AccountNotFound(account_index))?; - - let all_utxos: Vec<_> = account.utxos.values().cloned().collect(); - if all_utxos.is_empty() { - return Err(WalletError::InsufficientFunds); - } - - // Build the transaction using TransactionBuilder - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()); - - for (address, amount) in recipients { - builder = builder - .add_output(&address, amount) - .map_err(|e: BuilderError| WalletError::TransactionBuild(e.to_string()))?; + fn req(override_fee: Option<u64>) -> WalletPaymentRequest { + WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: "yMLhEsf1bbDqM5p9LyrPHgM7g4Pvqp1Fbb".to_string(), + amount_duffs: 10_000, + }], + override_fee, } - - builder = builder - .select_inputs( - &all_utxos, - SelectionStrategy::OptimalConsolidation, - current_height, - |_| None, // No private keys for unsigned transaction - ) - // TODO(RUST-002): String-based error classification — see #660 - .map_err(|e: BuilderError| match e.to_string() { - msg if msg.contains("Insufficient") => WalletError::InsufficientFunds, - msg => WalletError::TransactionBuild(msg), - })?; - - builder - .build() - .map_err(|e: BuilderError| WalletError::TransactionBuild(e.to_string())) } - fn sign_spv_transaction( - &self, - wm: &mut WalletManager<ManagedWalletInfo>, - wallet_id: &WalletId, - tx: Transaction, - ) -> Result<Transaction, TaskError> { - let wallet = wm - .get_wallet(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet object not found".to_string(), - })?; - let managed_info = - wm.get_wallet_info(wallet_id) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "Wallet info unavailable".to_string(), - })?; - let accounts = managed_info.accounts(); - let account = accounts - .standard_bip44_accounts - .get(&DEFAULT_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| TaskError::WalletPaymentFailed { - detail: "BIP44 account missing".to_string(), - })?; - - let secp = Secp256k1::new(); - let mut tx_signed = tx; - let cache = SighashCache::new(&tx_signed); - - let signing_data = tx_signed - .input - .iter() - .enumerate() - .map(|(index, input)| { - let utxo = account.utxos.get(&input.previous_output).ok_or_else(|| { - TaskError::WalletPaymentFailed { - detail: "Missing UTXO for signing".to_string(), - } - })?; - let sighash = cache - .legacy_signature_hash(index, &utxo.txout.script_pubkey, 1) - .map_err(|source| TaskError::SighashComputationFailed { source })?; - Ok((sighash, utxo.address.clone())) + #[tokio::test(flavor = "multi_thread")] + async fn override_fee_is_rejected_not_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = network_free_ctx(tmp.path()); + let err = ctx + .run_core_task(CoreTask::SendWalletPayment { + wallet: wallet_arc(), + request: req(Some(5_000)), }) - .collect::<Result<Vec<_>, TaskError>>()?; - - for (input, (sighash, address)) in tx_signed.input.iter_mut().zip(signing_data.into_iter()) - { - let digest: [u8; 32] = - sighash[..] - .try_into() - .map_err(|_| TaskError::WalletPaymentFailed { - detail: "Sighash digest has unexpected length".to_string(), - })?; - let message = Message::from_digest(digest); - - let addr_info = account.get_address_info(&address).ok_or_else(|| { - TaskError::WalletPaymentFailed { - detail: "Address metadata missing".to_string(), - } - })?; - let secret_key = wallet.derive_private_key(&addr_info.path).map_err(|e| { - TaskError::WalletPaymentFailed { - detail: format!("Failed to derive private key: {e}"), - } - })?; - let private_key = PrivateKey { - compressed: true, - network: self.network, - inner: secret_key, - }; - - let sig = secp.sign_ecdsa(&message, &private_key.inner); - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(1); - let mut serialized_pub_key = private_key.public_key(&secp).to_bytes(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - input.script_sig = dash_sdk::dpp::dashcore::ScriptBuf::from_bytes(script_sig); - } - - Ok(tx_signed) - } - - fn sum_outputs_to_script( - tx: &Transaction, - script: &dash_sdk::dpp::dashcore::ScriptBuf, - ) -> Option<u64> { - let mut total = 0u64; - for output in &tx.output { - if &output.script_pubkey == script { - total = total.saturating_add(output.value); - } - } - if total == 0 { None } else { Some(total) } - } - - fn estimate_p2pkh_tx_size(inputs: usize, outputs: usize) -> usize { - fn varint_size(value: usize) -> usize { - match value { - 0..=0xfc => 1, - 0xfd..=0xffff => 3, - 0x1_0000..=0xffff_ffff => 5, - _ => 9, - } - } - - let mut size = 8; // version/type/lock_time - size += varint_size(inputs); - size += varint_size(outputs); - size += inputs * 148; - size += outputs * 34; - size + .await + .expect_err("override_fee must be rejected"); + assert!( + matches!(err, TaskError::WalletPaymentOptionUnsupported), + "expected WalletPaymentOptionUnsupported, got {err:?}" + ); } } diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs deleted file mode 100644 index b645f9fa7..000000000 --- a/src/backend_task/core/recover_asset_locks.rs +++ /dev/null @@ -1,431 +0,0 @@ -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::Wallet; -use crate::spv::CoreBackendMode; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::dashcore::{Address, OutPoint}; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::prelude::AssetLockProof; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; - -impl AppContext { - /// Search for unused asset locks by scanning the Core wallet for asset lock transactions - /// that belong to this wallet but aren't tracked in the database. - /// - /// This operation requires Dash Core because it queries the Core wallet's - /// transaction and UTXO indices via `list_unspent` / `get_raw_transaction`. - /// In SPV mode, asset lock finality events are delivered directly to the - /// wallet as InstantLocks / ChainLocks arrive, so no explicit recovery - /// pass is needed — we return a zero-count success result rather than - /// failing on a missing Core RPC connection. - pub fn recover_asset_locks( - &self, - wallet: Arc<RwLock<Wallet>>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - if self.core_backend_mode() == CoreBackendMode::Spv { - tracing::info!( - "recover_asset_locks: SPV mode — asset locks are reconciled \ - automatically via InstantLock / ChainLock events. Nothing to \ - do here." - ); - return Ok(BackendTaskSuccessResult::RecoveredAssetLocks { - recovered_count: 0, - total_amount: 0, - }); - } - - let (known_addresses, seed_hash, already_tracked_txids, core_wallet_name) = { - let wallet_guard = wallet.read()?; - let addresses: Vec<Address> = wallet_guard.known_addresses.keys().cloned().collect(); - let tracked: HashSet<_> = wallet_guard - .unused_asset_locks - .iter() - .map(|(tx, _, _, _, _)| tx.txid()) - .collect(); - ( - addresses, - wallet_guard.seed_hash(), - tracked, - wallet_guard.core_wallet_name.clone(), - ) - }; - - tracing::info!( - "Searching for unused asset locks. Known addresses: {}, Already tracked: {}", - known_addresses.len(), - already_tracked_txids.len() - ); - - if known_addresses.is_empty() { - tracing::warn!("No known addresses in wallet - cannot search for asset locks"); - return Ok(BackendTaskSuccessResult::RecoveredAssetLocks { - recovered_count: 0, - total_amount: 0, - }); - } - - let client = self.core_client_for_wallet(core_wallet_name.as_deref())?; - - let mut recovered_count = 0; - let mut total_amount = 0u64; - - // First, import all known addresses to Core to ensure it's watching them - for address in &known_addresses { - if let Err(e) = client.import_address(address, None, Some(false)) { - tracing::debug!("import_address for {} returned: {:?}", address, e); - } - } - - // Method 1: Get unspent outputs for all known addresses - let address_refs: Vec<&Address> = known_addresses.iter().collect(); - let unspent = client.list_unspent(None, None, Some(&address_refs), Some(true), None)?; - - tracing::info!( - "Found {} unspent outputs for known addresses", - unspent.len() - ); - - // Check each unspent output to see if it's an asset lock - for utxo in &unspent { - let txid = utxo.txid; - - // Skip if already tracked - if already_tracked_txids.contains(&txid) { - tracing::debug!("Skipping {} - already tracked in wallet", txid); - continue; - } - - // Check if already in database - if let Ok(Some(_)) = self.db.get_asset_lock_transaction(txid.as_byte_array()) { - tracing::debug!("Skipping {} - already in database", txid); - continue; - } - - // Get the raw transaction to check if it's an asset lock - let raw_tx = match client.get_raw_transaction(&txid, None) { - Ok(tx) => tx, - Err(e) => { - tracing::debug!("Failed to get raw transaction {}: {}", txid, e); - continue; - } - }; - - // Check if this is an asset lock transaction - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &raw_tx.special_transaction_payload - else { - continue; - }; - - tracing::info!("Found asset lock transaction: {}", txid); - - // Find the credit output that belongs to our wallet - let mut credit_address = None; - let mut credit_amount = 0u64; - - for credit_output in &payload.credit_outputs { - if let Ok(addr) = Address::from_script(&credit_output.script_pubkey, self.network) { - tracing::debug!("Asset lock credit output address: {}", addr); - if known_addresses.contains(&addr) { - credit_address = Some(addr); - credit_amount = credit_output.value; - break; - } - } - } - - let Some(addr) = credit_address else { - tracing::debug!("Asset lock {} credit address not in known addresses", txid); - continue; - }; - - // Note: We cannot check if asset lock is "spent" via get_tx_out because - // asset lock transactions use OP_RETURN outputs which are never UTXOs. - // Platform tracks whether asset locks are used, not Core. - // We add the asset lock and let the user try to use it - Platform will - // reject if it's already been consumed. - - // Get transaction info for chain lock status - let tx_info = client.get_raw_transaction_info(&txid, None).ok(); - - // Build the proof - let (chain_locked_height, proof) = if let Some(ref info) = tx_info { - if info.chainlock && info.height.is_some() { - let height = info.height.unwrap() as u32; - tracing::debug!("Asset lock {} is chain-locked at height {}", txid, height); - ( - Some(height), - Some(AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: height, - out_point: OutPoint::new(txid, 0), - })), - ) - } else { - tracing::debug!( - "Asset lock {} not chain-locked yet (chainlock={}, height={:?}) — proof unavailable", - txid, - info.chainlock, - info.height - ); - (None, None) - } - } else { - tracing::debug!( - "Could not retrieve transaction info for asset lock {} — proof unavailable", - txid - ); - (None, None) - }; - - // Store the asset lock in the database - if let Err(e) = self.db.store_asset_lock_transaction( - &raw_tx, - credit_amount, - None, - &seed_hash, - self.network, - ) { - tracing::warn!("Failed to store asset lock {}: {}", txid, e); - continue; - } - - // Also store the chain locked height if available - if let Some(height) = chain_locked_height - && let Err(e) = self - .db - .update_asset_lock_chain_locked_height(txid.as_byte_array(), Some(height)) - { - tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); - } - - // Add to wallet's in-memory unused_asset_locks - { - let mut wallet_guard = wallet.write()?; - - let already_exists = wallet_guard - .unused_asset_locks - .iter() - .any(|(tx, _, _, _, _)| tx.txid() == txid); - - if !already_exists { - wallet_guard.unused_asset_locks.push(( - raw_tx.clone(), - addr, - credit_amount, - None, - proof, - )); - recovered_count += 1; - total_amount += credit_amount; - - tracing::info!( - "Found unused asset lock: txid={}, amount={} duffs", - txid, - credit_amount - ); - } - } - } - - // Method 2: Also check Core's wallet for any transactions we might have missed - // by scanning ALL unspent outputs (not filtered by address) - tracing::info!("Scanning all Core wallet unspent outputs..."); - if let Ok(all_unspent) = client.list_unspent(None, None, None, Some(true), None) { - tracing::info!( - "Core wallet has {} total unspent outputs", - all_unspent.len() - ); - - for utxo in all_unspent { - let txid = utxo.txid; - - // Skip if already processed or tracked - if already_tracked_txids.contains(&txid) { - continue; - } - if let Ok(Some(_)) = self.db.get_asset_lock_transaction(txid.as_byte_array()) { - continue; - } - - // Get the raw transaction - let raw_tx = match client.get_raw_transaction(&txid, None) { - Ok(tx) => tx, - Err(_) => continue, - }; - - // Check if this is an asset lock transaction - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &raw_tx.special_transaction_payload - else { - continue; - }; - - tracing::info!("Found asset lock in Core wallet scan: {}", txid); - - // Get the credit output address and amount - let Some(credit_output) = payload.credit_outputs.first() else { - continue; - }; - - let Ok(credit_addr) = - Address::from_script(&credit_output.script_pubkey, self.network) - else { - continue; - }; - - // Verify the credit address belongs to our wallet - if !known_addresses.contains(&credit_addr) { - tracing::debug!( - "Asset lock {} credit address {} not in wallet, skipping", - txid, - credit_addr - ); - continue; - } - - let credit_amount = credit_output.value; - - // Note: We cannot check if asset lock is "spent" via get_tx_out because - // asset lock transactions use OP_RETURN outputs which are never UTXOs. - // Platform tracks whether asset locks are used, not Core. - - // Get chain lock info - let tx_info = client.get_raw_transaction_info(&txid, None).ok(); - let (chain_locked_height, proof) = if let Some(ref info) = tx_info { - if info.chainlock && info.height.is_some() { - let height = info.height.unwrap() as u32; - tracing::debug!("Asset lock {} is chain-locked at height {}", txid, height); - ( - Some(height), - Some(AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: height, - out_point: OutPoint::new(txid, 0), - })), - ) - } else { - tracing::debug!( - "Asset lock {} not chain-locked yet (chainlock={}, height={:?}) — proof unavailable", - txid, - info.chainlock, - info.height - ); - (None, None) - } - } else { - tracing::debug!( - "Could not retrieve transaction info for asset lock {} — proof unavailable", - txid - ); - (None, None) - }; - - // Store in database - if let Err(e) = self.db.store_asset_lock_transaction( - &raw_tx, - credit_amount, - None, - &seed_hash, - self.network, - ) { - tracing::warn!("Failed to store asset lock {}: {}", txid, e); - continue; - } - - // Also store the chain locked height if available - if let Some(height) = chain_locked_height - && let Err(e) = self - .db - .update_asset_lock_chain_locked_height(txid.as_byte_array(), Some(height)) - { - tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); - } - - // Add to wallet - { - let mut wallet_guard = wallet.write()?; - - let already_exists = wallet_guard - .unused_asset_locks - .iter() - .any(|(tx, _, _, _, _)| tx.txid() == txid); - - if !already_exists { - wallet_guard.unused_asset_locks.push(( - raw_tx.clone(), - credit_addr, - credit_amount, - None, - proof, - )); - recovered_count += 1; - total_amount += credit_amount; - - tracing::info!( - "Found unused asset lock (full scan): txid={}, amount={} duffs", - txid, - credit_amount - ); - } - } - } - } - - // Clean up: Remove asset locks from wallet that don't belong to it - // (credit address not in known_addresses) - let mut txids_to_remove = Vec::new(); - let removed_count = { - let mut wallet_guard = wallet.write()?; - let before_count = wallet_guard.unused_asset_locks.len(); - - wallet_guard.unused_asset_locks.retain(|(tx, _, _, _, _)| { - // Get the credit output address from the transaction - if let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - && let Some(credit_output) = payload.credit_outputs.first() - && let Ok(addr) = - Address::from_script(&credit_output.script_pubkey, self.network) - && known_addresses.contains(&addr) - { - return true; // Keep this asset lock - } - tracing::info!( - "Removing asset lock {} - credit address not in wallet", - tx.txid() - ); - txids_to_remove.push(tx.txid()); - false // Remove this asset lock - }); - - before_count - wallet_guard.unused_asset_locks.len() - }; - - // Also delete from database - for txid in &txids_to_remove { - if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { - tracing::warn!("Failed to delete asset lock {} from database: {}", txid, e); - } - } - - if removed_count > 0 { - tracing::info!( - "Removed {} asset locks that don't belong to this wallet", - removed_count - ); - } - - tracing::info!( - "Asset lock search complete. Found {} unused asset locks worth {} duffs", - recovered_count, - total_amount - ); - - Ok(BackendTaskSuccessResult::RecoveredAssetLocks { - recovered_count, - total_amount, - }) - } -} diff --git a/src/backend_task/core/refresh_single_key_wallet_info.rs b/src/backend_task/core/refresh_single_key_wallet_info.rs deleted file mode 100644 index 71b9fde1f..000000000 --- a/src/backend_task/core/refresh_single_key_wallet_info.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Refresh Single Key Wallet Info - Reload UTXOs and balances for a single key wallet - -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::single_key::SingleKeyWallet; -use crate::spv::CoreBackendMode; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -impl AppContext { - /// Refresh a single key wallet by reloading UTXOs from Core RPC. - /// - /// Single-key wallets require Dash Core for UTXO discovery — the SPV - /// subsystem tracks HD-wallet-derived addresses only. In SPV mode this - /// function surfaces a typed error instead of silently hitting RPC and - /// producing a `ConnectionRefused` banner. - pub fn refresh_single_key_wallet_info( - &self, - wallet: Arc<RwLock<SingleKeyWallet>>, - ) -> Result<(), TaskError> { - if self.core_backend_mode() == CoreBackendMode::Spv { - return Err(TaskError::OperationRequiresDashCore { - operation: "Refreshing single-key wallet balances", - }); - } - - let (address, key_hash, core_wallet_name) = { - let wallet_guard = wallet.read()?; - ( - wallet_guard.address.clone(), - wallet_guard.key_hash, - wallet_guard.core_wallet_name.clone(), - ) - }; - - let client = self.core_client_for_wallet(core_wallet_name.as_deref())?; - - if let Err(e) = client.import_address(&address, None, Some(false)) { - tracing::debug!(?e, address = %address, "import_address failed during single key refresh"); - } - - let utxo_map = { - let utxos = client.list_unspent(Some(0), None, Some(&[&address]), None, None)?; - - let mut map: HashMap<OutPoint, TxOut> = HashMap::new(); - for utxo in utxos { - let outpoint = OutPoint::new(utxo.txid, utxo.vout); - let tx_out = TxOut { - value: utxo.amount.to_sat(), - script_pubkey: utxo.script_pub_key, - }; - map.insert(outpoint, tx_out); - } - map - }; - - let total_balance: u64 = utxo_map.values().map(|tx_out| tx_out.value).sum(); - - { - let mut wallet_guard = wallet.write()?; - wallet_guard.utxos = utxo_map.clone(); - wallet_guard.update_balances(total_balance, 0, total_balance); - } - - if let Err(e) = - self.db - .update_single_key_wallet_balances(&key_hash, total_balance, 0, total_balance) - { - tracing::warn!(error = %e, "Failed to persist single key wallet balances"); - } - - for (outpoint, tx_out) in &utxo_map { - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } - - Ok(()) - } -} diff --git a/src/backend_task/core/refresh_wallet_info.rs b/src/backend_task/core/refresh_wallet_info.rs deleted file mode 100644 index 085b0a67e..000000000 --- a/src/backend_task/core/refresh_wallet_info.rs +++ /dev/null @@ -1,389 +0,0 @@ -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::{DerivationPathHelpers, TransactionStatus, Wallet, WalletTransaction}; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dashcore_rpc::json::GetTransactionResultDetailCategory; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{Address, BlockHash, OutPoint, Transaction, TxOut, Txid}; -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, RwLock}; - -impl AppContext { - /// Refresh wallet info with minimal lock contention to avoid UI freezes. - /// - /// Strategy: Collect data with brief read locks, do all RPC calls without locks, - /// then update wallet with a single brief write lock at the end. - pub fn refresh_wallet_info( - &self, - wallet: Arc<RwLock<Wallet>>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - // Step 1: Collect data from wallet with brief read lock - let (addresses, asset_lock_txs, seed_hash, core_wallet_name) = { - let wallet_guard = wallet.read()?; - let addrs = wallet_guard - .known_addresses - .iter() - .filter(|(_, path)| !path.is_platform_payment(self.network)) - .map(|(addr, _)| addr.clone()) - .collect::<Vec<_>>(); - let asset_locks: Vec<Transaction> = wallet_guard - .unused_asset_locks - .iter() - .map(|(tx, _, _, _, _)| tx.clone()) - .collect(); - let seed = wallet_guard.seed_hash(); - let cwn = wallet_guard.core_wallet_name.clone(); - (addrs, asset_locks, seed, cwn) - }; - - let is_spv_mode = self.core_backend_mode() == crate::spv::CoreBackendMode::Spv; - - // Build an RPC client targeting the wallet's Core wallet (if set) - let client = self.core_client_for_wallet(core_wallet_name.as_deref())?; - - // Step 2: Import addresses to Core (no wallet lock needed) - for address in &addresses { - if let Err(e) = client.import_address(address, None, Some(false)) { - tracing::debug!(?e, address = %address, "import_address failed during refresh"); - } - } - - // Step 3: Fetch UTXOs from Core RPC (no wallet lock needed) - let utxo_map: HashMap<OutPoint, TxOut> = { - let utxos = if addresses.is_empty() { - Vec::new() - } else { - client.list_unspent( - None, - None, - Some(&addresses.iter().collect::<Vec<_>>()), - Some(false), - None, - )? - }; - - let mut map = HashMap::new(); - for utxo in utxos { - let outpoint = OutPoint::new(utxo.txid, utxo.vout); - let tx_out = TxOut { - value: utxo.amount.to_sat(), - script_pubkey: utxo.script_pub_key, - }; - map.insert(outpoint, tx_out); - } - map - }; - - // Step 4: Calculate balances from UTXOs (no lock needed) - let mut address_balances: HashMap<Address, u64> = HashMap::new(); - for tx_out in utxo_map.values() { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - *address_balances.entry(address).or_insert(0) += tx_out.value; - } - } - - // Step 5: Fetch total received for each address from Core RPC (no wallet lock) - let mut total_received_map: HashMap<Address, u64> = HashMap::new(); - { - for address in &addresses { - match client.get_received_by_address(address, None) { - Ok(amount) => { - total_received_map.insert(address.clone(), amount.to_sat()); - } - Err(e) => { - tracing::debug!( - ?e, - address = %address, - "get_received_by_address failed" - ); - } - } - } - } - - // Step 6: Load transaction history from Core RPC (skip in SPV mode) - // None = skip update (SPV mode or RPC failure), Some = replace DB/in-memory state - let mut tx_truncated = false; - let rpc_transactions: Option<Vec<WalletTransaction>> = if is_spv_mode { - None - } else { - let wallet_address_set: HashSet<Address> = addresses.iter().cloned().collect(); - - struct TxAggregate { - net_amount: i64, - fee: Option<u64>, - timestamp: u64, - height: Option<u32>, - block_hash: Option<BlockHash>, - label: Option<String>, - is_ours: bool, - } - - match client.list_transactions(None, Some(200), None, Some(true)) { - Ok(list_results) => { - if list_results.len() >= 200 { - tx_truncated = true; - tracing::warn!( - "list_transactions returned 200 entries — transaction history may be truncated" - ); - } - - let mut tx_aggregates: HashMap<Txid, TxAggregate> = HashMap::new(); - - for entry in &list_results { - let txid = entry.info.txid; - let amount_sat = entry.detail.amount.to_sat(); - let fee_sat = entry.detail.fee.map(|f| f.to_sat().unsigned_abs()); - - // Safety: list_transactions targets a wallet-scoped RPC endpoint - // (/wallet/<name>), so all entries belong to this wallet. Send - // entries are included unconditionally because outgoing transactions - // may reference external addresses not in our known set. - let entry_involves_wallet = entry - .detail - .address - .as_ref() - .map(|a| wallet_address_set.contains(a.assume_checked_ref())) - .unwrap_or(false) - || entry.detail.category == GetTransactionResultDetailCategory::Send; - - if !entry_involves_wallet { - continue; - } - - let is_send = - entry.detail.category == GetTransactionResultDetailCategory::Send; - let addr_is_ours = entry - .detail - .address - .as_ref() - .map(|a| wallet_address_set.contains(a.assume_checked_ref())) - .unwrap_or(false); - - match tx_aggregates.get_mut(&txid) { - Some(agg) => { - agg.net_amount += amount_sat; - if agg.fee.is_none() { - agg.fee = fee_sat; - } - if agg.label.is_none() { - agg.label = entry.detail.label.clone(); - } - if is_send || addr_is_ours { - agg.is_ours = true; - } - } - None => { - tx_aggregates.insert( - txid, - TxAggregate { - net_amount: amount_sat, - fee: fee_sat, - timestamp: entry.info.time, - height: entry.info.blockheight, - block_hash: entry.info.blockhash, - label: entry.detail.label.clone(), - is_ours: is_send || addr_is_ours, - }, - ); - } - } - } - - let mut wallet_txs = Vec::new(); - let mut raw_fetch_failed = false; - for (txid, agg) in tx_aggregates { - let raw_tx = - match client.get_raw_transaction(&txid, agg.block_hash.as_ref()) { - Ok(tx) => tx, - Err(e) => { - tracing::debug!( - ?e, - %txid, - "get_raw_transaction failed, skipping" - ); - raw_fetch_failed = true; - continue; - } - }; - - wallet_txs.push(WalletTransaction { - txid, - transaction: raw_tx, - timestamp: agg.timestamp, - height: agg.height, - block_hash: agg.block_hash, - net_amount: agg.net_amount, - fee: agg.fee, - label: agg.label, - is_ours: agg.is_ours, - // RPC only returns confirmed transactions - status: TransactionStatus::from_height(agg.height), - }); - } - - if raw_fetch_failed { - tracing::warn!( - "one or more get_raw_transaction calls failed; \ - preserving existing transaction history" - ); - tx_truncated = false; - None - } else { - tracing::info!( - rpc_transactions = wallet_txs.len(), - "loaded transaction history from Core RPC" - ); - Some(wallet_txs) - } - } - Err(e) => { - tracing::debug!(?e, "list_transactions failed, skipping transaction load"); - None - } - } - }; - - // Step 7: Check which asset locks are stale (no wallet lock needed) - let stale_txids: Vec<_> = { - asset_lock_txs - .iter() - .filter_map(|tx| { - let txid = tx.txid(); - match client.get_tx_out(&txid, 0, Some(true)) { - Ok(Some(_)) => None, - Ok(None) => { - tracing::info!( - "Asset lock {} has been used (UTXO spent), removing from unused list", - txid - ); - Some(txid) - } - Err(e) => { - tracing::debug!("Error checking asset lock UTXO {}: {}", txid, e); - None - } - } - }) - .collect() - }; - - // Step 8: Insert UTXOs into database (no wallet lock needed) - for (outpoint, tx_out) in &utxo_map { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } - } - - // Step 9: Delete stale asset locks from database (no wallet lock needed) - for txid in &stale_txids { - if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { - tracing::warn!("Failed to delete stale asset lock from database: {}", e); - } - } - - // Step 10: Calculate total balance (no lock needed) - let total_balance: u64 = utxo_map.values().map(|tx_out| tx_out.value).sum(); - - // Step 11: Persist transactions to database BEFORE the write lock so we - // can move (not clone) rpc_transactions into the wallet afterwards. - if let Some(ref txs) = rpc_transactions { - self.db - .replace_wallet_transactions(&seed_hash, &self.network, txs)?; - } - - // Step 12: Update wallet IN-MEMORY state only (brief write lock, no I/O) - let (changed_balances, changed_total_received): (Vec<_>, Vec<_>) = { - let mut wallet_guard = wallet.write()?; - - let new_outpoints: std::collections::HashSet<_> = utxo_map.keys().cloned().collect(); - - for utxos in wallet_guard.utxos.values_mut() { - utxos.retain(|outpoint, _| new_outpoints.contains(outpoint)); - } - wallet_guard.utxos.retain(|_, utxos| !utxos.is_empty()); - - for (outpoint, tx_out) in &utxo_map { - if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { - wallet_guard - .utxos - .entry(address) - .or_default() - .insert(*outpoint, tx_out.clone()); - } - } - - let mut balance_changes = Vec::new(); - for address in &addresses { - let balance = address_balances.get(address).cloned().unwrap_or(0); - let current = wallet_guard.address_balances.get(address).cloned(); - if current != Some(balance) { - wallet_guard - .address_balances - .insert(address.clone(), balance); - balance_changes.push((address.clone(), balance)); - } - } - - let mut received_changes = Vec::new(); - for (address, total_received) in &total_received_map { - let current = wallet_guard.address_total_received.get(address).cloned(); - if current != Some(*total_received) { - wallet_guard - .address_total_received - .insert(address.clone(), *total_received); - received_changes.push((address.clone(), *total_received)); - } - } - - if !stale_txids.is_empty() { - let stale_count = stale_txids.len(); - wallet_guard - .unused_asset_locks - .retain(|(tx, _, _, _, _)| !stale_txids.contains(&tx.txid())); - tracing::info!("Removed {} stale asset locks", stale_count); - } - - if let Some(txs) = rpc_transactions { - wallet_guard.set_transactions(txs); - } - - wallet_guard.update_spv_balances(total_balance, 0, total_balance); - - (balance_changes, received_changes) - }; - - // Step 13: Persist remaining changes to database (no wallet lock needed) - for (address, balance) in &changed_balances { - self.db - .update_address_balance(&seed_hash, address, *balance)?; - } - - for (address, total_received) in &changed_total_received { - self.db - .update_address_total_received(&seed_hash, address, *total_received)?; - } - - self.db - .update_wallet_balances(&seed_hash, total_balance, 0, total_balance)?; - - let warning = if tx_truncated { - Some( - "Transaction history may be incomplete. The most recent transactions are shown." - .to_string(), - ) - } else { - None - }; - - Ok(BackendTaskSuccessResult::RefreshedWallet { warning }) - } -} diff --git a/src/backend_task/core/send_single_key_wallet_payment.rs b/src/backend_task/core/send_single_key_wallet_payment.rs deleted file mode 100644 index 490a54035..000000000 --- a/src/backend_task/core/send_single_key_wallet_payment.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Send Single Key Wallet Payment - Send funds from a single key wallet - -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::core::WalletPaymentRequest; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::single_key::SingleKeyWallet; -use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::sighash::SighashCache; -use dash_sdk::dpp::dashcore::{EcdsaSighashType, secp256k1::Secp256k1}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; -use std::str::FromStr; -use std::sync::{Arc, RwLock}; - -impl AppContext { - /// Send a payment from a single key wallet. - /// - /// Builds and signs the transaction locally from the wallet's cached - /// UTXO set, then dispatches the broadcast to the configured Core - /// backend (RPC or SPV) via [`AppContext::broadcast_raw_transaction`]. - /// - /// Note that single-key wallets rely on Core RPC for UTXO discovery - /// (`refresh_single_key_wallet_info`). SPV mode can still broadcast - /// the resulting transaction but cannot populate UTXOs — callers - /// running in SPV mode must use an HD wallet for end-to-end flows. - pub async fn send_single_key_wallet_payment( - &self, - wallet: Arc<RwLock<SingleKeyWallet>>, - request: WalletPaymentRequest, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let mut outputs: Vec<TxOut> = Vec::new(); - let mut total_output: u64 = 0; - - for recipient in &request.recipients { - let address = Address::from_str(&recipient.address) - .map_err(|e| TaskError::InvalidRecipientAddress { - address: recipient.address.clone(), - source: e, - })? - .require_network(self.network) - .map_err(|e| TaskError::AddressNetworkMismatch { source: e })?; - - outputs.push(TxOut { - value: recipient.amount_duffs, - script_pubkey: address.script_pubkey(), - }); - total_output += recipient.amount_duffs; - } - - let (private_key, selected_utxos, change_address) = { - let wallet_guard = wallet.read()?; - - let private_key = wallet_guard - .private_key(self.network) - .ok_or(TaskError::WalletLocked)?; - - if wallet_guard.utxos.is_empty() { - return Err(TaskError::NoUtxosAvailable); - } - - let num_outputs = outputs.len() + 1; - let initial_fee_estimate = Self::estimate_p2pkh_tx_size(10, num_outputs); - let initial_fee = request - .override_fee - .unwrap_or_else(|| FeeRate::normal().calculate_fee(initial_fee_estimate)); - - let _target_amount = total_output + initial_fee; - - let mut all_utxos: Vec<(OutPoint, TxOut)> = wallet_guard - .utxos - .iter() - .map(|(op, tx_out)| (*op, tx_out.clone())) - .collect(); - all_utxos.sort_by(|a, b| b.1.value.cmp(&a.1.value)); - - let mut selected: Vec<(OutPoint, TxOut)> = Vec::new(); - let mut selected_total: u64 = 0; - - for (outpoint, tx_out) in all_utxos { - selected.push((outpoint, tx_out.clone())); - selected_total += tx_out.value; - - let current_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); - let current_fee = request - .override_fee - .unwrap_or_else(|| FeeRate::normal().calculate_fee(current_size)); - - if selected_total >= total_output + current_fee { - break; - } - } - - let final_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); - let final_fee = request - .override_fee - .unwrap_or_else(|| FeeRate::normal().calculate_fee(final_size)); - - if selected_total < total_output + final_fee { - return Err(TaskError::InsufficientFunds { - available: wallet_guard.total_balance, - required: total_output + final_fee, - }); - } - - let change_address = wallet_guard.address.clone(); - - (private_key, selected, change_address) - }; - - let num_outputs_with_change = outputs.len() + 1; - let estimated_size = - Self::estimate_p2pkh_tx_size(selected_utxos.len(), num_outputs_with_change); - let fee = request - .override_fee - .unwrap_or_else(|| FeeRate::normal().calculate_fee(estimated_size)); - - let total_input: u64 = selected_utxos.iter().map(|(_, tx_out)| tx_out.value).sum(); - - let change_amount = if request.subtract_fee_from_amount { - if outputs[0].value <= fee { - return Err(TaskError::OutputTooSmallForFee { fee }); - } - outputs[0].value -= fee; - total_input - total_output - } else { - total_input - total_output - fee - }; - - if change_amount > 546 { - outputs.push(TxOut { - value: change_amount, - script_pubkey: change_address.script_pubkey(), - }); - } - - let inputs: Vec<TxIn> = selected_utxos - .iter() - .map(|(outpoint, _)| TxIn { - previous_output: *outpoint, - ..Default::default() - }) - .collect(); - - let mut tx = Transaction { - version: 2, - lock_time: 0, - input: inputs, - output: outputs, - special_transaction_payload: None, - }; - - let secp = Secp256k1::new(); - - for (i, (_, tx_out)) in selected_utxos.iter().enumerate() { - let sighash = SighashCache::new(&tx) - .legacy_signature_hash(i, &tx_out.script_pubkey, EcdsaSighashType::All as u32) - .map_err(|e| TaskError::SighashComputationFailed { source: e })?; - - let message = - dash_sdk::dpp::dashcore::secp256k1::Message::from_digest(sighash.to_byte_array()); - let sig = secp.sign_ecdsa(&message, &private_key.inner); - - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(EcdsaSighashType::All as u8); - - let mut serialized_pub_key = private_key.public_key(&secp).to_bytes(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - - tx.input[i].script_sig = ScriptBuf::from_bytes(script_sig); - } - - let txid = self.broadcast_raw_transaction(&tx).await?; - - { - let mut wallet_guard = wallet.write()?; - - for (outpoint, _) in &selected_utxos { - wallet_guard.utxos.remove(outpoint); - } - - let change_output_index = tx.output.len() - 1; - if tx.output[change_output_index].script_pubkey == change_address.script_pubkey() { - let change_outpoint = OutPoint::new(txid, change_output_index as u32); - wallet_guard - .utxos - .insert(change_outpoint, tx.output[change_output_index].clone()); - } - - let new_balance: u64 = wallet_guard.utxos.values().map(|tx| tx.value).sum(); - wallet_guard.update_balances(new_balance, 0, new_balance); - } - - let key_hash = wallet.read()?.key_hash; - - for (outpoint, _) in &selected_utxos { - if let Err(e) = self.db.drop_utxo(outpoint, &self.network.to_string()) { - tracing::warn!( - "Failed to remove spent UTXO {:?} from database: {}", - outpoint, - e - ); - } - } - - let balance = wallet.read()?.total_balance; - if let Err(e) = self - .db - .update_single_key_wallet_balances(&key_hash, balance, 0, balance) - { - tracing::warn!( - "Failed to update single key wallet balances in database: {}", - e - ); - } - - let total_sent: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); - let recipients_result: Vec<(String, u64)> = request - .recipients - .iter() - .map(|r| (r.address.clone(), r.amount_duffs)) - .collect(); - - Ok(BackendTaskSuccessResult::WalletPayment { - txid: txid.to_string(), - total_amount: total_sent, - recipients: recipients_result, - }) - } -} diff --git a/src/backend_task/core/start_dash_qt.rs b/src/backend_task/core/start_dash_qt.rs deleted file mode 100644 index 2bc1db8d4..000000000 --- a/src/backend_task/core/start_dash_qt.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::app_dir::{create_dash_core_config_if_not_exists, data_file_path}; -use crate::context::AppContext; -use crate::utils::path::format_path_for_display; -use dash_sdk::dpp::dashcore::Network; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::process::{Child, Command}; - -impl AppContext { - /// Function to start Dash QT based on the selected network - pub(super) fn start_dash_qt( - &self, - network: Network, - dash_qt_path: PathBuf, - overwrite_dash_conf: bool, - ) -> std::io::Result<()> { - // Ensure the Dash-Qt binary path exists - if !dash_qt_path.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "Dash-Qt binary file not found at: {}", - format_path_for_display(&dash_qt_path) - ), - )); - } - - let mut command = Command::new(&dash_qt_path); - - // we need two separate file handles that will write to the same log file - let outlog = std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(data_file_path(&self.data_dir, "core.log")?)?; - - let errlog = std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(data_file_path(&self.data_dir, "core-err.log")?)?; - - command.stdout(outlog).stderr(errlog); // Suppress output - - if overwrite_dash_conf { - let config_path = create_dash_core_config_if_not_exists(&self.data_dir, network)?; - command.arg(format!("-conf={}", config_path.display())); - } else if network == Network::Testnet { - command.arg("-testnet"); - } else if network == Network::Devnet { - command.arg("-devnet"); - } else if network == Network::Regtest { - command.arg("-local"); - } - // Spawn the Dash-Qt process - let mut dash_qt = command.spawn().map_err(|e| { - std::io::Error::new( - e.kind(), - format!( - "Failed to start dash-qt binary at {}: {}", - format_path_for_display(&dash_qt_path), - e - ), - ) - })?; - - tracing::debug!(?command, pid = dash_qt.id(), "dash-qt started"); - - // Spawn a task to wait for the Dash-Qt process to exit - let cancel = self.subtasks.cancellation_token.clone(); - let db = Arc::clone(&self.db); - self.subtasks.spawn_sync("dash_qt_watcher", async move { - // Wait for the process to exit or current task to be cancelled - tokio::select! { - exited = dash_qt.wait() => { - match exited { - Err(e) => { - tracing::error!(error=?e, "dash-qt process failed"); - }, - Ok(status) => { - tracing::debug!(%status, "dash-qt process exited"); - } - }; - }, - _ = cancel.cancelled() => { - // Check the setting to determine if we should close Dash-Qt - let should_close = match db.get_close_dash_qt_on_exit() { - Ok(value) => { - tracing::debug!("close_dash_qt_on_exit setting read successfully: {}", value); - value - } - Err(e) => { - tracing::error!("Failed to read close_dash_qt_on_exit setting: {:?}, defaulting to true", e); - true - } - }; - if should_close { - tracing::debug!("dash-qt process was cancelled, sending SIGTERM"); - signal_term(&dash_qt) - .unwrap_or_else(|e| tracing::error!(error=?e, "Failed to send SIGTERM to dash-qt")); - let status = dash_qt.wait().await - .inspect_err(|e| tracing::error!(error=?e, "Failed to wait for dash-qt process to exit")); - tracing::debug!(?status, "dash-qt process stopped gracefully"); - } else { - tracing::debug!("dash-qt process was cancelled, but close_dash_qt_on_exit is disabled - leaving Dash-Qt running"); - } - } - } - }); - Ok(()) - } -} - -/// Send a SIGTERM signal to the Dash-Qt process to gracefully terminate it. -/// Only on UNIX-like systems. -#[cfg(unix)] -fn signal_term(child: &Child) -> Result<(), String> { - let Some(raw_pid) = child.id() else { - // No-op, most likely the child process has already exited - tracing::trace!("Child process ID is not available, cannot send SIGTERM."); - return Ok(()); - }; - - let pid = nix::unistd::Pid::from_raw(raw_pid as i32); - match nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM) { - Ok(_) => { - tracing::debug!( - "SIGTERM signal sent to Dash-Qt process with PID: {}", - raw_pid - ); - Ok(()) - } - Err(e) => Err(format!( - "Failed to send SIGTERM signal to dash-qt({}): {}", - raw_pid, e - )), - } -} - -#[cfg(windows)] -fn signal_term(_child: &Child) -> Result<(), String> { - // TODO: Implement graceful termination for Dash-Qt on Windows. - tracing::warn!( - "SIGTERM signal is not supported on Windows. Dash-Qt process will not be gracefully terminated." - ); - Ok(()) -} diff --git a/src/backend_task/dapi_discovery.rs b/src/backend_task/dapi_discovery.rs index 6b1983f0f..7a0b0c81a 100644 --- a/src/backend_task/dapi_discovery.rs +++ b/src/backend_task/dapi_discovery.rs @@ -87,7 +87,7 @@ pub async fn try_discover_nodes( let provider = TrustedHttpContextProvider::new( network, devnet_name.map(|s| s.to_string()), - NonZeroUsize::new(10).unwrap(), + NonZeroUsize::new(10).expect("invariant: 10 is non-zero"), ) .map_err(|source| DapiDiscoveryError::ProviderInit { source })?; diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 6a88ea55d..787a8ab5d 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -10,11 +10,8 @@ pub mod avatar_processing; pub mod contact_info; pub mod contact_requests; pub mod contacts; -pub mod dip14_derivation; pub mod encryption; -pub mod encryption_tests; pub mod errors; -pub mod hd_derivation; pub mod incoming_payments; pub mod payments; pub mod profile; @@ -41,6 +38,12 @@ pub enum DashPayTask { LoadContacts { identity: QualifiedIdentity, }, + /// Read the contact list from offline state only — rehydrated + /// relationships + private memos plus the DET contact-profile cache. No + /// network round-trip, so a view renders without connectivity. + LoadContactsOffline { + identity: QualifiedIdentity, + }, LoadContactRequests { identity: QualifiedIdentity, }, @@ -48,6 +51,12 @@ pub enum DashPayTask { identity: QualifiedIdentity, contact_id: Identifier, }, + /// Fetch an avatar image for a URL, consulting and populating the DET avatar + /// disk cache. Off the egui frame loop; the result flows back to the + /// screen's [`AvatarCache`](crate::ui::state::avatar_cache::AvatarCache). + FetchAvatar { + url: String, + }, SearchProfiles { search_query: String, }, @@ -78,7 +87,7 @@ pub enum DashPayTask { SendPaymentToContact { identity: QualifiedIdentity, contact_id: Identifier, - amount_dash: f64, + amount_duffs: u64, memo: Option<String>, }, UpdateContactInfo { @@ -93,6 +102,22 @@ pub enum DashPayTask { RegisterDashPayAddresses { identity: QualifiedIdentity, }, + /// Resolve received outputs (surfaced by the `EventBridge` when a wallet + /// transaction is first seen) against every local identity's DashPay + /// address map, recording the ones that pay a known contact. Idempotent + /// on re-scan — recording is keyed by `tx_id` with last-write-wins, and + /// the receive cursor only advances. + DetectIncomingContactPayments { + outputs: Vec<crate::model::dashpay::DetectedIncomingOutput>, + }, + /// Build an auto-accept contact QR payload. Resolves the ENCRYPTION key and + /// derives the auto-accept key through the JIT chokepoint in the backend, so + /// the UI never reads a seed. + GenerateAutoAcceptQrCode { + identity: QualifiedIdentity, + account_reference: u32, + validity_hours: u32, + }, } impl AppContext { @@ -116,6 +141,9 @@ impl AppContext { DashPayTask::LoadContacts { identity } => { Ok(contacts::load_contacts(self, sdk, identity).await?) } + DashPayTask::LoadContactsOffline { identity } => { + Ok(contacts::load_contacts_offline(self, identity).await?) + } DashPayTask::LoadContactRequests { identity } => { Ok(contact_requests::load_contact_requests(self, sdk, identity).await?) } @@ -123,6 +151,10 @@ impl AppContext { identity, contact_id, } => Ok(profile::fetch_contact_profile(self, sdk, identity, contact_id).await?), + DashPayTask::FetchAvatar { url } => { + let bytes = avatar_processing::fetch_avatar_cached(self, &url).await; + Ok(BackendTaskSuccessResult::DashPayAvatar { url, bytes }) + } DashPayTask::SearchProfiles { search_query } => { Ok(profile::search_profiles(self, sdk, search_query).await?) } @@ -171,19 +203,27 @@ impl AppContext { ), DashPayTask::LoadPaymentHistory { identity } => { let identity_id = identity.identity.id(); - let records = payments::load_payment_history(self, &identity_id, None) - .await - .map_err( - |e| crate::backend_task::dashpay::errors::DashPayError::Internal { - message: e, - }, - )?; + // Refresh-style action: kick upstream before reading so the + // view sees the latest contact / payment state. + if let Ok(backend) = self.wallet_backend() + && let Err(e) = backend.dashpay_sync(&identity_id).await + { + tracing::debug!( + identity = %identity_id, + error = ?e, + "LoadPaymentHistory: dashpay_sync degraded; reading cached state" + ); + } + + let records = payments::load_payment_history(self, &identity_id, None).await?; - let network_str = self.network.to_string(); - let contacts = self - .db - .load_dashpay_contacts(&identity_id, &network_str) - .unwrap_or_default(); + // The WalletBackend DashPay adapter is the sole source of truth + // for contacts. Before it is wired (e.g. cold start) we surface + // an empty list — a missing backend means "not loaded yet". + let contacts = match self.wallet_backend() { + Ok(backend) => backend.dashpay_view().contacts(&identity_id).await, + Err(_) => Vec::new(), + }; let results: Vec<_> = records .into_iter() @@ -223,15 +263,15 @@ impl AppContext { DashPayTask::SendPaymentToContact { identity, contact_id, - amount_dash, + amount_duffs, memo, } => { - payments::send_payment_to_contact_impl( + payments::send_payment_to_contact( self, sdk, identity, contact_id, - amount_dash, + amount_duffs, memo, ) .await @@ -257,23 +297,37 @@ impl AppContext { DashPayTask::RegisterDashPayAddresses { identity } => { let result = incoming_payments::register_dashpay_addresses_for_identity(self, &identity) - .await - .map_err(|e| { - crate::backend_task::dashpay::errors::DashPayError::Internal { - message: e, - } - })?; + .await?; - Ok(BackendTaskSuccessResult::Message(format!( - "Registered {} DashPay addresses for {} contacts{}", - result.addresses_registered, - result.contacts_processed, - if result.errors.is_empty() { - String::new() - } else { - format!(" ({} errors)", result.errors.len()) - } - ))) + Ok(BackendTaskSuccessResult::DashPayAddressesRegistered { + addresses: result.addresses_registered, + contacts: result.contacts_processed, + errors: result.errors.len(), + }) + } + DashPayTask::DetectIncomingContactPayments { outputs } => { + let recorded = + incoming_payments::detect_incoming_contact_payments(self, &outputs).await?; + if recorded == 0 { + Ok(BackendTaskSuccessResult::None) + } else { + Ok(BackendTaskSuccessResult::Refresh) + } + } + DashPayTask::GenerateAutoAcceptQrCode { + identity, + account_reference, + validity_hours, + } => { + let proof = auto_accept_proof::generate_auto_accept_proof( + &identity, + account_reference, + validity_hours, + ) + .await?; + Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode( + proof.to_qr_string(), + )) } } } diff --git a/src/backend_task/dashpay/auto_accept_handler.rs b/src/backend_task/dashpay/auto_accept_handler.rs index 51baff39e..5ec941065 100644 --- a/src/backend_task/dashpay/auto_accept_handler.rs +++ b/src/backend_task/dashpay/auto_accept_handler.rs @@ -80,7 +80,9 @@ pub async fn process_auto_accept_requests( identity.identity.id(), &identity, account_reference, - ) { + ) + .await + { Ok(true) => { tracing::debug!( "Valid autoAcceptProof, auto-accepting contact request from {}", diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 76c82c428..9e8f227da 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -1,4 +1,5 @@ -use super::hd_derivation::derive_auto_accept_key; +use crate::backend_task::error::TaskError; +use crate::model::dashpay_derivation::derive_auto_accept_key; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -123,15 +124,17 @@ impl AutoAcceptProofData { /// /// According to DIP-0015, the autoAcceptProof is a signature that allows the recipient /// to automatically accept the contact request and send one back without user interaction. -pub fn generate_auto_accept_proof( +pub async fn generate_auto_accept_proof( identity: &QualifiedIdentity, account_reference: u32, validity_hours: u32, -) -> Result<AutoAcceptProofData, String> { +) -> Result<AutoAcceptProofData, TaskError> { + use crate::backend_task::dashpay::errors::DashPayError; + // Calculate expiration timestamp let expires_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("Time error: {}", e))? + .map_err(|_| DashPayError::SystemClockInvalid)? .as_secs() + (validity_hours as u64 * 3600); @@ -145,36 +148,30 @@ pub fn generate_auto_accept_proof( HashSet::from([KeyType::ECDSA_SECP256K1]), false, ) - .ok_or( - "No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.", - )?; + .ok_or(DashPayError::MissingEncryptionKey)?; - let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + // Resolve the ENCRYPTION private key through the JIT chokepoint — no + // parked-seed read. let wallet_seed = identity - .private_keys - .get_resolve( - &( - crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, - signing_key.id(), - ), - &wallets, - identity.network, + .resolve_private_key_bytes( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + signing_key.id(), ) - .map_err(|e| format!("Error resolving private key: {}", e))? + .await? .map(|(_, private_key)| private_key) - .ok_or("Private key not found")?; + .ok_or(TaskError::WalletLocked)?; // Determine network from the identity let network = identity.network; - // Derive the auto-accept key using DIP-0015 path: m/9'/5'/16'/timestamp' + // Derive the auto-accept key using DIP-0015 path: m/9'/coin'/16'/timestamp' // Using expiration timestamp as the derivation index let auto_accept_xprv = derive_auto_accept_key( - &wallet_seed, + &wallet_seed[..], network, expires_at as u32, // Truncate to u32 for derivation ) - .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; + .map_err(|e| TaskError::DashPay(DashPayError::from(e)))?; // Extract the private key bytes (32 bytes) let proof_key = auto_accept_xprv.private_key.secret_bytes(); @@ -240,7 +237,7 @@ pub fn create_auto_accept_proof_bytes_with_key( /// /// This would be called when receiving a contact request with an autoAcceptProof field /// to determine if we should automatically accept and reciprocate. -pub fn verify_auto_accept_proof( +pub async fn verify_auto_accept_proof( proof_data: &[u8], sender_identity_id: Identifier, recipient_identity_id: Identifier, @@ -287,7 +284,6 @@ pub fn verify_auto_accept_proof( // Derive expected pubkey from our seed and key index (timestamp) // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 - let wallets: Vec<_> = our_identity.associated_wallets.values().cloned().collect(); let signing_key = our_identity .identity .get_first_public_key_matching( @@ -297,20 +293,18 @@ pub fn verify_auto_accept_proof( false, ) .ok_or("No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.")?; + // Resolve the ENCRYPTION private key through the JIT chokepoint — no + // parked-seed read. let wallet_seed = our_identity - .private_keys - .get_resolve( - &( - crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, - signing_key.id(), - ), - &wallets, - our_identity.network, + .resolve_private_key_bytes( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + signing_key.id(), ) + .await .map_err(|e| format!("Error resolving private key: {}", e))? .map(|(_, private_key)| private_key) .ok_or("Private key not found")?; - let xprv = derive_auto_accept_key(&wallet_seed, our_identity.network, key_index) + let xprv = derive_auto_accept_key(&wallet_seed[..], our_identity.network, key_index) .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; let pubkey = dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_secret_key( &secp, diff --git a/src/backend_task/dashpay/avatar_processing.rs b/src/backend_task/dashpay/avatar_processing.rs index a3637666b..19cd9b3d4 100644 --- a/src/backend_task/dashpay/avatar_processing.rs +++ b/src/backend_task/dashpay/avatar_processing.rs @@ -1,9 +1,43 @@ +use crate::context::AppContext; use image::{DynamicImage, GenericImageView}; use sha2::{Digest, Sha256}; +use std::sync::Arc; /// Maximum allowed size for avatar images (5MB) const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024; +/// Resolve an avatar's image bytes for `url`, serving the DET avatar disk cache +/// on a hit and fetching + populating it on a miss. The single avatar fetch +/// path for every DashPay screen (contacts list, profile, contact viewer). +/// +/// Returns `None` when the URL cannot be fetched or fails validation — the +/// caller renders the fallback avatar rather than surfacing an error banner, so +/// one broken avatar URL never disrupts the screen. +pub async fn fetch_avatar_cached(app_context: &Arc<AppContext>, url: &str) -> Option<Vec<u8>> { + // Cache hit: return the stored bytes without a network round-trip. + if let Ok(backend) = app_context.wallet_backend() + && let Some(cached) = backend.avatar_cache().get(url) + { + return Some(cached.bytes); + } + + // Cache miss: fetch once, then populate the cache for the next view. + match fetch_image_bytes(url).await { + Ok(bytes) => { + if let Ok(backend) = app_context.wallet_backend() + && let Err(e) = backend.avatar_cache().put(url, bytes.clone()) + { + tracing::debug!(error = ?e, "Failed to cache avatar; will re-fetch next view"); + } + Some(bytes) + } + Err(e) => { + tracing::warn!("Failed to fetch avatar image {url}: {e}"); + None + } + } +} + /// Calculate SHA-256 hash of image bytes pub fn calculate_avatar_hash(image_bytes: &[u8]) -> [u8; 32] { let mut hasher = Sha256::new(); @@ -100,22 +134,6 @@ impl DHashCalculator { hash.to_le_bytes() } - /// Convert RGB pixels to grayscale - #[allow(dead_code)] - fn to_grayscale(&self, rgb: &[u8]) -> Vec<u8> { - let mut grayscale = Vec::new(); - for chunk in rgb.chunks(3) { - if chunk.len() == 3 { - // Standard grayscale conversion: 0.299*R + 0.587*G + 0.114*B - let gray = (0.299 * chunk[0] as f32 - + 0.587 * chunk[1] as f32 - + 0.114 * chunk[2] as f32) as u8; - grayscale.push(gray); - } - } - grayscale - } - /// Simple box filter resize (nearest neighbor) fn resize(&self, pixels: &[u8], orig_width: usize, orig_height: usize) -> Vec<u8> { let mut resized = Vec::with_capacity(self.width * self.height); diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs index fd8120e64..974458af7 100644 --- a/src/backend_task/dashpay/contact_info.rs +++ b/src/backend_task/dashpay/contact_info.rs @@ -15,14 +15,13 @@ use dash_sdk::dpp::document::{ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; use dash_sdk::dpp::platform_value::{Bytes32, Value}; use dash_sdk::drive::query::{WhereClause, WhereOperator}; use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; use std::collections::{BTreeMap, HashSet}; -use std::str::FromStr; use std::sync::Arc; +use zeroize::Zeroizing; // ContactInfo private data structure #[derive(Debug, Clone, Default)] @@ -118,94 +117,43 @@ impl ContactInfoPrivateData { } } -/// Derive encryption keys for contactInfo using BIP32 CKDpriv as specified in DIP-0015. +/// Derive the DIP-0015 contactInfo encryption keys for `identity`, fetching +/// the wallet's HD seed just-in-time through the [`SecretAccess`] chokepoint. /// -/// DIP-0015 specifies: -/// - Key1 (for encToUserId): rootEncryptionKey/(2^16)'/index' -/// - Key2 (for privateData): rootEncryptionKey/(2^16 + 1)'/index' -/// -/// We use the wallet's master seed to derive a root encryption key, -/// then apply BIP32 hardened derivation for the two encryption keys. -fn derive_contact_info_keys( +/// The seed is obtained per-operation via +/// [`SecretAccess::with_secret`](crate::wallet_backend::SecretAccess::with_secret) +/// keyed by the identity's DashPay wallet seed hash, and the BIP-32 +/// derivation runs inside the closure through the shared +/// [`derive_contact_info_encryption_keys`](crate::wallet_backend::derive_contact_info_encryption_keys) +/// helper — the raw seed never enters this layer. +#[allow(clippy::type_complexity)] +async fn derive_contact_info_keys( + app_context: &Arc<AppContext>, identity: &QualifiedIdentity, derivation_index: u32, -) -> Result<([u8; 32], [u8; 32]), String> { - // Get the wallet seed from the identity's associated wallet - let wallet = identity - .associated_wallets - .values() - .next() - .ok_or("No wallet associated with identity for key derivation")?; - - let (seed, network) = { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if !wallet_guard.is_open() { - return Err("Wallet must be unlocked to derive encryption keys".to_string()); - } - let seed = wallet_guard - .seed_bytes() - .map_err(|e| format!("Wallet seed not available: {}", e))? - .to_vec(); - (seed, identity.network) - }; - - // Create master extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, &seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; - - // Derive to the root encryption key path: m/9'/5'/15'/0' - // This follows the DashPay derivation structure - let root_path = DerivationPath::from_str("m/9'/5'/15'/0'") - .map_err(|e| format!("Invalid derivation path: {}", e))?; - - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let root_encryption_key = master_xprv - .derive_priv(&secp, &root_path) - .map_err(|e| format!("Failed to derive root encryption key: {}", e))?; - - // Derive Key1 for encToUserId: rootEncryptionKey/(2^16)'/index' - // First derive at hardened index 2^16 (65536) - let key1_level1 = root_encryption_key - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65536) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key1 level1: {}", e))?; - - // Then derive at hardened derivation_index - let key1_final = key1_level1 - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key1 final: {}", e))?; - - // Derive Key2 for privateData: rootEncryptionKey/(2^16 + 1)'/index' - // First derive at hardened index 2^16 + 1 (65537) - let key2_level1 = root_encryption_key - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65537) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key2 level1: {}", e))?; - - // Then derive at hardened derivation_index - let key2_final = key2_level1 - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index) - .map_err(|e| format!("Invalid hardened index: {}", e))?], +) -> Result<(Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>), TaskError> { + let seed_hash = identity + .dashpay_wallet_seed_hash() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + let network = identity.network; + + app_context + .wallet_backend()? + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + crate::wallet_backend::derive_contact_info_encryption_keys( + seed, + network, + derivation_index, + ) + }, ) - .map_err(|e| format!("Failed to derive key2 final: {}", e))?; - - // Extract the private key bytes (32 bytes) for encryption - let key1_bytes: [u8; 32] = key1_final.private_key.secret_bytes(); - let key2_bytes: [u8; 32] = key2_final.private_key.secret_bytes(); - - Ok((key1_bytes, key2_bytes)) + .await } /// Encrypt toUserId using AES-256-ECB as specified by DIP-0015. @@ -296,32 +244,6 @@ fn encrypt_private_data(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> Ok(result) } -// Decrypt private data using AES-256-CBC -#[allow(dead_code)] -fn decrypt_private_data(encrypted_data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> { - use cbc::cipher::BlockDecryptMut; - use cbc::cipher::block_padding::Pkcs7; - type Aes256CbcDec = cbc::Decryptor<aes_gcm::aes::Aes256>; - - if encrypted_data.len() < 16 { - return Err("Encrypted data too short (no IV)".to_string()); - } - - // Extract IV and ciphertext - let iv = &encrypted_data[0..16]; - let ciphertext = &encrypted_data[16..]; - - // Decrypt - let cipher = Aes256CbcDec::new(key.into(), iv.into()); - - let mut buffer = ciphertext.to_vec(); - let decrypted = cipher - .decrypt_padded_mut::<Pkcs7>(&mut buffer) - .map_err(|e| format!("Decryption failed: {:?}", e))?; - - Ok(decrypted.to_vec()) -} - #[allow(clippy::too_many_arguments)] pub async fn create_or_update_contact_info( app_context: &Arc<AppContext>, @@ -372,8 +294,8 @@ pub async fn create_or_update_contact_info( // Get the root key index to derive keys if let Some(Value::U32(_root_idx)) = props.get("rootEncryptionKeyIndex") { // Derive keys for this document - let (enc_user_id_key, _) = derive_contact_info_keys(&identity, *deriv_idx) - .map_err(|e| TaskError::EncryptionError { detail: e })?; + let (enc_user_id_key, _) = + derive_contact_info_keys(app_context, &identity, *deriv_idx).await?; // Decrypt encToUserId to check if it matches if let Some(Value::Bytes(enc_user_id)) = props.get("encToUserId") { @@ -410,8 +332,8 @@ pub async fn create_or_update_contact_info( }; // Derive encryption keys - let (enc_user_id_key, private_data_key) = derive_contact_info_keys(&identity, derivation_index) - .map_err(|e| TaskError::EncryptionError { detail: e })?; + let (enc_user_id_key, private_data_key) = + derive_contact_info_keys(app_context, &identity, derivation_index).await?; // Encrypt toUserId let encrypted_user_id = encrypt_to_user_id(&contact_user_id.to_buffer(), &enc_user_id_key) diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index bac99be54..755e0d9e3 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -2,9 +2,6 @@ use super::encryption::{ encrypt_account_label, encrypt_extended_public_key, generate_ecdh_shared_key, }; use super::errors::DashPayError; -use super::hd_derivation::{ - calculate_account_reference, derive_dashpay_incoming_xpub, generate_contact_xpub_data, -}; use super::validation::validate_contact_request_before_send; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::dashpay::auto_accept_proof::{ @@ -13,6 +10,8 @@ use crate::backend_task::dashpay::auto_accept_proof::{ use crate::backend_task::error::TaskError; use crate::context::AppContext; 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. use bip39::rand::{SeedableRng, rngs::StdRng}; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -22,12 +21,13 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::{Identity, KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::{Bytes32, Value}; -use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::{ Document, DocumentQuery, Fetch, FetchMany, FetchUnproved, Identifier, IdentityPublicKey, }; use dash_sdk::query_types::{CurrentQuorumsInfo, NoParamQuery}; +use platform_wallet::ContactRequest as UpstreamContactRequest; use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; @@ -190,9 +190,9 @@ pub async fn send_contact_request_with_proof( // Step 1: Resolve the recipient identity let to_username_or_id = to_username_or_id.trim().to_string(); - if let Err(input) = crate::model::dpns::validate_dpns_input(&to_username_or_id) { + if crate::model::dpns::validate_dpns_input(&to_username_or_id).is_err() { return Err(TaskError::DashPay(DashPayError::InvalidUsername { - username: input, + username: to_username_or_id.clone(), })); } @@ -281,21 +281,15 @@ pub async fn send_contact_request_with_proof( ) .ok_or_else(|| TaskError::DashPay(DashPayError::MissingDecryptionKey))?; - // Step 4: Generate ECDH shared key and encrypt data - let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + // Step 4: Generate ECDH shared key and encrypt data. + // Resolve the ENCRYPTION private key through the JIT chokepoint — no + // parked-seed read. let sender_private_key = identity - .private_keys - .get_resolve( - &( - crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, - sender_encryption_key.id(), - ), - &wallets, - identity.network, + .resolve_private_key_bytes( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + sender_encryption_key.id(), ) - .map_err(|e| TaskError::EncryptionError { - detail: format!("Error resolving ENCRYPTION private key: {}", e), - })? + .await? .map(|(_, private_key)| private_key) .ok_or_else(|| { TaskError::DashPay(DashPayError::PrivateKeyResolution { @@ -304,48 +298,50 @@ pub async fn send_contact_request_with_proof( }) })?; - let shared_key = generate_ecdh_shared_key(&sender_private_key, recipient_key) + let shared_key = generate_ecdh_shared_key(&sender_private_key[..], recipient_key) .map_err(|e| TaskError::EncryptionError { detail: e })?; - // Generate extended public key for this contact using proper HD derivation - // For now, use the sender's private key as seed material - // In production, this would derive from the wallet's HD seed/mnemonic - let wallet_seed = sender_private_key; - - // Get the network from app context let network = app_context.network; - // Use account 0 for now (could be made configurable) + // DIP-15 account index for the relationship. Receive-side derivation + // (incoming_payments) must use the same value or funds land on + // addresses we never scan. let account_index = 0u32; - // Generate the extended public key data for this contact relationship - let (parent_fingerprint, chain_code, contact_public_key) = generate_contact_xpub_data( - &wallet_seed, - network, - account_index, - &identity.identity.id(), - &to_identity_id, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Also derive the full xpub for account reference calculation per DIP-0015 - let contact_xpub = derive_dashpay_incoming_xpub( - &wallet_seed, - network, - account_index, - &identity.identity.id(), - &to_identity_id, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - - // Calculate account reference per DIP-0015 (ASK-based shortening) - // Version 0 is the current version - let account_reference = calculate_account_reference( - &sender_private_key, - &contact_xpub, - account_index, - 0, // version - ); + // Derive the contact-relationship xpub from the wallet's real HD seed, + // obtained just-in-time through the JIT chokepoint. The wallet type and + // seed stay inside the wallet_backend seam; we receive only the published + // byte material plus the account reference. The seed determines where the + // contact's payments land, so it must be the same HD seed the receive-side + // address derivation uses. + let seed_hash = identity + .dashpay_wallet_seed_hash() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + let owner_id = identity.identity.id(); + let contact_material = app_context + .wallet_backend()? + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + crate::wallet_backend::derive_contact_xpub_material( + seed, + network, + account_index, + &owner_id, + &to_identity_id, + &sender_private_key, + ) + }, + ) + .await?; + let parent_fingerprint = contact_material.parent_fingerprint; + let chain_code = contact_material.chain_code; + let contact_public_key = contact_material.public_key; + let account_reference = contact_material.account_reference; let encrypted_public_key = encrypt_extended_public_key( parent_fingerprint, @@ -417,6 +413,9 @@ pub async fn send_contact_request_with_proof( "accountReference".to_string(), Value::U32(account_reference), ); + // Clone before the move so the record we build after document_create + // carries the real encrypted xpub (matches upstream behaviour). + let encrypted_public_key_record = encrypted_public_key.clone(); properties.insert( "encryptedPublicKey".to_string(), Value::Bytes(encrypted_public_key), @@ -513,15 +512,52 @@ pub async fn send_contact_request_with_proof( .document_create(builder, identity_key, &identity) .await?; - // Log the proof-verified document for audit trail - match result { + // Log the proof-verified document and capture timestamps for the local + // wallet-manager record. + let (core_height_created_at, created_at_ts) = match result { dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { tracing::info!( "Contact request created: doc_id={}, revision={:?}", doc.id(), doc.revision() ); + ( + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + ) } + }; + + // Mirror the sent request in the local wallet-manager. + // + // `dashpay_sync` auto-establishes a contact only when + // `sent_contact_requests[peer]` already exists locally. + // DET's custom send path bypasses + // `IdentityWallet::send_contact_request_with_external_signer`, so we + // must call `add_sent_contact_request` explicitly after the state + // transition commits. Without this, `established_contacts` is never + // populated and contact receiving-account registration is silently + // skipped for all users who send a contact request from DET. + let contact_record = UpstreamContactRequest::new( + owner_id, + to_identity_id, + sender_encryption_key.id(), + recipient_key.id(), + account_reference, + encrypted_public_key_record, + core_height_created_at, + created_at_ts, + ); + if let Ok(backend) = app_context.wallet_backend() + && 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", + ); } Ok(BackendTaskSuccessResult::DashPayContactRequestSent( @@ -538,6 +574,7 @@ async fn resolve_username_to_identity( // Use the cached DPNS contract from AppContext instead of fetching from network let domain_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: app_context.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![ @@ -552,6 +589,8 @@ async fn resolve_username_to_identity( value: Value::Text(normalized), }, ], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 1, start: None, @@ -655,6 +694,63 @@ pub async fn accept_contact_request( .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))? .clone(); + // Option A fix: record A's incoming CR into B's wallet-manager + // BEFORE sending the reciprocal. + // + // After record_sent_contact_request populates sent[A], upstream + // sync_contact_requests skips A's document (skip guard: + // sent[A] || incoming[A] || established[A] → continue) + // so dashpay_sync can never call add_incoming_contact_request and + // established_contacts stays empty. + // + // By pre-populating incoming[A] here, add_sent_contact_request for + // B's outgoing CR (fired by record_sent_contact_request at the end of + // send_contact_request_with_proof) finds incoming[A] and auto-establishes + // established_contacts[A] in-process — no dashpay_sync round-trip needed. + if let Some(seed_hash) = identity.dashpay_wallet_seed_hash() { + let owner_id = identity.identity.id(); + let props = doc.properties(); + let maybe_incoming_cr = (|| -> Option<UpstreamContactRequest> { + let sender_key_index = props.get("senderKeyIndex")?.to_integer::<u32>().ok()?; + let recipient_key_index = props.get("recipientKeyIndex")?.to_integer::<u32>().ok()?; + let account_reference = props.get("accountReference")?.to_integer::<u32>().ok()?; + let encrypted_public_key = props.get("encryptedPublicKey")?.as_bytes()?.clone(); + Some(UpstreamContactRequest::new( + from_identity_id, // sender = A + owner_id, // recipient = B + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key, + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + )) + })(); + + match maybe_incoming_cr { + Some(incoming_cr) => { + if let Ok(backend) = app_context.wallet_backend() + && let Err(err) = backend + .record_incoming_contact_request(&seed_hash, &owner_id, incoming_cr) + .await + { + tracing::warn!( + %err, + "record_incoming_contact_request failed; \ + auto-establishment will depend on dashpay_sync", + ); + } + } + None => { + tracing::warn!( + sender_id = %from_identity_id, + "accept_contact_request: incoming CR document missing required \ + fields; auto-establishment will depend on dashpay_sync", + ); + } + } + } + let result = send_contact_request( app_context, sdk, @@ -698,6 +794,9 @@ pub async fn reject_contact_request( .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. + let owner_id = identity.identity.id(); // Create or update contactInfo to mark this contact as hidden use super::contact_info::create_or_update_contact_info; @@ -714,6 +813,25 @@ 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. + // + // 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. + if let Ok(backend) = app_context.wallet_backend() + && let Err(e) = backend.dashpay_mark_rejected(&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" + ); + } + Ok(BackendTaskSuccessResult::DashPayContactRequestRejected( request_id, )) diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs index 38401e9d7..08750a2de 100644 --- a/src/backend_task/dashpay/contacts.rs +++ b/src/backend_task/dashpay/contacts.rs @@ -7,14 +7,13 @@ use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::DataContract; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; use dash_sdk::dpp::platform_value::Value; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier}; use futures::future::join_all; use std::collections::{HashMap, HashSet}; -use std::str::FromStr; use std::sync::Arc; +use zeroize::Zeroizing; // DashPay contract ID from the platform repo pub const DASHPAY_CONTRACT_ID: [u8; 32] = [ @@ -31,94 +30,43 @@ pub async fn get_dashpay_contract(sdk: &Sdk) -> Result<Arc<DataContract>, String .map(Arc::new) } -/// Derive encryption keys for contactInfo using BIP32 CKDpriv as specified in DIP-0015. +/// Derive the DIP-0015 contactInfo encryption keys for `identity`, fetching +/// the wallet's HD seed just-in-time through the [`SecretAccess`] chokepoint. /// -/// DIP-0015 specifies: -/// - Key1 (for encToUserId): rootEncryptionKey/(2^16)'/index' -/// - Key2 (for privateData): rootEncryptionKey/(2^16 + 1)'/index' -/// -/// We use the wallet's master seed to derive a root encryption key, -/// then apply BIP32 hardened derivation for the two encryption keys. -fn derive_contact_info_keys( +/// The seed is obtained per-operation via +/// [`SecretAccess::with_secret`](crate::wallet_backend::SecretAccess::with_secret) +/// keyed by the identity's DashPay wallet seed hash, and the BIP-32 +/// derivation runs inside the closure through the shared +/// [`derive_contact_info_encryption_keys`](crate::wallet_backend::derive_contact_info_encryption_keys) +/// helper — the raw seed never enters this layer. +#[allow(clippy::type_complexity)] +async fn derive_contact_info_keys( + app_context: &Arc<AppContext>, identity: &QualifiedIdentity, derivation_index: u32, -) -> Result<([u8; 32], [u8; 32]), String> { - // Get the wallet seed from the identity's associated wallet - let wallet = identity - .associated_wallets - .values() - .next() - .ok_or("No wallet associated with identity for key derivation")?; - - let (seed, network) = { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if !wallet_guard.is_open() { - return Err("Wallet must be unlocked to derive encryption keys".to_string()); - } - let seed = wallet_guard - .seed_bytes() - .map_err(|e| format!("Wallet seed not available: {}", e))? - .to_vec(); - (seed, identity.network) - }; - - // Create master extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, &seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; - - // Derive to the root encryption key path: m/9'/5'/15'/0' - // This follows the DashPay derivation structure - let root_path = DerivationPath::from_str("m/9'/5'/15'/0'") - .map_err(|e| format!("Invalid derivation path: {}", e))?; - - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let root_encryption_key = master_xprv - .derive_priv(&secp, &root_path) - .map_err(|e| format!("Failed to derive root encryption key: {}", e))?; - - // Derive Key1 for encToUserId: rootEncryptionKey/(2^16)'/index' - // First derive at hardened index 2^16 (65536) - let key1_level1 = root_encryption_key - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65536) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key1 level1: {}", e))?; - - // Then derive at hardened derivation_index - let key1_final = key1_level1 - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key1 final: {}", e))?; - - // Derive Key2 for privateData: rootEncryptionKey/(2^16 + 1)'/index' - // First derive at hardened index 2^16 + 1 (65537) - let key2_level1 = root_encryption_key - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65537) - .map_err(|e| format!("Invalid hardened index: {}", e))?], +) -> Result<(Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>), TaskError> { + let seed_hash = identity + .dashpay_wallet_seed_hash() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + let network = identity.network; + + app_context + .wallet_backend()? + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + crate::wallet_backend::derive_contact_info_encryption_keys( + seed, + network, + derivation_index, + ) + }, ) - .map_err(|e| format!("Failed to derive key2 level1: {}", e))?; - - // Then derive at hardened derivation_index - let key2_final = key2_level1 - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index) - .map_err(|e| format!("Invalid hardened index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive key2 final: {}", e))?; - - // Extract the private key bytes (32 bytes) for encryption - let key1_bytes: [u8; 32] = key1_final.private_key.secret_bytes(); - let key2_bytes: [u8; 32] = key2_final.private_key.secret_bytes(); - - Ok((key1_bytes, key2_bytes)) + .await } /// Decrypt toUserId using AES-256-ECB as specified by DIP-0015. @@ -307,7 +255,7 @@ pub async fn load_contacts( if let Some(Value::U32(deriv_idx)) = props.get("derivationEncryptionKeyIndex") { // Derive keys for this document let (enc_user_id_key, private_data_key) = - match derive_contact_info_keys(&identity, *deriv_idx) { + match derive_contact_info_keys(app_context, &identity, *deriv_idx).await { Ok(keys) => keys, Err(_) => continue, }; @@ -511,39 +459,114 @@ pub async fn load_contacts( } } + // Mirror the freshly-fetched contact profiles into the DET-side cache so + // the next view can paint names and avatars offline. Best- + // effort: a cache write miss only costs the offline optimisation. + cache_contact_profiles(app_context, &contact_list); + Ok(BackendTaskSuccessResult::DashPayContactsWithInfo( contact_list, )) } -pub async fn add_contact( - _app_context: &Arc<AppContext>, - _sdk: &Sdk, - _identity: QualifiedIdentity, - _contact_username: String, - _account_label: Option<String>, +/// Read the contact list for `identity` entirely from offline state: contact +/// relationships and private memos come from the upstream-rehydrated +/// `ManagedIdentity` (via [`crate::wallet_backend::DashpayView`]), and each +/// contact's display profile is served from the DET contact-profile cache. +/// +/// No network round-trip — a view rendered from this result does not require +/// connectivity. Profiles a contact has not yet been fetched for are returned +/// without display fields; an explicit refresh (network `load_contacts`) fills +/// and re-caches them. +pub async fn load_contacts_offline( + app_context: &Arc<AppContext>, + identity: QualifiedIdentity, ) -> Result<BackendTaskSuccessResult, TaskError> { - // TODO: Steps to implement: - // 1. Resolve username to identity ID via DPNS - // 2. Generate encryption keys for this contact relationship - // 3. Create the contactRequest document with encrypted fields - // 4. Broadcast the state transition - Err(DashPayError::NotSupported { - operation: "add_contact_by_username".to_string(), + let owner_id = identity.identity.id(); + let backend = app_context.wallet_backend()?; + + let stored = backend.dashpay_view().contacts(&owner_id).await; + + let mut contact_list = Vec::with_capacity(stored.len()); + for sc in stored { + // Skip the owner's own row defensively (contacts() does not emit it, + // but the network path filters it, so mirror that here). + let Ok(contact_id) = Identifier::from_bytes(&sc.contact_identity_id) else { + continue; + }; + if contact_id == owner_id { + continue; + } + + let cached = backend.contact_profile_cache().get(&contact_id); + let memo = backend + .dashpay_get_private_info(&owner_id, &contact_id) + .unwrap_or(None); + + contact_list.push(ContactData { + identity_id: contact_id, + nickname: memo + .as_ref() + .map(|m| m.nickname.clone()) + .filter(|s| !s.is_empty()), + note: memo + .as_ref() + .map(|m| m.notes.clone()) + .filter(|s| !s.is_empty()), + is_hidden: memo.as_ref().map(|m| m.is_hidden).unwrap_or(false), + // Carry the cached account reference so the "Account #N" badge shows + // and offline ordering matches the post-refresh view. Falls back to + // 0 ("default account") when no network read has cached one yet. + account_reference: cached + .as_ref() + .and_then(|c| c.account_reference) + .unwrap_or(0), + username: cached.as_ref().and_then(|c| c.username.clone()), + display_name: cached + .as_ref() + .and_then(|c| c.display_name.clone()) + .or_else(|| sc.display_name.clone()), + avatar_url: cached.as_ref().and_then(|c| c.avatar_url.clone()), + bio: cached.as_ref().and_then(|c| c.bio.clone()), + }); } - .into()) + + Ok(BackendTaskSuccessResult::DashPayContactsWithInfo( + contact_list, + )) } -pub async fn remove_contact( - _app_context: &Arc<AppContext>, - _sdk: &Sdk, - _identity: QualifiedIdentity, - _contact_id: Identifier, -) -> Result<BackendTaskSuccessResult, TaskError> { - // TODO: Implement contact removal - // This would involve deleting the contactInfo document if it exists - Err(DashPayError::NotSupported { - operation: "remove_contact".to_string(), +/// Write each contact's fetched display profile into the DET contact-profile +/// cache so later offline reads can serve it. Skips contacts with no +/// displayable field. Best-effort: write misses are logged and ignored. +fn cache_contact_profiles(app_context: &Arc<AppContext>, contacts: &[ContactData]) { + use crate::wallet_backend::CachedContactProfile; + + let Ok(backend) = app_context.wallet_backend() else { + return; + }; + let cache = backend.contact_profile_cache(); + for contact in contacts { + let profile = CachedContactProfile { + username: contact.username.clone(), + display_name: contact.display_name.clone(), + avatar_url: contact.avatar_url.clone(), + bio: contact.bio.clone(), + // Carry the account reference so the offline read can show the + // "Account #N" badge and sort consistently. 0 means "default + // account / not set", so it is not worth caching on its own. + account_reference: (contact.account_reference != 0) + .then_some(contact.account_reference), + }; + if profile.is_empty() { + continue; + } + if let Err(e) = cache.put(&contact.identity_id, &profile) { + tracing::debug!( + contact = %contact.identity_id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + error = ?e, + "Failed to cache contact profile for offline use" + ); + } } - .into()) } diff --git a/src/backend_task/dashpay/encryption.rs b/src/backend_task/dashpay/encryption.rs index 9e57a9cb6..8a402e6ff 100644 --- a/src/backend_task/dashpay/encryption.rs +++ b/src/backend_task/dashpay/encryption.rs @@ -6,13 +6,18 @@ use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; /// Generate ECDH shared key according to DashPay DIP-15 /// Uses libsecp256k1_ecdh method: SHA256((y[31]&0x1|0x2) || x) +/// +/// The returned key is symmetric DashPay encryption material, so it is wrapped +/// in [`Zeroizing`] and the 64-byte ECDH point it is derived from is wiped on +/// drop. The byte value is unchanged from the DIP-15 derivation. pub fn generate_ecdh_shared_key( private_key: &[u8], public_key: &IdentityPublicKey, -) -> Result<[u8; 32], String> { +) -> Result<Zeroizing<[u8; 32]>, String> { let _secp = Secp256k1::new(); // Parse the private key @@ -26,8 +31,14 @@ pub fn generate_ecdh_shared_key( let public_key = PublicKey::from_slice(public_key_data.as_slice()) .map_err(|e| format!("Invalid public key: {}", e))?; - // Perform ECDH to get shared secret - let shared_secret = dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point(&public_key, &secret_key); + // Perform ECDH to get shared secret. The 64-byte point is secret; + // hold it in `Zeroizing` so it is wiped once the key is hashed out. + let shared_secret = Zeroizing::new( + dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point( + &public_key, + &secret_key, + ), + ); // Extract x and y coordinates (64 bytes total: 32 + 32) let x = &shared_secret[..32]; @@ -41,8 +52,10 @@ pub fn generate_ecdh_shared_key( hasher.update([prefix]); hasher.update(x); - let result = hasher.finalize(); - let mut shared_key = [0u8; 32]; + // The digest is the shared key material; wrap it so the sha2 + // output buffer is wiped on drop after the copy. + let result = Zeroizing::new(hasher.finalize()); + let mut shared_key = Zeroizing::new([0u8; 32]); shared_key.copy_from_slice(&result); Ok(shared_key) @@ -301,6 +314,67 @@ mod tests { (secret_key, public_key) } + /// Build an `IdentityPublicKey` carrying a raw ECDSA_SECP256K1 public key + /// in its `data` field — the shape `generate_ecdh_shared_key` expects. + fn ecdsa_identity_public_key(public_key: &PublicKey) -> IdentityPublicKey { + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: public_key.serialize().to_vec().into(), + disabled_at: None, + }) + } + + /// Byte-identity guard for the DIP-15 ECDH derivation. + /// + /// Pins the shared key for a fixed private key + fixed counterpart public + /// key so the zeroize hardening (which wraps the result in `Zeroizing` and + /// wipes the sha2 digest) cannot silently alter the derived material. + #[test] + fn ecdh_shared_key_matches_pinned_vector() { + let secp = Secp256k1::new(); + + // Our private key: 0x01..=0x20 (same fixed vector the suite uses). + let private_key = [ + 0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, + ]; + + // Counterpart key derived from a different fixed secret so the ECDH + // point is non-degenerate. + let counterpart_secret = SecretKey::from_slice(&[ + 0xA1u8, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, + 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, + 0xBD, 0xBE, 0xBF, 0xC0, + ]) + .unwrap(); + let counterpart_pub = PublicKey::from_secret_key(&secp, &counterpart_secret); + let counterpart_ipk = ecdsa_identity_public_key(&counterpart_pub); + + let shared_key = generate_ecdh_shared_key(&private_key, &counterpart_ipk) + .expect("ECDH derivation should succeed"); + + const EXPECTED: [u8; 32] = [ + 0xda, 0x28, 0x35, 0x1f, 0x2d, 0xbd, 0x54, 0xc7, 0x5c, 0x5d, 0xf3, 0x37, 0x9e, 0x33, + 0xb2, 0x37, 0xac, 0x32, 0x3e, 0xb2, 0x63, 0x87, 0xf5, 0x8f, 0x98, 0x33, 0xec, 0x5a, + 0x5a, 0x2a, 0x83, 0x85, + ]; + assert_eq!( + *shared_key, + EXPECTED, + "DIP-15 ECDH shared key changed — crypto output is NOT byte-identical. \ + Got: {}", + hex::encode(*shared_key) + ); + } + #[test] fn test_encrypt_decrypt_extended_public_key_roundtrip() { // Generate test data @@ -448,6 +522,29 @@ mod tests { assert!(result.is_err(), "Decryption with wrong key should fail"); } + #[test] + fn zeroizing_shared_key_works_through_deref_coercion() { + // The DashPay callers hold the ECDH shared key as `Zeroizing<[u8; 32]>` + // and pass it by reference to the `encrypt_*` / `decrypt_*` helpers, + // which take `&[u8; 32]`. This pins the deref-coercion contract those + // callers rely on: a borrow of the zeroizing key keys the cipher + // identically to a borrow of a plain array. + let raw = generate_test_shared_key(); + let zeroizing_key = zeroize::Zeroizing::new(raw); + + let encrypted = + encrypt_account_label("Personal", &zeroizing_key).expect("encrypt with zeroizing key"); + let decrypted = + decrypt_account_label(&encrypted, &zeroizing_key).expect("decrypt with zeroizing key"); + assert_eq!(decrypted, "Personal"); + + // Same bytes via a plain array must decrypt the zeroizing-keyed + // ciphertext, proving the wrapper does not alter the key material. + let plain_decrypted = + decrypt_account_label(&encrypted, &raw).expect("decrypt with plain key"); + assert_eq!(plain_decrypted, "Personal"); + } + #[test] fn test_decrypt_account_label_with_wrong_key_fails() { let shared_key = generate_test_shared_key(); diff --git a/src/backend_task/dashpay/encryption_tests.rs b/src/backend_task/dashpay/encryption_tests.rs deleted file mode 100644 index 7db45f757..000000000 --- a/src/backend_task/dashpay/encryption_tests.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::backend_task::dashpay::encryption::{ - decrypt_account_label, decrypt_extended_public_key, encrypt_account_label, - encrypt_extended_public_key, -}; -use bip39::rand::{self, RngCore}; -use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; - -/// Test encryption and decryption of extended public keys -pub fn test_extended_public_key_encryption() -> Result<(), String> { - println!("Testing extended public key encryption/decryption..."); - - // Generate test data - let parent_fingerprint = [0x12, 0x34, 0x56, 0x78]; - let mut chain_code = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut chain_code); - - // Generate a test key pair - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, - 0x1F, 0x20, - ]) - .unwrap(); - let public_key = PublicKey::from_secret_key(&secp, &secret_key); - let public_key_bytes = public_key.serialize(); - - // Generate a shared key for encryption - let mut shared_key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut shared_key); - - // Test encryption - let encrypted = encrypt_extended_public_key( - parent_fingerprint, - chain_code, - public_key_bytes, - &shared_key, - )?; - - // Verify encrypted data length is 96 bytes (16 IV + 80 encrypted) - if encrypted.len() != 96 { - return Err(format!( - "Invalid encrypted length: {} (expected 96)", - encrypted.len() - )); - } - - println!("✓ Encryption produced 96 bytes as expected"); - - // Test decryption - let (decrypted_fingerprint, decrypted_chain_code, decrypted_public_key) = - decrypt_extended_public_key(&encrypted, &shared_key)?; - - // Verify decrypted data matches original - if decrypted_fingerprint != parent_fingerprint.to_vec() { - return Err("Parent fingerprint mismatch after decryption".to_string()); - } - - if decrypted_chain_code != chain_code { - return Err("Chain code mismatch after decryption".to_string()); - } - - if decrypted_public_key != public_key_bytes { - return Err("Public key mismatch after decryption".to_string()); - } - - println!("✓ Decryption successfully recovered original data"); - - // Test with wrong key fails - let mut wrong_key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut wrong_key); - - match decrypt_extended_public_key(&encrypted, &wrong_key) { - Ok(_) => return Err("Decryption should have failed with wrong key".to_string()), - Err(_) => println!("✓ Decryption correctly failed with wrong key"), - } - - Ok(()) -} - -/// Test encryption and decryption of account labels -pub fn test_account_label_encryption() -> Result<(), String> { - println!("\nTesting account label encryption/decryption..."); - - // Generate a shared key - let mut shared_key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut shared_key); - - // Test various label lengths - let test_labels = vec![ - "Personal", - "Business Account", - "Savings - Long Term Investment Fund 2024", - "Test with special chars: 你好世界 🚀", - ]; - - for label in test_labels { - println!(" Testing label: '{}'", label); - - // Encrypt - let encrypted = encrypt_account_label(label, &shared_key)?; - - // Verify encrypted length is in expected range (48-80 bytes) - if encrypted.len() < 48 || encrypted.len() > 80 { - return Err(format!( - "Invalid encrypted label length: {} (expected 48-80)", - encrypted.len() - )); - } - - // Decrypt - let decrypted = decrypt_account_label(&encrypted, &shared_key)?; - - // Verify match - if decrypted != label { - return Err(format!( - "Label mismatch after decryption: '{}' != '{}'", - decrypted, label - )); - } - - println!( - " ✓ Successfully encrypted/decrypted ({} bytes encrypted)", - encrypted.len() - ); - } - - // Test label that's too long - let long_label = "x".repeat(65); - match encrypt_account_label(&long_label, &shared_key) { - Ok(_) => return Err("Should have rejected label > 64 chars".to_string()), - Err(_) => println!(" ✓ Correctly rejected label > 64 characters"), - } - - Ok(()) -} - -/// Test ECDH shared key generation -pub fn test_ecdh_shared_key_generation() -> Result<(), String> { - println!("\nTesting ECDH shared key generation..."); - - // Skip the actual ECDH test for now due to IdentityPublicKey structure complexities - - // TODO: Complete ECDH test once we have proper IdentityPublicKey mock - // The issue is that IdentityPublicKey stores ECDSA keys differently than BLS keys - // and we need to properly mock the .data() method to return the right bytes - // For ECDSA_SECP256K1 keys, the data field is the raw 33-byte compressed public key - // but the IdentityPublicKey structure expects a BLS PublicKey type in the data field - - println!("✓ ECDH test skipped (needs proper mock implementation)"); - - // For now, let's test that the basic encryption/decryption functions work - // which is demonstrated in the other tests above - - Ok(()) -} - -/// Run all encryption tests -pub fn run_all_encryption_tests() -> Result<(), String> { - println!("=== Running DashPay Encryption Tests ===\n"); - - test_extended_public_key_encryption()?; - test_account_label_encryption()?; - test_ecdh_shared_key_generation()?; - - println!("\n=== All encryption tests passed! ==="); - - Ok(()) -} - -/// Create a test task to run encryption verification -pub fn create_encryption_test_task() -> crate::backend_task::BackendTask { - crate::backend_task::BackendTask::None -} diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index 6507a0338..8b1de0d51 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -12,27 +12,6 @@ pub enum DashPayError { #[error("Username '{username}' was not found. Please check the spelling and try again.")] UsernameResolutionFailed { username: String }, - #[error("A required security key is missing. Please refresh your identity and retry.")] - KeyNotFound { - key_id: u32, - identity_id: Identifier, - }, - - #[error("A required security key has been disabled. Please refresh your identity and retry.")] - KeyDisabled { - key_id: u32, - identity_id: Identifier, - }, - - #[error( - "A security key cannot be used for this operation. Please refresh your identity and retry." - )] - UnsuitableKeyType { - key_id: u32, - key_type: String, - operation: String, - }, - #[error( "Your identity is missing an encryption key required for contacts. Please add a compatible encryption key." )] @@ -43,41 +22,11 @@ pub enum DashPayError { )] MissingDecryptionKey, - #[error("Could not establish a secure connection with this contact. Please retry.")] - EcdhFailed { reason: String }, - - #[error("Could not encrypt the message. Please retry.")] - EncryptionFailed { reason: String }, - - #[error("Could not decrypt the message. Please retry.")] - DecryptionFailed { reason: String }, - // Document/Platform Errors - #[error("Could not send the contact request. Please retry.")] - DocumentCreationFailed { reason: String }, - - #[error("Could not send this request to the network. Please retry.")] - BroadcastFailed { reason: String }, - - #[error( - "Could not retrieve the requested information. Please check your connection and retry." - )] - QueryFailed { reason: String }, - #[error("The received data has an unexpected format. Please retry or update the application.")] InvalidDocument { reason: String }, // Validation Errors - #[error("The network data could not be verified. Please retry in a few moments.")] - InvalidCoreHeight { - height: u32, - current: Option<u32>, - reason: String, - }, - - #[error("The account reference is not valid. Please check the details and retry.")] - InvalidAccountReference { account: u32, reason: String }, - #[error("The contact request could not be verified. Please check the details and try again.")] ValidationFailed { errors: Vec<String> }, @@ -88,15 +37,9 @@ pub enum DashPayError { #[error("This QR code has expired. Please ask for a new one.")] QrCodeExpired { expired_at: u64, current_time: u64 }, - #[error("The automatic verification could not be completed. Please retry.")] - ProofVerificationFailed { reason: String }, - // Network/SDK Errors - #[error("Could not reach the network. Please check your connection and retry.")] - PlatformError { reason: String }, - #[error("Network connection failed. Please check your internet connection and retry.")] - NetworkError { reason: String }, + NetworkError, #[error("An unexpected error occurred while communicating with the network. Please retry.")] SdkError { @@ -117,26 +60,6 @@ pub enum DashPayError { #[error("A required field is missing. Please fill in all fields and try again.")] MissingField { field: String }, - // Contact Info Errors - #[error("Contact information could not be found. Please refresh and try again.")] - ContactInfoNotFound { contact_id: Identifier }, - - #[error("Contact information could not be read. Please refresh and try again.")] - ContactInfoDecryptionFailed { - contact_id: Identifier, - reason: String, - }, - - // General Errors - #[error("An unexpected error occurred. Please retry.")] - Internal { message: String }, - - #[error("This operation is not available. Please update the application.")] - NotSupported { operation: String }, - - #[error("Too many requests. Please wait a moment and try again.")] - RateLimited { operation: String }, - /// Failed to build a document query (schema / configuration error). #[error("Could not prepare the data request. Please retry or update the application.")] QueryCreation { @@ -177,84 +100,23 @@ pub enum DashPayError { /// 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<String> }, -} -impl DashPayError { - // TODO: `user_message()` is largely redundant now that Display messages are - // user-friendly. Consider removing it once the two UI callsites - // (contact_requests.rs:617, add_contact_screen.rs:350) are migrated to use - // Display directly. + /// DashPay HD key derivation failed. + #[error("The payment keys for this contact could not be derived. Please retry.")] + Derivation(#[from] crate::model::dashpay_derivation::DerivationError), - /// Convert to user-friendly error message - pub fn user_message(&self) -> String { - match self { - DashPayError::UsernameResolutionFailed { username } => { - format!( - "Username '{}' not found. Please check the spelling.", - username - ) - } - DashPayError::IdentityNotFound { .. } => { - "Contact not found. They may not be registered on Dash Platform.".to_string() - } - DashPayError::InvalidQrCode { .. } => { - "Invalid QR code. Please scan a valid DashPay contact QR code.".to_string() - } - DashPayError::QrCodeExpired { .. } => { - "QR code has expired. Please ask for a new one.".to_string() - } - DashPayError::NetworkError { .. } => { - "Network connection error. Please check your internet connection.".to_string() - } - DashPayError::ValidationFailed { errors } => { - if errors.len() == 1 { - format!("Validation error: {}", errors[0]) - } else { - format!("Multiple validation errors: {}", errors.join(", ")) - } - } - DashPayError::AccountLabelTooLong { max, .. } => { - format!( - "Account label too long. Maximum {} characters allowed.", - max - ) - } - DashPayError::InvalidUsername { .. } => { - "Invalid username format. Usernames must end with '.dash'.".to_string() - } - DashPayError::RateLimited { .. } => { - "Too many requests. Please wait a moment before trying again.".to_string() - } - DashPayError::Internal { message } => { - message.clone() - } - DashPayError::MissingEncryptionKey => { - "Your identity is missing an encryption key required for contacts. Please add a compatible encryption key.".to_string() - } - DashPayError::MissingDecryptionKey => { - "Your identity is missing a decryption key required for contacts. Please add a compatible decryption key.".to_string() - } - DashPayError::ContactInfoValidationFailed { .. } => { - "Contact info is too large to save. Try shortening your nickname or note." - .to_string() - } - DashPayError::CannotContactSelf => { - "You cannot send a contact request to yourself.".to_string() - } - _ => "An error occurred. Please try again.".to_string(), - } - } + /// The system clock is set before the Unix epoch, so an expiry time + /// cannot be computed. + #[error( + "Your device clock appears to be incorrect. Please set the correct date and time, then retry." + )] + SystemClockInvalid, +} +impl DashPayError { /// Check if error is recoverable (user can retry) pub fn is_recoverable(&self) -> bool { - matches!( - self, - DashPayError::NetworkError { .. } - | DashPayError::PlatformError { .. } - | DashPayError::RateLimited { .. } - | DashPayError::BroadcastFailed { .. } - | DashPayError::QueryFailed { .. } - ) + matches!(self, DashPayError::NetworkError) } /// Check if error requires user action (not a system error) @@ -275,53 +137,3 @@ impl DashPayError { ) } } - -/// Result type for DashPay operations -pub type DashPayResult<T> = Result<T, DashPayError>; - -/// Helper to convert string errors to DashPayError -impl From<String> for DashPayError { - fn from(error: String) -> Self { - DashPayError::Internal { message: error } - } -} - -/// Trait for converting various SDK errors to DashPayError -pub trait ToDashPayError<T> { - fn to_dashpay_error(self, context: &str) -> DashPayResult<T>; -} - -impl<T> ToDashPayError<T> for Result<T, dash_sdk::Error> { - fn to_dashpay_error(self, _context: &str) -> DashPayResult<T> { - self.map_err(|e| DashPayError::SdkError { - source: Box::new(e), - }) - } -} - -impl<T> ToDashPayError<T> for Result<T, String> { - fn to_dashpay_error(self, context: &str) -> DashPayResult<T> { - self.map_err(|e| DashPayError::Internal { - message: format!("{}: {}", context, e), - }) - } -} - -/// Helper to create validation errors -pub fn validation_error(errors: Vec<String>) -> DashPayError { - DashPayError::ValidationFailed { errors } -} - -/// Helper to create network errors -pub fn network_error(reason: impl Into<String>) -> DashPayError { - DashPayError::NetworkError { - reason: reason.into(), - } -} - -/// Helper to create platform errors -pub fn platform_error(reason: impl Into<String>) -> DashPayError { - DashPayError::PlatformError { - reason: reason.into(), - } -} diff --git a/src/backend_task/dashpay/incoming_payments.rs b/src/backend_task/dashpay/incoming_payments.rs index 5ddcbe95c..4007cbf2a 100644 --- a/src/backend_task/dashpay/incoming_payments.rs +++ b/src/backend_task/dashpay/incoming_payments.rs @@ -1,5 +1,7 @@ -use super::hd_derivation::{derive_dashpay_incoming_xpub, derive_payment_address}; +use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::ContactAddressIndex; +use crate::model::dashpay_derivation::{derive_dashpay_incoming_xpub, derive_payment_address}; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::{Address, Network}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -30,7 +32,7 @@ pub struct DashPayAddressRegistrationResult { /// Derive the receiving addresses for a contact relationship /// These are the addresses the CONTACT will use to pay US -/// Path: m/9'/5'/15'/account'/(our_id)/(contact_id)/index +/// Path: m/9'/coin'/15'/account'/(our_id)/(contact_id)/index pub fn derive_receiving_addresses_for_contact( master_seed: &[u8], network: Network, @@ -40,7 +42,7 @@ pub fn derive_receiving_addresses_for_contact( count: u32, ) -> Result<Vec<DashPayReceivingAddress>, String> { // For receiving payments, we derive from OUR xpub - // Path: m/9'/5'/15'/0'/(our_id)/(contact_id) + // Path: m/9'/coin'/15'/0'/(our_id)/(contact_id) // This is the key we sent to the contact in our contact request let xpub = derive_dashpay_incoming_xpub( master_seed, @@ -48,11 +50,12 @@ pub fn derive_receiving_addresses_for_contact( 0, // account 0 our_identity_id, contact_id, - )?; + ) + .map_err(|e| e.to_string())?; let mut addresses = Vec::with_capacity(count as usize); for i in start_index..(start_index + count) { - let address = derive_payment_address(&xpub, i)?; + let address = derive_payment_address(&xpub, i).map_err(|e| e.to_string())?; addresses.push(DashPayReceivingAddress { address, contact_id: *contact_id, @@ -70,145 +73,185 @@ pub fn derive_receiving_addresses_for_contact( pub async fn register_dashpay_addresses_for_identity( app_context: &Arc<AppContext>, identity: &QualifiedIdentity, -) -> Result<DashPayAddressRegistrationResult, String> { +) -> Result<DashPayAddressRegistrationResult, TaskError> { let mut result = DashPayAddressRegistrationResult::default(); let our_identity_id = identity.identity.id(); - // Get the wallet seed - let wallet = identity - .associated_wallets - .values() - .next() - .ok_or("No wallet associated with identity")?; - - let seed = { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if !wallet_guard.is_open() { - return Err("Wallet must be unlocked to register DashPay addresses".to_string()); - } - wallet_guard - .seed_bytes() - .map_err(|e| format!("Wallet seed not available: {}", e))? - .to_vec() - }; + // Select the DashPay wallet (lowest associated seed hash). The receive + // side must pick the SAME wallet the send side published the contact-xpub + // from, or the contact pays into addresses we never scan — both sides + // resolve through `QualifiedIdentity::dashpay_wallet` (SEC-W-001). + let (seed_hash, wallet) = identity.dashpay_wallet().ok_or(TaskError::WalletNotFound)?; + let wallet = wallet.clone(); - // Load all contacts for this identity from the database - let network_str = app_context.network.to_string(); - let contacts = app_context - .db - .load_dashpay_contacts(&our_identity_id, &network_str) - .map_err(|e| format!("Failed to load contacts: {}", e))?; + // Load all contacts for this identity from the WalletBackend DashPay + // adapter — the upstream-backed source of truth. After D4c there is no + // DB fallback: registration is meaningful only once the wallet is + // wired (it needs the wallet's seed and known-address map anyway). + let backend = app_context.wallet_backend()?; + let contacts = backend.dashpay_view().contacts(&our_identity_id).await; if contacts.is_empty() { return Ok(result); } - // Load address indices for all contacts - let address_indices = app_context - .db - .get_all_contact_address_indices(&our_identity_id) - .map_err(|e| format!("Failed to load address indices: {}", e))?; - - // Create a map for quick lookup - let indices_map: BTreeMap<Vec<u8>, _> = address_indices - .into_iter() - .map(|idx| (idx.contact_identity_id.clone(), idx)) - .collect(); - - let network = app_context.network; - - for contact in contacts { + // Hydrate the per-contact address-index cache from the k/v sidecar so + // we don't pay a kv read per contact below. + let mut indices_map: BTreeMap<Vec<u8>, ContactAddressIndex> = BTreeMap::new(); + for contact in &contacts { let contact_id = match Identifier::from_bytes(&contact.contact_identity_id) { Ok(id) => id, + Err(_) => continue, + }; + match backend.dashpay_get_address_index(&our_identity_id, &contact_id) { + Ok(Some(idx)) => { + indices_map.insert(contact.contact_identity_id.clone(), idx); + } + Ok(None) => {} Err(e) => { - result.errors.push(format!("Invalid contact ID: {}", e)); - continue; + result.errors.push(format!( + "Failed to load address index for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); } - }; - - // Get the current highest receive index for this contact - let highest_receive_index = indices_map - .get(&contact.contact_identity_id) - .map(|idx| idx.highest_receive_index) - .unwrap_or(0); - - // Get how many addresses are already registered with bloom filter - let bloom_registered = indices_map - .get(&contact.contact_identity_id) - .map(|idx| idx.bloom_registered_count) - .unwrap_or(0); - - // Calculate how many new addresses we need to derive - // We want addresses from 0 to (highest_receive_index + GAP_LIMIT) - let target_count = highest_receive_index.saturating_add(DASHPAY_GAP_LIMIT); - - // Only derive new addresses if we need more than what's registered - if target_count <= bloom_registered { - result.contacts_processed += 1; - continue; } + } - let start_index = bloom_registered; - let count = target_count - bloom_registered; - - // Derive the receiving addresses - match derive_receiving_addresses_for_contact( - &seed, - network, - &our_identity_id, - &contact_id, - start_index, - count, - ) { - Ok(addresses) => { - // Register each address with the wallet - for addr_info in &addresses { - if let Err(e) = register_dashpay_address( - app_context, - wallet, - &addr_info.address, + let network = app_context.network; + + // Fetch the HD seed just-in-time through the chokepoint and derive every + // contact's receiving addresses inside the one session scope — the seed + // is borrowed for this single registration run and zeroizes when the + // closure returns; it never enters this layer by value. + let derived = app_context + .wallet_backend()? + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + + let mut derived: Vec<(Identifier, u32, Vec<DashPayReceivingAddress>)> = Vec::new(); + for contact in &contacts { + let contact_id = match Identifier::from_bytes(&contact.contact_identity_id) { + Ok(id) => id, + Err(e) => { + result.errors.push(format!("Invalid contact ID: {}", e)); + continue; + } + }; + + let highest_receive_index = indices_map + .get(&contact.contact_identity_id) + .map(|idx| idx.highest_receive_index) + .unwrap_or(0); + let bloom_registered = indices_map + .get(&contact.contact_identity_id) + .map(|idx| idx.bloom_registered_count) + .unwrap_or(0); + + // Derive 0..(highest_receive_index + GAP_LIMIT), skipping + // what the bloom filter already covers. + let target_count = highest_receive_index.saturating_add(DASHPAY_GAP_LIMIT); + if target_count <= bloom_registered { + result.contacts_processed += 1; + continue; + } + + let start_index = bloom_registered; + let count = target_count - bloom_registered; + + match derive_receiving_addresses_for_contact( + seed, + network, &our_identity_id, &contact_id, - addr_info.address_index, + start_index, + count, ) { - result.errors.push(format!( - "Failed to register address for contact {}: {}", - contact_id.to_string(Encoding::Base58), - e - )); - } else { - result.addresses_registered += 1; + Ok(addresses) => derived.push((contact_id, target_count, addresses)), + Err(e) => { + result.errors.push(format!( + "Failed to derive addresses for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } } } - - // Update the bloom_registered_count in database - if let Err(e) = app_context.db.update_bloom_registered_count( - &our_identity_id, - &contact_id, - target_count, - ) { - result.errors.push(format!( - "Failed to update bloom count for contact {}: {}", - contact_id.to_string(Encoding::Base58), - e - )); - } - - result.contacts_processed += 1; - } - Err(e) => { + Ok(derived) + }, + ) + .await?; + + // Register the derived addresses with the wallet outside the secret scope + // — registration touches no plaintext seed. + for (contact_id, target_count, addresses) in derived { + for addr_info in &addresses { + if let Err(e) = register_dashpay_address( + app_context, + &wallet, + &addr_info.address, + &our_identity_id, + &contact_id, + addr_info.address_index, + ) { result.errors.push(format!( - "Failed to derive addresses for contact {}: {}", + "Failed to register address for contact {}: {}", contact_id.to_string(Encoding::Base58), e )); + } else { + result.addresses_registered += 1; } } + + // Update the bloom_registered_count in the sidecar (RMW the shared + // `ContactAddressIndex` record so we don't clobber a higher receive + // cursor written by a concurrent payment). + if let Err(e) = + set_bloom_registered_count(&backend, &our_identity_id, &contact_id, target_count) + { + result.errors.push(format!( + "Failed to update bloom count for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } + + result.contacts_processed += 1; } Ok(result) } +/// Helper: stamp `bloom_registered_count = count` onto the persisted +/// `ContactAddressIndex` for `(owner, contact)` without clobbering other +/// fields. Initialises a fresh record with the rest of the cursors at 0 +/// when no entry exists yet. +fn set_bloom_registered_count( + backend: &crate::wallet_backend::WalletBackend, + owner: &Identifier, + contact: &Identifier, + count: u32, +) -> Result<(), TaskError> { + let mut state = backend + .dashpay_get_address_index(owner, contact)? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + state.bloom_registered_count = count; + backend.dashpay_set_address_index(owner, contact, &state) +} + /// Register a single DashPay address with the wallet fn register_dashpay_address( app_context: &AppContext, @@ -218,27 +261,39 @@ fn register_dashpay_address( contact_id: &Identifier, address_index: u32, ) -> Result<(), String> { - use crate::model::wallet::{DerivationPathReference, DerivationPathType}; + use crate::model::wallet::{ + DerivationPathReference, DerivationPathType, coin_type_for_network, + }; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; // Create a derivation path representation for DashPay addresses - // m/9'/5'/15'/0'/<owner_hash>/<contact_hash>/<index> + // m/9'/coin'/15'/0'/<owner_hash>/<contact_hash>/<index> // Note: We use a simplified representation since full 256-bit paths don't fit in standard BIP32 + let coin_type = coin_type_for_network(app_context.network); + // Every index below is a valid BIP32 child index (< 2^31): the hardened + // constants (9, 15, 0) and the small per-network coin type are in range, + // and `hash_identifier_to_u32` masks its output with `& 0x7FFFFFFF`, so the + // identity indices are non-hardened by construction. `address_index` is a + // non-hardened receiving index, also below 2^31 by invariant. + let idx = "invariant: BIP32 child index is below 2^31"; let path = DerivationPath::from(vec![ - ChildNumber::from_hardened_idx(9).unwrap(), // Feature purpose - ChildNumber::from_hardened_idx(5).unwrap(), // Coin type (Dash) - ChildNumber::from_hardened_idx(15).unwrap(), // DashPay feature - ChildNumber::from_hardened_idx(0).unwrap(), // Account + ChildNumber::from_hardened_idx(9).expect(idx), // Feature purpose + ChildNumber::from_hardened_idx(coin_type).expect(idx), // Coin type (per network) + ChildNumber::from_hardened_idx(15).expect(idx), // DashPay feature + ChildNumber::from_hardened_idx(0).expect(idx), // Account // For the identity indices, we use a hash to fit in u32 - ChildNumber::from_normal_idx(hash_identifier_to_u32(owner_id)).unwrap(), - ChildNumber::from_normal_idx(hash_identifier_to_u32(contact_id)).unwrap(), - ChildNumber::from_normal_idx(address_index).unwrap(), + ChildNumber::from_normal_idx(hash_identifier_to_u32(owner_id)).expect(idx), + ChildNumber::from_normal_idx(hash_identifier_to_u32(contact_id)).expect(idx), + ChildNumber::from_normal_idx(address_index).expect(idx), ]); - // Store the DashPay address mapping in the database - app_context - .db - .save_dashpay_address_mapping(owner_id, contact_id, address, address_index) + // Store the DashPay address mapping in the k/v sidecar so the + // incoming-payment detector can resolve `address → (contact, index)`. + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + backend + .dashpay_set_address_mapping(owner_id, &address.to_string(), contact_id, address_index) .map_err(|e| format!("Failed to save address mapping: {}", e))?; // Register with the wallet's known addresses @@ -269,78 +324,182 @@ fn hash_identifier_to_u32(id: &Identifier) -> u32 { u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7FFFFFFF } -/// Match a received transaction to a DashPay contact -/// Returns the contact ID and payment details if the address belongs to a contact relationship +/// Match a received transaction to a DashPay contact for `owner_id`. +/// +/// Returns `(contact_id, address_index)` if the address is registered as +/// a DashPay receiving address for `owner_id`; `None` otherwise. +/// +/// The k/v sidecar partitions the address map by owner, so the caller is +/// responsible for narrowing the search to the identity that observed the +/// transaction (typically the identity whose SPV bloom filter matched). +/// `address` is the Base58 receiving address — the same form the sidecar +/// is keyed by. pub fn match_transaction_to_contact( app_context: &AppContext, - address: &Address, -) -> Result<Option<(Identifier, Identifier, u32)>, String> { - // Look up the address in the DashPay address mapping - app_context - .db - .get_dashpay_address_mapping(address) - .map_err(|e| format!("Failed to lookup address: {}", e)) + owner_id: &Identifier, + address: &str, +) -> Result<Option<(Identifier, u32)>, TaskError> { + let backend = app_context.wallet_backend()?; + backend.dashpay_get_address_mapping(owner_id, address) } -/// Process an incoming transaction that was detected by SPV -/// This should be called when WalletEvent::TransactionReceived is received +/// Process a received output for one identity: if its address is a DashPay +/// contact-receiving address for `owner_id`, advance the receive cursor and +/// record the incoming payment through the upstream persist path. +/// +/// `address` is the Base58 receiving address the output paid into. Returns +/// `Ok(None)` when the address is not a DashPay contact address for this +/// owner (the common case — most received outputs are plain wallet funds). +/// +/// Idempotent: the receive cursor only ever advances, and the recording is +/// keyed by `(tx_id, vout)` with last-write-wins upstream, so a re-scan of the +/// same output neither double-credits nor double-counts. Keying by the output +/// index — not the bare `tx_id` — keeps a transaction that pays two different +/// contact addresses recording both, rather than the second clobbering the +/// first. pub async fn process_incoming_payment( app_context: &Arc<AppContext>, + owner_id: &Identifier, tx_id: &str, - address: &Address, + vout: u32, + address: &str, amount_duffs: u64, -) -> Result<Option<IncomingPaymentInfo>, String> { - // Check if this address belongs to a DashPay contact relationship - let mapping = match match_transaction_to_contact(app_context, address)? { - Some(m) => m, - None => return Ok(None), // Not a DashPay address - }; - - let (owner_id, contact_id, address_index) = mapping; - - // Update the highest receive index if needed - let current_indices = app_context - .db - .get_contact_address_indices(&owner_id, &contact_id) - .map_err(|e| format!("Failed to get address indices: {}", e))?; +) -> Result<Option<IncomingPaymentInfo>, TaskError> { + // Check if this address belongs to a DashPay contact relationship. + let (contact_id, address_index) = + match match_transaction_to_contact(app_context, owner_id, address)? { + Some(m) => m, + None => return Ok(None), // Not a DashPay address + }; - if address_index >= current_indices.highest_receive_index { - app_context - .db - .update_highest_receive_index(&owner_id, &contact_id, address_index + 1) - .map_err(|e| format!("Failed to update receive index: {}", e))?; + // Bump the highest receive index if this address pushed past the cursor. + let backend = app_context.wallet_backend()?; + let mut state = backend + .dashpay_get_address_index(owner_id, &contact_id)? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner_id.to_buffer().to_vec(), + contact_identity_id: contact_id.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + if address_index >= state.highest_receive_index { + state.highest_receive_index = address_index + 1; + backend.dashpay_set_address_index(owner_id, &contact_id, &state)?; } - // Save the payment record - app_context - .db - .save_payment( - tx_id, - &contact_id, // from contact - &owner_id, // to us - amount_duffs as i64, - None, // memo - not available for incoming - "received", - ) - .map_err(|e| format!("Failed to save payment: {}", e))?; + // Mirror the incoming payment through the WalletBackend adapter so the + // upstream `ManagedIdentity` records it and the timestamp sidecar reflects + // when DET observed it. Keyed per output so two contact outputs in one + // transaction are both recorded. + super::payments::mirror_incoming_payment_to_backend( + app_context, + owner_id, + tx_id, + vout, + contact_id, + amount_duffs, + ) + .await; Ok(Some(IncomingPaymentInfo { tx_id: tx_id.to_string(), + vout, from_contact_id: contact_id, - to_identity_id: owner_id, - address: address.clone(), + to_identity_id: *owner_id, + address: address.to_string(), amount_duffs, address_index, })) } +/// Resolve a batch of received outputs against every local identity's DashPay +/// address map, recording the ones that pay a known contact. Returns the +/// number of payments recorded. +/// +/// This is the detection driver wired to the [`EventBridge`]: it owns the +/// owner-scoped match the sync event callback cannot perform. The address map +/// is partitioned per owner, so each candidate output is tried against the +/// local identities until one claims it. A receiving address belongs to exactly +/// one owning identity, so the scan stops at the first match — trying every +/// identity afterwards would double-record an output if the same address ever +/// appeared under two owners' maps. The vast majority of outputs miss every +/// owner (a regular receiving address is not a contact address) and are skipped +/// via the `None` arm of [`process_incoming_payment`]. A per-output match error +/// is logged and the scan continues — one unreadable sidecar entry must not +/// drop the rest of a block's payments. +/// +/// [`EventBridge`]: crate::wallet_backend::EventBridge +pub async fn detect_incoming_contact_payments( + app_context: &Arc<AppContext>, + outputs: &[crate::model::dashpay::DetectedIncomingOutput], +) -> Result<usize, TaskError> { + if outputs.is_empty() { + return Ok(0); + } + + let identities = app_context.load_local_qualified_identities()?; + if identities.is_empty() { + return Ok(0); + } + + let owner_ids: Vec<Identifier> = identities.iter().map(|i| i.identity.id()).collect(); + + let mut recorded = 0usize; + for output in outputs { + for owner_id in &owner_ids { + match process_incoming_payment( + app_context, + owner_id, + &output.txid, + output.vout, + &output.address, + output.amount_duffs, + ) + .await + { + // An output's address belongs to one owner only — record it + // and stop, so a cross-owner address collision can't + // double-record the same output. + Ok(Some(_info)) => { + recorded += 1; + break; + } + Ok(None) => {} + Err(e) => { + // A single owner/output failure must not abort the batch; + // log and move on so other identities still get their + // matching payments recorded. No financial PII at this + // level — only the non-sensitive error detail. + tracing::debug!( + error = ?e, + "Incoming DashPay payment detection skipped one output" + ); + } + } + } + } + + // Business event, no PII: counts only. + tracing::debug!( + candidate_outputs = outputs.len(), + recorded, + "Incoming DashPay contact-payment detection finished" + ); + + Ok(recorded) +} + /// Information about an incoming DashPay payment #[derive(Debug, Clone)] pub struct IncomingPaymentInfo { pub tx_id: String, + /// Output index within the transaction this payment was recorded under. + pub vout: u32, pub from_contact_id: Identifier, pub to_identity_id: Identifier, - pub address: Address, + /// Base58 receiving address the payment landed on. + pub address: String, pub amount_duffs: u64, pub address_index: u32, } diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index ecc5bd266..d99e62c5a 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -1,8 +1,11 @@ use super::encryption::decrypt_extended_public_key; -use super::hd_derivation::derive_payment_address; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::{ + PaymentDirection as StoredPaymentDirection, PaymentStatus as StoredPaymentStatus, +}; +use crate::model::dashpay_derivation::derive_payment_address; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::dashcore::Address; @@ -37,18 +40,22 @@ pub enum PaymentStatus { Failed(String), } -/// Get the next unused address index for a contact and increment it -/// Uses the database to track address indices per contact relationship +/// Get the next unused address index for a contact and increment it. +/// +/// Delegates to `WalletBackend::dashpay_increment_send_index`, which +/// serializes concurrent calls across the process via an internal mutex +/// so two parallel sends never receive the same index. async fn get_next_address_index( app_context: &Arc<AppContext>, identity_id: &Identifier, contact_id: &Identifier, ) -> Result<u32, String> { - // Get and increment the send index from database - app_context - .db - .get_and_increment_send_index(identity_id, contact_id) - .map_err(|e| format!("Failed to get address index from database: {}", e)) + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + backend + .dashpay_increment_send_index(identity_id, contact_id) + .map_err(|e| format!("Failed to allocate next DashPay address index: {}", e)) } /// Derive a payment address for a contact from their encrypted extended public key @@ -142,25 +149,20 @@ pub async fn derive_contact_payment_address( .find(|k| k.id() == sender_key_index) .ok_or_else(|| format!("Contact key with index {} not found", sender_key_index))?; - // Get our private key - let wallets: Vec<_> = our_identity.associated_wallets.values().cloned().collect(); + // Resolve our private key through the JIT chokepoint (no parked-seed read). let our_private_key = our_identity - .private_keys - .get_resolve( - &( - crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, - our_key.id(), - ), - &wallets, - our_identity.network, + .resolve_private_key_bytes( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + our_key.id(), ) + .await .map_err(|e| format!("Error resolving private key: {}", e))? .map(|(_, private_key)| private_key) .ok_or("Private key not found".to_string())?; // Generate ECDH shared key for decryption use super::encryption::generate_ecdh_shared_key; - let shared_key = generate_ecdh_shared_key(&our_private_key, contact_key) + let shared_key = generate_ecdh_shared_key(&our_private_key[..], contact_key) .map_err(|e| format!("Failed to generate shared key: {}", e))?; // Decrypt the extended public key @@ -185,7 +187,8 @@ pub async fn derive_contact_payment_address( network, depth: 0, parent_fingerprint: Fingerprint::default(), - child_number: ChildNumber::from_normal_idx(0).unwrap(), + child_number: ChildNumber::from_normal_idx(0) + .expect("invariant: BIP32 child index 0 is below 2^31"), public_key: pubkey, chain_code: ChainCode::from(chain_code), }; @@ -201,44 +204,22 @@ pub async fn derive_contact_payment_address( Ok((address, address_index)) } -/// Send a payment to a contact using the wallet's SPV capabilities -/// (Legacy function - preserved for reference) -#[allow(dead_code)] +/// Send a payment to a contact using the wallet's SPV capabilities. +/// +/// `amount_duffs` is the amount in duffs (1 DASH = 100,000,000 duffs); the UI +/// converts from user input at its edge, so no floating-point value crosses +/// this boundary. pub async fn send_payment_to_contact( app_context: &Arc<AppContext>, sdk: &Sdk, from_identity: QualifiedIdentity, to_contact_id: Identifier, - amount_dash: f64, - memo: Option<String>, -) -> Result<BackendTaskSuccessResult, TaskError> { - send_payment_to_contact_impl( - app_context, - sdk, - from_identity, - to_contact_id, - amount_dash, - memo, - ) - .await -} - -/// Send a payment to a contact using the wallet's SPV capabilities -/// This is the main implementation called from the DashPay task handler -pub async fn send_payment_to_contact_impl( - app_context: &Arc<AppContext>, - sdk: &Sdk, - from_identity: QualifiedIdentity, - to_contact_id: Identifier, - amount_dash: f64, + amount_duffs: u64, memo: Option<String>, ) -> Result<BackendTaskSuccessResult, TaskError> { use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - // Convert Dash to duffs (1 Dash = 100,000,000 duffs) - let amount_duffs = (amount_dash * 100_000_000.0).round() as u64; - // Get a wallet from the identity's associated wallets let wallet = from_identity .associated_wallets @@ -274,8 +255,6 @@ pub async fn send_payment_to_contact_impl( address: to_address.to_string(), amount_duffs, }], - subtract_fee_from_amount: false, - memo: memo.clone(), override_fee: None, }; @@ -327,43 +306,49 @@ pub async fn send_payment_to_contact_impl( payment.amount ); - // Save to database using the db interface - propagate errors - app_context.db.save_payment( - &txid, + // Mirror the outgoing payment through the WalletBackend adapter so the + // upstream `ManagedIdentity` records it and the timestamp sidecar + // reflects when DET broadcast it. + mirror_sent_payment_to_backend( + app_context, &from_identity.identity.id(), - &to_contact_id, - amount_duffs as i64, + &txid, + to_contact_id, + amount_duffs, memo.as_deref(), - "sent", - )?; - - // Convert to Dash for display - let amount_dash = amount_duffs as f64 / 100_000_000.0; + ) + .await; Ok(BackendTaskSuccessResult::DashPayPaymentSent( to_contact_id.to_string(Encoding::Base58), to_address.to_string(), - amount_dash, + amount_duffs, )) } -/// Load payment history from local database +/// Load payment history via the `WalletBackend` DashPay adapter — the +/// upstream-backed source of truth post-D4c. The local DET cache is no +/// longer consulted. pub async fn load_payment_history( app_context: &Arc<AppContext>, identity_id: &Identifier, contact_id: Option<&Identifier>, -) -> Result<Vec<PaymentRecord>, String> { - let stored_payments = app_context - .db - .load_payment_history(identity_id, 100) - .map_err(|e| format!("Failed to load payment history: {}", e))?; +) -> Result<Vec<PaymentRecord>, TaskError> { + let backend = app_context.wallet_backend()?; + let stored_payments = backend.dashpay_view().payments(identity_id).await; let mut records = Vec::new(); for sp in stored_payments { - let from_id = Identifier::from_bytes(&sp.from_identity_id) - .map_err(|e| format!("Invalid from_identity_id: {}", e))?; - let to_id = Identifier::from_bytes(&sp.to_identity_id) - .map_err(|e| format!("Invalid to_identity_id: {}", e))?; + let from_id = Identifier::from_bytes(&sp.from_identity_id).map_err(|_| { + TaskError::IdentifierParsingError { + input: hex::encode(&sp.from_identity_id), + } + })?; + let to_id = Identifier::from_bytes(&sp.to_identity_id).map_err(|_| { + TaskError::IdentifierParsingError { + input: hex::encode(&sp.to_identity_id), + } + })?; // If a contact filter is specified, skip non-matching records if let Some(filter_id) = contact_id @@ -373,11 +358,10 @@ pub async fn load_payment_history( continue; } - let status = match sp.status.as_str() { - "confirmed" => PaymentStatus::Confirmed(1), - "failed" => PaymentStatus::Failed("Transaction failed".to_string()), - "pending" => PaymentStatus::Pending, - _ => PaymentStatus::Broadcast, + let status = match sp.status { + StoredPaymentStatus::Confirmed => PaymentStatus::Confirmed(1), + StoredPaymentStatus::Failed => PaymentStatus::Failed("Transaction failed".to_string()), + StoredPaymentStatus::Pending => PaymentStatus::Pending, }; let amount = if sp.amount < 0 { @@ -420,30 +404,240 @@ pub async fn load_payment_history( Ok(records) } -/// Update payment status after broadcast or confirmation +/// Map a DET-local [`PaymentStatus`] onto the upstream +/// `platform_wallet` payment status the `PaymentEntry` carries. +/// +/// `Broadcast` collapses to upstream `Pending`: from Core's point of +/// view a broadcast-but-unconfirmed transaction is still pending. The +/// confirmation count in `Confirmed(_)` is not represented upstream — +/// any positive count means the transaction is on-chain. +fn det_status_to_upstream( + status: &PaymentStatus, +) -> platform_wallet::wallet::identity::types::dashpay::payment::PaymentStatus { + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentStatus as Upstream; + match status { + PaymentStatus::Pending | PaymentStatus::Broadcast => Upstream::Pending, + PaymentStatus::Confirmed(_) => Upstream::Confirmed, + PaymentStatus::Failed(_) => Upstream::Failed, + } +} + +/// Mirror a payment status transition into the upstream `ManagedIdentity` +/// and the k/v timestamp sidecar for `owner`. +/// +/// The authoritative on-chain state lives with Core/SPV; this is a local +/// mirror so [`load_payment_history`] reflects the new status without a +/// refetch. Upstream stores payments keyed by `tx_id` with last-write-wins +/// semantics, so the existing entry is read, its status field updated, and +/// the whole entry written back — preserving counterparty, amount, and memo. +/// +/// Best-effort: a missing wallet, an unknown payment, or a sidecar miss +/// is logged at `debug` and yields `Ok(())`. The caller has already +/// completed the authoritative action by the time this runs. pub async fn update_payment_status( - _app_context: &Arc<AppContext>, - payment_id: &str, + app_context: &Arc<AppContext>, + owner: &Identifier, + tx_id: &str, status: PaymentStatus, - tx_id: Option<String>, ) -> Result<(), String> { - // TODO: Update payment record in database - tracing::debug!( - "Would update payment {} status to {:?} with tx_id {:?}", - payment_id, - status, - tx_id - ); + use platform_wallet::wallet::identity::types::dashpay::payment::{ + PaymentDirection, PaymentEntry, + }; + + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + + // Read the existing entry so the rewrite preserves counterparty, + // amount, and memo — upstream replaces the whole entry on record. + let existing = backend + .dashpay_view() + .payments(owner) + .await + .into_iter() + .find(|p| p.tx_id == tx_id); + + let Some(existing) = existing else { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + "DashPay update_payment_status: no matching payment to update; skipping" + ); + return Ok(()); + }; + + let counterparty_bytes = if existing.payment_type == StoredPaymentDirection::Sent { + existing.to_identity_id + } else { + existing.from_identity_id + }; + let counterparty = Identifier::from_bytes(&counterparty_bytes) + .map_err(|e| format!("Invalid counterparty identity in payment record: {}", e))?; + + let direction = if existing.payment_type == StoredPaymentDirection::Sent { + PaymentDirection::Sent + } else { + PaymentDirection::Received + }; + + let entry = PaymentEntry { + counterparty_id: counterparty, + amount_duffs: existing.amount.max(0) as u64, + memo: existing.memo, + direction, + status: det_status_to_upstream(&status), + }; + + if let Err(e) = backend + .dashpay_record_payment(owner, tx_id.to_string(), entry) + .await + { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay update_payment_status mirror to WalletBackend failed" + ); + return Ok(()); + } + + // A confirmation stamps `confirmed_at`; other transitions preserve the + // existing creation stamp without touching confirmation. + if matches!(status, PaymentStatus::Confirmed(_)) { + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + let created_at = if existing.created_at > 0 { + existing.created_at + } else { + now_ms + }; + if let Err(e) = backend.dashpay_set_payment_timestamps(tx_id, created_at, Some(now_ms)) { + tracing::debug!( + tx_id = %tx_id, + error = ?e, + "DashPay update_payment_status confirmation timestamp write failed" + ); + } + } + Ok(()) } +/// Mirror an outgoing payment into the upstream `ManagedIdentity` and the +/// k/v timestamp sidecar so [`DashpayView::payments`] picks it up. +/// +/// Best-effort: the platform-side write already succeeded by the time we +/// get here, and a local mirror miss does not break correctness. +pub(super) async fn mirror_sent_payment_to_backend( + app_context: &Arc<AppContext>, + owner: &Identifier, + tx_id: &str, + counterparty: Identifier, + amount_duffs: u64, + memo: Option<&str>, +) { + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentEntry; + + let Ok(backend) = app_context.wallet_backend() else { + return; + }; + + let entry = PaymentEntry::new_sent(counterparty, amount_duffs, memo.map(str::to_string)); + if let Err(e) = backend + .dashpay_record_payment(owner, tx_id.to_string(), entry) + .await + { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay sent-payment mirror to WalletBackend failed" + ); + return; + } + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + if let Err(e) = backend.dashpay_set_payment_timestamps(tx_id, now_ms, None) { + tracing::debug!( + tx_id = %tx_id, + error = ?e, + "DashPay sent-payment timestamp sidecar write failed" + ); + } +} + +/// Mirror an incoming payment into the upstream `ManagedIdentity` and the +/// k/v timestamp sidecar. Incoming payments are recorded as +/// [`PaymentStatus::Confirmed`] because SPV only delivers them after +/// the transaction is observed on-chain. +/// +/// The upstream payment map keys by an opaque `String`, so the record is keyed +/// by `(tx_id, vout)` via [`payment_storage_key`] — a transaction paying two +/// different contact outputs records both, instead of the second overwriting +/// the first. The same composite key keys the timestamp sidecar, keeping each +/// output's timestamps independent. +pub(super) async fn mirror_incoming_payment_to_backend( + app_context: &Arc<AppContext>, + owner: &Identifier, + tx_id: &str, + vout: u32, + counterparty: Identifier, + amount_duffs: u64, +) { + use crate::model::dashpay::payment_storage_key; + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentEntry; + + let Ok(backend) = app_context.wallet_backend() else { + return; + }; + + let storage_key = payment_storage_key(tx_id, vout); + let entry = PaymentEntry::new_received(counterparty, amount_duffs, None); + if let Err(e) = backend + .dashpay_record_payment(owner, storage_key.clone(), entry) + .await + { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay incoming-payment mirror to WalletBackend failed" + ); + return; + } + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + // Incoming arrives confirmed — same ts for `created_at` and `confirmed_at`. + if let Err(e) = backend.dashpay_set_payment_timestamps(&storage_key, now_ms, Some(now_ms)) { + tracing::debug!( + tx_id = %tx_id, + error = ?e, + "DashPay incoming-payment timestamp sidecar write failed" + ); + } +} + /// Check if addresses have been used (for gap limit calculation) +/// +/// BLOCKED: returns all-unused. An upstream usage reader exists at +/// platform rev `35e4a2f` +/// (`PlatformWalletManager::account_address_pools_blocking` → +/// `AccountAddressInfoSnapshot::is_used`, sourced from the SPV-tracked +/// `AddressInfo.used`), but it is keyed by `(wallet_id, AccountType)`, +/// not by an arbitrary address. This function receives a context-free +/// address list, so it cannot route a lookup. More fundamentally, the +/// only DashPay addresses with derivation context are the contact-SEND +/// addresses derived from the contact's xpub in +/// [`derive_contact_payment_address`]; those never live in any of our +/// managed address pools (we only register `DashpayReceivingFunds` +/// accounts for incoming payments), so even a full per-account scan +/// would correctly report them absent and yield all-unused. Returning a +/// fabricated usage flag would corrupt gap-limit math, which is +/// address-derivation-adjacent and risky — hence the honest all-unused +/// stub pending a properly-scoped reader. pub async fn check_address_usage( _app_context: &Arc<AppContext>, addresses: Vec<Address>, ) -> Result<Vec<bool>, String> { - // TODO: This would need to query Core or check transaction history - // For now, return all as unused Ok(vec![false; addresses.len()]) } @@ -597,6 +791,34 @@ mod tests { assert_eq!(payment.tx_id, cloned.tx_id); } + #[test] + fn test_det_status_maps_to_upstream() { + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentStatus as Upstream; + + assert_eq!( + det_status_to_upstream(&PaymentStatus::Pending), + Upstream::Pending + ); + // Broadcast-but-unconfirmed is still pending from Core's view. + assert_eq!( + det_status_to_upstream(&PaymentStatus::Broadcast), + Upstream::Pending + ); + // Any positive confirmation count means on-chain. + assert_eq!( + det_status_to_upstream(&PaymentStatus::Confirmed(1)), + Upstream::Confirmed + ); + assert_eq!( + det_status_to_upstream(&PaymentStatus::Confirmed(99)), + Upstream::Confirmed + ); + assert_eq!( + det_status_to_upstream(&PaymentStatus::Failed("dropped".into())), + Upstream::Failed + ); + } + #[test] fn test_payment_status_debug_format() { // Test Debug trait implementation diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index 277a3626b..251ec76b4 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -60,31 +60,19 @@ pub async fn load_profile( .and_then(|v| v.as_text()) .unwrap_or_default(); - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror to upstream so DashpayView::profile observes the loaded state. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - if display_name.is_empty() { - None - } else { - Some(display_name) - }, - if bio.is_empty() { None } else { Some(bio) }, - if avatar_url.is_empty() { - None - } else { - Some(avatar_url) - }, - None, - ) { - tracing::error!("Failed to cache loaded profile in database: {}", e); - } else { - tracing::info!( - "Loaded profile cached in database for identity {}", - identity_id - ); - } + Some(BackendProfileFields { + display_name: non_empty(display_name), + bio: non_empty(bio), + avatar_url: non_empty(avatar_url), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfile(Some(( display_name.to_string(), @@ -92,20 +80,81 @@ pub async fn load_profile( avatar_url.to_string(), )))) } else { - // No profile found - cache this fact to avoid repeated network queries - let network_str = app_context.network.to_string(); - if let Err(e) = - app_context - .db - .save_dashpay_profile(&identity_id, &network_str, None, None, None, None) - { - tracing::error!("Failed to cache 'no profile' state in database: {}", e); - } + // No profile found — clear any stale upstream entry for this owner. + mirror_profile_to_backend(app_context, &identity_id, None).await; Ok(BackendTaskSuccessResult::DashPayProfile(None)) } } +/// Profile fields suitable for [`DashPayProfile`] construction. Used by the +/// thin mirror that pushes profile state down to upstream `WalletBackend`. +struct BackendProfileFields { + display_name: Option<String>, + bio: Option<String>, + avatar_url: Option<String>, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, +} + +fn non_empty(s: &str) -> Option<String> { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +/// Push the profile through the `WalletBackend` adapter, updating the +/// upstream `ManagedIdentity` and refreshing the DET-local timestamp +/// sidecar so [`DashpayView`] reports the current state. +/// +/// Logs at `debug!` on failure rather than propagating — the platform +/// document write already succeeded, and a local mirror miss does not +/// break correctness (next refresh will re-fetch from platform). +async fn mirror_profile_to_backend( + app_context: &Arc<AppContext>, + owner: &Identifier, + fields: Option<BackendProfileFields>, +) { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use platform_wallet::wallet::identity::types::dashpay::profile::DashPayProfile; + + let Ok(backend) = app_context.wallet_backend() else { + // Pre-init / headless: nothing to mirror. View paths fall back to + // the upstream-empty default until the backend is wired. + return; + }; + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + + let profile = fields.map(|f| DashPayProfile { + display_name: f.display_name, + bio: f.bio, + avatar_url: f.avatar_url, + avatar_hash: f.avatar_hash, + avatar_fingerprint: f.avatar_fingerprint, + public_message: None, + }); + + if let Err(e) = backend.dashpay_set_profile(owner, profile).await { + tracing::debug!( + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay profile mirror to WalletBackend failed; view will re-fetch on next refresh" + ); + return; + } + + if let Err(e) = backend.dashpay_set_timestamps(owner, now_ms, now_ms) { + tracing::debug!( + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay profile timestamp sidecar write failed; created_at/updated_at will read 0" + ); + } +} + pub async fn update_profile( app_context: &Arc<AppContext>, sdk: &Sdk, @@ -243,20 +292,19 @@ pub async fn update_profile( } } - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror updated profile into upstream so DashpayView sees it. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - display_name_for_db.as_deref(), - bio_for_db.as_deref(), - avatar_url_for_db.as_deref(), - None, - ) { - tracing::error!("Failed to cache updated profile in database: {}", e); - } else { - tracing::info!("Profile cached in database for identity {}", identity_id); - } + Some(BackendProfileFields { + display_name: display_name_for_db.clone(), + bio: bio_for_db.clone(), + avatar_url: avatar_url_for_db.clone(), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfileUpdated( identity.identity.id(), @@ -319,23 +367,19 @@ pub async fn update_profile( } } - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror new profile into upstream so DashpayView sees it. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - display_name_for_db.as_deref(), - bio_for_db.as_deref(), - avatar_url_for_db.as_deref(), - None, - ) { - tracing::error!("Failed to cache new profile in database: {}", e); - } else { - tracing::info!( - "New profile cached in database for identity {}", - identity_id - ); - } + Some(BackendProfileFields { + display_name: display_name_for_db.clone(), + bio: bio_for_db.clone(), + avatar_url: avatar_url_for_db.clone(), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfileUpdated( identity.identity.id(), @@ -343,25 +387,6 @@ pub async fn update_profile( } } -pub async fn send_payment( - app_context: &Arc<AppContext>, - sdk: &Sdk, - from_identity: QualifiedIdentity, - to_contact_id: Identifier, - amount_dash: f64, - memo: Option<String>, -) -> Result<BackendTaskSuccessResult, TaskError> { - super::payments::send_payment_to_contact( - app_context, - sdk, - from_identity, - to_contact_id, - amount_dash, - memo, - ) - .await -} - pub async fn load_payment_history( app_context: &Arc<AppContext>, _sdk: &Sdk, @@ -374,8 +399,7 @@ pub async fn load_payment_history( &identity.identity.id(), contact_id.as_ref(), ) - .await - .map_err(|e| DashPayError::Internal { message: e })?; + .await?; // Format the results if history.is_empty() { diff --git a/src/backend_task/dashpay/validation.rs b/src/backend_task/dashpay/validation.rs index 354f06aa0..3844ce929 100644 --- a/src/backend_task/dashpay/validation.rs +++ b/src/backend_task/dashpay/validation.rs @@ -265,36 +265,25 @@ pub fn validate_profile_field_sizes( avatar_hash: Option<&[u8]>, avatar_fingerprint: Option<&[u8]>, ) -> ContactRequestValidation { - let mut validation = ContactRequestValidation::new(); - - // Validate displayName (0-25 characters) - if let Some(name) = display_name.filter(|name| name.chars().count() > 25) { - validation.add_error(format!( - "displayName must be 0-25 characters, got {}", - name.chars().count() - )); - } + use crate::model::dashpay::{ProfileFieldError, validate_profile_fields}; - // Validate publicMessage (0-140 characters) - if let Some(msg) = public_message.filter(|msg| msg.chars().count() > 140) { - validation.add_error(format!( - "publicMessage must be 0-140 characters, got {}", - msg.chars().count() - )); - } - - // Validate avatarUrl (0-2048 characters) - if let Some(url) = avatar_url.filter(|url| url.chars().count() > 2048) { - validation.add_error(format!( - "avatarUrl must be 0-2048 characters, got {}", - url.chars().count() - )); - } + let mut validation = ContactRequestValidation::new(); - if avatar_url.is_some_and(|url| { - !url.is_empty() && !url.starts_with("https://") && !url.starts_with("http://") - }) { - validation.add_warning("avatarUrl should use HTTPS protocol".to_string()); + // Character-count and scheme checks for the three text fields come from the + // shared model validator, the single source of truth also used by the UI + // editors. The scheme violation is downgraded to a warning here (a non-HTTPS + // avatar URL is discouraged, not fatal), matching prior backend behaviour. + for error in validate_profile_fields( + display_name.unwrap_or_default(), + public_message.unwrap_or_default(), + avatar_url.unwrap_or_default(), + ) { + match error { + ProfileFieldError::AvatarUrlInvalidScheme => { + validation.add_warning("avatarUrl should use HTTPS protocol".to_string()); + } + other => validation.add_error(other.message()), + } } // Validate avatarHash (exactly 32 bytes if present) diff --git a/src/backend_task/document.rs b/src/backend_task/document.rs index a7c99baf2..efe551e2d 100644 --- a/src/backend_task/document.rs +++ b/src/backend_task/document.rs @@ -1,8 +1,8 @@ +use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::document_type::DocumentType; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -10,6 +10,7 @@ use dash_sdk::dpp::document::{DocumentV0Getters, DocumentV0Setters}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; +use dash_sdk::drive::query::SelectProjection; use dash_sdk::platform::documents::transitions::DocumentCreateResult; use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::documents::transitions::DocumentDeleteResult; @@ -31,70 +32,97 @@ use std::sync::Arc; #[derive(Debug, Clone, PartialEq)] pub enum DocumentTask { - BroadcastDocument( - Document, - Option<TokenPaymentInfo>, - [u8; 32], - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - ), - DeleteDocument( - Identifier, // Document ID - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - Option<TokenPaymentInfo>, - ), - ReplaceDocument( - Document, - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - Option<TokenPaymentInfo>, - ), - TransferDocument( - Identifier, // Document ID - Identifier, // New owner ID - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - Option<TokenPaymentInfo>, - ), - PurchaseDocument( - Credits, // Price in credits - Identifier, // Document ID - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - Option<TokenPaymentInfo>, - ), - SetDocumentPrice( - Credits, // Price in credits - Identifier, // Document ID - DocumentType, - Arc<DataContract>, - QualifiedIdentity, - IdentityPublicKey, - Option<TokenPaymentInfo>, - ), + BroadcastDocument { + document: Document, + token_payment_info: Option<TokenPaymentInfo>, + entropy: [u8; 32], + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + }, + DeleteDocument { + document_id: Identifier, + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + token_payment_info: Option<TokenPaymentInfo>, + }, + ReplaceDocument { + document: Document, + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + token_payment_info: Option<TokenPaymentInfo>, + }, + TransferDocument { + document_id: Identifier, + new_owner_id: Identifier, + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + token_payment_info: Option<TokenPaymentInfo>, + }, + PurchaseDocument { + price: Credits, + document_id: Identifier, + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + token_payment_info: Option<TokenPaymentInfo>, + }, + SetDocumentPrice { + price: Credits, + document_id: Identifier, + document_type: DocumentType, + data_contract: Arc<DataContract>, + qualified_identity: QualifiedIdentity, + identity_key: IdentityPublicKey, + token_payment_info: Option<TokenPaymentInfo>, + }, FetchDocuments(DocumentQuery), FetchDocumentsPage(DocumentQuery), } impl AppContext { + /// Fetches a single document by id and bumps its revision, preparing it for + /// a replace/transfer/purchase/set-price mutation. + async fn fetch_document_for_mutation( + &self, + sdk: &Sdk, + data_contract: Arc<DataContract>, + document_type: &DocumentType, + document_id: Identifier, + ) -> Result<Document, TaskError> { + let document_query = DocumentQuery { + select: SelectProjection::documents(), + data_contract, + document_type_name: document_type.name().to_string(), + where_clauses: vec![], + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses: vec![], + limit: 1, + start: None, + }; + let query_with_id = DocumentQuery::with_document_id(document_query, &document_id); + let mut document = Document::fetch(sdk, query_with_id) + .await + .map_err(TaskError::from)? + .ok_or(TaskError::DocumentNotFound)?; + document.bump_revision(); + Ok(document) + } + pub async fn run_document_task( &self, task: DocumentTask, sdk: &Sdk, - ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { - use crate::backend_task::error::TaskError; - + ) -> Result<BackendTaskSuccessResult, TaskError> { match task { DocumentTask::FetchDocuments(document_query) => { Document::fetch_many(sdk, document_query) @@ -139,18 +167,18 @@ impl AppContext { next_cursor, )) } - DocumentTask::BroadcastDocument( + DocumentTask::BroadcastDocument { document, token_payment_info, entropy, - doc_type, + document_type, data_contract, qualified_identity, identity_key, - ) => { + } => { let mut builder = DocumentCreateTransitionBuilder::new( data_contract, - doc_type.name().to_string(), + document_type.name().to_string(), document, entropy, ); @@ -178,14 +206,14 @@ impl AppContext { } } } - DocumentTask::DeleteDocument( + DocumentTask::DeleteDocument { document_id, document_type, data_contract, qualified_identity, identity_key, token_payment_info, - ) => { + } => { let mut builder = DocumentDeleteTransitionBuilder::new( data_contract, document_type.name().to_string(), @@ -210,22 +238,22 @@ impl AppContext { })?; // Handle the result - DocumentDeleteResult contains the deleted document ID - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); match result { DocumentDeleteResult::Deleted(deleted_id) => Ok( BackendTaskSuccessResult::DeletedDocument(deleted_id, fee_result), ), } } - DocumentTask::ReplaceDocument( + DocumentTask::ReplaceDocument { document, document_type, data_contract, qualified_identity, identity_key, token_payment_info, - ) => { + } => { let mut builder = DocumentReplaceTransitionBuilder::new( data_contract, document_type.name().to_string(), @@ -249,15 +277,15 @@ impl AppContext { })?; // Handle the result - DocumentReplaceResult contains the replaced document - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); match result { DocumentReplaceResult::Document(document) => Ok( BackendTaskSuccessResult::ReplacedDocument(document.id(), fee_result), ), } } - DocumentTask::TransferDocument( + DocumentTask::TransferDocument { document_id, new_owner_id, document_type, @@ -265,22 +293,15 @@ impl AppContext { qualified_identity, identity_key, token_payment_info, - ) => { - // First fetch the document to transfer - let document_query = DocumentQuery { - data_contract: data_contract.clone(), - document_type_name: document_type.name().to_string(), - where_clauses: vec![], - order_by_clauses: vec![], - limit: 1, - start: None, - }; - let query_with_id = DocumentQuery::with_document_id(document_query, &document_id); - let mut document = Document::fetch(sdk, query_with_id) - .await - .map_err(TaskError::from)? - .ok_or_else(|| TaskError::DocumentNotFound)?; - document.bump_revision(); + } => { + let document = self + .fetch_document_for_mutation( + sdk, + data_contract.clone(), + &document_type, + document_id, + ) + .await?; let mut builder = DocumentTransferTransitionBuilder::new( data_contract, @@ -306,15 +327,15 @@ impl AppContext { })?; // Handle the result - DocumentTransferResult contains the transferred document - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); match result { DocumentTransferResult::Document(document) => Ok( BackendTaskSuccessResult::TransferredDocument(document.id(), fee_result), ), } } - DocumentTask::PurchaseDocument( + DocumentTask::PurchaseDocument { price, document_id, document_type, @@ -322,22 +343,15 @@ impl AppContext { qualified_identity, identity_key, token_payment_info, - ) => { - // First fetch the document to purchase - let document_query = DocumentQuery { - data_contract: data_contract.clone(), - document_type_name: document_type.name().to_string(), - where_clauses: vec![], - order_by_clauses: vec![], - limit: 1, - start: None, - }; - let query_with_id = DocumentQuery::with_document_id(document_query, &document_id); - let mut document = Document::fetch(sdk, query_with_id) - .await - .map_err(TaskError::from)? - .ok_or_else(|| TaskError::DocumentNotFound)?; - document.bump_revision(); + } => { + let document = self + .fetch_document_for_mutation( + sdk, + data_contract.clone(), + &document_type, + document_id, + ) + .await?; let mut builder = DocumentPurchaseTransitionBuilder::new( data_contract, @@ -364,15 +378,15 @@ impl AppContext { })?; // Handle the result - DocumentPurchaseResult contains the purchased document - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); match result { DocumentPurchaseResult::Document(document) => Ok( BackendTaskSuccessResult::PurchasedDocument(document.id(), fee_result), ), } } - DocumentTask::SetDocumentPrice( + DocumentTask::SetDocumentPrice { price, document_id, document_type, @@ -380,22 +394,15 @@ impl AppContext { qualified_identity, identity_key, token_payment_info, - ) => { - // First fetch the document to set price on - let document_query = DocumentQuery { - data_contract: data_contract.clone(), - document_type_name: document_type.name().to_string(), - where_clauses: vec![], - order_by_clauses: vec![], - limit: 1, - start: None, - }; - let query_with_id = DocumentQuery::with_document_id(document_query, &document_id); - let mut document = Document::fetch(sdk, query_with_id) - .await - .map_err(TaskError::from)? - .ok_or_else(|| TaskError::DocumentNotFound)?; - document.bump_revision(); + } => { + let document = self + .fetch_document_for_mutation( + sdk, + data_contract.clone(), + &document_type, + document_id, + ) + .await?; let mut builder = DocumentSetPriceTransitionBuilder::new( data_contract, @@ -421,8 +428,8 @@ impl AppContext { })?; // Handle the result - DocumentSetPriceResult contains the document with updated price - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); match result { DocumentSetPriceResult::Document(document) => Ok( BackendTaskSuccessResult::SetDocumentPrice(document.id(), fee_result), diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 0a336e11a..f9c0f6936 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -16,6 +16,7 @@ use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; use thiserror::Error; /// Dash Core RPC error code: wallet file not specified (multi-wallet node). @@ -24,9 +25,611 @@ const RPC_WALLET_NOT_SPECIFIED: i32 = -19; /// App-level error envelope for backend tasks. #[derive(Debug, Error)] pub enum TaskError { - /// SPV subsystem errors. - #[error("{}", spv_user_message(.0))] - Spv(#[from] crate::spv::SpvError), + /// The wallet backend has not finished starting up yet. The lazy-init gate + /// in [`AppContext::wallet_backend`](crate::context::AppContext::wallet_backend) + /// returns this while the backend is still being built; every wallet and + /// identity task degrades through it until the backend is ready. + #[error("Your wallet is still starting up. Please wait a moment and try again.")] + WalletBackendNotYetWired, + + /// A wallet operation was requested before its wallet had finished loading + /// into the wallet backend. Distinct from + /// [`Self::WalletBackendNotYetWired`]: the backend is ready, but this + /// particular wallet is not yet registered with it (still loading, or + /// skipped during load). User-actionable — waiting and retrying resolves it. + #[error("This wallet is still loading. Please wait a moment and try again.")] + WalletNotLoaded, + + /// An internal wallet-state inconsistency: the wallet backend's records + /// disagree with each other in a way that should never happen (a wallet + /// present in one place but missing from another, or an account that just + /// could not be read back after being created). The technical specifics + /// live in `Debug` and the logs, never in the message. + #[error( + "Your wallet data could not be read correctly. Please restart the application and try again." + )] + WalletStateInconsistent, + + /// A short-lived signable wallet could not be built from the HD seed. DET + /// needs it to derive the hardened account xpubs the watch-only live + /// wallet cannot derive itself, so a failure here means the seed material + /// could not be turned into a usable wallet. The technical cause lives in + /// `Debug` and the logs. + #[error( + "Your wallet data could not be read correctly. Please restart the application and try again." + )] + SeedWalletBuildFailed { + #[source] + source: dash_sdk::dpp::key_wallet::Error, + }, + + /// Provisioning an identity-funding account (the registration account or a + /// per-identity top-up account) failed while deriving its key or + /// registering it with the wallet. The technical cause lives in `Debug` + /// and the logs. + #[error( + "Your wallet data could not be read correctly. Please restart the application and try again." + )] + IdentityFundingAccountProvisionFailed { + #[source] + source: dash_sdk::dpp::key_wallet::Error, + }, + + /// Single-key wallets are not supported in this version. Their data is + /// preserved; HD (recovery-phrase) wallets remain fully functional. + #[error( + "Single-key wallets are not supported in this version. \ + Your single-key wallet data is preserved and will work again in a \ + future update. To manage funds now, use an HD (recovery-phrase) wallet." + )] + SingleKeyWalletsUnsupported, + + /// A payment requested a manually set network fee, which the current + /// wallet engine cannot honor. Rejected explicitly rather than silently + /// ignored, so the fee actually paid always matches what the user set. + #[error( + "Setting the network fee manually is not available in this version. \ + Send the payment without a manual fee and the wallet will add the \ + network fee automatically." + )] + WalletPaymentOptionUnsupported, + + /// A wallet operation failed inside the upstream wallet runtime. + #[error("The wallet service could not complete this operation. Please retry in a moment.")] + WalletBackend { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// The wallet could not assemble and sign a payment transaction, for a + /// reason other than insufficient balance or too many inputs (those get + /// their own variants below — [`Self::InsufficientFunds`] and + /// [`Self::WalletPaymentTooManyInputs`] — so this message never has to + /// give balance advice for a non-balance failure). + #[error("The payment could not be prepared. Please review the amount and recipient, then try again.")] + WalletPaymentBuildFailed { + #[source] + source: Box< + dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::BuilderError, + >, + }, + + /// The payment would need more individual unspent outputs than fit in a + /// single standard transaction. + #[error( + "This payment needs to combine {count} small amounts from your wallet into one transaction, which is more than the {max} the network allows at once. Try sending a smaller amount, or consolidate your funds first by sending part of your balance to yourself." + )] + WalletPaymentTooManyInputs { count: usize, max: usize }, + + /// The network rejected an identity-registration submission. Covers + /// upstream SDK rejections (consensus errors, invalid IS-lock, key + /// conflict, version mismatch, etc.) and asset-lock broadcast rejections + /// surfaced during `register_identity_with_funding`. + #[error( + "Identity registration was rejected by the network. Your funds are safe and saved as a funding lock. To finish, start a new identity and choose to fund it from your existing asset lock." + )] + IdentityCreateRejected { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// The network rejected a top-up submission for a specific identity. + /// Covers upstream SDK rejections and asset-lock broadcast rejections + /// surfaced during `top_up_identity_with_funding`. + #[error( + "Top-up was rejected by the network for identity {identity_id}. Please try again in a moment." + )] + IdentityTopUpRejected { + identity_id: dash_sdk::platform::Identifier, + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// An identity op was attempted against an identity the wallet has not yet + /// registered in its active set. Retrying the same op cannot help — the + /// identity must be reloaded into the wallet first. + #[error( + "This identity is not ready to use in this wallet yet. Open the wallet's identities list, reload identity {identity_id}, then try again." + )] + IdentityNotManaged { + identity_id: dash_sdk::platform::Identifier, + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// A top-up specified an HD index that does not match the identity's + /// recorded wallet position. Using a wrong index would derive the wrong + /// funding account, so the op is stopped before any funds move. + /// + /// `requested_index` / `wallet_index` are numeric diagnostics for logs and + /// the `Debug` view only — kept out of the user-facing `Display` copy. + #[error( + "This identity's wallet position does not match what was requested, so the top-up was stopped to keep your funds safe. Open the wallet's identities list, reload identity {identity_id}, then try again." + )] + IdentityIndexMismatch { + identity_id: dash_sdk::platform::Identifier, + requested_index: u32, + wallet_index: u32, + }, + + /// A wallet-funded top-up targeted an identity this wallet does not own + /// (it has no HD funding slot here). Funding it from this wallet would + /// derive an unrelated asset-lock account, so the op is stopped before any + /// funds move. + #[error( + "This identity is not part of this wallet, so it cannot be topped up from it. Open the wallet that owns identity {identity_id}, then try again." + )] + IdentityNotWalletOwned { + identity_id: dash_sdk::platform::Identifier, + }, + + /// The asset-lock proof finalization (InstantSend → ChainLock fallback) + /// timed out without producing a usable proof for Platform. + #[error( + "The funding lock could not be confirmed in time. Your funds are safe and saved as a funding lock. Wait a minute, then start a new identity and choose to fund it from your existing asset lock." + )] + AssetLockFinalityTimeout { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// The wallet storage backend could not read or write wallet data. + #[error( + "Could not access wallet data. Check available disk space and restart the application." + )] + WalletStorage { + #[source] + source: platform_wallet_storage::WalletStorageError, + }, + + /// The on-disk wallet database was written by a newer build of the app + /// than the one running, so this build cannot open it. Distinct from + /// [`Self::WalletStorage`] because restarting or freeing disk space never + /// resolves a forward-version database — only updating the app does. + /// + /// `found` / `max_supported` are numeric diagnostics for logs and the + /// `Debug` view only; they are deliberately kept out of the user-facing + /// `Display` copy (no version-number jargon for the Everyday User). + #[error( + "Your wallet data was created by a newer version of this app. Update to the latest version to open it." + )] + WalletDataTooNew { found: i64, max_supported: i64 }, + + /// The on-disk wallet database was written under an incompatible storage + /// layout — the schema migration history diverges from what this build + /// applies, so the database cannot be opened. Distinct from + /// [`Self::WalletStorage`] because freeing disk space or restarting never + /// resolves an incompatible layout, and distinct from + /// [`Self::WalletDataTooNew`] because the data is not merely from a newer + /// build — its structure cannot be reconciled at all. + /// + /// The migration diagnostic is preserved through the `#[source]` chain for + /// logs and the `Debug` view; it is kept out of the user-facing `Display` + /// copy. On the active development branch the storage layout changed in an + /// incompatible way, so the practical action is to remove the local wallet + /// data and let the app recreate it (see `docs/kv-keys.md`). + #[error( + "Your wallet data is not compatible with this version of the app and cannot be opened. Remove the local wallet data so the app can create it fresh, then restart." + )] + WalletDataIncompatible { + #[source] + source: platform_wallet_storage::WalletStorageError, + }, + + /// The encrypted secret store could not be opened, read, or written. + /// Imported single-key material lives here; HD-wallet seeds are + /// surfaced through [`Self::WalletSeedStorage`] for a clearer + /// banner copy. + /// + /// The copy avoids guessing a single cause (the failure can be a held + /// file lock, a passphrase mismatch, or corrupt data — not disk space): + /// it points at the one self-service fix that resolves the common + /// "another copy is already running" lock case. A legacy passphrase vault + /// (`SecretStoreError::WrongPassphrase`) does not use this generic copy: on + /// the GUI boot path it is intercepted and routed to a passphrase prompt + /// (see [`Self::is_secret_store_wrong_passphrase`]), and the headless/CLI + /// context init surfaces it with its own passphrase-specific message — so + /// this text is shown only for the remaining (non-passphrase) failures. + #[error( + "Your saved keys could not be opened. Make sure no other copy of Dash Evo Tool is running, then open the app again." + )] + SecretStore { + #[source] + source: Box<platform_wallet_storage::secrets::SecretStoreError>, + }, + + /// The encrypted seed vault could not be read or written. Distinct + /// from [`Self::SecretStore`] so the banner can speak about "your + /// wallet" rather than imported keys. Backed by the same upstream + /// `SecretStore` file vault. + #[error( + "Could not access your wallet. Check available disk space and restart the application." + )] + WalletSeedStorage { + #[source] + source: Box<platform_wallet_storage::secrets::SecretStoreError>, + }, + + /// The secret seam (the single chokepoint that stores/loads raw wallet + /// secret bytes) could not write to or read from the upstream vault. The + /// low-level wrap shared by all three secret classes; class views may + /// surface their own flavored variants for banner copy. + #[error( + "Could not access your wallet's secure storage. Check available disk space and restart the application." + )] + SecretSeam { + #[source] + source: Box<platform_wallet_storage::secrets::SecretStoreError>, + }, + + /// A wallet secret's storage label was found in neither its raw form nor + /// any legacy form — the secret is gone. A loud, typed funds-safety signal + /// (never a silent miss that would drop a key). The user must restore the + /// wallet from its recovery phrase or re-import the key. + #[error( + "This wallet's secret could not be found on this device. Restore the wallet from its recovery phrase to keep using it." + )] + SecretSeamMissing, + + /// An identity private key could not be stored in or read from the secret + /// vault through the seam. Distinct from [`Self::SecretSeam`] so the banner + /// can speak about identity keys specifically. + #[error( + "Could not access this identity's signing key. Check available disk space and restart the application." + )] + IdentityKeyVault { + #[source] + source: Box<platform_wallet_storage::secrets::SecretStoreError>, + }, + + /// An identity private key was expected in the vault but is absent — the + /// stored identity references a key whose bytes are gone. Loud and typed + /// so a sign attempt fails observably rather than silently. + #[error( + "This identity's signing key could not be found on this device. Re-import the identity to keep signing with it." + )] + IdentityKeyMissing, + + /// An identity private key was found in the vault but its bytes are not a + /// usable signing key (vault corruption or a truncated write). Distinct + /// from [`Self::IdentityKeyMissing`] (genuinely absent) so the user gets + /// the right next step. Fieldless: the callsite logs the typed detail; no + /// secret or raw error string is stored here. + #[error( + "This identity's signing key is stored but unreadable on this device. Re-import the identity to refresh it." + )] + IdentityKeyMalformed, + + /// The password supplied for a password-protected identity key does not + /// unseal it. The just-in-time chokepoint catches this inside its re-ask + /// loop and re-prompts; it surfaces to the UI when removing protection with + /// the wrong password. No upstream error is preserved — the authenticated- + /// decryption failure carries no useful diagnostic and leaks no oracle. + #[error("That password is not correct. Try again.")] + IdentityKeyPassphraseIncorrect, + + /// A keyless (unprotected) write was refused over a password-protected + /// identity key, which would have silently stripped its protection. Raised + /// by the protection-aware store guard so adding or changing a key on a + /// protected identity cannot quietly downgrade it. Fieldless: the callsite + /// logs the typed detail; no secret or raw error string is stored here. + #[error( + "This identity's keys are password-protected, so this change cannot be saved without that password. Remove the password protection from this identity, make your change, then add the protection again." + )] + IdentityKeyProtectionDowngrade, + + /// A new key was accepted onto the identity ON-CHAIN, but sealing it into + /// the local secret vault afterward failed, so it is not yet saved on this + /// device. The on-chain broadcast and the local persist cannot be atomic, so + /// this is the unavoidable post-broadcast gap — surfaced as a loud, typed, + /// actionable error rather than a silent loss. It never falls back to a + /// keyless write (the protected invariant holds). The upstream seal + /// failure is preserved through `#[source]` for logs and the details panel. + #[error( + "The new key was added to your identity on the network, but it could not be saved on this device. Your identity and its existing keys are safe. Check available disk space, then try adding a key again." + )] + IdentityKeyAddedButNotSaved { + #[source] + source: Box<TaskError>, + }, + + /// Fail-closed guard at the opt-in protect boundary: the task found + /// keys still resident as plaintext on disk after the eager load-path vault + /// migration, so the identity cannot be reported as fully protected. The + /// migration only leaves resident plaintext when its vault write failed or + /// was skipped; proceeding would let the seal step silently skip those keys + /// and emit a false-protected result. Refusing here keeps the user from + /// believing the identity is sealed when it is not. Fieldless: the load-path + /// migration outcome is logged where it happens; no secret or raw error + /// string is stored here. + #[error( + "Some of this identity's keys are not fully protected yet. \ + Close and reopen the application, then try protecting this identity again." + )] + IdentityKeyProtectionIncomplete, + + /// Fail-closed guard at the opt-in protect boundary: the identity + /// still carries one or more keys saved in the legacy on-disk format this + /// version can neither read nor migrate into the protected store. Unlike + /// resident plaintext — which the load-path migration finishes on the next + /// launch — there is NO automatic migration for these keys, so reopening the + /// application would loop on the same error. The only way forward is to add + /// the identity again from its recovery phrase or private key, which replaces + /// the legacy key entries with ones this version can protect. Fieldless: the + /// offending key's presence is logged at the guard; no secret or raw error + /// string is stored here. + #[error( + "Some of this identity's keys are saved in an older format that cannot be protected. \ + Load this identity again using its recovery phrase or private key, then try protecting it." + )] + IdentityKeyProtectionLegacyFormat, + + /// A cross-network `det-app.sqlite` sidecar (wallet-metadata or + /// auth-pubkey-cache) could not be read or written. Both sidecars share + /// this user message; `sidecar` names which one failed for logs. + #[error( + "Could not access wallet details. Check available disk space and restart the application." + )] + KvSidecarStorage { + sidecar: &'static str, + #[source] + source: Box<crate::wallet_backend::KvAdapterError>, + }, + + /// The DET-owned identity-metadata sidecar (the password hint and prompt + /// copy for an identity whose keys are password-protected) could not be + /// read or written. Lives in the same cross-network `det-app.sqlite` k/v + /// file as [`Self::KvSidecarStorage`]; the sidecar is cosmetic (it never + /// gates whether a password is required — the vault scheme does), so a + /// failure here only costs the hint, and the user hint is the same calm + /// disk-space prompt. + #[error( + "Could not access identity details. Check available disk space and restart the application." + )] + IdentityMetaStorage { + #[source] + source: Box<crate::wallet_backend::KvAdapterError>, + }, + + /// The DET avatar image cache could not be read or written. + /// Lives in the same cross-network `det-app.sqlite` k/v file as + /// [`Self::KvSidecarStorage`]; a failure here only costs the offline + /// avatar cache (the image re-fetches from the network), so the user hint + /// is the same calm disk-space prompt. + #[error( + "Could not save the contact picture for offline use. Check available disk space and try again." + )] + AvatarCacheStorage { + #[source] + source: Box<crate::wallet_backend::KvAdapterError>, + }, + + /// The DET contact-profile cache (a contact's display name, DPNS username, + /// avatar URL, and bio, kept for offline viewing) could not be read or + /// written. Lives in the same cross-network `det-app.sqlite` k/v file as + /// [`Self::AvatarCacheStorage`]; a failure here only costs the offline + /// contact card (the profile re-fetches from the network), so the user hint + /// is the same calm disk-space prompt. + #[error( + "Could not save the contact details for offline use. Check available disk space and try again." + )] + ContactProfileCacheStorage { + #[source] + source: Box<crate::wallet_backend::KvAdapterError>, + }, + + /// A WIF-encoded private key supplied by the user could not be parsed. + /// Wrapped distinctly from [`Self::SecretStore`] so the user sees an + /// input-shape hint rather than a storage diagnostic. + #[error("This does not look like a valid private key. Check the characters and try again.")] + InvalidWif { + #[source] + source: Box<dash_sdk::dpp::dashcore::key::Error>, + }, + + /// The user supplied an uncompressed-format WIF. Imported keys are + /// rebuilt in compressed form on every launch, so storing an + /// uncompressed key would make its address change after a restart. + /// Rejected at import so the displayed address always stays stable. + #[error( + "This private key uses an older uncompressed format that is not supported. Re-export the key in compressed format and import it again." + )] + UncompressedWifUnsupported, + + /// The single-key metadata sidecar (alias / network / address index) + /// could not be read or written. Backed by the cross-network + /// `det-app.sqlite` k/v file the wallet-meta sidecar also uses; + /// distinct variant so the banner copy can speak about "imported + /// keys" rather than "wallet details". + #[error( + "Could not access your imported keys. Check available disk space and restart the application." + )] + SingleKeyMetaStorage { + #[source] + source: Box<crate::wallet_backend::KvAdapterError>, + }, + + /// The caller asked the single-key signer for an address that is not + /// in the secret store. Either it was never imported, or it was + /// forgotten between the lookup and the sign attempt. + #[error( + "This imported key is no longer available. Import the key again to keep using this address." + )] + ImportedKeyNotFound, + + /// Application settings could not be saved to the app k/v store. + #[error("Could not save your preferences. Check available disk space and try again.")] + AppSettingsWrite { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A scheduled DPNS vote could not be read or written in the per-network + /// wallet k/v store. + #[error( + "Could not access your scheduled vote queue. Check available disk space and try again." + )] + ScheduledVoteStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// An identity top-up history record could not be persisted to the + /// per-network wallet k/v store. + #[error("Could not save your top-up history. Check available disk space and try again.")] + TopUpHistoryStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A user-registered contract entry could not be read or written in + /// the per-network wallet k/v store. + #[error("Could not access your saved contracts. Check available disk space and try again.")] + ContractStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A serialized [`DataContract`](dash_sdk::platform::DataContract) blob + /// could not be round-tripped through the local cache. + #[error("Saved contract data is unreadable. Refresh the screen to fetch it again.")] + ContractEncoding { + #[source] + source: Box<dash_sdk::dpp::ProtocolError>, + }, + + /// A DPNS contest record could not be read or written in the + /// per-network wallet k/v store. + #[error("Could not access your DPNS contest data. Check available disk space and try again.")] + ContestStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A local identity record could not be read or written in the + /// per-network wallet k/v store. + #[error("Could not access your saved identities. Check available disk space and try again.")] + IdentityStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A voter identifier handed to a scheduled-vote operation was not a valid + /// 32-byte identity id. Callers always pass an [`Identifier`]'s bytes, so + /// this signals an internal inconsistency rather than user input. + #[error("Could not read the voter for this scheduled vote. Please refresh and try again.")] + InvalidVoterIdentifier { + #[source] + source: dash_sdk::dpp::platform_value::Error, + }, + + /// A stored [`QualifiedIdentity`](crate::model::qualified_identity::QualifiedIdentity) + /// blob could not be decoded. Private keys and balance state are at stake, + /// so this is surfaced rather than silently skipped. + #[error("A saved identity is unreadable. Reload the identity to refresh its data.")] + IdentityEncoding { + #[source] + source: bincode::error::DecodeError, + }, + + /// A token registry or balance record could not be read or written in + /// the per-network wallet k/v store. + #[error("Could not access your saved tokens. Check available disk space and try again.")] + TokenStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A serialized token configuration blob could not be decoded. + #[error("Saved token data is unreadable. Refresh the token list to fetch it again.")] + TokenConfigEncoding { + #[source] + source: bincode::error::DecodeError, + }, + + /// A token configuration could not be serialized for local storage. + #[error("Could not save token data. Check available disk space and try again.")] + TokenConfigSerialization { + #[source] + source: bincode::error::EncodeError, + }, + + /// A per-wallet platform-address-info or sync-cursor entry could not be + /// read or written in the per-wallet k/v store. + #[error( + "Could not access your saved Platform address details. Check available disk space and try again." + )] + PlatformAddressStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// A DashPay sidecar overlay entry (blocked / rejected marker, DET-local + /// timestamps) could not be read or written in the per-network k/v store. + /// The platform-side document succeeded — only the local annotation that + /// keeps the UI honest about it failed. + #[error( + "Could not save your DashPay update locally. The change reached the network — try refreshing in a moment, or try again if it stays out of sync." + )] + DashpaySidecarStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// Chain sync could not be started. + #[error( + "Could not start wallet sync. Please check your connection and restart the application." + )] + WalletSyncStartFailed { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// A wallet just registered with the SPV backend produced an address + /// signature that does not match the saved wallet's. The wallet was + /// rejected rather than watched, so funds are never routed to the wrong + /// place. This is a defensive fund-safety gate that should not occur in + /// normal use — the wallet was registered from its own seed. + #[error( + "This wallet could not be safely linked to your saved wallet, so it was not activated. Remove and re-import it from its recovery phrase." + )] + WalletRegistrationXpubMismatch, + + /// A stored wallet seed could not be decrypted (wrong password or + /// corrupted seed store). + #[error( + "Could not unlock a saved wallet. Re-enter your password; if it persists, restore the wallet from its recovery phrase." + )] + WalletSeedDecryptFailed, + + /// A local filesystem operation failed (e.g. creating a data directory). + #[error( + "Could not access local files. Check available disk space and restart the application." + )] + FileSystem { + #[source] + source: std::io::Error, + }, /// DashPay domain errors. #[error(transparent)] @@ -38,7 +641,7 @@ pub enum TaskError { /// GroveSTARK prover errors. #[error("Could not verify platform data. Please retry.")] - GroveStark(#[from] crate::model::grovestark_prover::GroveSTARKError), + GroveStark(#[from] crate::backend_task::grovestark::GroveSTARKError), /// Wallet errors. #[error(transparent)] @@ -101,14 +704,6 @@ pub enum TaskError { #[error("Wallet is locked. Please unlock your wallet and try again.")] WalletLocked, - /// Refreshing wallet UTXOs from Dash Core failed. - #[error("Could not refresh wallet balance. Please try again.")] - WalletUtxoReloadFailed { detail: String }, - - /// Recalculating address balances after a transaction failed. - #[error("Could not update wallet balances after transaction. Please refresh your wallet.")] - WalletBalanceRecalculationFailed { detail: String }, - /// The requested document could not be found on the platform. #[error("The document could not be found. It may have been deleted or the ID is incorrect.")] DocumentNotFound, @@ -152,14 +747,10 @@ pub enum TaskError { )] ConfirmationTimeout, - /// Dash Core peer-to-peer communication failed. - #[error(transparent)] - P2P(#[from] crate::components::core_p2p_handler::P2PError), - - /// The operation's prerequisite was auto-fixed (e.g., Core wallet detected). - /// Callers should retry the failed operation. - #[error("{0}")] - MustRetry(String), + /// The Core wallet association was auto-detected and linked; the operation's + /// prerequisite is now satisfied. Callers should retry the failed operation. + #[error("Detected the Core wallet '{wallet_name}'. Retrying your last action now.")] + CoreWalletAutoDetected { wallet_name: String }, /// Duplicate identity public key — this key's hash is already registered and /// the key is marked as unique, so it cannot be reused. @@ -340,7 +931,10 @@ pub enum TaskError { #[error( "Could not prepare wallet addresses for sync. Please close and reopen your wallet, then retry." )] - WalletAddressProviderSetupFailed { detail: String }, + WalletAddressProviderSetupFailed { + #[source] + source: crate::database::WalletError, + }, /// A Core address could not be converted to a Platform address. #[error("Could not convert a wallet address for platform use. Please retry.")] @@ -357,6 +951,18 @@ pub enum TaskError { #[error("Could not prepare a change address for this transaction. Please retry.")] ChangeAddressUnavailable { reason: &'static str }, + /// The network rejected an orchestrated platform-address funding for a + /// wallet-owned destination. Covers upstream SDK rejections and asset-lock + /// broadcast rejections surfaced during `fund_from_asset_lock`. The funding + /// lock is preserved by the orchestrator, so the user can resume it. + #[error( + "Funding the platform address was rejected by the network. Your funds are safe and saved as a funding lock. Wait a minute, then try funding from your existing asset lock." + )] + PlatformAddressFundRejected { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + // ────────────────────────────────────────────────────────────────────────── // Asset-lock transaction errors // ────────────────────────────────────────────────────────────────────────── @@ -458,13 +1064,6 @@ pub enum TaskError { )] DataContractNotFound, - // ────────────────────────────────────────────────────────────────────────── - // Serialization errors - // ────────────────────────────────────────────────────────────────────────── - /// A data serialization or deserialization operation failed (e.g. bincode). - #[error("Could not process the data. Please retry the operation.")] - SerializationError { detail: String }, - // ────────────────────────────────────────────────────────────────────────── // Identity creation / parsing errors // ────────────────────────────────────────────────────────────────────────── @@ -472,6 +1071,32 @@ pub enum TaskError { #[error("The identifier you entered could not be read. Please check the format and try again.")] IdentifierParsingError { input: String }, + /// A masternode or evonode with this ProTxHash is already loaded. Carries + /// the resolved identity id so the caller can point the user at the + /// existing node. + #[error( + "This masternode is already loaded. Open it from the list instead of loading it again." + )] + DuplicateProTxHash { identity_id: Identifier }, + + /// The ProTxHash could not be read as a hex ProTxHash or a Base58 identity + /// id. Carries the offending input (data, not a message). + #[error( + "The ProTxHash you entered could not be read. Enter a 64-character hex ProTxHash or the \ + Base58 identity ID." + )] + MalformedProTxHash { input: String }, + + /// A syntactically valid ProTxHash resolved to no masternode or evonode on + /// the network. Carries the resolved identity id so the user can double-check + /// which value was looked up. Distinct from `IdentityNotFound` so the message + /// speaks about a masternode, matching the load form the user is in. + #[error( + "No masternode or evonode was found on the network for this ProTxHash. Check the \ + ProTxHash and try again, or confirm the node is registered on this network." + )] + MasternodeNotFound { identity_id: Identifier }, + /// The identity could not be constructed from the given parameters. #[error("Could not create the identity. Please check your input and try again.")] IdentityCreationError { @@ -481,7 +1106,7 @@ pub enum TaskError { /// A private key could not be parsed or is invalid. #[error("The private key you entered is invalid. Please check the format and try again.")] - InvalidPrivateKey { detail: String }, + InvalidPrivateKey, /// Fetching DPNS names for an identity failed. #[error("Could not look up names for this identity. Please check your connection and retry.")] @@ -550,16 +1175,6 @@ pub enum TaskError { source: Box<SdkError>, }, - // ────────────────────────────────────────────────────────────────────────── - // Dash Core lifecycle errors - // ────────────────────────────────────────────────────────────────────────── - /// Dash Core could not be started (binary missing, config error, I/O failure). - #[error("Could not start Dash Core. Verify the installation and try again.")] - DashCoreStartError { - #[source] - source: std::io::Error, - }, - // ────────────────────────────────────────────────────────────────────────── // Network restriction errors // ────────────────────────────────────────────────────────────────────────── @@ -599,6 +1214,23 @@ pub enum TaskError { #[error("Could not process encrypted data. Please check your keys and try again.")] EncryptionError { detail: String }, + /// Sending a contact request needs the sender wallet's recovery phrase to + /// derive the contact's payment addresses, but no unlocked wallet holding + /// that recovery phrase is available for the identity. + #[error( + "Unlock the wallet for this identity before sending a contact request, so payments can reach the right addresses." + )] + ContactWalletSeedUnavailable, + + /// Deriving the per-contact encryption keys from the wallet's recovery + /// phrase failed. The seam already proved the seed is present, so this is a + /// derivation-math failure rather than a missing wallet. + #[error("Could not prepare the encryption keys for this contact. Please try again.")] + ContactKeyDerivationFailed { + #[source] + source: Box<dash_sdk::dpp::key_wallet::bip32::Error>, + }, + // ────────────────────────────────────────────────────────────────────────── // Wallet persistence errors // ────────────────────────────────────────────────────────────────────────── @@ -615,6 +1247,17 @@ pub enum TaskError { )] MasterKeyNotFound, + /// No withdrawal-capable key with locally-held private material was available + /// to sign the operation (Platform requires a Transfer or Owner key you control). + #[error( + "This identity does not have a Transfer or Owner key that you can sign with. \ + Open the Key Info screen for this identity, add a key whose private key you hold, then try again." + )] + NoWithdrawalSigningKey { + #[source] + source_error: Box<SdkError>, + }, + // ────────────────────────────────────────────────────────────────────────── // Token query errors // ────────────────────────────────────────────────────────────────────────── @@ -665,7 +1308,9 @@ pub enum TaskError { #[error( "Could not read the withdrawal details. The data may be incomplete or in an unexpected format. Please retry." )] - WithdrawalDocumentParsingError { detail: String }, + WithdrawalDocumentParsingError( + #[from] crate::backend_task::platform_info::WithdrawalParseError, + ), // ────────────────────────────────────────────────────────────────────────── // SDK / RPC setup errors @@ -677,10 +1322,6 @@ pub enum TaskError { )] SdkInitializationFailed { detail: String }, - /// An RPC context provider or Core RPC client could not be constructed. - #[error("Could not set up the Dash Core connection. Please check your settings and retry.")] - RpcProviderCreationFailed { detail: String }, - /// The Core wallet name supplied by the user is syntactically invalid. #[error("The Core wallet name '{name}' is invalid. Please check your wallet configuration.")] InvalidCoreWalletName { name: String }, @@ -689,50 +1330,33 @@ pub enum TaskError { #[error("No wallets are loaded in Dash Core. Please open a wallet in Dash Core and retry.")] NoCoreWalletsLoaded, - // ────────────────────────────────────────────────────────────────────────── - // SPV operation errors - // ────────────────────────────────────────────────────────────────────────── - /// The SPV data directory could not be cleared. - #[error( - "Could not clear SPV data. Please close the application and manually delete the SPV data directory." - )] - SpvClearDataFailed { detail: String }, - - /// The SPV client could not be started. - #[error("Could not start the SPV client. Please check your network settings and retry.")] - SpvStartFailed { detail: String }, - - /// A transaction could not be broadcast via the SPV client. - #[error("Could not broadcast the transaction. Please check your connection and retry.")] - SpvBroadcastFailed { detail: String }, - - // ────────────────────────────────────────────────────────────────────────── - // UTXO / asset-lock transaction build errors - // ────────────────────────────────────────────────────────────────────────── - /// A UTXO reload or removal operation failed. - #[error( - "Could not update your unspent transaction outputs. Please check your connection and retry." - )] - UtxoUpdateFailed { detail: String }, - - /// An asset lock transaction could not be built from the current wallet state. - #[error( - "Could not prepare the funding transaction. Please check your wallet balance and retry." - )] - AssetLockTransactionBuildFailed { detail: String }, - // ────────────────────────────────────────────────────────────────────────── // Wallet key / address errors // ────────────────────────────────────────────────────────────────────────── - /// A private key for a wallet address could not be found. + /// A private key for a wallet address could not be found or derived. #[error( "Could not find the key for this address in your wallet. Please check your wallet and retry." )] - WalletKeyLookupFailed { detail: String }, + WalletKeyLookupFailed, - /// A new receive or change address could not be derived from the wallet. + /// A wallet address or identity-auth key could not be derived. The upstream + /// detail (a legacy `String`) is logged at the call site, never stored here. #[error("Could not generate a wallet address. Please check your wallet and retry.")] - WalletAddressDerivationFailed { detail: String }, + WalletAddressDerivationFailed, + + /// A new Platform (DIP-17/18) receive address could not be derived or + /// registered. The underlying detail is logged, never shown to the user. + #[error("Could not generate a Platform receive address. Please check your wallet and retry.")] + WalletPlatformReceiveAddressFailed, + + /// Signing a message with a wallet-derived key failed during derivation or + /// signing. The underlying detail is logged, never shown to the user. + #[error("Could not sign the message. Please check your wallet and retry.")] + WalletMessageSigningFailed, + + /// The selected key type cannot be used to sign a message in this tool. + #[error("This key type cannot sign a message. Please choose an ECDSA key and try again.")] + WalletMessageSignUnsupportedKeyType, // ────────────────────────────────────────────────────────────────────────── // Payment errors @@ -754,6 +1378,23 @@ pub enum TaskError { source: dashcore::address::Error, }, + /// The payment had no recipients. A transaction must pay at least one + /// address, so the request is rejected before any funds move. + #[error("Add at least one recipient before sending a payment.")] + PaymentNoRecipients, + + /// Tracked-lock funding was invoked with no recipients. The UI always + /// supplies at least one, so this is a caller-contract violation (a future + /// programmatic caller, not a normal user action) rejected before any + /// pool-membership lookup or broadcast. + #[error("No funding recipients were provided for this asset lock.")] + NoFundingRecipients, + + /// A recipient was given a zero amount. Sending nothing wastes the network + /// fee and is almost always a slip, so it is rejected up front. + #[error("Enter an amount greater than zero for every recipient, then try again.")] + PaymentZeroAmount, + /// The wallet has no UTXOs available to cover the payment. #[error("Your wallet has no available funds to spend. Please receive some Dash first.")] NoUtxosAvailable, @@ -777,10 +1418,6 @@ pub enum TaskError { source: dashcore::sighash::Error, }, - /// A wallet payment operation failed (covers SPV and RPC payment paths). - #[error("Could not complete the payment. Please check your wallet balance and retry.")] - WalletPaymentFailed { detail: String }, - /// Could not access wallet information from the SPV manager. #[error("Your wallet is still loading. Please wait a moment and try again.")] WalletInfoUnavailable, @@ -855,12 +1492,26 @@ pub enum TaskError { #[error("No identities found up to wallet index {max_index}. Try a higher search range.")] NoWalletIdentitiesFound { max_index: u32 }, + /// An identity-authentication key could not be derived without unlocking the + /// wallet, and the caller asked not to prompt. The background identity sweep + /// uses this to skip a locked wallet's index instead of popping a passphrase + /// modal; the interactive search prompts instead, so users never see this. + #[error("Unlock this wallet to search it for identities, then try again.")] + AuthKeyUnlockRequired, + // ────────────────────────────────────────────────────────────────────────── // Key input validation errors // ────────────────────────────────────────────────────────────────────────── - /// A raw private-key input string failed format validation. - #[error("The {key_name} key is invalid: {detail}. Please check the key format and retry.")] - KeyInputValidationFailed { key_name: String, detail: String }, + /// A raw private-key input string failed format validation. The model + /// validator's `Display` is already a complete, actionable user sentence. + #[error(transparent)] + KeyInputValidationFailed(#[from] crate::model::key_input::KeyInputError), + + /// A supplied private key could not be verified against the identity's keys. + #[error("{0} Please check the key and retry.")] + IdentityKeyVerificationFailed( + #[from] crate::backend_task::identity::KeyVerificationError, + ), /// The identity's public keys could not be converted to the platform format. #[error("Could not process the identity keys. Please check your key configuration and retry.")] @@ -928,10 +1579,6 @@ pub enum TaskError { #[error("The platform address could not be found in your wallet. Please refresh and retry.")] PlatformAddressNotFound, - /// A Merkle witness could not be obtained for a shielded note. - #[error("Could not prepare the shielded transaction. Please sync your notes and retry.")] - ShieldedMerkleWitnessUnavailable { detail: String }, - /// Failed to build a shielded state transition (shield, transfer, unshield, withdrawal). #[error("Could not build the shielded transaction. Please retry.")] ShieldedTransitionBuildFailed { detail: String }, @@ -1006,28 +1653,336 @@ pub enum TaskError { )] ShieldedAssetLockTimeout, + /// A shield-from-asset-lock was sent but its confirmation could not be + /// verified. The funds may or may not have reached the shielded pool, so the + /// operation must not report success — the locked Core funds are tied to a + /// single-use asset lock that resumes the same shield on retry. + #[error( + "Your funds were sent to the shielded pool but the confirmation could not be verified. Wait a moment, then refresh your shielded balance before sending again." + )] + ShieldedConfirmationUnknown { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// A shield-credits transition (platform address into the shielded pool) was + /// broadcast but its confirmation could not be verified. The credits may or + /// may not have reached the pool, so the operation must not report success. + /// + /// Populated by `map_shielded_op_error` when the upstream coordinator returns + /// `ShieldedSpendUnconfirmed { operation: "shield", .. }`. + #[error( + "Your credits were sent to the shielded pool but the confirmation could not be verified. Wait a moment, then refresh your shielded balance before sending again." + )] + ShieldCreditsConfirmationUnknown { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// A shielded transfer (pool to pool) was broadcast but its confirmation + /// could not be verified. The notes may or may not have been spent, so the + /// operation must not report success and the spent notes are left untouched — + /// the next refresh reconciles spent notes against the network. + /// + /// Populated by `map_shielded_op_error` when the upstream coordinator returns + /// `ShieldedSpendUnconfirmed { operation: "transfer", .. }`. + #[error( + "Your shielded transfer was sent but the confirmation could not be verified. Wait a moment, then refresh your shielded balance before sending again." + )] + ShieldedTransferConfirmationUnknown { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// An unshield (pool to platform address) was broadcast but its confirmation + /// could not be verified. The notes may or may not have been spent, so the + /// operation must not report success and the spent notes are left untouched — + /// the next refresh reconciles spent notes against the network. + /// + /// Populated by `map_shielded_op_error` when the upstream coordinator returns + /// `ShieldedSpendUnconfirmed { operation: "unshield", .. }`. + #[error( + "Your unshield was sent but the confirmation could not be verified. Wait a moment, then refresh your shielded balance before sending again." + )] + UnshieldConfirmationUnknown { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// A shielded withdrawal (pool to a Dash address) was broadcast but its + /// confirmation could not be verified. The notes may or may not have been + /// spent, so the operation must not report success and the spent notes are + /// left untouched — the next refresh reconciles spent notes against the network. + /// + /// Populated by `map_shielded_op_error` when the upstream coordinator returns + /// `ShieldedSpendUnconfirmed { operation: "withdraw", .. }`. + #[error( + "Your withdrawal was sent but the confirmation could not be verified. Wait a moment, then refresh your shielded balance before sending again." + )] + ShieldedWithdrawalConfirmationUnknown { + #[source] + source: Box<platform_wallet::error::PlatformWalletError>, + }, + + /// The upstream shielded coordinator has not been configured — either + /// `configure_shielded` was not called during backend startup or the call + /// failed. Restarting the application is the user-actionable path. + #[error("The shielded pool is not available yet. Restart the application and try again.")] + ShieldedNotConfigured, + + /// A shielded operation was requested but this wallet's Orchard keys have + /// not been bound. `bind_shielded` is triggered automatically on wallet + /// unlock; this error surfaces when the operation races the bind (e.g. an + /// MCP tool call immediately after a headless wallet load). + #[error("Your shielded wallet is still loading. Unlock your wallet and try again.")] + ShieldedNotBound, + /// Failed to sync shielded notes from the platform. #[error( "Could not sync shielded notes from the platform. Please check your connection and retry." )] - ShieldedSyncFailed { detail: String }, + ShieldedSyncFailed(#[source] Box<SdkError>), + + /// Failed to persist a decrypted shielded note to the local sidecar. + /// + /// Surfaced before the commitment tree is advanced past the note's + /// position, so the next sync re-scans and re-persists it rather than + /// permanently skipping a spendable note. + #[error( + "Could not save a received shielded note. Please check available disk space and retry." + )] + ShieldedNotePersistFailed { + #[source] + source: rusqlite::Error, + }, - /// Failed to append or checkpoint the shielded commitment tree. + /// The shielded transition fee could not be computed for the active + /// protocol version. #[error( - "Could not update the local shielded data. Please check available disk space and retry." + "Could not calculate the shielded transaction fee. Update to the latest version and retry." )] - ShieldedTreeUpdateFailed { detail: String }, + ShieldedFeeComputationFailed { + #[source] + source: Box<dash_sdk::dpp::ProtocolError>, + }, - /// Nullifier sync failed. - #[error("Could not check for spent shielded notes. Please check your connection and retry.")] - ShieldedNullifierSyncFailed { detail: String }, + /// Reading the local shielded store (activity history or unspent notes) + /// failed. The concrete store error is preserved as the source so `Debug` + /// keeps the chain; `Display` stays user-facing. + #[error("Could not read your shielded activity. Wait for the next sync and try again.")] + ShieldedStoreReadFailed { + #[source] + source: platform_wallet::wallet::shielded::FileShieldedStoreError, + }, // ────────────────────────────────────────────────────────────────────────── // Network context errors // ────────────────────────────────────────────────────────────────────────── /// Creating a network context failed during a network switch. #[error("Could not connect to {network}. Check your network configuration and retry.")] - NetworkContextCreationFailed { network: Network, detail: String }, + NetworkContextCreationFailed { network: Network }, + + // ────────────────────────────────────────────────────────────────────────── + // Migration errors + // ────────────────────────────────────────────────────────────────────────── + /// Surfaced when wallet/identity/DashPay storage is being upgraded + /// from the legacy `data.db` and a task tried to touch it before + /// the migration finished. The user can retry once the migration + /// banner clears. + #[error("Your data is still being updated. Please wait a moment and try again.")] + WalletStorageNotReady, + + /// The post-unwire data migration failed. The user is asked to + /// restart so the migration can re-attempt cleanly — legacy + /// `data.db` rows are left intact. + /// + /// Wrapped as `Arc<MigrationError>` so the typed error chain can be + /// shared with the `MigrationState::Failed` UI banner state without + /// re-cloning the (non-`Clone`) `MigrationError` source. + #[error("Your data could not finish updating. Please restart the application to try again.")] + MigrationFailed { + #[source] + source: std::sync::Arc<crate::backend_task::migration::MigrationError>, + }, + + /// An HD wallet seed envelope decoded cleanly but its plaintext + /// length is not the expected 64 bytes. Surfaced when the cold-boot + /// hydration path would otherwise have silently degraded the + /// wallet to a closed state — now the user sees which wallet is + /// affected and why. + #[error( + "The wallet \"{wallet_label}\" could not be opened because its saved seed is the wrong size. Restore it from your recovery words to keep using it." + )] + SeedLengthInvalid { + /// Display alias for the affected wallet, or a fallback hex + /// prefix of the seed hash when no alias has been set. + wallet_label: String, + /// Length of the decoded seed blob in bytes. + got: u32, + /// Length the loader expected. + expected: u32, + }, + + /// An imported single-key entry is passphrase-protected and a + /// non-interactive caller tried to sign it directly. Interactive + /// signing routes through the JIT chokepoint + /// (`WalletBackend::sign_single_key`), which prompts for the passphrase + /// and decrypts just-in-time; this variant is the typed signal for + /// callers that have no prompt. + #[error("Enter the passphrase you set for the imported key {addr} to continue.")] + SingleKeyPassphraseRequired { + /// Base58 P2PKH address of the imported key — allowed in + /// user-facing copy per CLAUDE.md rule 6 as an opaque-but- + /// copyable handle. + addr: String, + }, + + /// The passphrase the user supplied does not decrypt the stored + /// single-key entry. No upstream error is preserved — AES-GCM's + /// authentication failure carries no useful diagnostic. + #[error("That passphrase is not correct. Try again.")] + SingleKeyPassphraseIncorrect, + + /// The passphrase the user supplied is shorter than the configured + /// minimum. Fail-fast at the import dialog so the user picks a + /// stronger value before the key is encrypted. + #[error("Passphrases must be at least {min} characters. Pick a longer one and try again.")] + SingleKeyPassphraseTooShort { min: u32 }, + + /// The "Passphrase" and "Confirm passphrase" fields in the import + /// dialog did not match. Caught client-side; this variant exists so + /// the validation message has a typed home rather than being a UI + /// string literal. + #[error("The two passphrases do not match. Type them again carefully.")] + SingleKeyPassphraseMismatch, + + /// Encrypting or decrypting an imported-key entry with the + /// user-supplied passphrase failed for a reason other than a wrong + /// passphrase — typically an AES-GCM library error during key + /// derivation. Fieldless: the upstream `String` carries no useful + /// typed diagnostic, and storing the message would conflict with + /// the no-user-strings-in-variants rule (CLAUDE.md rule 7). The + /// callsite logs the detail before constructing this variant. + #[error( + "Could not protect this imported key with a passphrase. Try again, or import it without a passphrase for now." + )] + SingleKeyCryptoFailure, + + /// Raw ECDSA signing with an imported single key failed inside the JIT + /// signer. Distinct from [`Self::SingleKeyCryptoFailure`] (which covers + /// passphrase encrypt/decrypt): this carries the typed signer cause so the + /// failing digest / secret-kind mismatch is preserved in `Debug` and logs. + #[error("Could not sign with this imported key. Please try again.")] + SingleKeySignFailed { + #[source] + source: crate::wallet_backend::DetSignerError, + }, + + /// A protected single-key restore (T-SK-03) was requested for an + /// address that is not present as an un-restored `uses_password=1` + /// row in the legacy table — it was already restored, never existed, + /// or belongs to another network. Fieldless: no upstream error and, + /// by design, never any secret. + #[error( + "This imported key is no longer waiting to be restored. It may already be available — check your imported keys." + )] + ProtectedSingleKeyRestoreTargetMissing, + + /// The user dismissed the just-in-time passphrase prompt (Cancel / X / + /// Escape / click-outside), or no interactive prompt was available + /// (headless / MCP). The operation aborts cleanly — nothing was + /// decrypted, signed, or persisted. Fieldless: cancellation carries + /// no upstream diagnostic and, by design, never any secret. + #[error("You cancelled. Nothing was changed. Try the action again when you're ready.")] + SecretPromptCancelled, + + /// The stored secret for a just-in-time scope could not be decrypted + /// for a reason other than a wrong passphrase — typically an AES-GCM + /// library error during key derivation or a malformed envelope. The + /// callsite logs the typed detail before constructing this variant; + /// no secret or raw error string is stored here (CLAUDE.md rule 7). + #[error( + "Could not unlock this wallet. Try again; if it persists, restore the wallet from its recovery phrase." + )] + SecretDecryptFailed, + + /// The passphrase the user supplied does not decrypt the stored HD + /// wallet seed. The just-in-time chokepoint catches this inside its + /// re-ask loop and re-prompts; it only surfaces to the UI when the + /// re-ask itself is cancelled. No upstream error is preserved — + /// AES-GCM's authentication failure carries no useful diagnostic. + #[error("That passphrase is not correct. Try again.")] + HdPassphraseIncorrect, + + /// A secret was needed but no interactive prompt is available in this + /// context — the operation ran headless (MCP / CLI), where there is no + /// window to ask for a passphrase. Per the Q-HEADLESS security ruling + /// there is no environment-variable or flag fallback for the + /// passphrase, so the operation cannot proceed here. Fieldless: this + /// carries no upstream diagnostic and, by design, never any secret. + #[error( + "This wallet is protected by a passphrase, which can only be entered in the app window. Open Dash Evo Tool and run this action there." + )] + SecretPromptUnavailable, +} + +impl TaskError { + /// Map a wallet-storage open failure to the right user-facing variant. + /// + /// Three storage failures get honest, distinct copy; everything else keeps + /// the generic disk/IO message: + /// + /// - A forward-version database (written by a newer build, schema beyond + /// what this binary applies) is surfaced as [`Self::WalletDataTooNew`] so + /// the banner tells the user to update the app — the only thing that + /// fixes it. + /// - A divergent migration history (e.g. a database written under an + /// earlier, incompatible storage layout that this build's migrations + /// cannot reconcile) is surfaced as [`Self::WalletDataIncompatible`] so + /// the banner tells the user to remove the local wallet data — freeing + /// disk space or restarting never resolves a structural mismatch. + /// - Every other storage failure keeps the generic disk/IO copy via + /// [`Self::WalletStorage`]. + /// + /// Discrimination is on the typed upstream variant + /// (`WalletStorageError::SchemaVersionUnsupported` / + /// `WalletStorageError::Migration`), never on its `Display` text. + pub fn from_wallet_storage_open_error( + source: platform_wallet_storage::WalletStorageError, + ) -> Self { + match source { + platform_wallet_storage::WalletStorageError::SchemaVersionUnsupported { + found, + max_supported, + } => Self::WalletDataTooNew { + found, + max_supported, + }, + other @ platform_wallet_storage::WalletStorageError::Migration(_) => { + Self::WalletDataIncompatible { source: other } + } + other => Self::WalletStorage { source: other }, + } + } + + /// Returns `true` when this is a [`Self::SecretStore`] open failure caused + /// specifically by a vault passphrase mismatch + /// ([`SecretStoreError::WrongPassphrase`]). + /// + /// The GUI boot path opens the vault keyless; a vault an older build wrote + /// with a real passphrase fails that open with `WrongPassphrase`. The boot + /// seam matches on this (never on `Display` text) to fall through to a + /// passphrase prompt instead of aborting startup. Every other secret-store + /// failure — corruption, permissions, a held lock — stays fatal. + /// + /// [`SecretStoreError::WrongPassphrase`]: platform_wallet_storage::secrets::SecretStoreError::WrongPassphrase + pub fn is_secret_store_wrong_passphrase(&self) -> bool { + use platform_wallet_storage::secrets::SecretStoreError; + matches!( + self, + Self::SecretStore { source } if matches!(**source, SecretStoreError::WrongPassphrase) + ) + } } /// Escapes control characters in a token name for safe display in error messages. @@ -1062,23 +2017,73 @@ pub fn is_rpc_connection_error(e: &dashcore_rpc::Error) -> bool { false } +/// Extracts the consensus error carried by an SDK error, whether it arrived as a +/// broadcast-rejection cause or a direct protocol consensus error. +pub fn consensus_cause(error: &SdkError) -> Option<&ConsensusError> { + match error { + SdkError::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), + SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + } +} + /// Returns `true` when the SDK error indicates an invalid instant asset lock /// proof signature — the structured equivalent of the old string-matching /// on `"Instant lock proof signature is invalid"`. pub fn is_instant_lock_proof_invalid(error: &SdkError) -> bool { - let consensus_error = match error { - SdkError::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), - SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; matches!( - consensus_error, + consensus_cause(error), Some(ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), )) ) } +/// Marker the upstream proof layer emits when a queried GroveDB subtree has +/// never been written. It originates as a merk `CorruptedCodeExecution` +/// (`"Cannot create proof for empty tree"`) and is carried verbatim into the +/// proof-error leaf string — the only signal the upstream exposes for this +/// case. +const EMPTY_TREE_PROOF_MARKER: &str = "empty tree"; + +/// Returns `true` when the SDK error is a proof-verification failure caused by +/// a never-written GroveDB subtree (an "empty tree"). +/// +/// A wallet that has never received platform credits has no balance subtree to +/// prove against, so an address-balance sync returns this rather than real +/// data — the expected first-sync state, not an error. +/// +/// Upstream exposes no typed variant for this case: the leaf message lives in a +/// `String` field of the proof-error types. This narrows the match to the two +/// proof-carrying `SdkError` variants and inspects only their leaf string, so a +/// stray "empty tree" substring elsewhere in an unrelated error chain cannot +/// trigger a false positive. Replace with a structural match once the proof +/// layer gains a typed empty-tree variant. +pub fn is_empty_tree_proof(error: &SdkError) -> bool { + fn proof_verifier_leaf(error: &dash_sdk::ProofVerifierError) -> Option<&str> { + match error { + dash_sdk::ProofVerifierError::GroveDBError { error, .. } + | dash_sdk::ProofVerifierError::DriveError { error } + | dash_sdk::ProofVerifierError::ProtocolError { error } => Some(error.as_str()), + _ => None, + } + } + + use dash_sdk::drive::error::proof::ProofError; + let leaf = match error { + SdkError::Proof(proof_err) => proof_verifier_leaf(proof_err), + SdkError::DriveProofError( + ProofError::CorruptedProof(detail) + | ProofError::IncorrectProof(detail) + | ProofError::UnexpectedResultProof(detail), + .., + ) => Some(detail.as_str()), + _ => None, + }; + + leaf.is_some_and(|s| s.to_lowercase().contains(EMPTY_TREE_PROOF_MARKER)) +} + // TODO: Replace string parsing with a pre-check on amount + fee > spendable // before calling the SDK builder, or wait for upstream to add a typed // ProtocolError variant (currently ProtocolError::ShieldedBuildError(String)). @@ -1135,11 +2140,7 @@ pub fn shielded_build_error(detail: String) -> TaskError { /// `ShieldedInsufficientPoolNotes` when matched, falling back to /// `ShieldedBroadcastFailed` otherwise. pub fn shielded_broadcast_error(e: SdkError) -> TaskError { - let consensus_error = match &e { - SdkError::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), - SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; + let consensus_error = consensus_cause(&e); if let Some(ConsensusError::StateError(StateError::InsufficientPoolNotesError(pool_err))) = consensus_error { @@ -1170,31 +2171,6 @@ pub fn shielded_broadcast_error(e: SdkError) -> TaskError { } } -/// Produce a user-friendly message for SPV subsystem errors. -/// -/// Inspects the specific `SpvError` variant to give actionable guidance. -fn spv_user_message(e: &crate::spv::SpvError) -> &'static str { - use crate::spv::SpvError; - match e { - SpvError::LockPoisoned(_) | SpvError::ChannelError(_) => { - "An internal error occurred. Please restart the application." - } - SpvError::ClientNotInitialized | SpvError::NotRunning => { - "The wallet sync service is not ready. Please restart the application." - } - SpvError::NetworkError(_) | SpvError::SyncFailed(_) => { - "Could not sync wallet data. Please check your connection and retry." - } - SpvError::WalletError(_) => { - "Could not process wallet data. Please check your wallet and retry." - } - SpvError::ConfigError(_) => { - "Wallet sync is not configured properly. Please check your settings." - } - SpvError::Other(_) => "Could not sync wallet data. Please retry.", - } -} - /// Blanket conversion for lock poisoning errors. This is the recommended approach: /// use `?` on `.read()`, `.write()`, or `.lock()` calls instead of explicit `map_err`. /// The resource name is derived from `type_name::<T>()` automatically. @@ -1206,6 +2182,38 @@ impl<T> From<std::sync::PoisonError<T>> for TaskError { } } +impl From<crate::model::wallet::passphrase::PassphraseError> for TaskError { + fn from(e: crate::model::wallet::passphrase::PassphraseError) -> Self { + use crate::model::wallet::passphrase::PassphraseError; + match e { + PassphraseError::TooShort { min } => TaskError::SingleKeyPassphraseTooShort { min }, + PassphraseError::Mismatch => TaskError::SingleKeyPassphraseMismatch, + } + } +} + +impl From<crate::model::wallet::PaymentValidationError> for TaskError { + fn from(e: crate::model::wallet::PaymentValidationError) -> Self { + use crate::model::wallet::PaymentValidationError; + match e { + PaymentValidationError::NoRecipients => TaskError::PaymentNoRecipients, + PaymentValidationError::ZeroAmount => TaskError::PaymentZeroAmount, + } + } +} + +impl From<crate::model::wallet::WalletCreationError> for TaskError { + fn from(e: crate::model::wallet::WalletCreationError) -> Self { + use crate::model::wallet::WalletCreationError; + match e { + WalletCreationError::Encryption { detail } => TaskError::EncryptionError { detail }, + WalletCreationError::KeyDerivation { source } => { + TaskError::WalletKeyDerivationFailed { source } + } + } + } +} + impl From<dashcore_rpc::Error> for TaskError { fn from(e: dashcore_rpc::Error) -> Self { if is_rpc_auth_error(&e) { @@ -1257,340 +2265,211 @@ impl From<SdkError> for TaskError { } } - enum ConsensusKind { - DuplicateKey, - DuplicateKeyId, - ContractBoundsConflict(String), - InvalidInstantLockProof, - InsufficientBalance { - available: u64, - required: u64, - }, - AssetLockOutPointInsufficientBalance { - available: u64, - required: u64, - }, - InsufficientPoolNotes { - current_count: u64, - minimum_required: u64, - }, - InvalidTokenNameCharacter { - form: String, - token_name: String, - }, - InvalidTokenNameLength { - form: String, - actual: usize, - min: usize, - max: usize, - }, - InvalidTokenLanguageCode { - language_code: String, - }, - TokenDecimalsOverLimit { - decimals: u8, - max_decimals: u8, - }, - InvalidTokenBaseSupply { - base_supply: u64, - }, - RecipientIdentityNotFound { - recipient_id: String, - }, - TokenAccountNotFrozen { - identity_id: String, - token_id: String, - action: String, - }, - } + // Each consensus arm names its DPP pattern and the `TaskError` it maps to + // in one place; the returned closure defers boxing the SDK error until + // after the borrow-checked match on the consensus cause ends. + type SdkErrorMapper = Box<dyn FnOnce(Box<SdkError>) -> TaskError>; - let kind: Option<ConsensusKind> = { - let consensus_error = match &error { - SdkError::StateTransitionBroadcastError(broadcast_err) => { - broadcast_err.cause.as_ref() - } - SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; - - consensus_error - .and_then(|ce| match ce { + let mapper: Option<SdkErrorMapper> = consensus_cause(&error) + .and_then(|ce| -> Option<SdkErrorMapper> { + match ce { ConsensusError::StateError( StateError::DuplicatedIdentityPublicKeyStateError(_), - ) => Some(ConsensusKind::DuplicateKey), + ) => Some(Box::new(|source_error| { + TaskError::DuplicateIdentityPublicKey { source_error } + })), ConsensusError::StateError( StateError::DuplicatedIdentityPublicKeyIdStateError(_), - ) => Some(ConsensusKind::DuplicateKeyId), + ) => Some(Box::new(|source_error| { + TaskError::DuplicateIdentityPublicKeyId { source_error } + })), ConsensusError::StateError( StateError::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError(e), - ) => Some(ConsensusKind::ContractBoundsConflict( - e.contract_id().to_string(Encoding::Base58), - )), + ) => { + let contract_id = e.contract_id().to_string(Encoding::Base58); + Some(Box::new(move |source_error| { + TaskError::IdentityPublicKeyContractBoundsConflict { + contract_id, + source_error, + } + })) + } ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(e)) => { - Some(ConsensusKind::InsufficientBalance { - available: e.balance(), - required: e.required_balance(), - }) + let (available, required) = (e.balance(), e.required_balance()); + Some(Box::new(move |source_error| { + TaskError::IdentityInsufficientBalance { + available, + required, + source_error, + } + })) } ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), - ) => Some(ConsensusKind::InvalidInstantLockProof), + ) => Some(Box::new(|source_error| { + TaskError::AssetLockInstantLockProofInvalid { source_error } + })), ConsensusError::BasicError( BasicError::IdentityAssetLockTransactionOutPointNotEnoughBalanceError(e), - ) => Some(ConsensusKind::AssetLockOutPointInsufficientBalance { - available: e.credits_left(), - required: e.credits_required(), - }), + ) => { + let (available, required) = (e.credits_left(), e.credits_required()); + Some(Box::new(move |source_error| { + TaskError::AssetLockOutPointInsufficientBalance { + available, + required, + source_error, + } + })) + } ConsensusError::StateError(StateError::InsufficientPoolNotesError(e)) => { - Some(ConsensusKind::InsufficientPoolNotes { - current_count: e.current_count(), - minimum_required: e.minimum_required(), - }) + let (current_count, minimum_required) = + (e.current_count(), e.minimum_required()); + Some(Box::new(move |source_error| { + TaskError::ShieldedInsufficientPoolNotes { + current_count, + minimum_required, + source_error, + } + })) } ConsensusError::BasicError(BasicError::InvalidTokenNameCharacterError(e)) => { - Some(ConsensusKind::InvalidTokenNameCharacter { - form: e.form().to_string(), - token_name: e.token_name().to_string(), - }) + let (form, token_name) = (e.form().to_string(), e.token_name().to_string()); + Some(Box::new(move |source_error| { + TaskError::InvalidTokenNameCharacter { + form, + token_name, + source_error, + } + })) } ConsensusError::BasicError(BasicError::InvalidTokenNameLengthError(e)) => { - Some(ConsensusKind::InvalidTokenNameLength { - form: e.form().to_string(), - actual: e.actual(), - min: e.min(), - max: e.max(), - }) + let (form, actual, min, max) = + (e.form().to_string(), e.actual(), e.min(), e.max()); + Some(Box::new(move |source_error| { + TaskError::InvalidTokenNameLength { + form, + actual, + min, + max, + source_error, + } + })) } ConsensusError::BasicError(BasicError::InvalidTokenLanguageCodeError(e)) => { - Some(ConsensusKind::InvalidTokenLanguageCode { - language_code: e.language_code().to_string(), - }) + let language_code = e.language_code().to_string(); + Some(Box::new(move |source_error| { + TaskError::InvalidTokenLanguageCode { + language_code, + source_error, + } + })) } ConsensusError::BasicError(BasicError::DecimalsOverLimitError(e)) => { - Some(ConsensusKind::TokenDecimalsOverLimit { - decimals: e.decimals(), - max_decimals: e.max_decimals(), - }) - } - ConsensusError::BasicError(BasicError::InvalidTokenBaseSupplyError(e)) => { - Some(ConsensusKind::InvalidTokenBaseSupply { - base_supply: e.base_supply(), - }) - } - ConsensusError::StateError(StateError::RecipientIdentityDoesNotExistError( - e, - )) => Some(ConsensusKind::RecipientIdentityNotFound { - recipient_id: e.recipient_id().to_string(Encoding::Base58), - }), - ConsensusError::StateError(StateError::IdentityTokenAccountNotFrozenError( - e, - )) => Some(ConsensusKind::TokenAccountNotFrozen { - identity_id: e.identity_id().to_string(Encoding::Base58), - token_id: e.token_id().to_string(Encoding::Base58), - action: e.action().to_string(), - }), - _ => None, - }) - .or_else(|| { - if let SdkError::StateTransitionBroadcastError(broadcast_err) = &error - && broadcast_err.cause.is_none() - { - let msg = broadcast_err.message.to_lowercase(); - if msg.contains("duplicate") { - return Some(ConsensusKind::DuplicateKey); - } + let (decimals, max_decimals) = (e.decimals(), e.max_decimals()); + Some(Box::new(move |source_error| { + TaskError::TokenDecimalsOverLimit { + decimals, + max_decimals, + source_error, + } + })) } - None - }) - }; - - let boxed = Box::new(error); - match kind { - Some(ConsensusKind::DuplicateKey) => TaskError::DuplicateIdentityPublicKey { - source_error: boxed, - }, - Some(ConsensusKind::DuplicateKeyId) => TaskError::DuplicateIdentityPublicKeyId { - source_error: boxed, - }, - Some(ConsensusKind::ContractBoundsConflict(contract_id)) => { - TaskError::IdentityPublicKeyContractBoundsConflict { - contract_id, - source_error: boxed, - } - } - Some(ConsensusKind::InvalidInstantLockProof) => { - TaskError::AssetLockInstantLockProofInvalid { - source_error: boxed, - } - } - Some(ConsensusKind::InsufficientBalance { - available, - required, - }) => TaskError::IdentityInsufficientBalance { - available, - required, - source_error: boxed, - }, - Some(ConsensusKind::AssetLockOutPointInsufficientBalance { - available, - required, - }) => TaskError::AssetLockOutPointInsufficientBalance { - available, - required, - source_error: boxed, - }, - Some(ConsensusKind::InsufficientPoolNotes { - current_count, - minimum_required, - }) => TaskError::ShieldedInsufficientPoolNotes { - current_count, - minimum_required, - source_error: boxed, - }, - Some(ConsensusKind::InvalidTokenNameCharacter { form, token_name }) => { - TaskError::InvalidTokenNameCharacter { - form, - token_name, - source_error: boxed, - } - } - Some(ConsensusKind::InvalidTokenNameLength { - form, - actual, - min, - max, - }) => TaskError::InvalidTokenNameLength { - form, - actual, - min, - max, - source_error: boxed, - }, - Some(ConsensusKind::InvalidTokenLanguageCode { language_code }) => { - TaskError::InvalidTokenLanguageCode { - language_code, - source_error: boxed, - } - } - Some(ConsensusKind::TokenDecimalsOverLimit { - decimals, - max_decimals, - }) => TaskError::TokenDecimalsOverLimit { - decimals, - max_decimals, - source_error: boxed, - }, - Some(ConsensusKind::InvalidTokenBaseSupply { base_supply }) => { - TaskError::InvalidTokenBaseSupply { - base_supply, - source_error: boxed, + ConsensusError::BasicError(BasicError::InvalidTokenBaseSupplyError(e)) => { + let base_supply = e.base_supply(); + Some(Box::new(move |source_error| { + TaskError::InvalidTokenBaseSupply { + base_supply, + source_error, + } + })) + } + ConsensusError::StateError(StateError::RecipientIdentityDoesNotExistError( + e, + )) => { + let recipient_id = e.recipient_id().to_string(Encoding::Base58); + Some(Box::new(move |source_error| { + TaskError::TokenRecipientIdentityNotFound { + recipient_id, + source_error, + } + })) + } + ConsensusError::StateError(StateError::IdentityTokenAccountNotFrozenError( + e, + )) => { + let (identity_id, token_id, action) = ( + e.identity_id().to_string(Encoding::Base58), + e.token_id().to_string(Encoding::Base58), + e.action().to_string(), + ); + Some(Box::new(move |source_error| { + TaskError::TokenAccountNotFrozen { + identity_id, + token_id, + action, + source_error, + } + })) + } + _ => None, } - } - Some(ConsensusKind::RecipientIdentityNotFound { recipient_id }) => { - TaskError::TokenRecipientIdentityNotFound { - recipient_id, - source_error: boxed, + }) + .or_else(|| -> Option<SdkErrorMapper> { + if let SdkError::StateTransitionBroadcastError(broadcast_err) = &error + && broadcast_err.cause.is_none() + && broadcast_err.message.to_lowercase().contains("duplicate") + { + return Some(Box::new(|source_error| { + TaskError::DuplicateIdentityPublicKey { source_error } + })); } - } - Some(ConsensusKind::TokenAccountNotFrozen { - identity_id, - token_id, - action, - }) => TaskError::TokenAccountNotFrozen { - identity_id, - token_id, - action, - source_error: boxed, - }, - None => { - // Extract timeout duration before consuming boxed. - let timeout_secs = if let SdkError::TimeoutReached(d, _) = &*boxed { - Some(d.as_secs()) - } else { - None - }; + None + }); + + if let Some(mapper) = mapper { + return mapper(Box::new(error)); + } + + let boxed = Box::new(error); + // Extract timeout duration before consuming boxed. + let timeout_secs = if let SdkError::TimeoutReached(d, _) = &*boxed { + Some(d.as_secs()) + } else { + None + }; - match &*boxed { - // gRPC transport errors - SdkError::DapiClientError(DapiClientError::Transport( - TransportError::Grpc(status), - )) => match status.code() { - Code::Unavailable => { - let msg = status.message().to_lowercase(); - if msg.contains("timed out") || msg.contains("timeout") { - TaskError::DapiTimeout { - source_error: boxed, - } - } else if msg.contains("connect error") - || msg.contains("connection refused") - { - TaskError::DapiConnectionRefused { - source_error: boxed, - } - } else { - TaskError::DapiUnavailable { - source_error: boxed, - } + match &*boxed { + // gRPC transport errors + SdkError::DapiClientError(DapiClientError::Transport(TransportError::Grpc(status))) => { + match status.code() { + Code::Unavailable => { + let msg = status.message().to_lowercase(); + if msg.contains("timed out") || msg.contains("timeout") { + TaskError::DapiTimeout { + source_error: boxed, } - } - Code::Internal => TaskError::DapiInternalError { - source_error: boxed, - }, - Code::DeadlineExceeded => TaskError::DapiDeadlineExceeded { - source_error: boxed, - }, - Code::Unauthenticated | Code::PermissionDenied => { - TaskError::DapiAccessDenied { + } else if msg.contains("connect error") + || msg.contains("connection refused") + { + TaskError::DapiConnectionRefused { + source_error: boxed, + } + } else { + TaskError::DapiUnavailable { source_error: boxed, } - } - Code::ResourceExhausted => TaskError::DapiResourceExhausted { - source_error: boxed, - }, - _ => TaskError::SdkError { - source_error: boxed, - }, - }, - // DAPI client errors (non-gRPC) - SdkError::DapiClientError(DapiClientError::NoAvailableAddresses) => { - TaskError::DapiNoAddresses { - source_error: boxed, - } - } - SdkError::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_)) => { - TaskError::DapiAllAddressesExhausted { - source_error: boxed, - } - } - SdkError::DapiClientError(_) => TaskError::SdkError { - source_error: boxed, - }, - // SDK-level errors - SdkError::StateTransitionBroadcastError(_) => TaskError::PlatformRejected { - source_error: boxed, - }, - SdkError::TimeoutReached(..) => TaskError::SdkTimeout { - timeout_secs: timeout_secs.unwrap_or(0), - source_error: boxed, - }, - SdkError::StaleNode(_) => TaskError::DapiStaleNode { - source_error: boxed, - }, - SdkError::NoAvailableAddressesToRetry(_) => { - TaskError::DapiAllAddressesExhausted { - source_error: boxed, } } - SdkError::Cancelled(_) => TaskError::OperationCancelled { + Code::Internal => TaskError::DapiInternalError { source_error: boxed, }, - SdkError::AlreadyExists(_) => TaskError::PlatformAlreadyExists { + Code::DeadlineExceeded => TaskError::DapiDeadlineExceeded { source_error: boxed, }, - SdkError::NonceOverflow(_) => TaskError::IdentityNonceOverflow { + Code::Unauthenticated | Code::PermissionDenied => TaskError::DapiAccessDenied { source_error: boxed, }, - SdkError::IdentityNonceNotFound(_) => TaskError::IdentityNonceNotFound { + Code::ResourceExhausted => TaskError::DapiResourceExhausted { source_error: boxed, }, _ => TaskError::SdkError { @@ -1598,6 +2477,56 @@ impl From<SdkError> for TaskError { }, } } + // DAPI client errors (non-gRPC) + SdkError::DapiClientError(DapiClientError::NoAvailableAddresses) => { + TaskError::DapiNoAddresses { + source_error: boxed, + } + } + SdkError::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_)) => { + TaskError::DapiAllAddressesExhausted { + source_error: boxed, + } + } + SdkError::DapiClientError(_) => TaskError::SdkError { + source_error: boxed, + }, + // SDK-level errors + SdkError::StateTransitionBroadcastError(_) => TaskError::PlatformRejected { + source_error: boxed, + }, + SdkError::TimeoutReached(..) => TaskError::SdkTimeout { + timeout_secs: timeout_secs.unwrap_or(0), + source_error: boxed, + }, + SdkError::StaleNode(_) => TaskError::DapiStaleNode { + source_error: boxed, + }, + SdkError::NoAvailableAddressesToRetry(_) => TaskError::DapiAllAddressesExhausted { + source_error: boxed, + }, + SdkError::Cancelled(_) => TaskError::OperationCancelled { + source_error: boxed, + }, + SdkError::AlreadyExists(_) => TaskError::PlatformAlreadyExists { + source_error: boxed, + }, + SdkError::NonceOverflow(_) => TaskError::IdentityNonceOverflow { + source_error: boxed, + }, + SdkError::IdentityNonceNotFound(_) => TaskError::IdentityNonceNotFound { + source_error: boxed, + }, + // Raised when a withdrawal/transfer is signed with (or falls back to) + // a key whose private material the signer does not hold. + SdkError::Protocol(ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing(_)) => { + TaskError::NoWithdrawalSigningKey { + source_error: boxed, + } + } + _ => TaskError::SdkError { + source_error: boxed, + }, } } } @@ -1619,6 +2548,54 @@ mod tests { use dash_sdk::dpp::identity::Purpose; use dash_sdk::platform::Identifier; + #[test] + fn wrong_passphrase_classifier_matches_only_secret_store_wrong_passphrase() { + use platform_wallet_storage::secrets::SecretStoreError; + + let wrong_pass = TaskError::SecretStore { + source: Box::new(SecretStoreError::WrongPassphrase), + }; + assert!( + wrong_pass.is_secret_store_wrong_passphrase(), + "SecretStore(WrongPassphrase) must route to the passphrase-prompt branch" + ); + + // Any other secret-store failure stays fatal — never prompts. + let corrupt = TaskError::SecretStore { + source: Box::new(SecretStoreError::Corruption), + }; + assert!( + !corrupt.is_secret_store_wrong_passphrase(), + "SecretStore(Corruption) must remain fatal, not prompt" + ); + + // WrongPassphrase wrapped in a *different* variant must not match — + // only the boot-path SecretStore open failure is recoverable. + let seed_wrong_pass = TaskError::WalletSeedStorage { + source: Box::new(SecretStoreError::WrongPassphrase), + }; + assert!( + !seed_wrong_pass.is_secret_store_wrong_passphrase(), + "Only the SecretStore variant is the boot open seam" + ); + + // A wholly unrelated variant is fatal. + assert!(!TaskError::ImportedKeyNotFound.is_secret_store_wrong_passphrase()); + } + + #[test] + fn secret_store_copy_drops_misleading_disk_space_claim() { + use platform_wallet_storage::secrets::SecretStoreError; + let msg = TaskError::SecretStore { + source: Box::new(SecretStoreError::WrongPassphrase), + } + .to_string(); + assert!( + !msg.to_lowercase().contains("disk space"), + "secret-store open failure is not a disk-space problem: {msg}" + ); + } + #[test] fn rpc_http_401_converts_to_core_rpc_auth_failed() { let http_err = dashcore_rpc::jsonrpc::simple_http::Error::HttpErrorCode(401); @@ -1672,9 +2649,14 @@ mod tests { } #[test] - fn must_retry_displays_inner_message() { - let err = TaskError::MustRetry("Auto-detected Core wallet 'mywallet'".to_string()); - assert_eq!(err.to_string(), "Auto-detected Core wallet 'mywallet'"); + fn core_wallet_auto_detected_displays_wallet_name() { + let err = TaskError::CoreWalletAutoDetected { + wallet_name: "mywallet".to_string(), + }; + assert_eq!( + err.to_string(), + "Detected the Core wallet 'mywallet'. Retrying your last action now." + ); } #[test] @@ -1727,6 +2709,52 @@ mod tests { )); } + #[test] + fn from_sdk_error_missing_signing_key_maps_to_no_withdrawal_signing_key() { + let sdk_err = SdkError::Protocol( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "specified withdrawal public key cannot be used for signing".to_string(), + ), + ); + let err = TaskError::from(sdk_err); + assert!( + matches!(err, TaskError::NoWithdrawalSigningKey { .. }), + "Expected NoWithdrawalSigningKey, got: {err:?}" + ); + } + + #[test] + fn no_withdrawal_signing_key_display_is_user_friendly() { + let sdk_err = SdkError::Protocol( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "specified withdrawal public key cannot be used for signing".to_string(), + ), + ); + let msg = TaskError::from(sdk_err).to_string(); + // Includes a concrete, self-serviceable next step. + assert!(msg.contains("Key Info screen"), "no action in: {msg}"); + assert!(msg.contains("try again"), "no retry cue in: {msg}"); + // No jargon and no raw SDK/protocol text leaked into the user message. + let lower = msg.to_lowercase(); + for jargon in [ + "consensus", + "sdk", + "nonce", + "rpc", + "protocol", + "securitylevel", + ] { + assert!( + !lower.contains(jargon), + "jargon '{jargon}' leaked in: {msg}" + ); + } + assert!( + !msg.contains("cannot be used for signing"), + "raw SDK text leaked in: {msg}" + ); + } + #[test] fn from_sdk_error_contract_bounds_conflict() { let contract_id = Identifier::random(); @@ -2809,6 +3837,30 @@ mod tests { ); } + /// mn-live-qa Bug 2: a masternode load that resolves to no node on chain must + /// surface a node-specific message — never the generic identity-not-found + /// copy, whose "ID or name" wording is wrong for a ProTxHash load form. + #[test] + fn masternode_not_found_message_is_node_specific() { + let node_msg = TaskError::MasternodeNotFound { + identity_id: Identifier::random(), + } + .to_string(); + assert!( + node_msg.contains("masternode"), + "Expected a masternode-specific message, got: {node_msg}" + ); + let generic_msg = TaskError::IdentityNotFound.to_string(); + assert!( + !node_msg.contains("ID or name"), + "The node message must not reuse the generic identity 'ID or name' copy: {node_msg}" + ); + assert_ne!( + node_msg, generic_msg, + "MasternodeNotFound must not reuse the IdentityNotFound message" + ); + } + #[test] fn test_identity_token_account_not_frozen_from_consensus_error() { use dash_sdk::dpp::consensus::state::token::IdentityTokenAccountNotFrozenError; @@ -2863,4 +3915,325 @@ mod tests { "Expected action in message, got: {msg}" ); } + + // ─── platform-wallet façade error variants ──────────────────────────────── + + #[test] + fn identity_create_rejected_display_is_user_friendly() { + let inner = platform_wallet::error::PlatformWalletError::TransactionBroadcast( + "asset-lock broadcast rejected".to_string(), + ); + let err = TaskError::IdentityCreateRejected { + source: Box::new(inner), + }; + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display should not be empty, got: {msg}"); + assert!( + msg.contains("rejected by the network"), + "Expected rejection wording, got: {msg}" + ); + assert!( + msg.contains("Your funds are safe") && msg.contains("existing asset lock"), + "Expected recoverable-funds guidance, got: {msg}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + #[test] + fn identity_top_up_rejected_display_includes_identity_id() { + let identity_id = Identifier::random(); + let inner = platform_wallet::error::PlatformWalletError::TransactionBroadcast( + "top-up broadcast rejected".to_string(), + ); + let err = TaskError::IdentityTopUpRejected { + identity_id, + source: Box::new(inner), + }; + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display should not be empty, got: {msg}"); + assert!( + msg.contains(&identity_id.to_string(Encoding::Base58)), + "Expected identity id in message, got: {msg}" + ); + assert!( + msg.contains("try again"), + "Expected actionable guidance, got: {msg}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + #[test] + fn asset_lock_finality_timeout_display_is_user_friendly() { + use dashcore::hashes::Hash; + let outpoint = dashcore::OutPoint::new(dashcore::Txid::from_byte_array([0u8; 32]), 0); + let inner = platform_wallet::error::PlatformWalletError::FinalityTimeout(outpoint); + let err = TaskError::AssetLockFinalityTimeout { + source: Box::new(inner), + }; + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display should not be empty, got: {msg}"); + assert!( + msg.contains("funding lock could not be confirmed"), + "Expected timeout wording, got: {msg}" + ); + assert!( + msg.contains("Wait a minute") && msg.contains("existing asset lock"), + "Expected actionable recovery guidance, got: {msg}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + /// TC-MIG-009 — `TaskError::WalletStorageNotReady` is present, + /// matchable, and renders as a user-friendly, actionable sentence + /// (US-J3: tools called mid-migration return a typed, actionable error). + #[test] + fn wallet_storage_not_ready_variant_is_matchable() { + let err = TaskError::WalletStorageNotReady; + assert!(matches!(err, TaskError::WalletStorageNotReady)); + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display should not be empty"); + assert!( + msg.contains("wait") || msg.contains("try again"), + "Expected actionable guidance, got: {msg}" + ); + // Source chain: variant is fieldless, so no source. + assert!( + std::error::Error::source(&err).is_none(), + "Fieldless variant should have no source" + ); + } + + /// `TaskError::MigrationFailed` preserves the wrapped `MigrationError` + /// in its `#[source]` chain and renders a user-friendly message. + #[test] + fn migration_failed_preserves_source_chain() { + use crate::backend_task::migration::MigrationError; + let inner = MigrationError::LegacyDbOpen { + path: "/tmp/data.db".into(), + source: rusqlite::Error::InvalidQuery, + }; + let err = TaskError::MigrationFailed { + source: std::sync::Arc::new(inner), + }; + let msg = err.to_string(); + assert!(msg.contains("could not finish updating")); + assert!(msg.contains("restart")); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + /// WB-001 — a forward-version wallet database (schema written by a newer + /// build) maps to the dedicated `WalletDataTooNew` variant whose `Display` + /// tells the user to update the app, NOT to free disk space or restart. + #[test] + fn schema_version_unsupported_maps_to_wallet_data_too_new() { + let upstream = platform_wallet_storage::WalletStorageError::SchemaVersionUnsupported { + found: 2, + max_supported: 1, + }; + let err = TaskError::from_wallet_storage_open_error(upstream); + assert!( + matches!( + err, + TaskError::WalletDataTooNew { + found: 2, + max_supported: 1 + } + ), + "Expected WalletDataTooNew, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("newer version") && msg.contains("Update"), + "Expected update guidance, got: {msg}" + ); + assert!( + !msg.contains("disk space"), + "Forward-version message must not mention disk space, got: {msg}" + ); + assert!( + !msg.contains("restart"), + "Forward-version message must not tell the user to restart, got: {msg}" + ); + // Numeric diagnostics stay out of the user-facing copy (no jargon). + assert!( + !msg.contains('2') && !msg.contains('1'), + "Version numbers must not leak into the user message, got: {msg}" + ); + } + + /// WB-001 — a genuine I/O storage failure still maps to `WalletStorage` + /// with the original disk/IO copy and preserves the source chain. + #[test] + fn io_storage_error_maps_to_wallet_storage() { + let upstream = platform_wallet_storage::WalletStorageError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )); + let err = TaskError::from_wallet_storage_open_error(upstream); + assert!( + matches!(err, TaskError::WalletStorage { .. }), + "Expected WalletStorage, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("disk space"), + "Expected disk/IO copy, got: {msg}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + /// Builds a genuine divergent-version [`refinery::Error`] by applying a + /// `V1__init` migration, then re-running with the same version/name but + /// different SQL (which changes refinery's checksum) and + /// `abort_divergent` on. This reproduces the real failure a database + /// written under an incompatible storage layout hits on open. + fn divergent_migration_error() -> refinery::Error { + use refinery::{Migration, Runner}; + + let mut conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); + + let first = Migration::unapplied("V1__init", "CREATE TABLE a (id INTEGER);") + .expect("valid migration"); + Runner::new(&[first]) + .run(&mut conn) + .expect("first run applies cleanly"); + + let divergent = Migration::unapplied("V1__init", "CREATE TABLE b (id INTEGER);") + .expect("valid migration"); + Runner::new(&[divergent]) + .set_abort_divergent(true) + .run(&mut conn) + .expect_err("divergent checksum must abort") + } + + /// A divergent migration history (database written under an + /// incompatible storage layout) maps to the dedicated + /// `WalletDataIncompatible` variant. Its `Display` tells the user to + /// remove the local wallet data, NOT the misleading "free disk space" copy. + #[test] + fn migration_error_maps_to_wallet_data_incompatible() { + let upstream = + platform_wallet_storage::WalletStorageError::Migration(divergent_migration_error()); + let err = TaskError::from_wallet_storage_open_error(upstream); + assert!( + matches!(err, TaskError::WalletDataIncompatible { .. }), + "Expected WalletDataIncompatible, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("not compatible") && msg.contains("Remove"), + "Expected incompatibility guidance, got: {msg}" + ); + assert!( + !msg.contains("disk space"), + "Incompatible-schema message must not mention disk space, got: {msg}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "Expected source chain to be preserved" + ); + } + + #[test] + fn empty_tree_proof_detects_grovedb_verifier_leaf() { + let err = SdkError::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes: Vec::new(), + path_query: None, + height: 0, + time_ms: 0, + error: "Cannot create proof for empty tree".to_string(), + }); + assert!(is_empty_tree_proof(&err)); + } + + #[test] + fn empty_tree_proof_detects_drive_proof_corrupted_leaf() { + use dash_sdk::drive::error::proof::ProofError; + let err = SdkError::DriveProofError( + ProofError::CorruptedProof("Cannot create proof for empty tree".to_string()), + Vec::new(), + dash_sdk::dpp::block::block_info::BlockInfo::default(), + ); + assert!(is_empty_tree_proof(&err)); + } + + #[test] + fn empty_tree_proof_ignores_unrelated_proof_leaf() { + let err = SdkError::Proof(dash_sdk::ProofVerifierError::GroveDBError { + proof_bytes: Vec::new(), + path_query: None, + height: 0, + time_ms: 0, + error: "signature verification failed".to_string(), + }); + assert!(!is_empty_tree_proof(&err)); + } + + #[test] + fn empty_tree_proof_ignores_non_proof_error() { + let err = SdkError::Generic("empty tree mentioned in unrelated text".to_string()); + assert!( + !is_empty_tree_proof(&err), + "the substring must not match outside a proof-error leaf" + ); + } + + /// Ported from the deleted `backend_task::shielded::bundle` tests (Phase D): + /// every per-op confirmation-unknown message must be actionable (tells the + /// user to wait and refresh), distinct (names its own operation), and free + /// of ZK / SDK jargon. `map_shielded_op_error` routes `ShieldedSpendUnconfirmed` + /// into these variants, so a wording regression here would surface verbatim. + #[test] + fn shielded_confirmation_unknown_messages_are_actionable_and_jargon_free() { + let boxed = || { + Box::new(platform_wallet::error::PlatformWalletError::Sdk( + dash_sdk::Error::Generic("boom".to_string()), + )) + }; + let messages = [ + TaskError::ShieldCreditsConfirmationUnknown { source: boxed() }.to_string(), + TaskError::ShieldedTransferConfirmationUnknown { source: boxed() }.to_string(), + TaskError::UnshieldConfirmationUnknown { source: boxed() }.to_string(), + TaskError::ShieldedWithdrawalConfirmationUnknown { source: boxed() }.to_string(), + TaskError::ShieldedConfirmationUnknown { source: boxed() }.to_string(), + ]; + for msg in &messages { + assert!( + msg.contains("refresh") && (msg.contains("Wait") || msg.contains("wait")), + "Expected concrete recovery guidance (wait + refresh), got: {msg}" + ); + for jargon in [ + "nonce", + "state transition", + "SDK", + "RPC", + "Orchard", + "anchor", + "nullifier", + ] { + assert!( + !msg.contains(jargon), + "Expected no jargon ({jargon}) in user message, got: {msg}" + ); + } + } + } } diff --git a/src/backend_task/grovestark.rs b/src/backend_task/grovestark.rs index fa250789d..19498e528 100644 --- a/src/backend_task/grovestark.rs +++ b/src/backend_task/grovestark.rs @@ -1,7 +1,23 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; -use crate::model::grovestark_prover::{GroveSTARKProver, ProofDataOutput}; +use crate::model::grovestark_prover::{ProofDataOutput, ProofMetadata, PublicInputsData}; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use dash_sdk::Sdk; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identifier::Identifier; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::documents::document_query::DocumentQuery; +use dash_sdk::platform::{ + Document, DriveDocumentQuery, Fetch, FetchMany, IdentityKeysQuery, IdentityPublicKey, +}; +use ed25519_dalek::{Signer, SigningKey}; +use grovestark::{ + GroveSTARK, PublicInputs, STARKConfig, STARKProof, create_witness_from_platform_proofs, +}; +use std::time::Instant; pub async fn run_grovestark_task( task: GroveSTARKTask, @@ -9,14 +25,29 @@ pub async fn run_grovestark_task( ) -> Result<BackendTaskSuccessResult, TaskError> { match task { GroveSTARKTask::GenerateProof { - identity_id, + identity, contract_id, document_type, document_id, key_id, - private_key, - public_key, } => { + let identity_id = identity.identity.id().to_string(Encoding::Base58); + + // Resolve the signing key through the JIT chokepoint (no parked-seed + // read), then derive its ed25519 public key. EDDSA_25519_HASH160 + // stores only the 20-byte hash on Platform, so the verifying key is + // recovered from the resolved private key rather than read back. + let (_, private_key) = identity + .resolve_private_key_bytes(PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id) + .await? + .ok_or(TaskError::WalletKeyLookupFailed)?; + + let public_key = { + use dash_sdk::dpp::ed25519_dalek::SigningKey; + let signing_key = SigningKey::from_bytes(&private_key); + *signing_key.verifying_key().as_bytes() + }; + let prover = GroveSTARKProver::new(); let proof_data = prover @@ -49,15 +80,260 @@ pub async fn run_grovestark_task( #[derive(Debug, Clone, PartialEq)] pub enum GroveSTARKTask { GenerateProof { - identity_id: String, + // Boxed: `QualifiedIdentity` is large, and boxing it keeps the enum + // (and the wrapping `BackendTask`) small. + identity: Box<QualifiedIdentity>, contract_id: String, document_type: String, document_id: String, key_id: u32, - private_key: [u8; 32], - public_key: [u8; 32], }, VerifyProof { proof_data: ProofDataOutput, }, } + +struct GroveSTARKProver { + prover: GroveSTARK, +} + +impl GroveSTARKProver { + fn new() -> Self { + Self { + prover: GroveSTARK::with_config(STARKConfig::default()), + } + } + + /// Generate a proof of document ownership. + #[allow(clippy::too_many_arguments)] + async fn generate_proof( + &self, + sdk: &Sdk, + identity_id: &str, + contract_id: &str, + document_type: &str, + document_id: &str, + key_id: u32, + private_key: &[u8; 32], + public_key: &[u8; 32], + ) -> Result<ProofDataOutput, GroveSTARKError> { + if cfg!(debug_assertions) { + return Err(GroveSTARKError::UnsupportedBuild); + } + + let start_time = Instant::now(); + tracing::debug!(%identity_id, %contract_id, %document_type, %document_id, "Starting ZK proof generation"); + + // Step 1: Parse identifiers. + let identity_identifier = Identifier::from_string(identity_id, Encoding::Base58) + .map_err(GroveSTARKError::InvalidIdentityId)?; + let contract_identifier = Identifier::from_string(contract_id, Encoding::Base58) + .map_err(GroveSTARKError::InvalidContractId)?; + + // Step 2: Fetch the specific key with proof. + let specific_key_ids: Vec<KeyID> = vec![key_id]; + let keys_query = IdentityKeysQuery::new(identity_identifier, specific_key_ids); + let (specific_keys, _metadata, key_proof) = + IdentityPublicKey::fetch_many_with_metadata_and_proof(sdk, keys_query, None) + .await + .map_err(|e| GroveSTARKError::Platform(Box::new(e)))?; + + let identity_key = specific_keys + .get(&key_id) + .and_then(|maybe_key| maybe_key.as_ref()) + .ok_or(GroveSTARKError::PrivateKeyNotAvailable)?; + + if identity_key.key_type() != KeyType::EDDSA_25519_HASH160 { + return Err(GroveSTARKError::NonEddsaKey); + } + + let public_key_bytes = *public_key; + + // Step 3: Fetch the contract and build a document query. + let contract = dash_sdk::platform::DataContract::fetch(sdk, contract_identifier) + .await + .map_err(|e| GroveSTARKError::Platform(Box::new(e)))? + .ok_or(GroveSTARKError::ContractNotFound)?; + + let document_id_identifier = Identifier::from_string(document_id, Encoding::Base58) + .map_err(GroveSTARKError::InvalidDocumentId)?; + + let query = DocumentQuery::new(contract, document_type) + .map_err(|e| GroveSTARKError::Platform(Box::new(e)))? + .with_document_id(&document_id_identifier); + + let (document_opt, _metadata, proof) = + Document::fetch_with_metadata_and_proof(sdk, query.clone(), None) + .await + .map_err(|e| GroveSTARKError::Platform(Box::new(e)))?; + + let document = document_opt.ok_or(GroveSTARKError::DocumentNotFound)?; + + let document_cbor = + serde_json::to_vec(&document).map_err(GroveSTARKError::Serialization)?; + + // Ownership check: a mismatch here means the proof will fail. + if document.owner_id() != identity_identifier { + tracing::warn!( + "Document owner does not match the proving identity; proof is expected to fail" + ); + } + + // Step 4: Recover the current state root from the document proof. + let drive_document_query: DriveDocumentQuery = (&query) + .try_into() + .map_err(|e: dash_sdk::error::Error| GroveSTARKError::Platform(Box::new(e)))?; + let (state_root, _documents) = drive_document_query + .verify_proof(&proof.grovedb_proof, sdk.version()) + .map_err(|e| GroveSTARKError::Platform(Box::new(dash_sdk::Error::Drive(e))))?; + + // Step 5: Sign the challenge with Ed25519. + let challenge = create_challenge(&state_root, contract_id, document_id); + let signing_key = SigningKey::from_bytes(private_key); + let sig_bytes = signing_key.sign(&challenge).to_bytes(); + let mut signature_r = [0u8; 32]; + let mut signature_s = [0u8; 32]; + signature_r.copy_from_slice(&sig_bytes[0..32]); + signature_s.copy_from_slice(&sig_bytes[32..64]); + + // Step 6: Build the witness. + let witness = create_witness_from_platform_proofs( + &proof.grovedb_proof, + &key_proof.grovedb_proof, + document_cbor, + &public_key_bytes, + &signature_r, + &signature_s, + &challenge, + ) + .map_err(|e| GroveSTARKError::WitnessCreation(format!("{e:?}")))?; + + // Step 7: Assemble public inputs and prove. + let public_inputs = PublicInputs { + state_root, + contract_id: contract_identifier.to_buffer(), + message_hash: challenge, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(GroveSTARKError::Time)? + .as_secs(), + }; + + tracing::debug!( + threads = rayon::current_num_threads(), + "Generating STARK proof (normally ~10s)" + ); + let proof = self + .prover + .prove(witness, public_inputs.clone()) + .map_err(|e| GroveSTARKError::ProofGeneration(e.to_string()))?; + + let serialized_proof = + serde_json::to_vec(&proof).map_err(GroveSTARKError::Serialization)?; + + let generation_time = start_time.elapsed(); + tracing::debug!( + elapsed_s = generation_time.as_secs_f32(), + "STARK proof generated" + ); + + Ok(ProofDataOutput { + proof: serialized_proof.clone(), + public_inputs: PublicInputsData { + state_root: public_inputs.state_root, + contract_id: public_inputs.contract_id, + message_hash: public_inputs.message_hash, + timestamp: public_inputs.timestamp, + }, + metadata: ProofMetadata { + created_at: public_inputs.timestamp, + proof_size: serialized_proof.len(), + generation_time_ms: generation_time.as_millis() as u64, + security_level: 128, + }, + }) + } + + /// Verify a previously generated proof. + fn verify_proof(&self, proof_data: &ProofDataOutput) -> Result<bool, GroveSTARKError> { + if cfg!(debug_assertions) { + return Err(GroveSTARKError::UnsupportedBuild); + } + + let stark_proof: STARKProof = + serde_json::from_slice(&proof_data.proof).map_err(GroveSTARKError::Deserialization)?; + + let public_inputs = PublicInputs { + state_root: proof_data.public_inputs.state_root, + contract_id: proof_data.public_inputs.contract_id, + message_hash: proof_data.public_inputs.message_hash, + timestamp: proof_data.public_inputs.timestamp, + }; + + self.prover + .verify(&stark_proof, &public_inputs) + .map_err(|e| GroveSTARKError::Verification(e.to_string())) + } +} + +/// Bind the state root and document identity into a 32-byte signing challenge. +fn create_challenge(state_root: &[u8; 32], contract_id: &str, document_id: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(state_root); + hasher.update(contract_id.as_bytes()); + hasher.update(document_id.as_bytes()); + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&hasher.finalize()); + hash +} + +#[derive(Debug, thiserror::Error)] +pub enum GroveSTARKError { + #[error("Could not reach Dash Platform. Please check your connection and try again.")] + Platform(#[source] Box<dash_sdk::Error>), + + #[error("The identity ID is not valid. Please check it and try again.")] + InvalidIdentityId(#[source] dash_sdk::dpp::platform_value::Error), + + #[error("The contract ID is not valid. Please check it and try again.")] + InvalidContractId(#[source] dash_sdk::dpp::platform_value::Error), + + #[error("The document ID is not valid. Please check it and try again.")] + InvalidDocumentId(#[source] dash_sdk::dpp::platform_value::Error), + + #[error("The contract could not be found. Please check the contract ID and try again.")] + ContractNotFound, + + #[error("The document could not be found. Please check the document ID and try again.")] + DocumentNotFound, + + #[error("The selected key cannot be used for this proof. Choose an EdDSA key and try again.")] + NonEddsaKey, + + #[error("The signing key is not available. Unlock the wallet and try again.")] + PrivateKeyNotAvailable, + + #[error("Could not build the proof witness. Please try again.")] + WitnessCreation(String), + + #[error("Could not generate the proof. Please try again.")] + ProofGeneration(String), + + #[error("Could not verify the proof.")] + Verification(String), + + #[error("Could not read the proof data.")] + Serialization(#[source] serde_json::Error), + + #[error("Could not read the proof data.")] + Deserialization(#[source] serde_json::Error), + + #[error("The system clock is set incorrectly. Fix the clock and try again.")] + Time(#[source] std::time::SystemTimeError), + + #[error("Proof generation requires a release build (run with cargo run --release).")] + UnsupportedBuild, +} diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index febaa8610..11e248ac4 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -2,10 +2,11 @@ use super::BackendTaskSuccessResult; use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use crate::wallet_backend::secret_prompt::SecretScope; +use crate::wallet_backend::{SecretAccess, VerifiedIdentityPassword}; use dash_sdk::Error as SdkError; use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -27,6 +28,20 @@ impl AppContext { mut public_key_to_add: QualifiedIdentityPublicKey, private_key: [u8; 32], ) -> Result<BackendTaskSuccessResult, TaskError> { + // O-2: enforce the protected-identity precondition BEFORE any + // on-chain side effect. If this identity is password-protected, prompt + // for and VERIFY its object password up front; a headless host or a + // wrong password fails closed here, so the AddKeys state transition + // below is never built or broadcast for a protected identity we cannot + // seal — no on-chain/local divergence. A keyless identity yields `None` + // and the existing broadcast-then-keyless-persist path is unchanged. + let verify_scope = self.protected_identity_verify_scope(&qualified_identity)?; + let verified_password = verify_protected_identity_precondition( + &self.wallet_backend()?.secret_access(), + verify_scope, + ) + .await?; + let new_identity_nonce = sdk .get_identity_nonce(qualified_identity.identity.id(), true, None) .await?; @@ -51,7 +66,7 @@ impl AppContext { ); // Track balance before operation for fee calculation let balance_before = qualified_identity.identity.balance(); - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_update(); + let estimated_fee = self.fee_estimator().estimate_identity_update(); let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( &qualified_identity.identity, @@ -64,6 +79,7 @@ impl AppContext { sdk.version(), None, ) + .await .map_err(|e| TaskError::IdentityUpdateTransitionError { source_error: Box::new(SdkError::Protocol(e)), })?; @@ -120,8 +136,266 @@ impl AppContext { let fee_result = FeeResult::new(estimated_fee, actual_fee); - self.update_local_qualified_identity(&qualified_identity) - .map_err(|e| TaskError::Database { source: e })?; + // A password-protected identity must never acquire a keyless + // key. The object password was already verified up front (before the + // broadcast above), so here we just seal the newly-added key Tier-2 + // under that SAME password and mark it `InVault` BEFORE saving, so the + // at-rest encode writes no plaintext for it. The encode-path guard + // (`encode_identity_blob_vault_first` → `IdentityKeyProtectionDowngrade`) + // still fails closed if this seal is ever skipped. + // + // This seal is the one fallible disk write between the broadcast above + // and the persist below, and on-chain + local cannot be made atomic. If + // it fails (I/O error, corrupt keystore), the key is already on-chain but + // not saved here: fail with the typed, actionable + // `IdentityKeyAddedButNotSaved` (the key is on the network; retry after + // freeing disk space) instead of a silent loss or a misleading storage + // error — and NEVER fall back to a keyless write (that would strip the + // protection this branch exists to preserve). + let new_key = ( + PrivateKeyOnMainIdentity, + public_key_to_add.identity_public_key.id(), + ); + if let Some(password) = verified_password { + self.wallet_backend()? + .secret_access() + .seal_new_identity_key_with_password( + qualified_identity.identity.id().to_buffer(), + &new_key.0, + new_key.1, + &private_key, + &password, + ) + .map_err(key_added_but_not_saved)?; + // O-1: `mark_in_vault` reports whether the key was present to flip. + // In this single-threaded flow the key we just inserted is always + // present, so a `false` is an unexpected invariant break — warn. + // Persistence stays safe regardless: the at-rest encode guard fails + // closed on any unmarked resident plaintext key of a protected + // identity, so no keyless key can ever be written. + if !qualified_identity.private_keys.mark_in_vault(&new_key) { + tracing::warn!( + target = "backend_task::identity", + "Sealed identity key was unexpectedly absent when marking it in-vault", + ); + } + } + + self.update_local_qualified_identity(&qualified_identity)?; Ok(BackendTaskSuccessResult::AddedKeyToIdentity(fee_result)) } } + +/// O-2 add-key precondition (no SDK, no network): when the target +/// identity is password-protected, prompt for and VERIFY its object password +/// before the caller performs any irreversible on-chain action. `verify_scope` +/// is [`AppContext::protected_identity_verify_scope`]'s result — `Some(existing +/// protected key)` for a protected identity, `None` for a keyless one. +/// +/// A protected identity that cannot be verified — headless +/// ([`NullSecretPrompt`](crate::wallet_backend::secret_prompt::NullSecretPrompt)) +/// → [`TaskError::SecretPromptUnavailable`], or a wrong/cancelled password — +/// fails closed HERE. Since [`AppContext::add_key_to_identity`] calls this with +/// `?` before it builds or broadcasts the AddKeys state transition, that error +/// returns the task before any on-chain side effect: no on-chain/local +/// divergence. A keyless identity returns `Ok(None)` and the keyless add path is +/// unchanged. On success the verified password is returned to seal the new key +/// after the broadcast — a single prompt, split across it. +async fn verify_protected_identity_precondition( + secret_access: &SecretAccess, + verify_scope: Option<SecretScope>, +) -> Result<Option<VerifiedIdentityPassword>, TaskError> { + match verify_scope { + Some(verify) => Ok(Some( + secret_access + .verify_identity_object_password(&verify) + .await?, + )), + None => Ok(None), + } +} + +/// Map a POST-broadcast seal failure to the typed +/// [`TaskError::IdentityKeyAddedButNotSaved`]. By the time the seal runs the new +/// key is already accepted on-chain, so a vault-write failure here cannot be +/// undone — surface a loud, actionable error (the key is on the network; retry +/// after freeing disk space) that preserves the upstream seal failure in its +/// `#[source]` chain, rather than a silent loss or a misleading storage message. +/// Never falls back to a keyless write (the protected invariant holds). +fn key_added_but_not_saved(source: TaskError) -> TaskError { + TaskError::IdentityKeyAddedButNotSaved { + source: Box::new(source), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::wallet_backend::SecretSeam; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::secret_prompt::{NullSecretPrompt, SecretPrompt}; + use crate::wallet_backend::single_key::open_secret_store; + use dash_sdk::dpp::dashcore::Network; + use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretString, WalletId as SecretWalletId, + }; + use std::sync::Arc; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + Arc::new(open_secret_store(&dir.join("secrets.pwsvault")).expect("open vault")) + } + + fn access(store: Arc<SecretStore>, prompt: Arc<dyn SecretPrompt>) -> SecretAccess { + SecretAccess::new(store, prompt, Network::Testnet) + } + + /// Seal a raw identity key Tier-2 under `password`, making the identity + /// password-protected (the precondition's verify anchor). + fn store_protected_identity_key( + store: &Arc<SecretStore>, + identity_id: [u8; 32], + target: &PrivateKeyTarget, + key_id: u32, + key: &[u8; 32], + password: &str, + ) { + let label = SecretScope::identity_key_label(target, key_id); + SecretSeam::new(store) + .put_secret_protected( + &SecretWalletId::from(identity_id), + &label, + &SecretBytes::from_slice(key), + &SecretString::new(password), + ) + .expect("seal identity key tier-2"); + } + + fn main_identity_scope(identity_id: [u8; 32], key_id: u32) -> SecretScope { + SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id, + } + } + + /// O-2 fail-closed: a HEADLESS add-key precondition for a PROTECTED identity + /// returns `SecretPromptUnavailable`. `add_key_to_identity` propagates this + /// with `?` BEFORE it builds or broadcasts the AddKeys state transition, so + /// no on-chain state transition is ever produced — proving the headless add + /// fails closed before the broadcast. + #[tokio::test] + async fn headless_protected_precondition_fails_closed_before_broadcast() { + let dir = tempfile::tempdir().unwrap(); + let identity_id = [0x71u8; 32]; + let store = fresh_store(dir.path()); + // Make the identity protected via an existing Tier-2 key — the verify + // scope `protected_identity_verify_scope` would derive. + store_protected_identity_key( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x15u8; 32], + "identity-object-passwordpw", + ); + let sa = access(store, Arc::new(NullSecretPrompt)); + + let err = + verify_protected_identity_precondition(&sa, Some(main_identity_scope(identity_id, 0))) + .await + .expect_err("headless protected precondition must fail closed"); + assert!( + matches!(err, TaskError::SecretPromptUnavailable), + "expected SecretPromptUnavailable, got {err:?}" + ); + } + + /// The keyless (non-protected) add path is unchanged: a `None` verify scope + /// returns `Ok(None)` without ever prompting, so the broadcast-then-keyless + /// -persist flow proceeds exactly as before. + #[tokio::test] + async fn keyless_precondition_returns_none_without_prompting() { + let dir = tempfile::tempdir().unwrap(); + // `TestPrompt::never()` panics if asked — proving no prompt fires. + let sa = access(fresh_store(dir.path()), Arc::new(TestPrompt::never())); + + let result = verify_protected_identity_precondition(&sa, None) + .await + .expect("keyless precondition is a no-op"); + assert!( + result.is_none(), + "keyless identity yields no verified password", + ); + } + + /// An interactive add-key to a protected identity verifies the correct + /// password up front (one prompt) — the precondition the GUI satisfies + /// before the broadcast — yielding the password used to seal afterwards. + #[tokio::test] + async fn interactive_protected_precondition_verifies_then_yields_password() { + let dir = tempfile::tempdir().unwrap(); + let identity_id = [0x72u8; 32]; + const PW: &str = "identity-object-passwordpw"; + let store = fresh_store(dir.path()); + store_protected_identity_key( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x16u8; 32], + PW, + ); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(PW)])); + let sa = access(store, prompt.clone()); + + let password = + verify_protected_identity_precondition(&sa, Some(main_identity_scope(identity_id, 0))) + .await + .expect("interactive verify succeeds") + .expect("protected identity yields a verified password"); + assert_eq!(prompt.ask_count(), 1, "verified with a single prompt"); + + // The yielded password seals a new key Tier-2 with no further prompt. + sa.seal_new_identity_key_with_password( + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5, + &[0x26u8; 32], + &password, + ) + .expect("seal new key with the verified password"); + assert_eq!(prompt.ask_count(), 1, "sealing did not prompt again"); + } + + /// A post-broadcast seal failure maps to the typed + /// `IdentityKeyAddedButNotSaved` and preserves the upstream cause in the + /// `#[source]` chain — so the banner can speak about the on-chain key while + /// logs keep the storage diagnostic, and the key is never silently dropped. + #[test] + fn post_broadcast_seal_failure_maps_to_typed_orphan_error() { + use std::error::Error as _; + // Any upstream seal error stands in for a vault-write failure; the + // mapping wraps it without inspecting the specific variant. + let mapped = key_added_but_not_saved(TaskError::IdentityKeyMissing); + assert!( + matches!(mapped, TaskError::IdentityKeyAddedButNotSaved { .. }), + "a post-broadcast seal failure must map to the typed orphan error, got {mapped:?}" + ); + // The upstream cause survives in the source chain (Display/Debug split). + let source = mapped.source().expect("upstream seal error is preserved"); + assert!( + source + .to_string() + .contains("could not be found on this device"), + "expected the upstream cause in the chain, got {source}" + ); + // The user-facing message states the key is on the network and is + // actionable (free disk space, retry) — no jargon, no silent loss. + let shown = mapped.to_string(); + assert!( + shown.contains("added to your identity on the network"), + "message must tell the user the key is on-chain, got {shown}" + ); + } +} diff --git a/src/backend_task/identity/auth_pubkey_resolve.rs b/src/backend_task/identity/auth_pubkey_resolve.rs new file mode 100644 index 000000000..888dc9b8f --- /dev/null +++ b/src/backend_task/identity/auth_pubkey_resolve.rs @@ -0,0 +1,207 @@ +//! Cache-hit-else-JIT resolution of identity-authentication public keys (D4b). +//! +//! The identity-auth derivation path is hardened to the leaf, so its +//! public keys cannot be derived from a stored xpub; instead they are +//! memoised in the DET KV sidecar (see +//! [`AuthPubkeyCache`](crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache)). +//! These helpers are the single place the identity load/discover tasks go +//! through to read those keys: a warm cache serves them with zero seed +//! access; a cold miss resolves the seed *once* through the +//! [`SecretAccess`](crate::wallet_backend::SecretAccess) chokepoint, +//! derives the missing keys, writes them back, and returns them. +//! +//! Correctness never depends on the cache being present — an absent or +//! corrupt blob reads as cold and self-heals on first touch. + +use std::collections::BTreeMap; +use std::ops::Range; +use std::sync::{Arc, RwLock}; + +use dash_sdk::dpp::dashcore::PublicKey; + +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::wallet::{AuthKeyMaps, DerivedAuthKeyMaps, Wallet}; +use crate::wallet_backend::SecretScope; + +/// The two identity-auth lookup maps a data-map resolution returns: +/// compressed-pubkey-bytes -> key_index and hash160 -> key_index. +type AuthPubkeyDataMaps = (BTreeMap<Vec<u8>, u32>, BTreeMap<[u8; 20], u32>); + +impl AppContext { + /// Resolve one identity-auth ECDSA public key, cache-first. + /// + /// Reads the wallet's [`AuthPubkeyCache`](crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache) + /// for `(network, identity_index, key_index)`; on a hit returns it with + /// no seed access. On a miss, opens one `with_secret` scope, derives + /// the key from the seed, writes it back to the cache, and returns it. + /// + /// `allow_prompt` controls the cold-cache path: when `false`, a miss on a + /// passphrase-protected wallet that is not session-unlocked returns + /// [`TaskError::AuthKeyUnlockRequired`] instead of opening a `with_secret` + /// scope — so the background sweep skips a locked wallet rather than popping + /// a passphrase modal. The interactive search passes `true`. + pub(super) async fn resolve_identity_auth_pubkey( + &self, + wallet: &Arc<RwLock<Wallet>>, + allow_prompt: bool, + identity_index: u32, + key_index: u32, + ) -> Result<PublicKey, TaskError> { + let network = self.network; + let seed_hash = wallet.read()?.seed_hash(); + + let backend = self.wallet_backend()?; + let cache = backend.auth_pubkey_cache().get(network, &seed_hash); + + if let Some(public_key) = cache.get(network, identity_index, key_index) { + return Ok(public_key); + } + + if !allow_prompt + && !backend + .secret_access() + .can_resolve_without_prompt(&SecretScope::HdSeed { seed_hash }) + { + return Err(TaskError::AuthKeyUnlockRequired); + } + + let wallet = Arc::clone(wallet); + backend + .secret_access() + .with_secret(&SecretScope::HdSeed { seed_hash }, |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + let public_key = wallet + .read()? + .identity_authentication_ecdsa_public_key_from_seed( + seed, + network, + identity_index, + key_index, + ) + .map_err(|detail| { + tracing::warn!(error = %detail, "identity-auth key derivation failed"); + TaskError::WalletAddressDerivationFailed + })?; + + let mut cache = cache; + if cache.insert(network, identity_index, key_index, &public_key) { + backend + .auth_pubkey_cache() + .put(network, &seed_hash, &cache)?; + } + Ok(public_key) + }) + .await + } + + /// Resolve the two identity-auth lookup maps for `key_index_range`, + /// cache-first, partitioning all cache misses into a single + /// `with_secret` scope (one prompt for the whole request, not one per + /// key). + /// + /// `register_addresses` mirrors the legacy data-map flag: when set, + /// each key's P2PKH address is registered on the wallet regardless of + /// whether the key came from the cache or a cold derivation. + /// + /// `allow_prompt` controls the cold-cache path exactly as in + /// [`Self::resolve_identity_auth_pubkey`]: when `false`, any cache miss on a + /// passphrase-protected wallet that is not session-unlocked returns + /// [`TaskError::AuthKeyUnlockRequired`] before opening a `with_secret` + /// scope, so the background sweep never triggers a passphrase modal. + pub(super) async fn resolve_identity_auth_pubkeys_data_map( + &self, + wallet: &Arc<RwLock<Wallet>>, + register_addresses: bool, + allow_prompt: bool, + identity_index: u32, + key_index_range: Range<u32>, + ) -> Result<AuthPubkeyDataMaps, TaskError> { + let network = self.network; + let seed_hash = wallet.read()?.seed_hash(); + + let backend = self.wallet_backend()?; + let cache = backend.auth_pubkey_cache().get(network, &seed_hash); + + let AuthKeyMaps { + by_serialized: mut public_key_map, + by_hash160: mut public_key_hash_map, + misses, + } = { + let mut guard = wallet.write()?; + guard + .identity_authentication_ecdsa_public_keys_data_map_cached( + self, + register_addresses, + &cache, + network, + identity_index, + key_index_range, + ) + .map_err(|detail| { + tracing::warn!(error = %detail, "identity-auth key map derivation failed"); + TaskError::WalletAddressDerivationFailed + })? + }; + + if misses.is_empty() { + return Ok((public_key_map, public_key_hash_map)); + } + + if !allow_prompt + && !backend + .secret_access() + .can_resolve_without_prompt(&SecretScope::HdSeed { seed_hash }) + { + return Err(TaskError::AuthKeyUnlockRequired); + } + + let wallet = Arc::clone(wallet); + backend + .secret_access() + .with_secret(&SecretScope::HdSeed { seed_hash }, |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::ContactWalletSeedUnavailable)?; + let DerivedAuthKeyMaps { + by_serialized: miss_key_map, + by_hash160: miss_hash_map, + derived, + } = { + let mut guard = wallet.write()?; + guard + .identity_authentication_ecdsa_public_keys_data_map_from_seed( + self, + register_addresses, + seed, + network, + identity_index, + &misses, + ) + .map_err(|detail| { + tracing::warn!(error = %detail, "identity-auth key map derivation failed"); + TaskError::WalletAddressDerivationFailed + })? + }; + public_key_map.extend(miss_key_map); + public_key_hash_map.extend(miss_hash_map); + + if !derived.is_empty() { + let mut cache = cache; + let mut changed = false; + for (key_index, public_key) in &derived { + changed |= cache.insert(network, identity_index, *key_index, public_key); + } + if changed { + backend + .auth_pubkey_cache() + .put(network, &seed_hash, &cache)?; + } + } + Ok((public_key_map, public_key_hash_map)) + }) + .await + } +} diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index c16b61e09..d0f5f1424 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -1,57 +1,127 @@ +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::identity_discovery::{ + DiscoverySummary, IDENTITY_GAP_LIMIT, IDENTITY_SCAN_HARD_CAP, should_continue_scan, +}; use crate::model::qualified_identity::DPNSNameInfo; use crate::model::wallet::Wallet; +use crate::utils::egui_mpsc::SenderAsync; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use std::sync::{Arc, RwLock}; +/// Number of authentication-key indices probed per identity index before +/// concluding no identity is registered there. +const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; + impl AppContext { - /// Discover and load identities derived from a wallet by checking the network. - /// This is called automatically on wallet unlock to find any identities that - /// were registered using keys from the wallet. - pub(crate) async fn discover_identities_from_wallet( + /// Discover and load identities derived from a wallet by checking the + /// network, with a rolling gap-limited lookahead. + /// + /// The scan starts at index 0 and keeps probing while it is within + /// [`IDENTITY_GAP_LIMIT`](crate::model::identity_discovery::IDENTITY_GAP_LIMIT) + /// indices of the highest index that produced an identity, so each new + /// discovery extends the window. `seed_from_index`, together with the + /// wallet's already-known identity indices, seeds that window so a + /// prior-session high index is never missed even if the early indices are + /// empty. + /// + /// `allow_prompt` controls the secret path: with `true` (the interactive + /// search) a cold auth-key cache miss prompts for the passphrase; with + /// `false` (the background sweep) a locked, protected wallet is skipped + /// instead of prompting. + /// + /// When `progress` is `Some`, a [`BackendTaskSuccessResult::Progress`] event + /// is sent before each probed index. + pub(crate) async fn discover_identities_gap_limited( self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>, - max_identity_index: u32, - ) -> Result<(), String> { + seed_from_index: u32, + allow_prompt: bool, + progress: Option<&SenderAsync<TaskResult>>, + ) -> Result<DiscoverySummary, TaskError> { use dash_sdk::platform::Fetch; use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; - const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; - let sdk = self.sdk.load().as_ref().clone(); - let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + let scan_network = self.network; + let seed_hash = wallet.read()?.seed_hash(); + + // Seed the rolling window from the explicit seed index and from any + // identity already known to this wallet, so a high prior-session index + // keeps the scan open long enough to re-reach it. + let highest_known_index = { + let guard = wallet.read()?; + guard.identities.keys().copied().max() + }; + let seed_window = match highest_known_index { + Some(known) => Some(known.max(seed_from_index)), + None if seed_from_index > 0 => Some(seed_from_index), + None => None, + }; tracing::info!( seed = %hex::encode(seed_hash), - "Starting identity discovery for wallet (checking indices 0..{})", - max_identity_index + seed_window = ?seed_window, + allow_prompt, + "Starting gap-limited identity discovery for wallet" ); - let mut found_count = 0; + let mut summary = DiscoverySummary::default(); + let mut highest_found = seed_window; + let mut current_index = 0u32; + + while should_continue_scan(current_index, highest_found) { + if let Some(sender) = progress { + let next = current_index.saturating_add(1); + // Soft total: the current rolling-window upper bound (it grows + // as identities are found), clamped to the hard cap. A rolling + // scan has no fixed end, so this is a best-effort denominator. + let soft_total = highest_found + .map_or(IDENTITY_GAP_LIMIT, |h| h.saturating_add(IDENTITY_GAP_LIMIT)) + .saturating_add(1) + .min(IDENTITY_SCAN_HARD_CAP); + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::Progress { + message: format!( + "Searching wallet identity index {next} of about {soft_total}." + ), + current: next, + total: soft_total, + }, + ))) + .await + .map_err(|_| TaskError::InternalSendError)?; + } - for identity_index in 0..=max_identity_index { - // Try to find an identity at this index by checking authentication keys let mut fetched_identity = None; let mut matched_key_index = None; for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { - let public_key = { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - match wallet_guard.identity_authentication_ecdsa_public_key( - self.network, - identity_index, - key_index, - ) { - Ok(key) => key, - Err(e) => { - tracing::debug!( - "Could not derive key at index {}/{}: {}", - identity_index, - key_index, - e - ); - continue; - } + let public_key = match self + .resolve_identity_auth_pubkey(wallet, allow_prompt, current_index, key_index) + .await + { + Ok(key) => key, + // A locked, protected wallet in the no-prompt path: skip the + // whole wallet — every later index needs the same seed. + Err(TaskError::AuthKeyUnlockRequired) => { + tracing::debug!( + seed = %hex::encode(seed_hash), + "Skipping locked wallet during background identity discovery" + ); + return Ok(summary); + } + Err(e) => { + tracing::debug!( + error = %e, + current_index, + key_index, + "Could not derive auth key during discovery" + ); + continue; } }; @@ -70,88 +140,142 @@ impl AppContext { Ok(None) => continue, Err(e) => { tracing::debug!( - "Error querying identity at index {}/{}: {}", - identity_index, + error = %e, + current_index, key_index, - e + "Error querying identity during discovery" ); continue; } } } - // If we found an identity, process and store it if let Some(identity) = fetched_identity { let identity_id = identity.id(); tracing::info!( identity_id = %identity_id, - identity_index, + current_index, key_index = ?matched_key_index, "Discovered identity from wallet" ); - // Check if we already have this identity stored - let already_exists = { - let wallets = self.wallets.read().map_err(|e| e.to_string())?; - let existing = self.db.get_identity_by_id(&identity_id, self, &wallets); - existing.is_ok() && existing.unwrap().is_some() - }; + summary.found = summary.found.saturating_add(1); + highest_found = Some(highest_found.map_or(current_index, |h| h.max(current_index))); - if already_exists { - tracing::info!( - identity_id = %identity_id, - "Identity already loaded, skipping" - ); - continue; - } - - // Build qualified identity with wallet key derivation paths match self - .build_qualified_identity_from_wallet(&sdk, identity, wallet, identity_index) + .upsert_discovered_identity( + &sdk, + identity, + wallet, + scan_network, + allow_prompt, + current_index, + ) .await { - Ok(qualified_identity) => { - // Store the identity - if let Err(e) = self.insert_local_qualified_identity( - &qualified_identity, - &Some((seed_hash, identity_index)), - ) { - tracing::warn!( - identity_id = %identity_id, - error = %e, - "Failed to store discovered identity" - ); - } else { - // Add to wallet's identities map - if let Ok(mut wallet_guard) = wallet.write() { - wallet_guard - .identities - .insert(identity_index, qualified_identity.identity.clone()); - } - found_count += 1; - tracing::info!( - identity_id = %identity_id, - "Successfully loaded discovered identity" - ); - } - } - Err(e) => { - tracing::warn!( - identity_id = %identity_id, - error = %e, - "Failed to build qualified identity" - ); - } + Ok(()) => summary.stored = summary.stored.saturating_add(1), + Err(e) => tracing::warn!( + identity_id = %identity_id, + error = %e, + "Failed to store discovered identity" + ), } } + + current_index = current_index.saturating_add(1); } tracing::info!( seed = %hex::encode(seed_hash), - found_count, - "Identity discovery complete" + found = summary.found, + stored = summary.stored, + "Gap-limited identity discovery complete" ); + Ok(summary) + } + + /// Discover and load identities derived from a wallet on wallet unlock. + /// + /// Thin wrapper over [`Self::discover_identities_gap_limited`] that prompts + /// for the seed if needed (the unlock gesture already implies user consent) + /// and seeds the rolling window from `max_identity_index`. + pub(crate) async fn discover_identities_from_wallet( + self: &Arc<Self>, + wallet: &Arc<RwLock<Wallet>>, + max_identity_index: u32, + ) -> Result<(), TaskError> { + self.discover_identities_gap_limited(wallet, max_identity_index, true, None) + .await?; + Ok(()) + } + + /// Fetch, build, and store one discovered identity, preserving DET-only + /// metadata (the user alias) from any existing stored record. + /// + /// The freshly-built [`QualifiedIdentity`](crate::model::qualified_identity::QualifiedIdentity) + /// carries `alias: None`; before storing, the existing record's alias is + /// copied onto it so a re-discovery refreshes keys and DPNS names without + /// wiping a user-assigned alias. Wallet association and top-up history are + /// preserved by [`AppContext::update_local_qualified_identity`] / + /// the separate top-up KV key, respectively. + /// + /// `scan_network` is the network the scan started on. If the active network + /// changed mid-scan, the store is skipped — defense-in-depth so an in-flight + /// pass never writes a discovered identity under the wrong network scope. + async fn upsert_discovered_identity( + self: &Arc<Self>, + sdk: &dash_sdk::Sdk, + identity: dash_sdk::platform::Identity, + wallet: &Arc<RwLock<Wallet>>, + scan_network: dash_sdk::dpp::dashcore::Network, + allow_prompt: bool, + identity_index: u32, + ) -> Result<(), TaskError> { + if self.network != scan_network { + tracing::debug!("Network changed mid-scan; skipping store of discovered identity"); + return Ok(()); + } + + let identity_id = identity.id(); + let seed_hash = wallet.read()?.seed_hash(); + + let mut qualified_identity = self + .build_qualified_identity_from_wallet( + sdk, + identity, + wallet, + allow_prompt, + identity_index, + ) + .await?; + + match self.get_identity_by_id(&identity_id)? { + Some(existing) => { + // Carry DET-only metadata onto the refreshed identity, then + // update in place — `update_local_qualified_identity` keeps the + // stored wallet association, and top-ups live under a separate + // KV key untouched by this write. + qualified_identity.alias = existing.alias; + self.update_local_qualified_identity(&qualified_identity)?; + } + None => { + self.insert_local_qualified_identity( + &qualified_identity, + &Some((seed_hash, identity_index)), + )?; + } + } + + if let Ok(mut wallet_guard) = wallet.write() { + wallet_guard + .identities + .insert(identity_index, qualified_identity.identity.clone()); + } + tracing::info!( + identity_id = %identity_id, + "Successfully loaded discovered identity" + ); Ok(()) } @@ -162,8 +286,9 @@ impl AppContext { sdk: &dash_sdk::Sdk, identity: dash_sdk::platform::Identity, wallet: &Arc<RwLock<Wallet>>, + allow_prompt: bool, identity_index: u32, - ) -> Result<crate::model::qualified_identity::QualifiedIdentity, String> { + ) -> Result<crate::model::qualified_identity::QualifiedIdentity, TaskError> { use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; @@ -176,31 +301,23 @@ impl AppContext { use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; - let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + let seed_hash = wallet.read()?.seed_hash(); // Get the highest key ID in the identity to know how many keys to derive let highest_key_id = identity.public_keys().keys().max().copied().unwrap_or(0); let derive_up_to = highest_key_id.saturating_add(6); // Add buffer for future keys - // Derive authentication keys from wallet and build lookup maps - let mut public_key_to_index: std::collections::BTreeMap<Vec<u8>, u32> = - std::collections::BTreeMap::new(); - let mut public_key_hash_to_index: std::collections::BTreeMap<[u8; 20], u32> = - std::collections::BTreeMap::new(); - - { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - for key_index in 0..=derive_up_to { - if let Ok(public_key) = wallet_guard.identity_authentication_ecdsa_public_key( - self.network, - identity_index, - key_index, - ) { - public_key_to_index.insert(public_key.to_bytes().to_vec(), key_index); - public_key_hash_to_index.insert(public_key.pubkey_hash().into(), key_index); - } - } - } + // Derive authentication keys from wallet and build lookup maps, + // cache-first (one JIT scope on a cold cache). + let (public_key_to_index, public_key_hash_to_index) = self + .resolve_identity_auth_pubkeys_data_map( + wallet, + false, + allow_prompt, + identity_index, + 0..derive_up_to.saturating_add(1), + ) + .await?; // Match identity keys with wallet derivation paths let private_keys_map: std::collections::BTreeMap<_, _> = identity @@ -248,10 +365,11 @@ impl AppContext { let dpns_names = { use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::Value; - use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; let query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -259,6 +377,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(identity.id().into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -309,6 +429,7 @@ impl AppContext { private_keys: private_keys_map.into(), dpns_names, associated_wallets, + secret_access: self.wallet_backend().ok().map(|b| b.secret_access()), wallet_index: Some(identity_index), top_ups: Default::default(), status: IdentityStatus::Unknown, diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c576e04cd..776a2b392 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1,7 +1,9 @@ use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; -use crate::backend_task::identity::{IdentityInputToLoad, verify_key_input}; +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode}; use crate::context::AppContext; +use crate::model::identity_key_protection::validate_protection_password; +use crate::model::key_input::verify_key_input; use crate::model::qualified_identity::PrivateKeyTarget::{ self, PrivateKeyOnMainIdentity, PrivateKeyOnVoterIdentity, }; @@ -27,16 +29,40 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity}; -use egui::ahash::HashMap; use std::collections::BTreeMap; +use std::collections::HashMap; use std::convert::TryInto; use std::sync::{Arc, RwLock}; type WalletKeyMap = BTreeMap<(PrivateKeyTarget, u32), (QualifiedIdentityPublicKey, PrivateKeyData)>; type WalletMatchResult = Option<(WalletSeedHash, u32, WalletKeyMap)>; +/// Merge an already-stored identity's keys and associations into a freshly +/// built one, preserving anything the new (partial) load did not resupply +/// (§10.8, the "Add voting key" in-place update). Keys the new load provides +/// win on collision; keys it omits (e.g. Owner/Payout on a voting-key-only +/// update) are carried over from `existing` rather than lost. The existing +/// alias and identity associations are kept only when the new build lacks them. +fn merge_existing_keys_into(new: &mut QualifiedIdentity, existing: QualifiedIdentity) { + for (key, value) in existing.private_keys.private_keys { + new.private_keys.private_keys.entry(key).or_insert(value); + } + if new.alias.is_none() { + new.alias = existing.alias; + } + if new.associated_voter_identity.is_none() { + new.associated_voter_identity = existing.associated_voter_identity; + } + if new.associated_operator_identity.is_none() { + new.associated_operator_identity = existing.associated_operator_identity; + } + if new.associated_owner_key_id.is_none() { + new.associated_owner_key_id = existing.associated_owner_key_id; + } +} + impl AppContext { pub(super) async fn load_identity( &self, @@ -53,31 +79,25 @@ impl AppContext { keys_input, derive_keys_from_wallets, selected_wallet_seed_hash, + encryption_password, + load_mode, } = input; - // Verify the voting private key - let owner_private_key_bytes = - verify_key_input(owner_private_key_input, "Owner").map_err(|e| { - TaskError::KeyInputValidationFailed { - key_name: "Owner".to_string(), - detail: e, - } - })?; + // FR-8: validate the load-time encryption password up front, before the + // network fetch, so a too-short password fails fast. The seal path + // re-enforces the same rule authoritatively after insert. + if let Some(password) = &encryption_password { + validate_protection_password(password)?; + } + + // Verify the owner private key + let owner_private_key_bytes = verify_key_input(owner_private_key_input, "Owner")?; // Verify the voting private key - let voting_private_key_bytes = verify_key_input(voting_private_key_input, "Voting") - .map_err(|e| TaskError::KeyInputValidationFailed { - key_name: "Voting".to_string(), - detail: e, - })?; + let voting_private_key_bytes = verify_key_input(voting_private_key_input, "Voting")?; let payout_address_private_key_bytes = - verify_key_input(payout_address_private_key_input, "Payout Address").map_err(|e| { - TaskError::KeyInputValidationFailed { - key_name: "Payout Address".to_string(), - detail: e, - } - })?; + verify_key_input(payout_address_private_key_input, "Payout Address")?; // Parse the identity ID let identity_id = match Identifier::from_string(&identity_id_input, Encoding::Base58) @@ -85,15 +105,63 @@ impl AppContext { { Ok(id) => id, Err(_e) => { + // For masternodes/evonodes the identity id field IS a ProTxHash + // (hex) or Base58 identity id — surface the ProTxHash-specific + // message so the user is told the exact accepted formats. + if identity_type != IdentityType::User { + return Err(TaskError::MalformedProTxHash { + input: identity_id_input, + }); + } return Err(TaskError::IdentifierParsingError { input: identity_id_input, }); } }; + // §10.9 / TC-EDGE-07: a fresh load rejects a ProTxHash already stored, + // before any network fetch — so the existing node's alias/keys/protection + // tier are never silently overwritten. Checked here, at the storage + // layer, so every `RejectIfExists` caller is guarded uniformly. + let existing_stored = self.get_local_qualified_identity(&identity_id)?; + match load_mode { + IdentityLoadMode::RejectIfExists if existing_stored.is_some() => { + return Err(TaskError::DuplicateProTxHash { identity_id }); + } + _ => {} + } + + // An in-place merge into a password-protected (Tier-2) node must + // seal the newly-supplied key Tier-2, or the plaintext key would trip + // the insert's fail-closed guard. Verify the node's object password UP + // FRONT — before the network fetch — so a wrong or headless password + // fails closed with no wasted round-trip and no partial state, mirroring + // add_key_to_identity's verify-before-broadcast / seal-after order. The + // verified password seals the merged plaintext keys just before insert. + let merge_seal_password = match (&load_mode, existing_stored.as_ref()) { + (IdentityLoadMode::MergeIntoExisting, Some(existing)) => { + match self.protected_identity_verify_scope(existing)? { + Some(verify_scope) => Some( + self.wallet_backend()? + .secret_access() + .verify_identity_object_password(&verify_scope) + .await?, + ), + None => None, + } + } + _ => None, + }; + // Fetch the identity using the SDK let identity = match Identity::fetch_by_identifier(sdk, identity_id).await { Ok(Some(identity)) => identity, + // For masternode/evonode loads the input is a ProTxHash, so surface a + // node-specific message instead of the generic identity-not-found copy + // (which talks about an "ID or name" the user never entered here). + Ok(None) if identity_type != IdentityType::User => { + return Err(TaskError::MasternodeNotFound { identity_id }); + } Ok(None) => return Err(TaskError::IdentityNotFound), Err(e) => return Err(TaskError::from(e)), }; @@ -104,11 +172,13 @@ impl AppContext { if identity_type == IdentityType::User && derive_keys_from_wallets - && let Some((_, _, wallet_private_keys)) = self.match_user_identity_keys_with_wallet( - &identity, - &wallets, - selected_wallet_seed_hash, - )? + && let Some((_, _, wallet_private_keys)) = self + .match_user_identity_keys_with_wallet( + &identity, + &wallets, + selected_wallet_seed_hash, + ) + .await? { encrypted_private_keys.extend(wallet_private_keys); } @@ -116,12 +186,8 @@ impl AppContext { if identity_type != IdentityType::User && let Some(owner_private_key_bytes) = owner_private_key_bytes { - let key = self - .verify_owner_key_exists_on_identity(&identity, &owner_private_key_bytes) - .map_err(|e| TaskError::KeyInputValidationFailed { - key_name: "Owner".to_string(), - detail: e, - })?; + let key = + self.verify_owner_key_exists_on_identity(&identity, &owner_private_key_bytes)?; let key_id = key.id(); let qualified_key = QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( @@ -141,15 +207,10 @@ impl AppContext { if identity_type != IdentityType::User && let Some(payout_address_private_key_bytes) = payout_address_private_key_bytes { - let key = self - .verify_payout_address_key_exists_on_identity( - &identity, - &payout_address_private_key_bytes, - ) - .map_err(|e| TaskError::KeyInputValidationFailed { - key_name: "Payout Address".to_string(), - detail: e, - })?; + let key = self.verify_payout_address_key_exists_on_identity( + &identity, + &payout_address_private_key_bytes, + )?; let key_id = key.id(); let qualified_key = QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( @@ -187,15 +248,10 @@ impl AppContext { Err(e) => return Err(TaskError::from(e)), }; - let key = self - .verify_voting_key_exists_on_identity( - &voter_identity, - &voting_private_key_bytes, - ) - .map_err(|e| TaskError::KeyInputValidationFailed { - key_name: "Voting".to_string(), - detail: e, - })?; + let key = self.verify_voting_key_exists_on_identity( + &voter_identity, + &voting_private_key_bytes, + )?; let qualified_key = QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( key.clone(), @@ -211,9 +267,7 @@ impl AppContext { ); Some((voter_identity, key)) } else { - return Err(TaskError::InvalidPrivateKey { - detail: "Voting private key is not valid".to_string(), - }); + return Err(TaskError::InvalidPrivateKey); } } else { None @@ -230,17 +284,11 @@ impl AppContext { .filter_map(|key_string| { Some( verify_key_input(key_string, "User Key") - .map_err(|e| TaskError::KeyInputValidationFailed { - key_name: "User Key".to_string(), - detail: e, - }) + .map_err(TaskError::from) .transpose()? .and_then(|sk| { - PrivateKey::from_byte_array(&sk, self.network).map_err(|e| { - TaskError::InvalidPrivateKey { - detail: e.to_string(), - } - }) + PrivateKey::from_byte_array(&sk, self.network) + .map_err(|_| TaskError::InvalidPrivateKey) }), ) }) @@ -316,6 +364,7 @@ impl AppContext { // Fetch DPNS names using SDK let dpns_names_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -323,6 +372,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -368,7 +419,7 @@ impl AppContext { None }; - let qualified_identity = QualifiedIdentity { + let mut qualified_identity = QualifiedIdentity { identity, associated_voter_identity, associated_operator_identity: None, @@ -384,11 +435,31 @@ impl AppContext { Ok::<_, TaskError>((w.seed_hash(), wallet.clone())) }) .collect::<Result<_, _>>()?, + secret_access: self.wallet_backend().ok().map(|b| b.secret_access()), wallet_index: None, //todo top_ups: Default::default(), status: IdentityStatus::Active, network: self.network, }; + // §10.8: an in-place update (the "Add voting key" fix-up) merges the + // newly-supplied keys into the already-stored identity's keys instead of + // clobbering them — the new voting key is added while the existing + // Owner/Payout keys (which the update leaves blank) survive. + if load_mode == IdentityLoadMode::MergeIntoExisting + && let Some(existing) = existing_stored + { + merge_existing_keys_into(&mut qualified_identity, existing); + } + + // When merging into a Tier-2 node, seal the newly-merged plaintext + // keys Tier-2 under the already-verified password and mark them InVault + // BEFORE the insert, so the fail-closed guard sees no resident plaintext + // on a protected identity — the same seal-before-persist add_key_to_identity + // performs. The password was verified up front, before the network fetch. + if let Some(password) = &merge_seal_password { + self.seal_merged_plaintext_keys(&mut qualified_identity, password)?; + } + let wallet_info = qualified_identity .determine_wallet_info() .map_err(|e| TaskError::WalletInfoDeterminationFailed { detail: e })?; @@ -405,10 +476,45 @@ impl AppContext { .insert(identity_index, qualified_identity.identity.clone()); } + // FR-8: when a load-time password was supplied, seal the just-inserted + // keyless keys Tier-2 through the existing per-identity protect + // envelope. `insert_local_qualified_identity` migrated the resident + // plaintext into the keyless vault, so `protect_identity_keys` + // (validate → fail-closed guard → seal via the secret_seam chokepoint) + // reloads from the DB and seals them — one path, no new crypto. + if let Some(password) = encryption_password { + self.protect_identity_keys(qualified_identity.identity.id(), password, None)?; + } + Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } - pub(super) fn match_user_identity_keys_with_wallet( + /// Seal every resident-plaintext key of `qi` Tier-2 under an + /// already-verified identity object `password`, marking each `InVault`. + /// Called on the in-place merge path when the target node is + /// password-protected, BEFORE the at-rest insert, so the fail-closed guard + /// (`encode_identity_blob_vault_first`) never sees a keyless key on a + /// protected identity. The one seal fallible write per new key is the + /// merge-path twin of `add_key_to_identity`'s post-broadcast seal. + pub(super) fn seal_merged_plaintext_keys( + &self, + qi: &mut QualifiedIdentity, + password: &crate::wallet_backend::VerifiedIdentityPassword, + ) -> Result<(), TaskError> { + let backend = self.wallet_backend()?; + let secret_access = backend.secret_access(); + let id = qi.identity.id().to_buffer(); + // `take_plaintext_for_vault` flips each Clear/AlwaysClear key to `InVault` + // and hands back its raw bytes; sealing each Tier-2 leaves the identity + // fully protected with no keyless residue. + for ((target, key_id), raw) in qi.private_keys.take_plaintext_for_vault() { + secret_access + .seal_new_identity_key_with_password(id, &target, key_id, &raw, password)?; + } + Ok(()) + } + + pub(super) async fn match_user_identity_keys_with_wallet( &self, identity: &Identity, wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, @@ -421,23 +527,22 @@ impl AppContext { if wallet_filter.is_some_and(|filter| filter != wallet_seed_hash) { continue; } - let mut wallet = match wallet_arc.write() { - Ok(guard) => guard, - Err(_) => continue, // Skip poisoned wallets rather than failing the whole operation - }; - if !wallet.is_open() { - continue; + // Skip poisoned or closed wallets rather than failing the + // whole operation; the read guard is released before any await. + match wallet_arc.read() { + Ok(guard) if guard.is_open() => {} + _ => continue, } if let Some((identity_index, wallet_private_keys)) = self .attempt_match_identity_with_wallet( identity, - &mut wallet, + wallet_arc, wallet_seed_hash, top_bound, - )? + ) + .await? { - drop(wallet); return Ok(Some(( wallet_seed_hash, identity_index, @@ -449,29 +554,32 @@ impl AppContext { Ok(None) } - fn attempt_match_identity_with_wallet( + async fn attempt_match_identity_with_wallet( &self, identity: &Identity, - wallet: &mut Wallet, + wallet: &Arc<RwLock<Wallet>>, wallet_seed_hash: WalletSeedHash, top_bound: u32, ) -> Result<Option<(u32, WalletKeyMap)>, TaskError> { let identity_id = identity.id(); - if let Some((&identity_index, _)) = wallet + let existing_index = wallet + .read()? .identities .iter() .find(|(_, existing)| existing.id() == identity_id) - { - let (public_key_map, public_key_hash_map) = wallet - .identity_authentication_ecdsa_public_keys_data_map( - self, + .map(|(&index, _)| index); + + if let Some(identity_index) = existing_index { + let (public_key_map, public_key_hash_map) = self + .resolve_identity_auth_pubkeys_data_map( + wallet, + true, true, - self.network, identity_index, 0..top_bound, ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; + .await?; let wallet_private_keys = self.build_wallet_private_key_map( identity, wallet_seed_hash, @@ -486,15 +594,15 @@ impl AppContext { } for candidate_index in 0..MAX_IDENTITY_INDEX { - let (public_key_map, public_key_hash_map) = wallet - .identity_authentication_ecdsa_public_keys_data_map( - self, + let (public_key_map, public_key_hash_map) = self + .resolve_identity_auth_pubkeys_data_map( + wallet, false, - self.network, + true, candidate_index, 0..top_bound, ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; + .await?; if !Self::identity_matches_wallet_key_material( identity, @@ -504,15 +612,15 @@ impl AppContext { continue; } - let (public_key_map, public_key_hash_map) = wallet - .identity_authentication_ecdsa_public_keys_data_map( - self, + let (public_key_map, public_key_hash_map) = self + .resolve_identity_auth_pubkeys_data_map( + wallet, + true, true, - self.network, candidate_index, 0..top_bound, ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; + .await?; let wallet_private_keys = self.build_wallet_private_key_map( identity, @@ -618,3 +726,542 @@ impl AppContext { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::secret::Secret; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use crate::wallet_backend::IdentityKeyView; + use crate::wallet_backend::secret_seam::SecretScheme; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::KeyID; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + use platform_wallet_storage::secrets::SecretString; + + const M: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnMainIdentity; + const V: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnVoterIdentity; + + /// A keyless masternode-shaped identity: an owner key + an identity auth key + /// on the main identity, plus a voting key on the voter identity — the shape + /// `load_identity` builds for a Masternode. Returns the qi and its + /// `(target, key_id)` triple. + fn masternode_shaped_qi() -> (QualifiedIdentity, [(PrivateKeyTarget, KeyID); 3]) { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let owner = IdentityPublicKey::random_key(1, Some(1), pv); + let voter = IdentityPublicKey::random_key(2, Some(2), pv); + let id_key = IdentityPublicKey::random_key(3, Some(3), pv); + let triple = [(M, owner.id()), (V, voter.id()), (M, id_key.id())]; + ks.private_keys.insert( + (M, owner.id()), + ( + QualifiedIdentityPublicKey::from(owner), + PrivateKeyData::Clear([0xA0; 32]), + ), + ); + ks.private_keys.insert( + (V, voter.id()), + ( + QualifiedIdentityPublicKey::from(voter), + PrivateKeyData::Clear([0xB0; 32]), + ), + ); + ks.private_keys.insert( + (M, id_key.id()), + ( + QualifiedIdentityPublicKey::from(id_key), + PrivateKeyData::Clear([0xC0; 32]), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + (qi, triple) + } + + /// TC-FR8-01/02/10 — a load-time encryption password seals ALL of a + /// masternode's keys (voting, owner, and identity auth) Tier-2 through the + /// existing per-identity protect envelope. Without a password the same keys + /// stay keyless (Tier-1) after insert. Drives the exact call + /// [`load_identity`] makes when `encryption_password` is `Some`, on an + /// offline wired `AppContext` (no network I/O). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn load_time_password_seals_voting_owner_and_identity_keys() { + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let (qi, triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + // Insert migrates the resident-plaintext keys into the keyless vault + // (Tier-1), exactly as the load path does before the optional seal. + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + + // No-password (None) path: every key is keyless after insert. + for (t, k) in &triple { + assert_eq!( + view.scheme(t, *k).expect("scheme"), + SecretScheme::Unprotected, + "key ({t:?}, {k}) must be keyless before any load-time seal", + ); + } + + // The exact call `load_identity` makes for `encryption_password = Some`. + ctx.protect_identity_keys(identity_id, Secret::new("one-identity-password"), None) + .expect("load-time seal must succeed"); + + let pw = SecretString::new("one-identity-password"); + for (t, k) in &triple { + assert_eq!( + view.scheme(t, *k).expect("scheme"), + SecretScheme::Protected, + "key ({t:?}, {k}) must be sealed Tier-2 after the load-time password", + ); + assert!( + view.get_protected(t, *k, &pw) + .expect("get_protected") + .is_some(), + "sealed key ({t:?}, {k}) must round-trip under the password", + ); + } + + backend.shutdown().await; + } + + /// §10.8 — the testable core of the "Add voting key" in-place + /// update. A voter-key-only rebuild (blank Owner/Payout, so `associated_*` + /// and the Owner/Payout private keys are absent) MUST NOT erase the + /// already-stored Owner and Payout keys: `merge_existing_keys_into` carries + /// over every key the new partial build omitted, while the resupplied voting + /// key wins on collision. + #[test] + fn merge_preserves_owner_and_payout_when_only_voting_key_resupplied() { + // `existing`: a fully-loaded masternode (owner + voter + identity keys). + let (existing, triple) = masternode_shaped_qi(); + let [owner_key, voter_key, idkey_key] = triple; + + // `new`: what the scoped "Add voting key" prompt rebuilds — a voter key + // only. It carries the freshly-entered voting key but nothing else. + let mut new = existing.clone(); + new.alias = None; + new.associated_voter_identity = None; + new.associated_operator_identity = None; + new.associated_owner_key_id = None; + new.private_keys = KeyStorage::default(); + // Resupply ONLY the voting key, with a distinct byte so we can prove the + // new value wins on collision. + let (voter_pk, _) = existing + .private_keys + .private_keys + .get(&voter_key) + .expect("existing voter key") + .clone(); + new.private_keys.private_keys.insert( + voter_key.clone(), + (voter_pk, PrivateKeyData::Clear([0xEE; 32])), + ); + + merge_existing_keys_into(&mut new, existing); + + // Owner and identity-auth keys survive the voter-key-only update. + assert!( + new.private_keys.private_keys.contains_key(&owner_key), + "owner key must survive a voting-key-only update", + ); + assert!( + new.private_keys.private_keys.contains_key(&idkey_key), + "identity-auth key must survive a voting-key-only update", + ); + // The resupplied voting key wins on collision (0xEE, not the old 0xB0). + let (_, merged_voter) = new + .private_keys + .private_keys + .get(&voter_key) + .expect("voter key present after merge"); + assert!( + matches!(merged_voter, PrivateKeyData::Clear(b) if *b == [0xEE; 32]), + "the resupplied voting key must win on collision", + ); + } + + /// §10.9 / TC-EDGE-07 — a fresh load (`RejectIfExists`) of a + /// ProTxHash already stored is rejected with [`TaskError::DuplicateProTxHash`] + /// BEFORE any network fetch, and the already-stored node is left untouched. + /// Runs fully offline: the existence check fires before the SDK is used. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reject_if_exists_rejects_duplicate_pro_tx_hash_offline() { + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let (qi, triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert first masternode identity"); + + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::Masternode, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::RejectIfExists, + }; + + let sdk = ctx.sdk(); + let result = ctx.load_identity(&sdk, input).await; + match result { + Err(TaskError::DuplicateProTxHash { identity_id: got }) => { + assert_eq!(got, identity_id, "reject must name the duplicate id"); + } + other => panic!("expected DuplicateProTxHash, got {other:?}"), + } + + // The first node's stored keys are untouched by the rejected load. + let still = ctx + .get_local_qualified_identity(&identity_id) + .expect("read stored identity") + .expect("first node still stored"); + for (t, k) in &triple { + assert!( + still + .private_keys + .private_keys + .contains_key(&(t.clone(), *k)), + "key ({t:?}, {k}) of the first node must survive a rejected duplicate load", + ); + } + + ctx.wallet_backend().expect("backend").shutdown().await; + } + + /// Merge×Tier-2 (success path) — merging a new key into a password-protected + /// (Tier-2) node seals the new key Tier-2 *before* the at-rest insert, so + /// the fail-closed guard (`encode_identity_blob_vault_first`) never rejects + /// it. Drives the exact merge-seal step `load_identity` runs: seed a Tier-2 + /// masternode, add a resident-plaintext voting key (as the merge produces), + /// verify the object password through the app prompt, then + /// `seal_merged_plaintext_keys`. The new key must flip to `InVault`, insert + /// cleanly (no `IdentityKeyProtectionDowngrade`), and read back `Protected`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_into_tier2_node_seals_new_key_and_insert_succeeds() { + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + + const PW: &str = "one-identity-password"; + + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + // The scoped merge prompt asks for the node's object password once. + ctx.install_secret_prompt(Arc::new(TestPrompt::new([ScriptedAnswer::once(PW)]))); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // Seed a masternode and seal all its keys Tier-2. + let (qi, _triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + ctx.protect_identity_keys(identity_id, Secret::new(PW), None) + .expect("seal Tier-2"); + + // Reload the sealed node: every key is now InVault. + let mut existing = ctx + .get_local_qualified_identity(&identity_id) + .expect("read stored identity") + .expect("node stored"); + + // Simulate the merge product: a freshly-supplied resident-plaintext + // voting key on a new key id (what `merge_existing_keys_into` yields). + let pv = PlatformVersion::latest(); + let new_voter = IdentityPublicKey::random_key(9, Some(9), pv); + let new_voter_id = new_voter.id(); + let new_key = (V, new_voter_id); + existing.private_keys.private_keys.insert( + new_key.clone(), + ( + QualifiedIdentityPublicKey::from(new_voter), + PrivateKeyData::Clear([0xDD; 32]), + ), + ); + + // Verify the node's object password up front (as the load path does), + // then seal the merged plaintext key Tier-2 before insert. + let verify_scope = ctx + .protected_identity_verify_scope(&existing) + .expect("verify scope lookup") + .expect("node is Tier-2, so a verify scope exists"); + let password = ctx + .wallet_backend() + .expect("backend wired") + .secret_access() + .verify_identity_object_password(&verify_scope) + .await + .expect("scripted password verifies"); + ctx.seal_merged_plaintext_keys(&mut existing, &password) + .expect("seal merged plaintext key"); + + // The new key flipped to InVault in the in-memory identity... + assert!( + matches!( + existing.private_keys.private_keys.get(&new_key), + Some((_, PrivateKeyData::InVault)), + ), + "the merged voting key must be marked InVault after sealing", + ); + + // ...the at-rest insert now passes the fail-closed guard... + ctx.insert_local_qualified_identity(&existing, &None) + .expect("insert of a Tier-2 node with a sealed new key must succeed"); + + // ...and the new key reads back as a Tier-2 (Protected) sealed secret. + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert_eq!( + view.scheme(&V, new_voter_id).expect("scheme"), + SecretScheme::Protected, + "the merged voting key must be sealed Tier-2", + ); + assert!( + view.get_protected(&V, new_voter_id, &SecretString::new(PW)) + .expect("get_protected") + .is_some(), + "the sealed voting key must round-trip under the object password", + ); + + backend.shutdown().await; + } + + /// Merge×Tier-2 (headless fail-closed) — a `MergeIntoExisting` load into a Tier-2 + /// node with no interactive prompt (the default `NullSecretPrompt`) fails + /// closed with [`TaskError::SecretPromptUnavailable`] and — critically — + /// BEFORE the network fetch, because the object password is verified up + /// front. No prompt means no way to seal the merged key, so the load is + /// rejected rather than silently dropping to a keyless downgrade. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_into_tier2_node_headless_fails_closed_before_fetch() { + const PW: &str = "one-identity-password"; + + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + // No prompt installed: the default NullSecretPrompt fails closed. + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // Seed a Tier-2 masternode. + let (qi, _triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + ctx.protect_identity_keys(identity_id, Secret::new(PW), None) + .expect("seal Tier-2"); + + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::Masternode, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::MergeIntoExisting, + }; + + // The verify happens before the SDK fetch, so this resolves offline. + let sdk = ctx.sdk(); + let result = ctx.load_identity(&sdk, input).await; + assert!( + matches!(result, Err(TaskError::SecretPromptUnavailable)), + "a headless merge into a Tier-2 node must fail closed, got {result:?}", + ); + + // The stored node is untouched — still fully Tier-2. + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert_eq!( + view.scheme(&M, 1).expect("scheme"), + SecretScheme::Protected, + "a rejected headless merge must leave the node's keys sealed", + ); + + backend.shutdown().await; + } + + /// A malformed identity-id input surfaces the ProTxHash-specific + /// [`TaskError::MalformedProTxHash`] for masternode/evonode loads (where the + /// field IS a ProTxHash), and the generic [`TaskError::IdentifierParsingError`] + /// for User loads — both offline, at the parse arm, before any network fetch. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn malformed_id_routes_to_pro_tx_hash_error_for_nodes_only() { + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let make_input = |identity_type| IdentityInputToLoad { + identity_id_input: "not-a-valid-identifier".to_string(), + identity_type, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, + }; + + let sdk = ctx.sdk(); + let node_result = ctx + .load_identity(&sdk, make_input(IdentityType::Masternode)) + .await; + assert!( + matches!(node_result, Err(TaskError::MalformedProTxHash { .. })), + "a masternode load with a malformed id must report MalformedProTxHash, got {node_result:?}", + ); + + let user_result = ctx + .load_identity(&sdk, make_input(IdentityType::User)) + .await; + assert!( + matches!(user_result, Err(TaskError::IdentifierParsingError { .. })), + "a User load with a malformed id must report IdentifierParsingError, got {user_result:?}", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 954a507d5..669a778cd 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -8,7 +8,7 @@ use crate::model::wallet::WalletSeedHash; use dash_sdk::Sdk; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; impl AppContext { @@ -23,6 +23,7 @@ impl AppContext { // Query the DPNS contract for the domain document let domain_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![ @@ -37,6 +38,8 @@ impl AppContext { value: Value::Text(normalized_name.clone()), }, ], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 1, start: None, @@ -73,6 +76,7 @@ impl AppContext { // Fetch all DPNS names owned by this identity let dpns_names_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -80,6 +84,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -117,11 +123,10 @@ impl AppContext { // Try to derive keys from wallets if requested let mut encrypted_private_keys = std::collections::BTreeMap::new(); - if let Some((_, _, wallet_private_keys)) = self.match_user_identity_keys_with_wallet( - &identity, - &wallets, - selected_wallet_seed_hash, - )? { + if let Some((_, _, wallet_private_keys)) = self + .match_user_identity_keys_with_wallet(&identity, &wallets, selected_wallet_seed_hash) + .await? + { encrypted_private_keys.extend(wallet_private_keys); } @@ -141,6 +146,7 @@ impl AppContext { Ok::<_, TaskError>((w.seed_hash(), wallet.clone())) }) .collect::<Result<_, _>>()?, + secret_access: self.wallet_backend().ok().map(|b| b.secret_access()), wallet_index: None, top_ups: Default::default(), status: IdentityStatus::Active, diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 0fc330b70..484d8bdfd 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -18,10 +18,11 @@ use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; use std::collections::BTreeMap; +use std::sync::Arc; impl AppContext { pub(super) async fn load_user_identity_from_wallet( @@ -38,16 +39,14 @@ impl AppContext { let mut queried_wallet_key_index = None; for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { - let public_key = { - let wallet = wallet_arc_ref.wallet.write()?; - wallet - .identity_authentication_ecdsa_public_key( - self.network, - identity_index, - key_index, - ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })? - }; + let public_key = self + .resolve_identity_auth_pubkey( + &wallet_arc_ref.wallet, + true, + identity_index, + key_index, + ) + .await?; let key_hash = public_key.pubkey_hash().into(); let query = NonUniquePublicKeyHashQuery { @@ -101,6 +100,7 @@ impl AppContext { let identity_id = identity.id(); let dpns_names_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -108,6 +108,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -155,20 +157,16 @@ impl AppContext { top_bound = top_bound.max(queried_wallet_key_index.saturating_add(1)); top_bound = top_bound.saturating_add(5); - let wallet_seed_hash; - let (public_key_result_map, public_key_hash_result_map) = { - let mut wallet = wallet_arc_ref.wallet.write()?; - wallet_seed_hash = wallet.seed_hash(); - wallet - .identity_authentication_ecdsa_public_keys_data_map( - self, - true, - self.network, - identity_index, - 0..top_bound, - ) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })? - }; + let wallet_seed_hash = wallet_arc_ref.wallet.read()?.seed_hash(); + let (public_key_result_map, public_key_hash_result_map) = self + .resolve_identity_auth_pubkeys_data_map( + &wallet_arc_ref.wallet, + true, + true, + identity_index, + 0..top_bound, + ) + .await?; let private_keys_map = identity .public_keys() @@ -232,6 +230,7 @@ impl AppContext { private_keys: Default::default(), dpns_names: Vec::new(), associated_wallets: BTreeMap::new(), + secret_access: None, wallet_index: None, top_ups: Default::default(), status: IdentityStatus::Active, @@ -243,15 +242,22 @@ impl AppContext { qualified_identity.dpns_names = maybe_owned_dpns_names; qualified_identity.associated_wallets = BTreeMap::from([(wallet_seed_hash, wallet_arc_ref.wallet.clone())]); + qualified_identity.secret_access = self.wallet_backend().ok().map(|b| b.secret_access()); qualified_identity.wallet_index = Some(identity_index); qualified_identity.status = IdentityStatus::Active; qualified_identity.network = self.network; - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_seed_hash, identity_index)), - ) - .map_err(|e| TaskError::Database { source: e })?; + // Carry the user-assigned alias from any existing record so a re-load + // refreshes keys/DPNS without wiping DET-only metadata. + if let Some(existing) = self.get_identity_by_id(&identity_id)? { + qualified_identity.alias = existing.alias; + self.update_local_qualified_identity(&qualified_identity)?; + } else { + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_seed_hash, identity_index)), + )?; + } { let mut wallet = wallet_arc_ref.wallet.write()?; @@ -260,83 +266,34 @@ impl AppContext { .insert(identity_index, qualified_identity.identity.clone()); } - Ok(BackendTaskSuccessResult::Message( - "Successfully loaded identity".to_string(), - )) + Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) } pub(super) async fn load_user_identities_up_to_index( - &self, - sdk: &Sdk, + self: &Arc<Self>, wallet_arc_ref: WalletArcRef, - max_identity_index: IdentityIndex, + seed_identity_index: IdentityIndex, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { - let wallet_ref = wallet_arc_ref; - - let mut loaded_indices = Vec::new(); - - for identity_index in 0..=max_identity_index { - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Progress { - message: format!( - "Searching wallet identity index {current} of {total}.", - current = identity_index + 1, - total = max_identity_index + 1, - ), - current: identity_index + 1, - total: max_identity_index + 1, - }, - ))) - .await - .map_err(|_| TaskError::InternalSendError)?; - - match self - .load_user_identity_from_wallet( - sdk, - wallet_ref.clone(), - identity_index, - sender.clone(), - ) - .await - { - Ok(_) => { - loaded_indices.push(identity_index); - } - Err(TaskError::WalletIdentityNotFound { .. }) => { - continue; - } - Err(error) => { - return Err(error); - } - } - } + // The interactive search seeds the rolling window from the user-supplied + // index and may prompt for the passphrase on a cold cache. + let summary = self + .discover_identities_gap_limited( + &wallet_arc_ref.wallet, + seed_identity_index, + true, + Some(&sender), + ) + .await?; - if loaded_indices.is_empty() { + if summary.found == 0 { return Err(TaskError::NoWalletIdentitiesFound { - max_index: max_identity_index, + max_index: seed_identity_index, }); } - let summary = if loaded_indices.len() == 1 { - format!( - "Successfully loaded 1 identity at index {}.", - loaded_indices[0] - ) - } else { - let loaded_display = loaded_indices - .iter() - .map(|idx| idx.to_string()) - .collect::<Vec<_>>() - .join(", "); - format!( - "Successfully loaded {} identities at indexes {}.", - loaded_indices.len(), - loaded_display - ) - }; - - Ok(BackendTaskSuccessResult::Message(summary)) + Ok(BackendTaskSuccessResult::IdentitiesLoaded { + count: summary.found, + }) } } diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 3ad5f4277..54d9daf32 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -1,8 +1,10 @@ mod add_key_to_identity; +mod auth_pubkey_resolve; mod discover_identities; mod load_identity; mod load_identity_by_dpns_name; mod load_identity_from_wallet; +mod protect_identity_keys; mod refresh_identity; mod refresh_loaded_identities_dpns_names; mod register_dpns_name; @@ -20,12 +22,11 @@ use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, Qualified use crate::model::secret::Secret; use crate::model::wallet::{Wallet, WalletArcRef, WalletSeedHash}; use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dashcore_rpc::dashcore::{Address, PrivateKey, TxOut}; +use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{OutPoint, Transaction}; +use dash_sdk::dpp::dashcore::{Network, OutPoint, PublicKey}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -33,12 +34,34 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyID, KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::key_wallet::bip32::DerivationPath; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::platform::{Identifier, Identity, IdentityPublicKey}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; +/// How a load resolves against an already-stored identity of the same id. +/// +/// The storage layer (`insert_local_qualified_identity`) is `INSERT OR REPLACE`, +/// so a load with no guard silently overwrites an existing record and its keys. +/// This enum lets each entry point declare its intent so the two masternode +/// paths — a *new* load (must reject a duplicate ProTxHash, §10.9) and an +/// *in-place* voter-key update (must merge, not clobber, §10.8) — never share +/// one blind-overwrite path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IdentityLoadMode { + /// Overwrite any existing stored identity (legacy behaviour; the User + /// re-load and headless flows that always resubmit their full key set). + #[default] + Overwrite, + /// A fresh load: reject with [`TaskError::DuplicateProTxHash`] when an + /// identity with this id is already stored (§10.9 / TC-EDGE-07). + RejectIfExists, + /// An in-place update: merge the newly-supplied keys into the existing + /// stored identity's keys, preserving keys the caller did not resupply + /// (§10.8 / the "Add voting key" fix-up). Exempt from duplicate rejection. + MergeIntoExisting, +} + #[derive(Debug, Clone, PartialEq)] pub struct IdentityInputToLoad { pub identity_id_input: String, @@ -50,71 +73,174 @@ pub struct IdentityInputToLoad { pub keys_input: Vec<Secret>, pub derive_keys_from_wallets: bool, pub selected_wallet_seed_hash: Option<WalletSeedHash>, + /// Optional load-time key encryption (FR-8). When `Some`, the loaded + /// voting/owner/payout and identity keys are sealed Tier-2 under this + /// password at load time through the existing per-identity protect + /// envelope (Argon2id + XChaCha20-Poly1305) — no new crypto, no second + /// persistence path. When `None`, the keyless Tier-1 path is unchanged. + pub encryption_password: Option<Secret>, + /// How this load resolves against an already-stored identity of the same id. + pub load_mode: IdentityLoadMode, } -/// A key input tuple containing the private key with derivation path, key type, purpose, -/// security level, and optional contract bounds. -pub type KeyInput = ( - (PrivateKey, DerivationPath), - KeyType, - Purpose, - SecurityLevel, - Option<ContractBounds>, -); - -#[derive(Debug, Clone, PartialEq)] -pub struct IdentityKeys { - pub(crate) master_private_key: Option<(PrivateKey, DerivationPath)>, - pub(crate) master_private_key_type: KeyType, - pub(crate) keys_input: Vec<KeyInput>, +/// One chosen identity key, public-only. +/// +/// Carries the derived **public** key plus the wallet derivation path it came +/// from. The matching private key is never held here — it is materialized +/// just-in-time at signing time from `derivation_path` through the JIT +/// chokepoint (`QualifiedIdentity` signer + `get_resolve_with_seed`). The +/// public key and the path are derived from the same +/// `(network, identity_index, key_index)`, so the key submitted to Platform +/// and the key that signs are the two faces of one path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityKeyEntry { + pub public_key: PublicKey, + pub derivation_path: DerivationPath, + pub key_type: KeyType, + pub purpose: Purpose, + pub security_level: SecurityLevel, + pub contract_bounds: Option<ContractBounds>, } -impl IdentityKeys { - pub fn new( - master_private_key: Option<(PrivateKey, DerivationPath)>, - master_private_key_type: KeyType, - keys_input: Vec<KeyInput>, +impl IdentityKeyEntry { + /// Build an entry from a cached public key, deriving the matching path + /// from the **same** `(network, identity_index, key_index)`. + /// + /// RK-1 single-constructor rule: the public key and the path always come + /// from one coordinate, so the key submitted to Platform and the key the + /// chokepoint signs with at registration are byte-for-byte the same key. + #[allow(clippy::too_many_arguments)] + pub fn from_cached_public_key( + public_key: PublicKey, + network: Network, + identity_index: u32, + key_index: u32, + key_type: KeyType, + purpose: Purpose, + security_level: SecurityLevel, + contract_bounds: Option<ContractBounds>, ) -> Self { + let derivation_path = DerivationPath::identity_authentication_path( + network, + KeyDerivationType::ECDSA, + identity_index, + key_index, + ); + Self { + public_key, + derivation_path, + key_type, + purpose, + security_level, + contract_bounds, + } + } + + /// Build an entry by deriving the public key from a borrowed HD seed. + /// + /// Seed-param form used by the seed-driven registration prep (production + /// `build_identity_registration` and the e2e helper). The public key is + /// derived from the same coordinate as the path, satisfying the RK-1 + /// single-constructor rule. The seed is borrowed for the derivation only + /// and never retained. + #[allow(clippy::too_many_arguments)] + pub fn from_seed( + wallet: &Wallet, + seed: &[u8; 64], + network: Network, + identity_index: u32, + key_index: u32, + key_type: KeyType, + purpose: Purpose, + security_level: SecurityLevel, + contract_bounds: Option<ContractBounds>, + ) -> Result<Self, TaskError> { + let public_key = wallet + .identity_authentication_ecdsa_public_key_from_seed( + seed, + network, + identity_index, + key_index, + ) + .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; + Ok(Self::from_cached_public_key( + public_key, + network, + identity_index, + key_index, + key_type, + purpose, + security_level, + contract_bounds, + )) + } +} + +/// The chosen key set for a new identity, public-only. +/// +/// Holds public material + derivation paths (key *specs*), not private keys: +/// the chooser reads public keys from the +/// [`AuthPubkeyCache`](crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache) +/// and signing derives the private keys just-in-time at registration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityKeySpecs { + /// The master AUTHENTICATION/MASTER key, when present. `None` until the + /// chooser has populated keys (a cold auth-pubkey cache, before warming). + pub(crate) master: Option<IdentityKeyEntry>, + /// The additional non-master keys, in id order (id = index + 1). + pub(crate) others: Vec<IdentityKeyEntry>, +} + +impl IdentityKeySpecs { + pub fn new(master: Option<IdentityKeyEntry>, others: Vec<IdentityKeyEntry>) -> Self { + Self { master, others } + } + + /// An empty, not-yet-populated key set (cold auth-pubkey cache). + pub fn empty() -> Self { Self { - master_private_key, - master_private_key_type, - keys_input, + master: None, + others: Vec::new(), + } + } + + /// Whether the master key has been populated. Registration is gated on + /// this — a cold cache leaves it `None` and the create button stays + /// disabled (fail-closed). + pub fn has_master(&self) -> bool { + self.master.is_some() + } + + fn key_data(entry: &IdentityKeyEntry) -> dash_sdk::dpp::platform_value::BinaryData { + match entry.key_type { + KeyType::ECDSA_HASH160 => entry + .public_key + .pubkey_hash() + .to_byte_array() + .to_vec() + .into(), + _ => entry.public_key.to_bytes().into(), } } pub fn to_key_storage(&self, wallet_seed_hash: WalletSeedHash) -> KeyStorage { - let Self { - master_private_key, - master_private_key_type, - keys_input, - } = self; - let secp = Secp256k1::new(); let mut key_map = BTreeMap::new(); - if let Some((master_private_key, master_private_key_derivation_path)) = master_private_key { - let data = match master_private_key_type { - KeyType::ECDSA_HASH160 => master_private_key - .public_key(&secp) - .pubkey_hash() - .to_byte_array() - .to_vec() - .into(), - _ => master_private_key.public_key(&secp).to_bytes().into(), - }; + if let Some(master) = &self.master { let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { id: 0, purpose: Purpose::AUTHENTICATION, security_level: SecurityLevel::MASTER, contract_bounds: None, - key_type: *master_private_key_type, + key_type: master.key_type, read_only: false, - data, + data: Self::key_data(master), disabled_at: None, }); let wallet_derivation_path = WalletDerivationPath { wallet_seed_hash, - derivation_path: master_private_key_derivation_path.clone(), + derivation_path: master.derivation_path.clone(), }; let qualified_identity_public_key = QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( @@ -127,99 +253,67 @@ impl IdentityKeys { ); } - key_map.extend(keys_input.iter().enumerate().map( - |( - i, - ( - (private_key, derivation_path), - key_type, - purpose, - security_level, - contract_bounds, - ), - )| { - let id = (i + 1) as KeyID; - let data = match key_type { - KeyType::ECDSA_HASH160 => private_key - .public_key(&secp) - .pubkey_hash() - .to_byte_array() - .to_vec() - .into(), - _ => private_key.public_key(&secp).to_bytes().into(), - }; - let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id, - purpose: *purpose, - security_level: *security_level, - contract_bounds: contract_bounds.clone(), - key_type: *key_type, - read_only: false, - data, - disabled_at: None, - }); - - let wallet_derivation_path = WalletDerivationPath { - wallet_seed_hash, - derivation_path: derivation_path.clone(), - }; + key_map.extend(self.others.iter().enumerate().map(|(i, entry)| { + let id = (i + 1) as KeyID; + let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: entry.purpose, + security_level: entry.security_level, + contract_bounds: entry.contract_bounds.clone(), + key_type: entry.key_type, + read_only: false, + data: Self::key_data(entry), + disabled_at: None, + }); - let qualified_identity_public_key = - QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( - identity_public_key, - Some(wallet_derivation_path.clone()), - ); - ( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, id), - (qualified_identity_public_key, wallet_derivation_path), - ) - }, - )); + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash, + derivation_path: entry.derivation_path.clone(), + }; + + let qualified_identity_public_key = + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + identity_public_key, + Some(wallet_derivation_path.clone()), + ); + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, id), + (qualified_identity_public_key, wallet_derivation_path), + ) + })); key_map.into() } pub fn to_public_keys_map(&self) -> Result<BTreeMap<KeyID, IdentityPublicKey>, String> { - let Self { - master_private_key, - master_private_key_type, - keys_input, - .. - } = self; - let secp = Secp256k1::new(); let mut key_map = BTreeMap::new(); - if let Some((master_private_key, _)) = master_private_key { - let data = match master_private_key_type { - KeyType::ECDSA_SECP256K1 => master_private_key.public_key(&secp).to_bytes().into(), - KeyType::ECDSA_HASH160 => master_private_key - .public_key(&secp) - .pubkey_hash() - .to_byte_array() - .to_vec() - .into(), + if let Some(master) = &self.master { + match master.key_type { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => {} other => { return Err(format!( "Unsupported master key type: {:?}. Only ECDSA_SECP256K1 and ECDSA_HASH160 are supported.", other )); } - }; + } let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { id: 0, purpose: Purpose::AUTHENTICATION, security_level: SecurityLevel::MASTER, contract_bounds: None, - key_type: *master_private_key_type, + key_type: master.key_type, read_only: false, - data, + data: Self::key_data(master), disabled_at: None, }); key_map.insert(0, key); } - for (i, ((private_key, _), key_type, purpose, security_level, contract_bounds)) in - keys_input.iter().enumerate() - { + for (i, entry) in self.others.iter().enumerate() { let id = (i + 1) as KeyID; + let key_type = &entry.key_type; + let purpose = &entry.purpose; + let security_level = &entry.security_level; // Validate security level matches key purpose (defense-in-depth) match purpose { @@ -253,29 +347,23 @@ impl IdentityKeys { _ => {} } - let data = match key_type { - KeyType::ECDSA_SECP256K1 => private_key.public_key(&secp).to_bytes().into(), - KeyType::ECDSA_HASH160 => private_key - .public_key(&secp) - .pubkey_hash() - .to_byte_array() - .to_vec() - .into(), + match key_type { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => {} other => { return Err(format!( "Unsupported key type for key {}: {:?}. Only ECDSA_SECP256K1 and ECDSA_HASH160 are supported.", id, other )); } - }; + } let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { id, purpose: *purpose, security_level: *security_level, - contract_bounds: contract_bounds.clone(), + contract_bounds: entry.contract_bounds.clone(), key_type: *key_type, read_only: false, - data, + data: Self::key_data(entry), disabled_at: None, }); key_map.insert(id, identity_public_key); @@ -289,8 +377,14 @@ pub type IdentityIndex = u32; pub type TopUpIndex = u32; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RegisterIdentityFundingMethod { - UseAssetLock(Address, Box<AssetLockProof>, Box<Transaction>), - FundWithUtxo(OutPoint, TxOut, Address, IdentityIndex), + /// Resume from an asset lock already tracked by the upstream + /// `AssetLockManager` (identified by its credit-output outpoint). + /// Upstream re-derives the credit-output key from the seed and + /// drives the IS→CL fallback + identity-create submission. + UseAssetLock { + out_point: OutPoint, + identity_index: IdentityIndex, + }, FundWithWallet(Duffs, IdentityIndex), /// Fund identity creation from Platform addresses FundWithPlatformAddresses { @@ -303,15 +397,20 @@ pub enum RegisterIdentityFundingMethod { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TopUpIdentityFundingMethod { - UseAssetLock(Address, Box<AssetLockProof>, Box<Transaction>), - FundWithUtxo(OutPoint, TxOut, Address, IdentityIndex, TopUpIndex), + /// Resume from an asset lock already tracked by the upstream + /// `AssetLockManager` (identified by its credit-output outpoint). + UseAssetLock { + out_point: OutPoint, + identity_index: IdentityIndex, + top_up_index: TopUpIndex, + }, FundWithWallet(Duffs, IdentityIndex, TopUpIndex), } #[derive(Debug, Clone)] pub struct IdentityRegistrationInfo { pub alias_input: String, - pub keys: IdentityKeys, + pub keys: IdentityKeySpecs, pub wallet: Arc<RwLock<Wallet>>, pub wallet_identity_index: u32, pub identity_funding_method: RegisterIdentityFundingMethod, @@ -348,7 +447,6 @@ pub struct RegisterDpnsNameInput { #[derive(Debug, Clone, PartialEq)] pub enum IdentityTask { LoadIdentity(IdentityInputToLoad), - #[allow(dead_code)] // May be used for finding identities in wallets SearchIdentityFromWallet(WalletArcRef, IdentityIndex), SearchIdentitiesUpToIndex(WalletArcRef, IdentityIndex), /// Search for an identity by its DPNS name (without .dash suffix) @@ -365,6 +463,32 @@ pub enum IdentityTask { wallet_seed_hash: WalletSeedHash, }, AddKeyToIdentity(QualifiedIdentity, QualifiedIdentityPublicKey, [u8; 32]), + /// Opt-in: seal every keyless (Tier-1) vault-stored key of this + /// identity under ONE per-identity object `password` (Tier-2), and store + /// `hint` for the sign-time prompt copy. Idempotent (an already-protected + /// key is skipped) and crash-safe (same-label in-place upsert, vault before + /// sidecar). After this, signing with this identity prompts for the + /// password; headless/MCP signing yields `SecretPromptUnavailable`. + ProtectIdentityKeys { + /// The identity whose keys to protect. + identity_id: Identifier, + /// The per-identity password the user chose. Isolated per secret — + /// never the wallet's object password. + password: Secret, + /// Optional user-set hint shown next to the sign-time prompt. + hint: Option<String>, + }, + /// Opt-out: revert every password-protected (Tier-2) vault-stored + /// key of this identity back to keyless (Tier-1), after verifying + /// `password`. Idempotent (an already-keyless key is skipped) and crash-safe + /// (vault downgrade before sidecar delete). After this, signing is + /// prompt-free again, including headless/MCP. + UnprotectIdentityKeys { + /// The identity whose key protection to remove. + identity_id: Identifier, + /// The current per-identity password, verified before downgrading. + password: Secret, + }, WithdrawFromIdentity(QualifiedIdentity, Option<Address>, Credits, Option<KeyID>), Transfer(QualifiedIdentity, Identifier, Credits, Option<KeyID>), /// Transfer credits from identity to Platform addresses @@ -380,37 +504,6 @@ pub enum IdentityTask { RefreshLoadedIdentitiesOwnedDPNSNames, } -fn verify_key_input( - untrimmed_private_key: Secret, - type_key: &str, -) -> Result<Option<[u8; 32]>, String> { - let private_key = untrimmed_private_key.expose_secret().trim(); - match private_key.len() { - 64 => { - // hex - match hex::decode(private_key) { - Ok(decoded) => Ok(Some(decoded.try_into().unwrap())), - Err(_) => Err(format!( - "{} key is the size of a hex key but isn't hex", - type_key - )), - } - } - 51 | 52 => { - // wif - match PrivateKey::from_wif(private_key) { - Ok(key) => Ok(Some(key.inner.secret_bytes())), - Err(_) => Err(format!( - "{} key is the length of a WIF key but is invalid", - type_key - )), - } - } - 0 => Ok(None), - _ => Err(format!("{} key is of incorrect size", type_key)), - } -} - /// Returns the default key specifications for a new identity. /// /// The returned vector contains tuples of (KeyType, Purpose, SecurityLevel, Option<ContractBounds>): @@ -465,63 +558,66 @@ pub fn default_identity_key_specs( ] } -/// Build an [`IdentityRegistrationInfo`] for a wallet-funded identity. +/// Build an [`IdentityRegistrationInfo`] for a wallet-funded identity from a +/// borrowed HD seed. /// -/// Derives the master key and additional keys from the wallet at the given -/// `identity_index`. This is the canonical way to prepare identity -/// registration data from a wallet — used by both UI screens and tests. -#[allow(dead_code)] // Used by backend-e2e tests via pub(crate) visibility -pub(crate) fn build_identity_registration( +/// Derives the **public** master key and additional keys from `seed` at the +/// given `identity_index`. Seed-param form: the caller resolves the seed once +/// through the JIT chokepoint and passes it by borrow — the private keys are +/// never materialized here (signing derives them JIT at registration). The +/// public key and the wallet derivation path of each entry come from the same +/// coordinate (RK-1), so the keys submitted to Platform and the keys the +/// chokepoint signs with are identical. This is the canonical way to prepare +/// identity registration data — used by the async UI/registration path and by +/// tests. +pub fn build_identity_registration_with_seed( app_context: &Arc<AppContext>, wallet_arc: &Arc<RwLock<Wallet>>, + seed: &[u8; 64], identity_index: u32, funding_amount: Duffs, ) -> Result<IdentityRegistrationInfo, TaskError> { let dashpay_contract_id = app_context.dashpay_contract.id(); let key_specs = default_identity_key_specs(dashpay_contract_id); - - let mut wallet = wallet_arc.write()?; - - let (master_private_key, master_derivation_path) = wallet - .identity_authentication_ecdsa_private_key( - app_context, - app_context.network, - identity_index, - 0, - ) - .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; - - let mut keys_input: Vec<KeyInput> = Vec::new(); + let network = app_context.network; + + let wallet = wallet_arc.read()?; + + let master = IdentityKeyEntry::from_seed( + &wallet, + seed, + network, + identity_index, + 0, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + None, + )?; + + let mut others: Vec<IdentityKeyEntry> = Vec::with_capacity(key_specs.len()); for (i, (key_type, purpose, security_level, contract_bounds)) in key_specs.into_iter().enumerate() { let key_index = (i + 1) as u32; - let (private_key, derivation_path) = wallet - .identity_authentication_ecdsa_private_key( - app_context, - app_context.network, - identity_index, - key_index, - ) - .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; - keys_input.push(( - (private_key, derivation_path), + others.push(IdentityKeyEntry::from_seed( + &wallet, + seed, + network, + identity_index, + key_index, key_type, purpose, security_level, contract_bounds, - )); + )?); } drop(wallet); Ok(IdentityRegistrationInfo { alias_input: String::new(), - keys: IdentityKeys::new( - Some((master_private_key, master_derivation_path)), - KeyType::ECDSA_HASH160, - keys_input, - ), + keys: IdentityKeySpecs::new(Some(master), others), wallet: wallet_arc.clone(), wallet_identity_index: identity_index, identity_funding_method: RegisterIdentityFundingMethod::FundWithWallet( @@ -531,17 +627,54 @@ pub(crate) fn build_identity_registration( }) } -/// Get a receive address string from a wallet. -#[allow(dead_code)] // Used by backend-e2e tests via pub(crate) visibility -pub(crate) fn get_receive_address( - app_context: &AppContext, +/// Async wrapper over [`build_identity_registration_with_seed`]. +/// +/// Resolves the wallet's HD seed once through the JIT chokepoint and delegates; +/// callers that can `await` use this and never read the wallet's parked seed. +// Exercised by the backend-e2e integration tests (a separate crate the lib +// build does not see), so it is dead in the lib build itself. +#[allow(dead_code)] +pub async fn build_identity_registration( + app_context: &Arc<AppContext>, wallet_arc: &Arc<RwLock<Wallet>>, -) -> Result<String, TaskError> { - let mut wallet = wallet_arc.write()?; - wallet - .receive_address(app_context.network, false, Some(app_context)) - .map(|addr| addr.to_string()) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e }) + identity_index: u32, + funding_amount: Duffs, +) -> Result<IdentityRegistrationInfo, TaskError> { + let seed_hash = wallet_arc.read()?.seed_hash(); + let backend = app_context.wallet_backend()?; + backend + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + build_identity_registration_with_seed( + app_context, + wallet_arc, + seed, + identity_index, + funding_amount, + ) + }, + ) + .await +} + +/// Failure while checking that an identity carries a public key matching a +/// user-supplied private key. +#[derive(Debug, thiserror::Error)] +pub enum KeyVerificationError { + /// The identity has no keys of the required purpose. + #[error("This identity does not contain any {purpose} keys.")] + NoKeysForPurpose { purpose: &'static str }, + + /// No public key on the identity matches the supplied private key. + #[error("This identity has no {purpose} public key matching this private key.")] + NoMatchingKey { purpose: &'static str }, + + /// The public key could not be derived from the supplied private key. + #[error("The public key could not be derived from the supplied private key.")] + PublicKeyDerivation(#[source] Box<ProtocolError>), } impl AppContext { @@ -549,7 +682,7 @@ impl AppContext { &self, voting_identity: &Identity, private_voting_key: &[u8; 32], - ) -> Result<IdentityPublicKey, String> { + ) -> Result<IdentityPublicKey, KeyVerificationError> { // We start by getting all the voting keys let voting_keys: Vec<IdentityPublicKey> = voting_identity .public_keys() @@ -562,7 +695,7 @@ impl AppContext { }) .collect(); if voting_keys.is_empty() { - return Err("This identity does not contain any voting keys".to_string()); + return Err(KeyVerificationError::NoKeysForPurpose { purpose: "voting" }); } // Then we get all the key types of the voting keys let key_types: HashSet<KeyType> = voting_keys.iter().map(|key| key.key_type()).collect(); @@ -577,7 +710,7 @@ impl AppContext { )) }) .collect::<Result<HashMap<KeyType, Vec<u8>>, ProtocolError>>() - .map_err(|e| e.to_string())?; + .map_err(|e| KeyVerificationError::PublicKeyDerivation(Box::new(e)))?; let Some(key) = voting_keys.into_iter().find(|key| { let Some(public_key_bytes) = public_key_bytes_for_each_key_type.get(&key.key_type()) else { @@ -585,9 +718,7 @@ impl AppContext { }; key.data().as_slice() == public_key_bytes.as_slice() }) else { - return Err( - "Identity does not have a voting public key matching this private key".to_string(), - ); + return Err(KeyVerificationError::NoMatchingKey { purpose: "voting" }); }; Ok(key) } @@ -596,7 +727,7 @@ impl AppContext { &self, identity: &Identity, private_voting_key: &[u8; 32], - ) -> Result<IdentityPublicKey, String> { + ) -> Result<IdentityPublicKey, KeyVerificationError> { // We start by getting all the voting keys let owner_keys: Vec<IdentityPublicKey> = identity .public_keys() @@ -609,7 +740,7 @@ impl AppContext { }) .collect(); if owner_keys.is_empty() { - return Err("This identity does not contain any owner keys".to_string()); + return Err(KeyVerificationError::NoKeysForPurpose { purpose: "owner" }); } // Then we get all the key types of the voting keys let key_types: HashSet<KeyType> = owner_keys.iter().map(|key| key.key_type()).collect(); @@ -624,7 +755,7 @@ impl AppContext { )) }) .collect::<Result<HashMap<KeyType, Vec<u8>>, ProtocolError>>() - .map_err(|e| e.to_string())?; + .map_err(|e| KeyVerificationError::PublicKeyDerivation(Box::new(e)))?; let Some(key) = owner_keys.into_iter().find(|key| { let Some(public_key_bytes) = public_key_bytes_for_each_key_type.get(&key.key_type()) else { @@ -632,9 +763,7 @@ impl AppContext { }; key.data().as_slice() == public_key_bytes.as_slice() }) else { - return Err( - "Identity does not have an owner public key matching this private key".to_string(), - ); + return Err(KeyVerificationError::NoMatchingKey { purpose: "owner" }); }; Ok(key) } @@ -643,7 +772,7 @@ impl AppContext { &self, identity: &Identity, private_voting_key: &[u8; 32], - ) -> Result<IdentityPublicKey, String> { + ) -> Result<IdentityPublicKey, KeyVerificationError> { // We start by getting all the voting keys let owner_keys: Vec<IdentityPublicKey> = identity .public_keys() @@ -659,7 +788,9 @@ impl AppContext { }) .collect(); if owner_keys.is_empty() { - return Err("This identity does not contain any owner keys".to_string()); + return Err(KeyVerificationError::NoKeysForPurpose { + purpose: "payout address", + }); } // Then we get all the key types of the voting keys let key_types: HashSet<KeyType> = owner_keys.iter().map(|key| key.key_type()).collect(); @@ -674,7 +805,7 @@ impl AppContext { )) }) .collect::<Result<HashMap<KeyType, Vec<u8>>, ProtocolError>>() - .map_err(|e| e.to_string())?; + .map_err(|e| KeyVerificationError::PublicKeyDerivation(Box::new(e)))?; let Some(key) = owner_keys.into_iter().find(|key| { let Some(public_key_bytes) = public_key_bytes_for_each_key_type.get(&key.key_type()) else { @@ -682,15 +813,15 @@ impl AppContext { }; key.data().as_slice() == public_key_bytes.as_slice() }) else { - return Err( - "Identity does not have a payout address matching this private key".to_string(), - ); + return Err(KeyVerificationError::NoMatchingKey { + purpose: "payout address", + }); }; Ok(key) } pub async fn run_identity_task( - &self, + self: &Arc<Self>, task: IdentityTask, sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, @@ -722,7 +853,7 @@ impl AppContext { .load_user_identity_from_wallet(sdk, wallet, identity_index, sender) .await?), IdentityTask::SearchIdentitiesUpToIndex(wallet, max_identity_index) => Ok(self - .load_user_identities_up_to_index(sdk, wallet, max_identity_index, sender) + .load_user_identities_up_to_index(wallet, max_identity_index, sender) .await?), IdentityTask::SearchIdentityByDpnsName(dpns_name, wallet_seed_hash) => Ok(self .load_identity_by_dpns_name(sdk, dpns_name, wallet_seed_hash) @@ -754,6 +885,15 @@ impl AppContext { IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames => { Ok(self.refresh_loaded_identities_dpns_names(sender).await?) } + IdentityTask::ProtectIdentityKeys { + identity_id, + password, + hint, + } => self.protect_identity_keys(identity_id, password, hint), + IdentityTask::UnprotectIdentityKeys { + identity_id, + password, + } => self.unprotect_identity_keys(identity_id, password), } } @@ -765,11 +905,11 @@ impl AppContext { inputs: BTreeMap<dash_sdk::dpp::address_funds::PlatformAddress, Credits>, wallet_seed_hash: WalletSeedHash, ) -> Result<BackendTaskSuccessResult, TaskError> { - use crate::model::fee_estimation::PlatformFeeEstimator; use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; - // Estimate fee for top-up from platform addresses - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup(); + // Estimate the top-up fee with the active network fee multiplier + // (context estimator) so the figure shown to the user is accurate. + let estimated_fee = self.fee_estimator().estimate_identity_topup(); tracing::info!( "top_up_identity_from_platform_addresses: identity={}, inputs={:?}", @@ -777,34 +917,45 @@ impl AppContext { inputs ); - // Get the wallet for signing - clone it to avoid holding guard across await - let wallet_clone = { - let wallet = { - let wallets = self.wallets.read()?; - wallets - .get(&wallet_seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let wallet_guard = wallet.read()?; + // Clone the wallet for the pure address→path index (needed across the + // async boundary). The signing key never lives in this snapshot — it is + // derived JIT from the borrowed HD seed inside the secret scope below. + // No `is_open()` gate: the chokepoint resolves an unprotected or + // session-cached wallet without a prompt, and prompts a locked protected + // one — returning `WalletLocked` only if the seed is truly unavailable. + let wallet_snapshot = self.wallet_arc(&wallet_seed_hash)?.read()?.clone(); - // Ensure wallet is open - if !wallet_guard.is_open() { - return Err(TaskError::WalletLocked); - } - - wallet_guard.clone() - }; - - tracing::info!("Wallet loaded and open, calling top_up_from_addresses..."); + tracing::info!("Wallet loaded, calling top_up_from_addresses..."); // Get the identity let identity = qualified_identity.identity.clone(); + // Sign each top-up input through a JIT platform signer that borrows the + // HD seed only for the duration of the SDK call. The pure path index is + // built before the secret scope; the seed zeroizes on return and never + // enters this layer by value. + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex, SecretScope}; + let network = self.network; + let path_index = PlatformPathIndex::from_wallet(&wallet_snapshot, network); + let backend = self.wallet_backend()?; + // Execute the top-up - let (address_infos, new_balance) = identity - .top_up_from_addresses(sdk, inputs, &wallet_clone, None) + let (address_infos, new_balance, _height) = backend + .secret_access() + .with_secret_session( + &SecretScope::HdSeed { + seed_hash: wallet_seed_hash, + }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + identity + .top_up_from_addresses(sdk, inputs, &signer, None) + .await + .map_err(TaskError::from) + }, + ) .await?; tracing::info!( @@ -824,10 +975,9 @@ impl AppContext { updated_identity.identity.set_balance(new_balance); // Store the updated identity (use update to preserve wallet association) - self.update_local_qualified_identity(&updated_identity) - .map_err(|e| TaskError::Database { source: e })?; + self.update_local_qualified_identity(&updated_identity)?; - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let fee_result = FeeResult::estimated_only(estimated_fee); Ok(BackendTaskSuccessResult::ToppedUpIdentity( updated_identity, fee_result, @@ -842,7 +992,6 @@ impl AppContext { outputs: BTreeMap<dash_sdk::dpp::address_funds::PlatformAddress, Credits>, key_id: Option<KeyID>, ) -> Result<BackendTaskSuccessResult, TaskError> { - use crate::model::fee_estimation::PlatformFeeEstimator; use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; // Get the identity @@ -853,11 +1002,11 @@ impl AppContext { // Track balance before transfer for fee calculation let balance_before = identity.balance(); - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.fee_estimator(); let estimated_fee = fee_estimator.estimate_credit_transfer_to_addresses(outputs.len()); // Execute the transfer - qualified_identity is consumed here as the signer - let (address_infos, new_balance) = identity + let (address_infos, new_balance, _height) = identity .transfer_credits_to_addresses( sdk, outputs.clone(), @@ -907,8 +1056,7 @@ impl AppContext { } // Store the updated identity (use update to preserve wallet association) - self.update_local_qualified_identity(&updated_identity) - .map_err(|e| TaskError::Database { source: e })?; + self.update_local_qualified_identity(&updated_identity)?; let fee_result = FeeResult::new(estimated_fee, actual_fee); Ok(BackendTaskSuccessResult::TransferredCredits(fee_result)) @@ -1102,4 +1250,153 @@ mod tests { } } } + + /// RK-1 / no-drift: the public key the chooser shows (from the cache, + /// reconstructed via `from_cached_public_key`) and the private key the + /// chokepoint derives at registration (the path's BIP-32 private + /// derivation) are the two faces of one `(network, identity_index, + /// key_index)` coordinate. If these ever diverge, the key submitted to + /// Platform would not match the key that signs — identity-create would be + /// rejected or the wallet could not sign for its own identity. + #[test] + fn identity_key_entry_public_matches_private_derivation() { + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + + let seed = [0x42u8; 64]; + let secp = Secp256k1::new(); + + for network in [Network::Testnet, Network::Mainnet] { + let wallet = Wallet::new_from_seed(seed, network, None, None).expect("build wallet"); + + for identity_index in [0u32, 7] { + for key_index in 0u32..6 { + // The public key the chooser caches/shows. + let cached_pk = wallet + .identity_authentication_ecdsa_public_key_from_seed( + &seed, + network, + identity_index, + key_index, + ) + .expect("public derive"); + + // The entry the chooser builds from that cached key. + let entry = IdentityKeyEntry::from_cached_public_key( + cached_pk, + network, + identity_index, + key_index, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + None, + ); + + // The private key the chokepoint derives at the entry's + // path at signing time. + let private_key = entry + .derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("private derive") + .to_priv(); + + assert_eq!( + entry.public_key.inner.serialize(), + private_key.public_key(&secp).inner.serialize(), + "public/private key drift at ({network:?}, id={identity_index}, key={key_index})" + ); + + // The seed-param entry constructor must produce the same + // entry (same public key + same path). + let from_seed_entry = IdentityKeyEntry::from_seed( + &wallet, + &seed, + network, + identity_index, + key_index, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + None, + ) + .expect("from_seed"); + assert_eq!( + entry, from_seed_entry, + "from_cached_public_key and from_seed diverged at ({network:?}, id={identity_index}, key={key_index})" + ); + } + } + } + } + + /// The reshaped `to_public_keys_map` / `to_key_storage` produce the same + /// public key data and stored derivation paths the registration flow + /// expects, with no private key held anywhere in the spec set. + #[test] + fn identity_key_specs_roundtrip_public_and_paths() { + let seed = [0x11u8; 64]; + let network = Network::Testnet; + let wallet = Wallet::new_from_seed(seed, network, None, None).expect("build wallet"); + let seed_hash = wallet.seed_hash(); + + let master = IdentityKeyEntry::from_seed( + &wallet, + &seed, + network, + 0, + 0, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + None, + ) + .expect("master"); + let other = IdentityKeyEntry::from_seed( + &wallet, + &seed, + network, + 0, + 1, + KeyType::ECDSA_SECP256K1, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + None, + ) + .expect("other"); + + let specs = IdentityKeySpecs::new(Some(master.clone()), vec![other.clone()]); + + // Public keys map: id 0 (master) + id 1 (other), both present. + let public_map = specs.to_public_keys_map().expect("public map"); + assert_eq!(public_map.len(), 2); + assert!(public_map.contains_key(&0)); + assert!(public_map.contains_key(&1)); + + // Key storage carries the entries' derivation paths verbatim, keyed by + // the same wallet seed hash the chokepoint resolves from. Resolve via + // the seed-param path (the JIT chokepoint's resolver) with a borrowed + // seed — the legacy parked-seed `get_resolve` is gone. + let storage = specs.to_key_storage(seed_hash); + let stored = storage + .get_resolve_with_seed( + &(PrivateKeyTarget::PrivateKeyOnMainIdentity, 0), + std::slice::from_ref(&std::sync::Arc::new(std::sync::RwLock::new( + Wallet::new_from_seed(seed, network, None, None).expect("wallet"), + ))), + &seed, + network, + ) + .expect("resolve master") + .expect("master present"); + // The resolved private key's public key must match the spec's public key. + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let resolved_pub = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&stored.1[..]) + .expect("secret") + .public_key(&secp); + assert_eq!( + master.public_key.inner.serialize(), + resolved_pub.serialize(), + "stored path resolves to the spec's public key" + ); + } } diff --git a/src/backend_task/identity/protect_identity_keys.rs b/src/backend_task/identity/protect_identity_keys.rs new file mode 100644 index 000000000..b4cced4d3 --- /dev/null +++ b/src/backend_task/identity/protect_identity_keys.rs @@ -0,0 +1,821 @@ +//! Identity key password protection opt-in / opt-out migrations: seal an identity's keys under one +//! per-identity password (Tier-2) or revert them to keyless (Tier-1). +//! +//! Both operate over the identity's existing per-key vault labels, in place +//! (same-label upsert), so there is no second label to orphan and the classic +//! "vault-write BEFORE sidecar" crash-safety ordering collapses to "vault first, +//! then the cosmetic hint sidecar." Crash mid-iteration leaves a recoverable +//! mix (some keys Tier-2, some Tier-1) — every label always holds a complete, +//! readable secret, and re-running with the same password finishes the job +//! (idempotent: an already-converted key is skipped). + +use std::collections::BTreeSet; + +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use platform_wallet_storage::secrets::SecretString; + +use super::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::identity_key_protection::validate_protection_password; +use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; +use crate::model::qualified_identity::identity_meta::IdentityMeta; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; +use crate::model::secret::Secret; +use crate::wallet_backend::IdentityKeyView; +use crate::wallet_backend::secret_seam::SecretScheme; + +/// Every `(target, key_id)` of an identity, the iteration unit for both +/// migrations. +type IdentityKeySet = BTreeSet<(PrivateKeyTarget, KeyID)>; + +impl AppContext { + /// Opt-in: seal this identity's keyless vault keys Tier-2 under one + /// per-identity `password`, then record `hint` for the prompt copy. + pub(super) fn protect_identity_keys( + &self, + identity_id: Identifier, + password: Secret, + hint: Option<String>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + // Backend = authoritative validation: re-enforce the password + // policy here, not only in the UI, so a future MCP/CLI caller cannot + // seal under a too-short password. + validate_protection_password(&password)?; + + let qi = self + .get_identity_by_id(&identity_id)? + .ok_or(TaskError::IdentityNotFoundLocally)?; + + self.protect_loaded_identity_keys(&qi, &password, hint) + } + + /// Seal an ALREADY-LOADED identity's keyless vault keys Tier-2 under one + /// per-identity `password`, then record `hint`. Split from + /// [`Self::protect_identity_keys`] so the fail-closed guard, the seal, and + /// the success result are exercised on a real `qi` as the task runs them — + /// proving the guard is wired into the protect path, not merely callable. + fn protect_loaded_identity_keys( + &self, + qi: &QualifiedIdentity, + password: &Secret, + hint: Option<String>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let identity_id = qi.identity.id(); + + // Fail-closed: any resident plaintext key left by an incomplete + // get-path migration has an `Absent` label `seal_identity_keys` would + // skip, so refuse here rather than emit a false-protected result. + reject_resident_identity_plaintext(&qi.private_keys)?; + + let backend = self.wallet_backend()?; + let id = identity_id.to_buffer(); + let keys = qi.private_keys.keys_set(); + let view = IdentityKeyView::new(backend.secret_store(), id); + let pw = SecretString::new(password.expose_secret()); + + // Vault first (the funds-/protection-safe part). + let count = seal_identity_keys(&view, &keys, &pw)?; + + // Then the cosmetic hint sidecar — best-effort: the keys are already + // protected, so a sidecar write failure must not report the opt-in as + // failed (it would only cost the prompt hint). + let hint = hint.filter(|h| !h.trim().is_empty()); + if let Err(e) = backend.identity_meta().set( + self.network, + &id, + &IdentityMeta { + password_hint: hint, + }, + ) { + tracing::warn!( + target = "backend_task::identity::protect_identity_keys", + identity = %identity_id, + error = ?e, + "Identity keys sealed, but recording the password hint failed", + ); + } + + tracing::info!( + target = "backend_task::identity::protect_identity_keys", + identity = %identity_id, + count, + "Sealed identity keys under a per-identity password", + ); + Ok(BackendTaskSuccessResult::IdentityKeysProtected { identity_id, count }) + } + + /// Opt-out: revert this identity's password-protected vault keys to + /// keyless (Tier-1) after verifying `password`, then drop the hint sidecar. + pub(super) fn unprotect_identity_keys( + &self, + identity_id: Identifier, + password: Secret, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let qi = self + .get_identity_by_id(&identity_id)? + .ok_or(TaskError::IdentityNotFoundLocally)?; + let backend = self.wallet_backend()?; + let id = qi.identity.id().to_buffer(); + let keys = qi.private_keys.keys_set(); + let view = IdentityKeyView::new(backend.secret_store(), id); + let pw = SecretString::new(password.expose_secret()); + + // Vault downgrade first (a wrong password aborts before any key is + // touched, since all keys share the one per-identity password). + let reverted = unseal_identity_keys(&view, &keys, &pw)?; + + // Then drop the now-irrelevant hint sidecar — best-effort: the keys are + // already keyless, so a stale hint is harmless and must not fail opt-out. + if let Err(e) = backend.identity_meta().delete(self.network, &id) { + tracing::warn!( + target = "backend_task::identity::protect_identity_keys", + identity = %identity_id, + error = ?e, + "Identity protection removed, but deleting the password hint failed", + ); + } + + tracing::info!( + target = "backend_task::identity::protect_identity_keys", + identity = %identity_id, + reverted, + "Removed per-identity password protection", + ); + Ok(BackendTaskSuccessResult::IdentityKeysUnprotected { identity_id }) + } +} + +/// Fail-closed guard for the protect boundary: reject an identity that +/// still carries resident plaintext (`Clear`/`AlwaysClear`) keys on disk. Such a +/// key means the eager load-path vault migration did not complete — its vault +/// write failed, or it was skipped on an already-protected identity — so the key +/// has no vault label and [`seal_identity_keys`] would silently skip its +/// `Absent` scheme and report a false success. Wallet-derived +/// (`AtWalletDerivationPath`) and already-vaulted (`InVault`) keys carry no +/// resident plaintext, so a legitimately keyless / wallet-derived identity is +/// never rejected. +/// +/// Also rejects legacy `Encrypted` keys (decode-only, no current producer): +/// their vault scheme is also `Absent`, so the seal step would silently skip +/// them and issue a false-protected result. See [`KeyStorage::has_encrypted_legacy_keys`]. +/// +/// The two rejections carry DIFFERENT recovery actions, so they map to distinct +/// errors: resident plaintext is finished by the load-path migration on the next +/// launch ([`TaskError::IdentityKeyProtectionIncomplete`] → "close and reopen"), +/// whereas a legacy `Encrypted` key has no migration path +/// ([`TaskError::IdentityKeyProtectionLegacyFormat`] → "load the identity again"). +/// Legacy keys are checked first: re-loading the identity also clears any +/// resident plaintext, so it is the single action that resolves both. +fn reject_resident_identity_plaintext(private_keys: &KeyStorage) -> Result<(), TaskError> { + if private_keys.has_encrypted_legacy_keys() { + return Err(TaskError::IdentityKeyProtectionLegacyFormat); + } + if private_keys.has_plaintext_for_vault() { + return Err(TaskError::IdentityKeyProtectionIncomplete); + } + Ok(()) +} + +/// Seal every keyless (`Unprotected`) vault key in `keys` Tier-2 under +/// `password`, returning how many were newly sealed. Idempotent: an +/// already-`Protected` key is skipped, and an `Absent` key (not vault-stored — +/// a wallet-derived or resident-plaintext key, protected by other means) is +/// skipped. Crash-safe: the same-label upsert never loses a key, so a re-run +/// finishes a partial migration. +/// +/// At-rest residual (known): the in-place upsert replaces the value at +/// the label, but the PRE-opt-in keyless plaintext may persist in freed +/// filesystem blocks (atomic-rename/copy-on-write residue, filesystem-owned) +/// until those blocks are reused. This is a strict improvement over the keyless +/// default and matches the residual already accepted for the seed/single-key +/// Tier-2 re-wrap; secure-erase of freed blocks is out of this layer's control. +fn seal_identity_keys( + view: &IdentityKeyView<'_>, + keys: &IdentityKeySet, + password: &SecretString, +) -> Result<usize, TaskError> { + // One-password invariant: a Mixed-state "Finish protecting" re-run + // (some keys already Tier-2 from a prior partial opt-in, some still keyless) + // must not seal the remaining keys under a DIFFERENT password than the + // existing ones. Verify the supplied password opens every already-`Protected` + // key BEFORE mutating any label, so a mismatch returns up front with zero + // state changes — the identity can never be split across two passwords. + verify_existing_protection_password(view, keys, password)?; + + let mut sealed = 0usize; + for (target, key_id) in keys { + match view.scheme(target, *key_id)? { + SecretScheme::Unprotected => { + let raw = view + .get(target, *key_id)? + .ok_or(TaskError::IdentityKeyMissing)?; + view.store_protected(target, *key_id, &raw, password)?; + sealed += 1; + } + SecretScheme::Protected | SecretScheme::Absent => {} + } + } + Ok(sealed) +} + +/// Verify `password` opens EVERY already-`Protected` key in `keys`, before any +/// vault mutation. Both migrations call this up front so they are atomic +/// by construction: if `password` fails to open any protected key, the mismatch +/// surfaces from `get_protected` as [`TaskError::IdentityKeyPassphraseIncorrect`] +/// (no oracle) with zero state changes — opt-in can't seal the rest under a +/// second password, and opt-out can't strip a prefix before aborting. Keyless +/// (`Unprotected`) and `Absent` keys impose no password constraint and are skipped. +fn verify_existing_protection_password( + view: &IdentityKeyView<'_>, + keys: &IdentityKeySet, + password: &SecretString, +) -> Result<(), TaskError> { + for (target, key_id) in keys { + if view.scheme(target, *key_id)? == SecretScheme::Protected { + view.get_protected(target, *key_id, password)? + .ok_or(TaskError::IdentityKeyMissing)?; + } + } + Ok(()) +} + +/// Revert every `Protected` vault key in `keys` to keyless (Tier-1), verifying +/// `password`, returning how many were reverted. Idempotent: an already-keyless +/// (`Unprotected`) or `Absent` key is skipped. Crash-safe: the in-place +/// downgrade never loses a key, so a re-run finishes a partial opt-out. +fn unseal_identity_keys( + view: &IdentityKeyView<'_>, + keys: &IdentityKeySet, + password: &SecretString, +) -> Result<usize, TaskError> { + // Atomic opt-out: prove `password` opens EVERY `Protected` key + // BEFORE downgrading any label (mirrors the opt-in preflight), so a password + // that opens only a prefix can't leave that prefix stripped. Mismatch → no-op. + verify_existing_protection_password(view, keys, password)?; + + let mut reverted = 0usize; + for (target, key_id) in keys { + if view.scheme(target, *key_id)? == SecretScheme::Protected { + let raw = view + .get_protected(target, *key_id, password)? + .ok_or(TaskError::IdentityKeyMissing)?; + view.store_unprotected(target, *key_id, &raw)?; + reverted += 1; + } + } + Ok(reverted) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use std::collections::BTreeMap; + + use platform_wallet_storage::secrets::SecretStore; + use zeroize::Zeroizing; + + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use crate::wallet_backend::single_key::open_secret_store; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + Arc::new(open_secret_store(&dir.join("secrets.pwsvault")).expect("open vault")) + } + + fn key_set(pairs: &[(PrivateKeyTarget, KeyID)]) -> IdentityKeySet { + pairs.iter().cloned().collect() + } + + const M: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnMainIdentity; + const V: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnVoterIdentity; + + /// Opt-in seals every keyless key Tier-2, the round-trips back under the + /// password, and a re-run seals nothing (idempotent). + #[test] + fn seal_then_idempotent_rerun() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x01u8; 32]); + view.store(&M, 0, &[0xA0; 32]).unwrap(); + view.store(&M, 1, &[0xA1; 32]).unwrap(); + view.store(&V, 0, &[0xB0; 32]).unwrap(); + let keys = key_set(&[(M, 0), (M, 1), (V, 0)]); + let pw = SecretString::new("one-identity-password"); + + let sealed = seal_identity_keys(&view, &keys, &pw).unwrap(); + assert_eq!(sealed, 3, "all three keyless keys sealed"); + for (t, k) in &keys { + assert_eq!(view.scheme(t, *k).unwrap(), SecretScheme::Protected); + } + assert_eq!( + *view.get_protected(&M, 1, &pw).unwrap().unwrap(), + [0xA1; 32], + "sealed key round-trips under the password", + ); + + // Re-run seals nothing — already protected. + assert_eq!(seal_identity_keys(&view, &keys, &pw).unwrap(), 0); + } + + /// Opt-out reverts every protected key to keyless and a re-run reverts + /// nothing (idempotent); the exact bytes survive the round trip. + #[test] + fn unseal_then_idempotent_rerun() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x02u8; 32]); + let pw = SecretString::new("one-identity-password"); + view.store_protected(&M, 0, &[0xC0; 32], &pw).unwrap(); + view.store_protected(&M, 1, &[0xC1; 32], &pw).unwrap(); + let keys = key_set(&[(M, 0), (M, 1)]); + + let reverted = unseal_identity_keys(&view, &keys, &pw).unwrap(); + assert_eq!(reverted, 2); + assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Unprotected); + assert_eq!(*view.get(&M, 1).unwrap().unwrap(), [0xC1; 32]); + + assert_eq!(unseal_identity_keys(&view, &keys, &pw).unwrap(), 0); + } + + /// A wrong opt-out password aborts on the FIRST protected key, before any + /// key is downgraded — no partial, silent strip. + #[test] + fn unseal_wrong_password_aborts_without_downgrade() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x03u8; 32]); + let pw = SecretString::new("the-right-password-aa"); + view.store_protected(&M, 0, &[0xD0; 32], &pw).unwrap(); + view.store_protected(&M, 1, &[0xD1; 32], &pw).unwrap(); + let keys = key_set(&[(M, 0), (M, 1)]); + + let err = unseal_identity_keys(&view, &keys, &SecretString::new("wrong-password-bbbb")) + .expect_err("wrong password"); + assert!( + matches!(err, TaskError::IdentityKeyPassphraseIncorrect), + "expected IdentityKeyPassphraseIncorrect, got {err:?}" + ); + // Both keys remain protected — nothing was downgraded. + assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Protected); + assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected); + } + + /// Atomic opt-out (CWE-460): on a Mixed-password identity — key 0 + /// sealed under password A, key 1 under password B — an opt-out with + /// password A must NOT downgrade the key it CAN open before aborting on the + /// one it cannot. The one-password invariant forbids this state, but a + /// tampered or legacy vault could still present it, so opt-out must be + /// all-or-nothing by construction. The all-keys preflight rejects up front + /// with `IdentityKeyPassphraseIncorrect`, leaving BOTH keys protected — no + /// silent partial protection downgrade. Without the preflight, key 0 (which + /// password A opens, and which sorts first) would be stripped to keyless + /// plaintext while key 1 stayed sealed. + #[test] + fn unseal_mixed_password_aborts_without_partial_downgrade() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x08u8; 32]); + let pw_a = SecretString::new("password-for-key-zero"); + let pw_b = SecretString::new("password-for-key-one-"); + // (M, 0) sorts before (M, 1): a downgrade-as-you-go loop would reach + // key 0 first and strip it before failing the password check on key 1. + view.store_protected(&M, 0, &[0x80; 32], &pw_a).unwrap(); + view.store_protected(&M, 1, &[0x81; 32], &pw_b).unwrap(); + let keys = key_set(&[(M, 0), (M, 1)]); + + let err = unseal_identity_keys(&view, &keys, &pw_a) + .expect_err("password A does not open key 1 — opt-out must abort"); + assert!( + matches!(err, TaskError::IdentityKeyPassphraseIncorrect), + "expected IdentityKeyPassphraseIncorrect, got {err:?}" + ); + // Neither key was downgraded: key 0 — which password A COULD open — is + // still Protected because the preflight ran before any mutation. + assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Protected); + assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected); + // The sealed bytes are intact under each key's original password. + assert_eq!( + *view.get_protected(&M, 0, &pw_a).unwrap().unwrap(), + [0x80; 32] + ); + assert_eq!( + *view.get_protected(&M, 1, &pw_b).unwrap().unwrap(), + [0x81; 32] + ); + } + + /// A partial-crash mix (some keys Tier-2, some Tier-1) re-runs to a clean, + /// fully-protected state — the same-label upsert never loses a key. + #[test] + fn seal_finishes_a_partial_mix() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x04u8; 32]); + let pw = SecretString::new("one-identity-password"); + // Simulate a crash mid opt-in: key 0 sealed, key 1 still keyless. + view.store_protected(&M, 0, &[0xE0; 32], &pw).unwrap(); + view.store(&M, 1, &[0xE1; 32]).unwrap(); + let keys = key_set(&[(M, 0), (M, 1)]); + + let sealed = seal_identity_keys(&view, &keys, &pw).unwrap(); + assert_eq!(sealed, 1, "only the still-keyless key is sealed"); + assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Protected); + assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected); + assert_eq!( + *view.get_protected(&M, 0, &pw).unwrap().unwrap(), + [0xE0; 32] + ); + assert_eq!( + *view.get_protected(&M, 1, &pw).unwrap().unwrap(), + [0xE1; 32] + ); + } + + /// One-password invariant: a Mixed-state "Finish protecting" re-run + /// supplied with a DIFFERENT password than the already-sealed key is + /// rejected up front with `IdentityKeyPassphraseIncorrect`, leaving every + /// key untouched — the identity can never be split across two passwords. + /// Re-running with the ORIGINAL password finishes the job, sealing all keys + /// under that one password. + #[test] + fn seal_rejects_mismatched_password_on_mixed_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x07u8; 32]); + let original = SecretString::new("the-original-password"); + // Crash mid opt-in: key 0 sealed under the original password, key 1 + // still keyless. + view.store_protected(&M, 0, &[0xF0; 32], &original).unwrap(); + view.store(&M, 1, &[0xF1; 32]).unwrap(); + let keys = key_set(&[(M, 0), (M, 1)]); + + // A re-run with a DIFFERENT password is rejected before any sealing. + let err = seal_identity_keys(&view, &keys, &SecretString::new("a-different-password")) + .expect_err("mismatched password must be rejected"); + assert!( + matches!(err, TaskError::IdentityKeyPassphraseIncorrect), + "expected IdentityKeyPassphraseIncorrect, got {err:?}" + ); + // Nothing changed: key 0 still Protected (under the original password), + // key 1 still keyless — no split, no partial seal. + assert_eq!(view.scheme(&M, 0).unwrap(), SecretScheme::Protected); + assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Unprotected); + + // Re-running with the ORIGINAL password finishes the job: both keys end + // sealed under the one per-identity password. + let sealed = seal_identity_keys(&view, &keys, &original).unwrap(); + assert_eq!(sealed, 1, "only the still-keyless key is sealed"); + assert_eq!(view.scheme(&M, 1).unwrap(), SecretScheme::Protected); + assert_eq!( + *view.get_protected(&M, 0, &original).unwrap().unwrap(), + [0xF0; 32] + ); + assert_eq!( + *view.get_protected(&M, 1, &original).unwrap().unwrap(), + [0xF1; 32] + ); + } + + /// `Absent` keys (not vault-stored — wallet-derived/resident) are skipped by + /// both directions without error. + #[test] + fn absent_keys_are_skipped() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x05u8; 32]); + let pw = SecretString::new("one-identity-password"); + let keys = key_set(&[(M, 7), (V, 9)]); // nothing stored under these + assert_eq!(seal_identity_keys(&view, &keys, &pw).unwrap(), 0); + assert_eq!(unseal_identity_keys(&view, &keys, &pw).unwrap(), 0); + } + + /// The backend enforces the password policy — a too-short password + /// is rejected with the typed error before any sealing, regardless of the UI. + #[test] + fn weak_password_is_rejected_by_backend_policy() { + let err = validate_protection_password(&Secret::new("short")).expect_err("too short"); + assert!( + matches!(err, TaskError::SingleKeyPassphraseTooShort { .. }), + "expected SingleKeyPassphraseTooShort, got {err:?}" + ); + // A policy-compliant password passes. + validate_protection_password(&Secret::new("long-enough-password")) + .expect("compliant password accepted"); + } + + /// A full round trip with a Zeroizing-backed raw key proves the bytes are + /// preserved through seal → unseal. + #[test] + fn seal_unseal_round_trip_preserves_bytes() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x06u8; 32]); + let raw = Zeroizing::new([0x5Au8; 32]); + view.store(&M, 0, &raw).unwrap(); + let keys = key_set(&[(M, 0)]); + let pw = SecretString::new("round-trip-password-x"); + + seal_identity_keys(&view, &keys, &pw).unwrap(); + unseal_identity_keys(&view, &keys, &pw).unwrap(); + assert_eq!(*view.get(&M, 0).unwrap().unwrap(), *raw); + } + + /// A `KeyStorage` holding a single resident-plaintext `Clear` key — the state + /// the load-path vault migration leaves behind when its vault write failed or + /// was skipped, so the key's vault label is `Absent`. + fn ks_with_resident_clear() -> KeyStorage { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let k = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (M, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Clear([0xCC; 32]), + ), + ); + ks + } + + /// A `KeyStorage` holding a single legacy `Encrypted` key — the decode-only + /// variant an old DET version left behind. Its vault scheme is `Absent` (no + /// migration path), so the seal step would silently skip it. + fn ks_with_encrypted_legacy() -> KeyStorage { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let k = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (M, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Encrypted(vec![0x33; 48]), + ), + ); + ks + } + + /// A `KeyStorage` whose keys are all legitimately not-resident: one already + /// vault-backed (`InVault`) and one wallet-derived (`AtWalletDerivationPath`, + /// whose vault scheme is `Absent` by design, not by a failed migration). + fn ks_invault_plus_wallet_derived() -> KeyStorage { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let vaulted = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (M, vaulted.id()), + ( + QualifiedIdentityPublicKey::from(vaulted), + PrivateKeyData::InVault, + ), + ); + let derived = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (M, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + ks + } + + /// A keyless `QualifiedIdentity` with two resident-plaintext keys (`Clear` + /// and `AlwaysClear`) plus one wallet-derived key — the normal opt-in shape + /// after a fresh import. + fn qi_clear_pair_plus_wallet_derived() -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let a = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (M, a.id()), + ( + QualifiedIdentityPublicKey::from(a), + PrivateKeyData::Clear([0xA0; 32]), + ), + ); + let b = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (M, b.id()), + ( + QualifiedIdentityPublicKey::from(b), + PrivateKeyData::AlwaysClear([0xB0; 32]), + ), + ); + let derived = IdentityPublicKey::random_key(3, Some(3), pv); + ks.private_keys.insert( + (M, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::default(), pv).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: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// Fail-closed: an identity still carrying a resident-plaintext key + /// (the load-path vault migration did not move it, so its vault label is + /// `Absent`) is rejected at the protect boundary rather than reported as + /// protected — the false-`IdentityKeysProtected{count:0}` regression. + #[test] + fn protect_rejects_resident_plaintext_key() { + let ks = ks_with_resident_clear(); + let err = reject_resident_identity_plaintext(&ks) + .expect_err("resident plaintext must fail closed"); + assert!( + matches!(err, TaskError::IdentityKeyProtectionIncomplete), + "expected IdentityKeyProtectionIncomplete, got {err:?}" + ); + } + + /// Fail-closed: an identity carrying a legacy `Encrypted` key (no + /// migration path) is rejected with the dedicated + /// [`TaskError::IdentityKeyProtectionLegacyFormat`] — NOT the resident- + /// plaintext `IdentityKeyProtectionIncomplete` — so the user is told to load + /// the identity again rather than uselessly close and reopen. + #[test] + fn protect_rejects_legacy_encrypted_key_with_distinct_error() { + let ks = ks_with_encrypted_legacy(); + let err = reject_resident_identity_plaintext(&ks) + .expect_err("legacy Encrypted key must fail closed"); + assert!( + matches!(err, TaskError::IdentityKeyProtectionLegacyFormat), + "expected IdentityKeyProtectionLegacyFormat, got {err:?}" + ); + } + + /// No false positive: an identity whose keys are wallet-derived + /// (`AtWalletDerivationPath`, legitimately `Absent`) or already vault-backed + /// (`InVault`) carries no resident plaintext and is accepted — opt-in must + /// not regress for normal identities. + #[test] + fn protect_accepts_wallet_derived_and_vaulted_keys() { + let ks = ks_invault_plus_wallet_derived(); + reject_resident_identity_plaintext(&ks) + .expect("wallet-derived / already-vaulted keys must not be rejected"); + } + + /// End-to-end no-false-positive: a normal keyless opt-in still succeeds. The + /// insert migrates the two resident-plaintext keys into the keyless vault, + /// `protect_identity_keys` passes the fail-closed guard, seals exactly those + /// two keys Tier-2, and skips the wallet-derived (`Absent`) key. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn protect_normal_opt_in_seals_vault_keys_and_skips_wallet_derived() { + use crate::app::TaskResult; + 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; + + // Offline wired AppContext (no network I/O) so the secret store is a real, + // writable vault the insert/opt-in paths can migrate into. + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let qi = qi_clear_pair_plus_wallet_derived(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert identity (migrates resident plaintext into the keyless vault)"); + + let result = ctx + .protect_identity_keys(identity_id, Secret::new("one-identity-password"), None) + .expect("normal opt-in must succeed, not fail closed"); + match result { + BackendTaskSuccessResult::IdentityKeysProtected { + identity_id: got, + count, + } => { + assert_eq!(got, identity_id, "result reports the same identity"); + assert_eq!( + count, 2, + "both keyless vault keys sealed; the wallet-derived key skipped, not rejected", + ); + } + other => panic!("expected IdentityKeysProtected, got {other:?}"), + } + + if let Ok(backend) = ctx.wallet_backend() { + backend.shutdown().await; + } + } + + /// Wiring guard: the fail-closed check must be PLUGGED INTO the + /// protect path, not merely callable in isolation. Drive the real post-load + /// protect logic (`protect_loaded_identity_keys`, which `protect_identity_keys` + /// runs after `get_identity_by_id`) on a `qi` carrying resident plaintext and + /// assert it returns `IdentityKeyProtectionIncomplete` — NOT + /// `Ok(IdentityKeysProtected{count:0})`. Deleting the guard line makes this + /// test fail: the seal then skips the vault-`Absent` keys and reports a false + /// success. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn protect_loaded_identity_with_resident_plaintext_fails_closed() { + use crate::app::TaskResult; + 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; + + // Offline wired AppContext (backend wired so the post-guard seal path is + // real — with the guard deleted it reaches the seal and returns the false + // `count:0`, which is exactly what this test must catch). + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // A loaded identity still carrying resident plaintext (the state an + // incomplete get-path migration leaves: `Clear`/`AlwaysClear` with an + // `Absent` vault label). It is NOT stored in the vault, so the seal would + // see only `Absent` and report a false success without the guard. + let qi = qi_clear_pair_plus_wallet_derived(); + let err = ctx + .protect_loaded_identity_keys(&qi, &Secret::new("one-identity-password"), None) + .expect_err("resident plaintext must fail closed, not report count:0"); + assert!( + matches!(err, TaskError::IdentityKeyProtectionIncomplete), + "expected IdentityKeyProtectionIncomplete, got {err:?}" + ); + + if let Ok(backend) = ctx.wallet_backend() { + backend.shutdown().await; + } + } +} diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 9d32f0794..7af6c18c0 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -6,7 +6,7 @@ use crate::model::qualified_identity::DPNSNameInfo; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; impl AppContext { @@ -22,6 +22,7 @@ impl AppContext { let identity_id = qualified_identity.identity.id(); let dpns_names_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -29,6 +30,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(identity_id.into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -72,8 +75,7 @@ impl AppContext { qualified_identity.alias = Some(format!("{}.dash", dpns_name)); } - self.update_local_qualified_identity(&qualified_identity) - .map_err(|e| TaskError::Database { source: e })?; + self.update_local_qualified_identity(&qualified_identity)?; } sender diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 1dc7a5e5c..176149698 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -2,7 +2,6 @@ use std::collections::BTreeMap; use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo}; use bip39::rand::{Rng, SeedableRng, rngs::StdRng}; use dash_sdk::{ @@ -16,7 +15,7 @@ use dash_sdk::{ platform_value::{Bytes32, Value}, util::{hash::hash_double, strings::convert_to_homograph_safe_chars}, }, - drive::query::{WhereClause, WhereOperator}, + drive::query::{SelectProjection, WhereClause, WhereOperator}, platform::Fetch, platform::{Document, DocumentQuery, FetchMany, transition::put_document::PutDocument}, }; @@ -128,7 +127,7 @@ impl AppContext { .document_signing_key(&preorder_document_type) .ok_or(TaskError::NoDocumentSigningKey)?; - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(2); let balance_before = qualified_identity.identity.balance(); @@ -158,6 +157,7 @@ impl AppContext { .await?; let dpns_names_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { @@ -165,6 +165,8 @@ impl AppContext { operator: WhereOperator::Equal, value: Value::Identifier(qualified_identity.identity.id().into()), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 100, start: None, @@ -233,8 +235,7 @@ impl AppContext { qualified_identity.identity = refreshed_identity; - self.update_local_qualified_identity(&qualified_identity) - .map_err(|e| TaskError::Database { source: e })?; + self.update_local_qualified_identity(&qualified_identity)?; let fee_result = FeeResult::new(estimated_fee, actual_fee); Ok(BackendTaskSuccessResult::RegisteredDpnsName(fee_result)) diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index ca6a675d9..fca03f667 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,28 +1,28 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info}; -use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::proof_log_item::RequestType; +use crate::context::AppContext; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use crate::model::request_type::RequestType; use dash_sdk::dash_spv::Network; -use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey}; use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::native_bls::NativeBlsModule; -use dash_sdk::dpp::prelude::{AddressNonce, AssetLockProof}; -use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; -use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; -use dash_sdk::platform::transition::put_identity::PutIdentity; -use dash_sdk::platform::{Fetch, FetchMany, Identity}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::prelude::AddressNonce; +use dash_sdk::platform::{FetchMany, Identity}; use dash_sdk::query_types::AddressInfo; -use dash_sdk::{Error, Sdk}; use std::collections::BTreeMap; +/// Whether `qi` still carries the all-zeros placeholder id assigned before +/// the upstream wallet-backend path learns the real identity id. +/// +/// A placeholder identity must never be persisted: keyed by the all-zeros +/// id it pollutes the identity store and enumeration index with a phantom +/// entry that every subsequent failure overwrites. +fn is_placeholder_identity(qi: &QualifiedIdentity) -> bool { + qi.identity.id() == dash_sdk::platform::Identifier::default() +} + impl AppContext { pub(super) async fn register_identity( &self, @@ -38,110 +38,60 @@ impl AppContext { let sdk = self.sdk.load().as_ref().clone(); - let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None).await?; + let public_keys = keys + .to_public_keys_map() + .map_err(|e| TaskError::PublicKeyMapBuildFailed { detail: e })?; + let key_count = public_keys.len(); + let estimated_fee = self.fee_estimator().estimate_identity_create(key_count); - let wallet_id; + let wallet_seed_hash = { wallet.read().map_err(TaskError::from)?.seed_hash() }; - let (asset_lock_proof, asset_lock_proof_private_key, tx_id) = match identity_funding_method - { - RegisterIdentityFundingMethod::UseAssetLock(address, asset_lock_proof, transaction) => { - let tx_id = transaction.txid(); - - // Scope the read guard so it's dropped before the async DAPI call below - let private_key = { - let wallet = wallet.read().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - wallet - .private_key_for_address(&address, self.network) - .map_err(|e| TaskError::WalletKeyLookupFailed { detail: e })? - .ok_or(TaskError::AssetLockNotValidForWallet)? - }; - let asset_lock_proof = if let AssetLockProof::Instant(instant_asset_lock_proof) = - asset_lock_proof.as_ref() - { - // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { - // Transaction is old enough that instant lock may have expired - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }) - } else { - // Platform hasn't verified this Core block yet - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - AssetLockProof::Instant(instant_asset_lock_proof.clone()) - } - } else { - asset_lock_proof.as_ref().clone() - }; - (asset_lock_proof, private_key, tx_id) + // Wallet-funded paths (fresh asset lock or resume from a tracked + // asset lock) are handled end-to-end by the upstream + // `IdentityWallet`. It builds (or resumes) the asset lock, + // broadcasts, waits for IS/CL, submits the identity-create state + // transition with the upstream IS→CL fallback, and cleans up the + // tracked asset lock on success. + match identity_funding_method { + RegisterIdentityFundingMethod::FundWithWallet(amount_duffs, identity_index) => { + let funding = + platform_wallet::wallet::asset_lock::AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index: 0, + }; + self.register_identity_via_wallet_backend( + funding, + identity_index, + wallet_identity_index, + public_keys, + keys, + wallet, + wallet_seed_hash, + alias_input, + estimated_fee, + ) + .await } - RegisterIdentityFundingMethod::FundWithWallet(amount, identity_index) => { - // Scope the write lock to avoid holding it across an await. - // UTXOs are selected but NOT removed yet — removal happens after broadcast. - let (asset_lock_transaction, asset_lock_proof_private_key, _, used_utxos) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - match wallet.registration_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - None, - ) { - Ok(transaction) => transaction, - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { - detail: e, - }); - } - wallet - .registration_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })? - } - } - }; - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - amount, - &wallet_id, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - (asset_lock_proof, asset_lock_proof_private_key, tx_id) + RegisterIdentityFundingMethod::UseAssetLock { + out_point, + identity_index, + } => { + let funding = + platform_wallet::wallet::asset_lock::AssetLockFunding::FromExistingAssetLock { + out_point, + }; + self.register_identity_via_wallet_backend( + funding, + identity_index, + wallet_identity_index, + public_keys, + keys, + wallet, + wallet_seed_hash, + alias_input, + estimated_fee, + ) + .await } RegisterIdentityFundingMethod::FundWithPlatformAddresses { inputs, @@ -176,344 +126,130 @@ impl AppContext { }) .collect::<BTreeMap<PlatformAddress, (AddressNonce, Credits)>>(); - return self - .register_identity_from_platform_addresses( - alias_input, - keys, - wallet, - wallet_identity_index, - inputs_with_nonces, - wallet_seed_hash, - ) - .await; - } - RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - input_address, - identity_index, - ) => { - // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet_id = wallet.seed_hash(); - wallet - .registration_asset_lock_transaction_for_utxo( - self, - sdk.network, - utxo, - tx_out.clone(), - input_address.clone(), - identity_index, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? - }; - - let used_utxos = BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - tx_out.value, - &wallet_id, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - (asset_lock_proof, asset_lock_proof_private_key, tx_id) - } - }; - - let identity_id = asset_lock_proof - .create_identifier() - .map_err(|e| TaskError::from(dash_sdk::Error::Protocol(e)))?; - - let public_keys = keys - .to_public_keys_map() - .map_err(|e| TaskError::PublicKeyMapBuildFailed { detail: e })?; - - // Debug: Log the keys being registered to verify contract bounds are set - for (key_id, key) in &public_keys { - match key { - dash_sdk::dpp::identity::IdentityPublicKey::V0(key_v0) => { - tracing::info!( - "Identity key {}: purpose={:?}, security_level={:?}, key_type={:?}, contract_bounds={:?}", - key_id, - key_v0.purpose, - key_v0.security_level, - key_v0.key_type, - key_v0.contract_bounds - ); - } + self.register_identity_from_platform_addresses( + alias_input, + keys, + wallet, + wallet_identity_index, + inputs_with_nonces, + wallet_seed_hash, + ) + .await } } + } - // Calculate fee estimate for identity creation - let key_count = public_keys.len(); - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); - - let existing_identity = match Identity::fetch_by_identifier(&sdk, identity_id).await { - Ok(result) => result, - Err(e) => return Err(TaskError::from(e)), - }; - - let identity = match existing_identity.clone() { - Some(id) => id, - None => Identity::new_with_id_and_keys(identity_id, public_keys, sdk.version()) - .map_err(|e| TaskError::IdentityCreationError { - source: Box::new(e), - })?, - }; + /// Drive identity registration through the upstream signer-driven + /// orchestrator. Upstream owns asset-lock build/broadcast, IS→CL + /// fallback, the actual submit, and the tracked-asset-lock cleanup — + /// DET stays out of that loop and only updates its own local mirror. + #[allow(clippy::too_many_arguments)] + async fn register_identity_via_wallet_backend( + &self, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + identity_index: u32, + wallet_identity_index: u32, + public_keys: BTreeMap< + dash_sdk::dpp::identity::KeyID, + dash_sdk::dpp::identity::IdentityPublicKey, + >, + keys: super::IdentityKeySpecs, + wallet: std::sync::Arc<std::sync::RwLock<super::Wallet>>, + wallet_seed_hash: super::WalletSeedHash, + alias_input: String, + estimated_fee: u64, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let backend = self.wallet_backend()?; + + // Build a placeholder identity to seed the local QualifiedIdentity + // bookkeeping; the upstream call returns the authoritative Identity + // and we replace it on success. + let placeholder_id = dash_sdk::platform::Identifier::default(); + let placeholder_identity = Identity::new_with_id_and_keys( + placeholder_id, + public_keys.clone(), + self.platform_version(), + ) + .map_err(|e| TaskError::IdentityCreationError { + source: Box::new(e), + })?; - let wallet_seed_hash = { wallet.read().map_err(TaskError::from)?.seed_hash() }; let mut qualified_identity = QualifiedIdentity { - identity: identity.clone(), + identity: placeholder_identity, associated_voter_identity: None, associated_operator_identity: None, associated_owner_key_id: None, identity_type: IdentityType::User, - alias: None, + alias: if alias_input.is_empty() { + None + } else { + Some(alias_input) + }, private_keys: keys.to_key_storage(wallet_seed_hash), dpns_names: vec![], - associated_wallets: BTreeMap::from([( - wallet.read().map_err(TaskError::from)?.seed_hash(), - wallet.clone(), - )]), + associated_wallets: BTreeMap::from([(wallet_seed_hash, wallet.clone())]), + secret_access: self.wallet_backend().ok().map(|b| b.secret_access()), wallet_index: Some(wallet_identity_index), top_ups: Default::default(), status: IdentityStatus::PendingCreation, network: self.network, }; - if !alias_input.is_empty() { - qualified_identity.alias = Some(alias_input); - } - - if let Some(existing_identity) = existing_identity { - qualified_identity.identity = existing_identity; - qualified_identity.status = IdentityStatus::Unknown; - - self.insert_local_qualified_identity( + let registered_identity = backend + .register_identity( + &wallet_seed_hash, + identity_index, + funding, + public_keys, &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - wallet - .identities - .insert(wallet_identity_index, qualified_identity.identity.clone()); - } - - self.db - .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes())?; - - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - return Ok(BackendTaskSuccessResult::RegisteredIdentity( - qualified_identity, - fee_result, - )); - } - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - self.db - .set_asset_lock_identity_id_before_confirmation_by_network( - tx_id.as_byte_array(), - identity_id.as_bytes(), - )?; - - match self - .put_new_identity_to_platform( - &sdk, - &identity, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, - qualified_identity.clone(), + None, ) .await - { - Ok(updated_identity) => { - qualified_identity.identity = updated_identity; - qualified_identity.status = IdentityStatus::Unknown; // force refresh of the status - } - Err(e) => { - if matches!(e, TaskError::AssetLockInstantLockProofInvalid { .. }) { - // Try to use chain asset lock proof instead - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked && tx_info.height > 0 { - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - let chain_asset_lock_proof = - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }); - - // Retry with chain asset lock proof - match self - .put_new_identity_to_platform( - &sdk, - &identity, - chain_asset_lock_proof, - &asset_lock_proof_private_key, - qualified_identity.clone(), - ) - .await - { - Ok(updated_identity) => { - qualified_identity.identity = updated_identity; - qualified_identity.status = IdentityStatus::Unknown; - } - Err(retry_err) => { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(retry_err); - } - } - } else { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(TaskError::AssetLockInstantLockExpiredNotChainlocked); - } - } else { - // we failed, set the status accordingly and terminate the process - qualified_identity - .status - .update(IdentityStatus::FailedCreation); - - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - )?; - - return Err(e); + .inspect_err(|_| { + // On this path the real identity id is only known once upstream + // succeeds; the local mirror still carries the all-zeros + // placeholder. Persisting it would key a bogus zero-id record in + // the identity store and enumeration index, surfacing a phantom + // identity that every later failure overwrites. There is no real + // id to anchor a FailedCreation marker to, so skip the insert — + // the upstream discovery loop owns the asset-lock bookkeeping. + if is_placeholder_identity(&qualified_identity) { + return; } - } - } + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + let _ = self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_seed_hash, wallet_identity_index)), + ); + })?; + + qualified_identity.identity = registered_identity.clone(); + qualified_identity.status = IdentityStatus::Unknown; // force refresh self.insert_local_qualified_identity( &qualified_identity, - &Some((wallet_id, wallet_identity_index)), + &Some((wallet_seed_hash, wallet_identity_index)), )?; { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - wallet.identities.insert(wallet_identity_index, identity); + let mut wallet_w = wallet.write().map_err(TaskError::from)?; + wallet_w + .identities + .insert(wallet_identity_index, registered_identity); } + // The upstream identity discovery loop owns the asset-lock → identity + // mapping on the new path; the DET-side `asset_lock_to_identity_id` + // table is only consulted on the legacy staged-asset-lock recovery + // path, so no mirror write is needed here. - self.db - .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes())?; - - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let fee_result = FeeResult::estimated_only(estimated_fee); Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, fee_result, )) } - async fn put_new_identity_to_platform( - &self, - sdk: &Sdk, - identity: &Identity, - asset_lock_proof: AssetLockProof, - asset_lock_proof_private_key: &PrivateKey, - qualified_identity: QualifiedIdentity, - ) -> Result<Identity, TaskError> { - match identity - .put_to_platform_and_wait_for_response( - sdk, - asset_lock_proof.clone(), - asset_lock_proof_private_key, - &qualified_identity, - None, - ) - .await - { - Ok(updated_identity) => Ok(updated_identity), - Err(e) => { - if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { - identity - .put_to_platform_and_wait_for_response( - sdk, - asset_lock_proof.clone(), - asset_lock_proof_private_key, - &qualified_identity, - None, - ) - .await - .map_err(|retry_err| { - let logged = self.log_drive_proof_error(retry_err, RequestType::BroadcastStateTransition); - // If the logged variant is ProofError, return it directly; - // otherwise log the reconstructed transition for debugging. - if matches!(logged, TaskError::ProofError { .. }) { - return logged; - } - if let Ok(transition) = IdentityCreateTransition::try_from_identity_with_signer( - identity, - asset_lock_proof, - asset_lock_proof_private_key.inner.as_ref(), - &qualified_identity, - &NativeBlsModule, - 0, - self.platform_version(), - ) { - tracing::debug!( - "Register identity retry failed; reconstructed transition: {:?}", - transition - ); - } - logged - }) - } else { - Err(self.log_drive_proof_error(e, RequestType::BroadcastStateTransition)) - } - } - } - } - /// Register a new identity funded by Platform addresses. /// /// `inputs` is a map of Platform addresses to (nonce, credits) tuples. Nonces must be incremented by 1 @@ -521,7 +257,7 @@ impl AppContext { async fn register_identity_from_platform_addresses( &self, alias_input: String, - keys: super::IdentityKeys, + keys: super::IdentityKeySpecs, wallet: std::sync::Arc<std::sync::RwLock<super::Wallet>>, wallet_identity_index: u32, inputs: BTreeMap< @@ -541,14 +277,14 @@ impl AppContext { // Calculate fee estimate for identity creation from platform addresses let key_count = public_keys.len(); let input_count = inputs.len(); - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create_from_addresses( - input_count, - false, - key_count, - ); + let estimated_fee = self + .fee_estimator() + .estimate_identity_create_from_addresses(input_count, false, key_count); - // Clone the wallet for use as the address signer (needed across async boundary) - let wallet_clone = { wallet.read().map_err(TaskError::from)?.clone() }; + // Clone the wallet for the pure address→path index (needed across the + // async boundary). The signing key never lives in this snapshot — it is + // derived JIT from the borrowed HD seed inside the secret scope below. + let wallet_snapshot = { wallet.read().map_err(TaskError::from)?.clone() }; let identity = Identity::new_with_input_addresses_and_keys( &inputs, @@ -570,6 +306,7 @@ impl AppContext { private_keys: keys.to_key_storage(wallet_seed_hash_actual), dpns_names: vec![], associated_wallets: BTreeMap::from([(wallet_seed_hash_actual, wallet.clone())]), + secret_access: self.wallet_backend().ok().map(|b| b.secret_access()), wallet_index: Some(wallet_identity_index), top_ups: Default::default(), status: IdentityStatus::PendingCreation, @@ -580,12 +317,42 @@ impl AppContext { qualified_identity.alias = Some(alias_input); } + // Sign each funding input through a JIT platform signer that borrows the + // HD seed only for the duration of the SDK call. The pure path index is + // built before the secret scope; the seed zeroizes on return and never + // enters this layer by value. + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex, SecretScope}; + let network = self.network; + let path_index = PlatformPathIndex::from_wallet(&wallet_snapshot, network); + let backend = self.wallet_backend()?; + // Send to Platform using address funding and wait for response - match identity - .put_with_address_funding(&sdk, inputs, None, &qualified_identity, &wallet_clone, None) - .await - { - Ok((updated_identity, address_infos)) => { + let put_result = backend + .secret_access() + .with_secret_session( + &SecretScope::HdSeed { + seed_hash: wallet_seed_hash, + }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + Ok(identity + .put_with_address_funding( + &sdk, + inputs, + None, + &qualified_identity, + &signer, + None, + ) + .await) + }, + ) + .await?; + + match put_result { + Ok((updated_identity, address_infos, _height)) => { qualified_identity.identity = updated_identity; qualified_identity.status = IdentityStatus::Unknown; // Force refresh @@ -608,7 +375,7 @@ impl AppContext { .insert(wallet_identity_index, qualified_identity.identity.clone()); } - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let fee_result = FeeResult::estimated_only(estimated_fee); Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, fee_result, @@ -668,3 +435,53 @@ impl AppContext { recent_info } } + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + fn qualified_identity_with_id(id: Identifier) -> QualifiedIdentity { + let identity = + Identity::create_basic_identity(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: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: IdentityStatus::PendingCreation, + network: Network::Testnet, + } + } + + /// A failed wallet-backend registration must not persist its all-zeros + /// placeholder identity. The guard keys on the placeholder id; this pins + /// that an unresolved (default-id) identity is recognised as a placeholder + /// and a real-id identity is not. + #[test] + fn placeholder_identity_is_not_persistable() { + let placeholder = qualified_identity_with_id(Identifier::default()); + assert!( + is_placeholder_identity(&placeholder), + "all-zeros id must be treated as a placeholder and skipped on the failure path" + ); + + let mut real_id_bytes = [0u8; 32]; + real_id_bytes[0] = 7; + real_id_bytes[31] = 9; + let real = qualified_identity_with_id(Identifier::from(real_id_bytes)); + assert!( + !is_placeholder_identity(&real), + "a real identity id must be persisted, not skipped" + ); + } +} diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 6916074d4..2723375e8 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,22 +1,34 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info}; -use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::proof_log_item::RequestType; -use dash_sdk::Error; -use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; -use dash_sdk::dpp::dashcore::OutPoint; -use dash_sdk::dpp::dashcore::hashes::Hash; +use crate::context::AppContext; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::prelude::AssetLockProof; -use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; -use dash_sdk::dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; -use dash_sdk::platform::Fetch; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; -use std::collections::BTreeMap; +use dash_sdk::platform::Identifier; + +/// Validate a wallet-funded top-up's HD index against the identity's recorded +/// wallet position, fail-secure. +/// +/// The op derives its asset-lock funding account from the wallet's HD tree at +/// `identity_index`, so that index must equal the identity's authoritative +/// `wallet_index`, and the identity must be wallet-owned (`Some`) at all. A +/// `None` wallet index means the identity is not owned by this wallet — there is +/// no HD slot to fund from, and the callers pass a sentinel index for it, which +/// must never reach the derivation. Pure — no I/O — so it is unit-testable. +fn validate_topup_index( + identity_id: Identifier, + wallet_index: Option<u32>, + identity_index: u32, +) -> Result<(), TaskError> { + match wallet_index { + Some(wallet_index) if wallet_index == identity_index => Ok(()), + Some(wallet_index) => Err(TaskError::IdentityIndexMismatch { + identity_id, + requested_index: identity_index, + wallet_index, + }), + None => Err(TaskError::IdentityNotWalletOwned { identity_id }), + } +} impl AppContext { pub(super) async fn top_up_identity( @@ -29,375 +41,144 @@ impl AppContext { identity_funding_method, } = input; - let sdk = self.sdk.load().as_ref().clone(); - - let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None).await?; - - let (asset_lock_proof, asset_lock_proof_private_key, tx_id, top_up_index) = + let balance_before = qualified_identity.identity.balance(); + // This estimate is shown to the user and feeds the actual-fee + // plausibility band, so it must track the active network fee multiplier — + // use the context estimator rather than the hardcoded default. + let fee_estimator = self.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + + // Both wallet-funded top-up paths (fresh asset lock or resume from a + // tracked asset lock) run end-to-end through the upstream + // `IdentityWallet::top_up_identity_with_funding`. Upstream owns the + // asset-lock build/broadcast, the IS→CL fallback, the + // `TopUpIdentity` submission, and the asset-lock cleanup. DET only + // mirrors the new balance into its local stores. + let (funding, identity_index, top_up_index, amount_duffs_for_fee) = match identity_funding_method { - TopUpIdentityFundingMethod::UseAssetLock( - address, - asset_lock_proof, - transaction, - ) => { - let tx_id = transaction.txid(); - - // Scope the read guard so it's dropped before the async DAPI call below - let private_key = { - let wallet = wallet.read().map_err(TaskError::from)?; - wallet - .private_key_for_address(&address, self.network) - .map_err(|e| TaskError::WalletKeyLookupFailed { detail: e })? - .ok_or(TaskError::AssetLockNotValidForWallet)? - }; - let asset_lock_proof = - if let AssetLockProof::Instant(instant_asset_lock_proof) = - asset_lock_proof.as_ref() - { - // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info(&sdk, &tx_id).await?; - - if tx_info.is_chain_locked - && tx_info.height > 0 - && tx_info.confirmations > 8 - { - // Transaction is old enough that instant lock may have expired - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }) - } else { - // Platform hasn't verified this Core block yet - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - AssetLockProof::Instant(instant_asset_lock_proof.clone()) - } - } else { - asset_lock_proof.as_ref().clone() - }; - (asset_lock_proof, private_key, tx_id, None) - } TopUpIdentityFundingMethod::FundWithWallet( amount, identity_index, top_up_index, ) => { - // Scope the write lock to avoid holding it across an await. - // UTXOs are selected but NOT removed yet — removal happens after broadcast. - let ( - asset_lock_transaction, - asset_lock_proof_private_key, - _, - used_utxos, - wallet_seed_hash, - ) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - let seed_hash = wallet.seed_hash(); - let tx_result = match wallet.top_up_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - top_up_index, - None, - ) { - Ok(transaction) => transaction, - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { - detail: e, - }); - } - wallet - .top_up_asset_lock_transaction( - self, - sdk.network, - amount, - true, - identity_index, - top_up_index, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })? - } + let funding = + platform_wallet::wallet::asset_lock::AssetLockFunding::FromWalletBalance { + amount_duffs: amount, + account_index: 0, }; - ( - tx_result.0, - tx_result.1, - tx_result.2, - tx_result.3, - seed_hash, - ) - }; - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - amount, - &wallet_seed_hash, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - ( - asset_lock_proof, - asset_lock_proof_private_key, - tx_id, - Some((amount, top_up_index)), - ) + (funding, identity_index, top_up_index, Some(amount)) } - TopUpIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - input_address, + TopUpIdentityFundingMethod::UseAssetLock { + out_point, identity_index, top_up_index, - ) => { - // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key, wallet_seed_hash) = { - let mut wallet = wallet.write().map_err(TaskError::from)?; - let seed_hash = wallet.seed_hash(); - let tx_result = wallet - .top_up_asset_lock_transaction_for_utxo( - self, - sdk.network, - utxo, - tx_out.clone(), - input_address.clone(), - identity_index, - top_up_index, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { - detail: e, - })?; - (tx_result.0, tx_result.1, seed_hash) + } => { + let funding = platform_wallet::wallet::asset_lock::AssetLockFunding::FromExistingAssetLock { + out_point, }; - - let used_utxos = - BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - tx_out.value, - &wallet_seed_hash, - &wallet, - &used_utxos, - ) - .await?; - - let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; - - ( - asset_lock_proof, - asset_lock_proof_private_key, - tx_id, - Some((tx_out.value, top_up_index)), - ) + (funding, identity_index, top_up_index, None) } }; - self.db - .set_asset_lock_identity_id_before_confirmation_by_network( - tx_id.as_byte_array(), - qualified_identity.identity.id().as_bytes(), - )?; - - // Track balance before top-up for fee calculation - let balance_before = qualified_identity.identity.balance(); - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup(); - - let updated_identity_balance = match qualified_identity - .identity + let seed_hash = wallet.read().map_err(TaskError::from)?.seed_hash(); + let identity_id = qualified_identity.identity.id(); + // Fail-secure: a wallet-funded top-up derives its asset-lock account + // from this wallet's HD tree at the identity's index, so the op index + // must equal the identity's recorded `wallet_index` AND the identity + // must be wallet-owned at all. Reject before any funds move — a foreign + // identity has no HD slot here (the UI/MCP pass a sentinel index for + // `None`, which must never reach the funding derivation). Verified: + // `wallet_index == None` iff the identity is not wallet-owned, so every + // valid target carries `Some(index)`. + validate_topup_index(identity_id, qualified_identity.wallet_index, identity_index)?; + let backend = self.wallet_backend()?; + let new_balance = backend .top_up_identity( - &sdk, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, - None, + &seed_hash, + &qualified_identity.identity, + funding, + identity_index, None, ) - .await - { - Ok(updated_identity) => updated_identity, - Err(e) => { - if crate::backend_task::error::is_instant_lock_proof_invalid(&e) { - // Try to use chain asset lock proof instead - let tx_info = get_transaction_info(&sdk, &tx_id).await?; + .await?; + qualified_identity.identity.set_balance(new_balance); - if tx_info.is_chain_locked && tx_info.height > 0 { - let tx_block_height = tx_info.height; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has verified this Core block, use chain lock proof - let chain_asset_lock_proof = - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }); - - // Retry with chain asset lock proof - qualified_identity - .identity - .top_up_identity( - &sdk, - chain_asset_lock_proof, - &asset_lock_proof_private_key, - None, - None, - ) - .await - .map_err(|e| { - self.log_drive_proof_error( - e, - RequestType::BroadcastStateTransition, - ) - })? - } else { - return Err(TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - return Err(TaskError::AssetLockInstantLockExpiredNotChainlocked); - } - } else if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { - qualified_identity - .identity - .top_up_identity( - &sdk, - asset_lock_proof.clone(), - &asset_lock_proof_private_key, - None, - None, - ) - .await - .map_err(|retry_err| { - let logged = self.log_drive_proof_error( - retry_err, - RequestType::BroadcastStateTransition, - ); - if matches!(logged, TaskError::ProofError { .. }) { - return logged; - } - // Log the reconstructed transition for debugging before returning the error. - if let Ok(transition) = IdentityTopUpTransition::try_from_identity( - &qualified_identity.identity, - asset_lock_proof, - asset_lock_proof_private_key.inner.as_ref(), - 0, - self.platform_version(), - None, - ) { - tracing::debug!( - "Top-up retry failed; reconstructed transition: {:?}", - transition - ); - } - logged - })? - } else { - return Err( - self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) - ); - } + let actual_fee = match amount_duffs_for_fee { + Some(amount) => { + fee_estimator.resolve_identity_topup_actual_fee(amount, balance_before, new_balance) } + None => estimated_fee, }; - - qualified_identity - .identity - .set_balance(updated_identity_balance); - - // Calculate and log actual fee paid - // For top-ups, the "fee" is the difference between expected new balance and actual - let expected_credits_from_topup = if let Some((amount, _)) = top_up_index { - // amount is in duffs, 1 duff = 1000 credits - amount * 1000 - } else { - // For asset lock method, calculate from the asset lock amount - 0 // Can't easily determine without more info - }; - - if expected_credits_from_topup > 0 { - let balance_increase = updated_identity_balance.saturating_sub(balance_before); - let actual_fee = expected_credits_from_topup.saturating_sub(balance_increase); - tracing::info!( - "Identity top-up complete: topped up {} credits (from {} duffs), estimated fee {} credits, actual fee {} credits, balance increased by {} credits", - expected_credits_from_topup, - expected_credits_from_topup / 1000, - estimated_fee, - actual_fee, - balance_increase - ); - if actual_fee != estimated_fee { - tracing::warn!( - "Top-up fee mismatch: estimated {} vs actual {} (diff: {})", - estimated_fee, - actual_fee, - actual_fee as i128 - estimated_fee as i128 - ); - } - } else { - tracing::info!( - "Identity top-up complete: balance before {} credits, balance after {} credits", - balance_before, - updated_identity_balance - ); - } - - self.update_local_qualified_identity(&qualified_identity)?; - - { - let mut wallet = wallet.write().map_err(TaskError::from)?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - } - - self.db.set_asset_lock_identity_id( - tx_id.as_byte_array(), - qualified_identity.identity.id().as_bytes(), - )?; - - if let Some((amount, top_up_index)) = top_up_index { - self.db.insert_top_up( - qualified_identity.identity.id().as_bytes(), - top_up_index, - amount, + tracing::info!( + "Identity top-up complete: balance before {} credits, balance after {} credits, estimated fee {} credits, actual fee {} credits", + balance_before, + new_balance, + estimated_fee, + actual_fee, + ); + + if let Some(amount) = amount_duffs_for_fee { + qualified_identity.top_ups.insert(top_up_index, amount); + self.save_top_ups( + &qualified_identity.identity.id(), + &qualified_identity.top_ups, )?; } + self.update_local_qualified_identity(&qualified_identity)?; - // Calculate actual fee for the FeeResult - let actual_fee = if expected_credits_from_topup > 0 { - let balance_increase = updated_identity_balance.saturating_sub(balance_before); - expected_credits_from_topup.saturating_sub(balance_increase) - } else { - estimated_fee // Fall back to estimated when we can't calculate actual - }; let fee_result = FeeResult::new(estimated_fee, actual_fee); - Ok(BackendTaskSuccessResult::ToppedUpIdentity( qualified_identity, fee_result, )) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A wallet-owned identity whose op index matches its wallet index passes. + #[test] + fn validate_topup_index_accepts_matching_wallet_index() { + let id = Identifier::random(); + assert!(validate_topup_index(id, Some(5), 5).is_ok()); + } + + /// A divergent op index is rejected with the indices captured as typed + /// fields (not a string). + #[test] + fn validate_topup_index_rejects_mismatch_with_indices() { + let id = Identifier::random(); + let err = + validate_topup_index(id, Some(5), 3).expect_err("a divergent op index must reject"); + match err { + TaskError::IdentityIndexMismatch { + identity_id, + requested_index, + wallet_index, + } => { + assert_eq!(identity_id, id); + assert_eq!(requested_index, 3); + assert_eq!(wallet_index, 5); + } + other => panic!("expected IdentityIndexMismatch, got: {other:?}"), + } + } + + /// A non-wallet-owned identity (`wallet_index == None`) fails closed even + /// with the sentinel indices the UI (`u32::MAX >> 1`) and MCP (`0`) pass — + /// the funds-safety hole this guard closes. + #[test] + fn validate_topup_index_rejects_non_wallet_owned() { + let id = Identifier::random(); + for sentinel in [0u32, u32::MAX >> 1] { + let err = validate_topup_index(id, None, sentinel) + .expect_err("a non-wallet-owned identity must reject"); + assert!( + matches!(err, TaskError::IdentityNotWalletOwned { identity_id } if identity_id == id), + "expected IdentityNotWalletOwned, got: {err:?}" + ); + } + } +} diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index b660729d0..4513679f8 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -1,7 +1,6 @@ use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::KeyID; @@ -22,7 +21,7 @@ impl AppContext { let sdk = self.sdk.load().as_ref().clone(); let balance_before = qualified_identity.identity.balance(); - let estimated_fee = PlatformFeeEstimator::new().estimate_credit_transfer(); + let estimated_fee = self.fee_estimator().estimate_credit_transfer(); let (sender_balance, receiver_balance) = qualified_identity .identity @@ -63,14 +62,12 @@ impl AppContext { .find(|qi| qi.identity.id() == to_identifier) { receiver.identity.set_balance(receiver_balance); - self.update_local_qualified_identity(receiver) - .map_err(|e| TaskError::Database { source: e })?; + self.update_local_qualified_identity(receiver)?; } let fee_result = FeeResult::new(estimated_fee, actual_fee); self.update_local_qualified_identity(&qualified_identity) .map(|_| BackendTaskSuccessResult::TransferredCredits(fee_result)) - .map_err(|e| TaskError::Database { source: e }) } } diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 57df40f6a..58f2baf70 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -1,7 +1,6 @@ use crate::backend_task::FeeResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::fee::Credits; @@ -74,7 +73,7 @@ impl AppContext { ); let balance_before = qualified_identity.identity.balance(); - let estimated_fee = PlatformFeeEstimator::new().estimate_credit_withdrawal(); + let estimated_fee = self.fee_estimator().estimate_credit_withdrawal(); let remaining_balance = qualified_identity .identity @@ -114,6 +113,5 @@ impl AppContext { self.update_local_qualified_identity(&qualified_identity) .map(|_| BackendTaskSuccessResult::WithdrewFromIdentity(fee_result)) - .map_err(|e| TaskError::Database { source: e }) } } diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs new file mode 100644 index 000000000..edc7cad62 --- /dev/null +++ b/src/backend_task/migration/finish_unwire.rs @@ -0,0 +1,3002 @@ +//! Post-PR-#860 cold-start migration orchestrator. +//! +//! Drains legacy `data.db` rows that the unwire left behind into the +//! upstream `platform-wallet-storage` k/v store and `SecretStore`. +//! Idempotent: a per-network completion sentinel under +//! [`sentinel_key_for`] in `det-app.sqlite` short-circuits subsequent +//! launches **on the same network**. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +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::wallet_backend::{DetScope, KvAdapterError, network_prefix}; + +/// Sentinel key format string. The migration body filters every +/// legacy table by `WHERE network = ?1`, so the sentinel must mirror +/// that scope — otherwise an upgrade on mainnet writes the sentinel +/// and a later switch to testnet skips the migration even though +/// testnet wallets are still in the legacy file. Versioned (`:v1`) so +/// a future format change bumps the key rather than re-interpreting +/// the existing payload. +const SENTINEL_KEY_PREFIX: &str = "det:migration:finish_unwire"; +const SENTINEL_KEY_VERSION: &str = "v1"; + +/// Per-network sentinel key. The migration filters legacy rows by +/// `WHERE network = ?1`, so the sentinel scope must match. A previous +/// global key let an upgrade on mainnet hide all testnet wallets after +/// a network switch. +pub fn sentinel_key_for(network: Network) -> String { + format!( + "{SENTINEL_KEY_PREFIX}:{}:{SENTINEL_KEY_VERSION}", + network_prefix(network) + ) +} + +// 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"]; + +/// Persisted sentinel payload. Lives in `det-app.sqlite` under the +/// per-network sentinel key returned by [`sentinel_key_for`]. +/// `network_count` is informational — kept for diagnostics so older +/// payloads still round-trip cleanly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MigrationCompletion { + /// Unix-epoch seconds at completion. Used for diagnostics — never + /// parsed back into business logic. + pub completed_at: i64, + /// Git SHA / version tag of the running build. Lets a future + /// reader correlate the sentinel with the binary that produced it. + pub sha: String, + /// How many network entries the migration walked on this pass. + /// Always `0` or `1` now that the sentinel is per-network — kept + /// in the payload so a forward-compatible reader can re-aggregate + /// across networks without a schema bump. + pub network_count: u32, +} + +/// Domain error envelope for the migration orchestrator. +/// +/// Variants wrap upstream error types via `#[source]`; the +/// user-facing message lives on [`TaskError::MigrationFailed`]. +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + /// Could not open the legacy `data.db` SQLite file to sniff for + /// legacy rows. + #[error("could not open legacy data.db at {path}")] + LegacyDbOpen { + path: String, + #[source] + source: rusqlite::Error, + }, + + /// A SQL query against legacy `data.db` failed (table missing, + /// truncated blob, etc.). Distinct from `LegacyDbOpen` so the UI + /// can attribute partially-readable corruption separately from + /// inaccessible-file errors. + #[error("could not read legacy table `{table}`")] + LegacyDbRead { + table: &'static str, + #[source] + source: rusqlite::Error, + }, + + /// Could not read or write the migration sentinel in + /// `det-app.sqlite`. Treated as fatal — without the sentinel the + /// migration would re-run on every launch. + #[error("could not access migration sentinel")] + Sentinel { + #[source] + source: KvAdapterError, + }, + + /// At least one legacy `single_key_wallet` row failed to migrate. + /// Fatal only when `failed > 0`; password-protected rows count as + /// `skipped_password_protected`, not as failures. + #[error("could not finish single-key migration: {failed} row(s) failed")] + SingleKeyPartialFailure { + /// Number of rows successfully imported (or already present + /// idempotent re-runs) in the secret store. + imported: u32, + /// Number of `uses_password=1` rows the migration skipped + /// because it has no password material here — T-SK-03's UX + /// prompt will resolve these. + skipped_password_protected: u32, + /// Number of rows that could not be decoded into a usable + /// private key (corrupt blob, wrong size). Triggers the error + /// path — sentinel is not written so a re-run can retry. + failed: u32, + }, + + /// At least one legacy `wallet` row could not be migrated into the + /// DET wallet-metadata sidecar in this run. Captures the imported / + /// failed counters so the orchestrator can decide whether to write + /// the sentinel. Fatal only when `failed > 0`. + #[error("could not finish wallet-meta migration: {failed} row(s) failed")] + WalletMetaPartialFailure { + /// Rows whose meta blob was written to the sidecar (or + /// idempotently overwritten on a re-run). + imported: u32, + /// Rows that could not be decoded (seed_hash wrong size, etc.). + /// Triggers the error path — sentinel stays unwritten. + failed: u32, + }, + + /// At least one legacy HD wallet seed row could not be migrated + /// into the upstream secret vault in this run. The migrator copies + /// the full envelope verbatim — no decryption — so the only way + /// to land here is a malformed legacy row (wrong `seed_hash` size, + /// unreadable SQLite blob, etc.). Fatal whenever `failed > 0`; + /// the sentinel stays unwritten so a re-run can retry. + #[error("could not finish wallet-seed migration: {failed} row(s) failed")] + WalletSeedsPartialFailure { + /// Rows whose envelope was written to the vault (or + /// idempotently overwritten on a re-run). + imported: u32, + /// Rows that could not be decoded (seed_hash wrong size, + /// corrupted blob, etc.). Triggers the error path. + failed: u32, + }, + + /// 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 + /// production. Kept as a typed variant so a future regression is + /// caught immediately instead of silently no-oping. + #[error("wallet backend not available during migration")] + WalletBackendUnavailable, + + /// Returned by [`guard_single_key_table_droppable`] when dropping the + /// legacy single-key table would destroy a password-protected key that + /// has no copy anywhere else. `remaining` is the un-restored row count. + #[error( + "could not drop legacy single-key table: {remaining} protected key(s) not yet restored" + )] + ProtectedSingleKeysNotRestored { + /// Number of `uses_password=1` rows still present and not yet + /// restored into the modern vault. + remaining: u32, + }, + + /// Post-migration re-hydration of `ctx.wallets` from the freshly + /// populated sidecars failed, so the migrated wallets were not + /// reconstructed in memory and could not be registered upstream. The + /// completion sentinel is withheld so the next cold boot — which + /// re-hydrates from the same sidecars during backend construction — + /// retries. + #[error("could not re-hydrate migrated wallets")] + Hydration { + #[source] + source: Box<TaskError>, + }, + + /// At least one open (resolvable) wallet was migrated but did not land + /// in the upstream wallet store after bootstrap registration. The + /// completion sentinel is withheld so a re-run (the next cold start, or + /// the "Retry now" banner) retries the idempotent registration. Locked + /// password-protected wallets are excluded — they register on their + /// unlock gesture — so this never fires for a protected-only install. + #[error("could not finish wallet registration: {unregistered} wallet(s) not yet registered")] + RegistrationIncomplete { + /// Number of currently-open wallets still missing from the upstream + /// store after the bootstrap registration pass. + unregistered: usize, + }, +} + +impl MigrationError { + /// `true` for failures that clear themselves once the wallet backend + /// finishes wiring. The cold-start dispatcher retries these on a later + /// frame instead of burning the per-network guard and stranding the + /// network's wallets behind a manual "Retry now". + pub fn is_backend_not_ready(&self) -> bool { + matches!(self, MigrationError::WalletBackendUnavailable) + } +} + +/// Run the FinishUnwire migration. Idempotent — completes a no-op when +/// the sentinel is 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. +/// +/// Drains single-key wallet rows, HD wallet seeds, and wallet metadata +/// into the upstream store, registers the migrated wallets, then writes +/// the completion sentinel. +pub async fn run(app_context: &Arc<AppContext>) -> Result<bool, TaskError> { + let status = app_context.migration_status(); + let app_kv = app_context.app_kv(); + let network = app_context.network; + + // Idempotency: if the sentinel for *this network* already exists, + // this launch has nothing to do. The sentinel is per-network + // because every migration body filters legacy rows by `WHERE + // network = ?1` — a shared sentinel would let an upgrade on + // mainnet silently skip the testnet migration after a switch. + if let Some(completion) = read_sentinel(&app_kv, network)? { + tracing::info!( + target = "migration::finish_unwire", + network = ?network, + completed_at = completion.completed_at, + sha = %completion.sha, + 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); + } + + status.set_state(MigrationState::Running { + step: MigrationStep::Detecting, + }); + + let legacy_present = detect_legacy_rows(app_context)?; + if !legacy_present { + tracing::info!( + target = "migration::finish_unwire", + network = ?network, + "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); + } + + tracing::info!( + target = "migration::finish_unwire", + "Legacy data.db rows detected — beginning migration", + ); + + status.set_state(MigrationState::Running { + step: MigrationStep::SingleKey, + }); + migrate_single_key_rows(app_context).await?; + + // Copy every legacy HD wallet seed envelope into + // the upstream encrypted vault. The envelope bytes travel verbatim + // (no decryption); password-protected and unprotected rows take + // the same path so the per-wallet password UX stays intact. + status.set_state(MigrationState::Running { + step: MigrationStep::WalletSeeds, + }); + migrate_wallet_seeds_rows(app_context)?; + + // T-W-00 — mirror legacy `wallet` rows (alias / `is_main` / + // `core_wallet_name` / master xpub) into the DET wallet-metadata + // sidecar so the wallet picker keeps the names a user already + // chose and can render at cold boot without unlocking seeds. + // Idempotent. + status.set_state(MigrationState::Running { + step: MigrationStep::WalletMeta, + }); + migrate_wallet_meta_rows(app_context)?; + + status.set_state(MigrationState::Running { + step: MigrationStep::Finalize, + }); + + // Register the migrated wallets upstream BEFORE the completion sentinel, + // so the sentinel can never claim "done" while a migratable unprotected + // wallet is still absent from `spv/<net>/platform-wallet.sqlite`. On failure + // this returns `Err` (the sentinel is skipped) so the next cold start — or + // the "Retry now" banner — re-runs the idempotent migration. + register_migrated_wallets(app_context).await?; + + write_sentinel(&app_kv, network, 1)?; + + tracing::info!( + target = "migration::finish_unwire", + network = ?network, + "FinishUnwire migration complete", + ); + status.set_state(MigrationState::Success); + Ok(true) +} + +/// 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 +/// recorded while a migratable unprotected wallet is still unregistered. +/// Locked protected wallets and genuinely-unusable rows are excluded — +/// both register or land safely elsewhere. Idempotent. +async fn register_migrated_wallets(app_context: &Arc<AppContext>) -> Result<(), MigrationError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + // `WalletBackend::new` ran `hydrate_context_wallets` earlier this boot + // against the then-EMPTY sidecars; the migration just populated them, so + // re-hydrate to make the wallets visible without a restart. A hydration + // failure means the wallets are not reconstructed, so do not claim + // completion — the next cold boot re-hydrates from the same sidecars. + backend + .hydrate_context_wallets(app_context) + .map_err(|source| MigrationError::Hydration { + source: Box::new(source), + })?; + + // Re-run the cold-boot W2 bridge now that `ctx.wallets` is populated, so the + // just-migrated open wallets are registered upstream (`id_map` + persistor) + // without a restart. Idempotent and prompt-free; locked protected wallets + // are skipped and register on their unlock gesture. + app_context.bootstrap_loaded_wallets().await; + + let unregistered = app_context.unregistered_open_wallet_count(); + if unregistered > 0 { + return Err(MigrationError::RegistrationIncomplete { unregistered }); + } + Ok(()) +} + +/// 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. +fn detect_legacy_rows(app_context: &AppContext) -> Result<bool, MigrationError> { + let Some(path) = app_context.db.db_file_path() else { + // In-memory DBs (tests, headless) have no legacy file to drain. + return Ok(false); + }; + if !path.exists() { + return Ok(false); + } + let path_str = path.to_string_lossy().to_string(); + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path_str, + source: e, + })?; + for &table in LEGACY_TABLES { + if table_has_rows(&conn, table)? { + tracing::debug!( + target = "migration::finish_unwire", + table, + "Legacy table holds rows", + ); + return Ok(true); + } + } + Ok(false) +} + +/// `SELECT 1 FROM <table> LIMIT 1` — returns `false` for missing +/// tables. Uses a typed `legacy_table_exists` pre-check so the missing +/// table case never reaches `conn.prepare` — any `rusqlite` error from +/// here on is a hard error, not a "table missing" string-parsed branch. +fn table_has_rows(conn: &Connection, table: &'static str) -> Result<bool, MigrationError> { + if !legacy_table_exists_named(conn, table)? { + return Ok(false); + } + // Caller passes a static identifier from `LEGACY_TABLES`, so the + // `format!` here cannot interpolate user input. SQLite parameter + // binding does not support table names, so this is the canonical + // shape. + let sql = format!("SELECT 1 FROM {table} LIMIT 1"); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| MigrationError::LegacyDbRead { table, source: e })?; + let mut rows = stmt + .query([]) + .map_err(|e| MigrationError::LegacyDbRead { table, source: e })?; + let has_row = rows + .next() + .map_err(|e| MigrationError::LegacyDbRead { table, source: e })? + .is_some(); + Ok(has_row) +} + +/// 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. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct SingleKeyMigrationOutcome { + /// Rows whose raw private key was written to the secret store + /// (or were already present — idempotent re-runs land here too). + imported: u32, + /// Rows the migration skipped because they were encrypted with a + /// per-wallet password we do not have on this code path. T-SK-03's + /// UX prompt will resolve them later — they do not count as a + /// failure so a pure password-protected install still writes the + /// sentinel on the next launch. + skipped_password_protected: u32, + /// Rows that could not be decoded into 32 raw private-key bytes + /// (wrong blob length, sqlite read error, address-derivation + /// failure). Triggers the error path — sentinel stays unwritten. + failed: u32, +} + +/// Walks the legacy `single_key_wallet` table for `network` and imports +/// every `uses_password=0` row into the secret store under the canonical +/// `single_key_priv.<addr>` label. Idempotent. Password-protected rows +/// are skipped and reported separately, not as failures. +async fn migrate_single_key_rows(app_context: &Arc<AppContext>) -> Result<(), TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + let Some(path) = app_context.db.db_file_path() else { + // In-memory / headless: nothing to migrate. + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + let path_str = path.to_string_lossy().to_string(); + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path_str, + source: e, + })?; + + let view = backend.single_key(); + let outcome = migrate_single_key_rows_from_conn( + &conn, + |wif, alias| view.import_wif(wif, alias).map(|_| ()), + app_context.network, + )?; + tracing::info!( + target = "migration::finish_unwire", + imported = outcome.imported, + skipped_password_protected = outcome.skipped_password_protected, + failed = outcome.failed, + network = ?app_context.network, + "Single-key migration pass complete", + ); + + if outcome.failed > 0 { + return Err(MigrationError::SingleKeyPartialFailure { + imported: outcome.imported, + skipped_password_protected: outcome.skipped_password_protected, + failed: outcome.failed, + } + .into()); + } + Ok(()) +} + +/// Pure migration body (testable without an `AppContext`). Decodes every +/// `uses_password=0` row at `conn` into a WIF and imports it via `import`. +/// Returns counters rather than erroring on partial readability. A +/// missing table is not an error — a fresh install has none. +fn migrate_single_key_rows_from_conn<F>( + conn: &Connection, + mut import: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<SingleKeyMigrationOutcome, MigrationError> +where + F: FnMut(&str, Option<String>) -> Result<(), TaskError>, +{ + use dash_sdk::dpp::dashcore::PrivateKey; + + if !legacy_table_exists_named(conn, "single_key_wallet")? { + return Ok(SingleKeyMigrationOutcome::default()); + } + let sql = "SELECT encrypted_private_key, alias, uses_password \ + FROM single_key_wallet WHERE network = ?1"; + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + + let rows = stmt + .query_map(rusqlite::params![network.to_string()], |row| { + let encrypted: Vec<u8> = row.get(0)?; + let alias: Option<String> = row.get(1)?; + let uses_password: i32 = row.get(2)?; + Ok((encrypted, alias, uses_password)) + }) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + + let mut outcome = SingleKeyMigrationOutcome::default(); + for row in rows { + let (encrypted, alias, uses_password) = match row { + Ok(t) => t, + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Skipping unreadable single_key_wallet row", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + if uses_password != 0 { + // Password-protected rows need the user's password. T-SK-03 + // will surface a one-time UX prompt; until then count and + // skip — they do not block the rest of the migration. + tracing::warn!( + target = "migration::finish_unwire", + "Skipping password-protected single_key_wallet row (T-SK-03 UX prompt deferred)", + ); + outcome.skipped_password_protected = + outcome.skipped_password_protected.saturating_add(1); + continue; + } + + // Per legacy schema: `uses_password=0` rows store the raw + // 32-byte private key directly in `encrypted_private_key` + // (salt/nonce are empty). See `model/wallet/single_key.rs` + // `SingleKeyData::open_no_password`. + let key_bytes: [u8; 32] = match encrypted.as_slice().try_into() { + Ok(b) => b, + Err(_) => { + tracing::warn!( + target = "migration::finish_unwire", + blob_len = encrypted.len(), + "Skipping single_key_wallet row with non-32-byte raw key blob", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + let priv_key = match PrivateKey::from_byte_array(&key_bytes, network) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = %e, + "Skipping single_key_wallet row — invalid private key bytes", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + let wif = priv_key.to_wif(); + + // `import` is `SingleKeyView::import_wif` in production; the + // view writes to the secret store under the canonical + // `single_key_priv.<addr>` label and seeds the in-memory + // index. Re-import on the same address overwrites the same + // bytes — idempotent (TC-SK-002). + match import(&wif, alias) { + Ok(_) => outcome.imported = outcome.imported.saturating_add(1), + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Failed to import single_key_wallet row into secret store", + ); + outcome.failed = outcome.failed.saturating_add(1); + } + } + } + + Ok(outcome) +} + +/// Count legacy `single_key_wallet` rows for `network` that are +/// password-protected (`uses_password=1`) and have NOT yet been restored +/// into the modern vault. +/// +/// **Data-loss gate (S3).** A protected row holds a private key encrypted +/// under the user's OLD legacy password. Until the user supplies that +/// password and the key is re-encrypted into the modern secret-store +/// vault (T-SK-03), the legacy row is the ONLY copy. Dropping the table +/// while any such row remains permanently destroys the key. +/// +/// A row counts as **restored** when `is_restored(address)` returns +/// `true` — in production that closure checks the modern single-key +/// sidecar for a matching entry at the same address. The closure shape +/// (mirroring [`migrate_single_key_rows_from_conn`]) keeps this body +/// testable without standing up a `WalletBackend`. +/// +/// **Missing table is not a hazard** — a fresh install (or one whose +/// table was already cleaned up after all rows were restored) returns +/// `0`. Rows with an unreadable `address`/`uses_password` are +/// conservatively counted as un-restored so a corrupt row can never let +/// the table be dropped. +fn count_unrestored_protected_single_keys<F>( + conn: &Connection, + mut is_restored: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<u32, MigrationError> +where + F: FnMut(&str) -> bool, +{ + if !legacy_table_exists_named(conn, "single_key_wallet")? { + return Ok(0); + } + let sql = "SELECT address, uses_password FROM single_key_wallet WHERE network = ?1"; + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + let rows = stmt + .query_map(rusqlite::params![network.to_string()], |row| { + let address: Option<String> = row.get(0)?; + let uses_password: i32 = row.get(1)?; + Ok((address, uses_password)) + }) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + + let mut remaining: u32 = 0; + for row in rows { + let (address, uses_password) = match row { + Ok(t) => t, + Err(e) => { + // An unreadable row can't be proven restored — count it + // so the table stays put rather than risk a silent drop. + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Counting unreadable single_key_wallet row as un-restored (drop guard)", + ); + remaining = remaining.saturating_add(1); + continue; + } + }; + if uses_password == 0 { + // Unprotected rows migrate without the user's password and + // carry no data-loss hazard — they are out of scope here. + continue; + } + match address { + Some(addr) if is_restored(&addr) => {} + _ => remaining = remaining.saturating_add(1), + } + } + Ok(remaining) +} + +/// Data-loss gate: returns `Ok(())` only when the legacy +/// `single_key_wallet` table for `network` may be safely dropped — i.e. +/// every password-protected row has been restored into the modern vault. +/// Otherwise returns [`MigrationError::ProtectedSingleKeysNotRestored`] +/// with the remaining count. +/// +/// **Every cleanup path that drops the legacy single-key table MUST call +/// this first and abort on error.** This is the single structural +/// chokepoint that prevents the permanent-key-loss scenario described on +/// [`MigrationError::ProtectedSingleKeysNotRestored`] (Smythe S3). +fn guard_single_key_table_droppable<F>( + conn: &Connection, + is_restored: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<(), MigrationError> +where + F: FnMut(&str) -> bool, +{ + let remaining = count_unrestored_protected_single_keys(conn, is_restored, network)?; + if remaining > 0 { + tracing::warn!( + target = "migration::finish_unwire", + remaining, + network = ?network, + "Refusing to drop legacy single-key table — protected keys not yet restored", + ); + return Err(MigrationError::ProtectedSingleKeysNotRestored { remaining }); + } + Ok(()) +} + +/// Drop the legacy `single_key_wallet` table for `network`, but ONLY +/// after [`guard_single_key_table_droppable`] confirms no protected row +/// remains un-restored. This is the one sanctioned way to remove the +/// legacy single-key table; the drop is unconditionally gated so a +/// future cleanup path cannot bypass the data-loss check (Smythe S3). +/// +/// `is_restored` is the same predicate the guard uses — in production it +/// checks the modern single-key sidecar for a matching restored entry. +/// The table is dropped with `DROP TABLE IF EXISTS` so a re-run after a +/// successful drop is a no-op. +fn drop_legacy_single_key_table<F>( + conn: &Connection, + is_restored: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<(), MigrationError> +where + F: FnMut(&str) -> bool, +{ + guard_single_key_table_droppable(conn, is_restored, network)?; + conn.execute("DROP TABLE IF EXISTS single_key_wallet", []) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + tracing::info!( + target = "migration::finish_unwire", + network = ?network, + "Dropped legacy single-key table (all protected keys restored)", + ); + Ok(()) +} + +/// Salt expected by Argon2id during the legacy AES-GCM seed encryption +/// (16 bytes, see `src/model/wallet/encryption.rs`). +const LEGACY_SALT_LEN: usize = 16; + +/// GCM nonce expected by AES-256-GCM during the legacy seed encryption +/// (12 bytes, see `src/model/wallet/encryption.rs`). +const LEGACY_NONCE_LEN: usize = 12; + +/// Row-level length guard for the password-related crypto +/// fields on a legacy `wallet` row. Password-protected rows must carry a +/// 16-byte salt and a 12-byte nonce; unprotected rows must carry empty +/// fields (the legacy DB writer bypasses encryption when +/// `uses_password = false`). Anything else is corruption — the caller +/// skips the row and logs. +fn crypto_field_lengths_ok(salt: &[u8], nonce: &[u8], uses_password: bool) -> bool { + if uses_password { + salt.len() == LEGACY_SALT_LEN && nonce.len() == LEGACY_NONCE_LEN + } else { + salt.is_empty() && nonce.is_empty() + } +} + +/// Expected plaintext length of a BIP-39 seed (64 bytes, PBKDF2 output), +/// mirroring `wallet_backend::hydration::EXPECTED_SEED_LEN`. An unprotected +/// wallet whose `encrypted_seed` is a different length is rejected by +/// hydration, so the copy step rejects it too — see [`hd_seed_row_is_hydratable`]. +const HYDRATABLE_SEED_LEN: usize = 64; + +/// Whether a legacy `wallet` row will survive cold-boot hydration (mirrors +/// `wallet_backend::hydration`: master xpub must decode, and an unprotected +/// row's seed must be exactly 64 bytes). Copy-acceptance must be a subset +/// of hydration-acceptance, or an unusable row could pass the copy step yet +/// stay invisible to the registration gate, letting the sentinel falsely +/// read "done". +fn hd_seed_row_is_hydratable( + uses_password: bool, + encrypted_seed: &[u8], + xpub_encoded: &[u8], +) -> bool { + use dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey; + if xpub_encoded.is_empty() || ExtendedPubKey::decode(xpub_encoded).is_err() { + return false; + } + if !uses_password && encrypted_seed.len() != HYDRATABLE_SEED_LEN { + return false; + } + true +} + +/// Returns `true` when `table` exists in the SQLite schema at `conn`. +/// Propagates a typed static table name into the error variant so +/// partial-failure paths stay attributable. Used by [`table_has_rows`] +/// and the per-domain `migrate_*_rows_from_conn` bodies as a typed +/// pre-check that replaces the previous `msg.contains("no such table")` +/// arms. +fn legacy_table_exists_named( + conn: &Connection, + table: &'static str, +) -> Result<bool, MigrationError> { + 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 }) +} + +/// Outcome counters from one [`migrate_wallet_meta_rows`] pass. +/// `imported` includes idempotent re-imports — re-running the migration +/// after success is a per-row `set()` overwrite, not a no-op skip, so +/// the counter is meaningful even when the sidecar already holds the +/// same value. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct WalletMetaMigrationOutcome { + /// Rows for `app_context.network` written into the wallet-meta + /// sidecar. A re-run with the same legacy rows lands here again — + /// `set` upserts. + imported: u32, + /// Rows that could not be decoded (seed-hash wrong length). + /// Triggers the error path — sentinel stays unwritten. + failed: u32, +} + +/// Copies legacy `wallet` rows (alias / `is_main` / `core_wallet_name`) +/// into the DET wallet-metadata sidecar. Idempotent. `core_wallet_name` +/// is optional — a recent legacy schema drop means older installs may +/// still have it, so the reader probes for it at row-read time. +fn migrate_wallet_meta_rows(app_context: &Arc<AppContext>) -> Result<(), TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + let Some(path) = app_context.db.db_file_path() else { + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + let path_str = path.to_string_lossy().to_string(); + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path_str, + source: e, + })?; + + let view = backend.wallet_meta(); + let outcome = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(app_context.network, &seed_hash, &meta), + app_context.network, + )?; + tracing::info!( + target = "migration::finish_unwire", + imported = outcome.imported, + failed = outcome.failed, + network = ?app_context.network, + "Wallet-meta migration pass complete", + ); + + if outcome.failed > 0 { + return Err(MigrationError::WalletMetaPartialFailure { + imported: outcome.imported, + failed: outcome.failed, + } + .into()); + } + Ok(()) +} + +/// Pure migration body (testable without an `AppContext`). Forwards each +/// `(seed_hash, meta)` row at `conn` to `set`; returns counters. A missing +/// table or missing `core_wallet_name` column is not an error. +fn migrate_wallet_meta_rows_from_conn<F>( + conn: &Connection, + mut set: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<WalletMetaMigrationOutcome, MigrationError> +where + F: FnMut( + crate::model::wallet::WalletSeedHash, + crate::model::wallet::meta::WalletMeta, + ) -> Result<(), TaskError>, +{ + if !legacy_table_exists_named(conn, "wallet")? { + return Ok(WalletMetaMigrationOutcome::default()); + } + // `core_wallet_name` is the only optional column, so it is probed and + // NULL-substituted. `uses_password`/`password_hint` are read unprobed — + // the seed migration selects them unconditionally and runs first over + // the same table, so a schema lacking them already fails there. + let core_wallet_name_present = wallet_table_has_core_wallet_name(conn)?; + let sql = if core_wallet_name_present { + "SELECT seed_hash, alias, is_main, core_wallet_name, master_ecdsa_bip44_account_0_epk, \ + uses_password, password_hint \ + FROM wallet WHERE network = ?1" + } else { + "SELECT seed_hash, alias, is_main, NULL AS core_wallet_name, \ + master_ecdsa_bip44_account_0_epk, uses_password, password_hint \ + FROM wallet WHERE network = ?1" + }; + + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "wallet", + source: e, + })?; + + let rows = stmt + .query_map(rusqlite::params![network.to_string()], |row| { + let seed_hash: Vec<u8> = row.get(0)?; + let alias: Option<String> = row.get(1)?; + let is_main: Option<bool> = row.get(2)?; + let core_wallet_name: Option<String> = row.get(3)?; + let xpub_encoded: Vec<u8> = row.get(4)?; + let uses_password: bool = row.get(5)?; + let password_hint: Option<String> = row.get(6)?; + Ok(( + seed_hash, + alias, + is_main, + core_wallet_name, + xpub_encoded, + uses_password, + password_hint, + )) + }) + .map_err(|e| MigrationError::LegacyDbRead { + table: "wallet", + source: e, + })?; + + let mut outcome = WalletMetaMigrationOutcome::default(); + for row in rows { + let ( + seed_hash_bytes, + alias, + is_main, + core_wallet_name, + xpub_encoded, + uses_password, + password_hint, + ) = match row { + Ok(t) => t, + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Skipping unreadable wallet row", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + let seed_hash: crate::model::wallet::WalletSeedHash = + match seed_hash_bytes.as_slice().try_into() { + Ok(b) => b, + Err(_) => { + tracing::warn!( + target = "migration::finish_unwire", + blob_len = seed_hash_bytes.len(), + "Skipping wallet row with non-32-byte seed_hash", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + let meta = crate::model::wallet::meta::WalletMeta { + alias: alias.unwrap_or_default(), + is_main: is_main.unwrap_or(false), + core_wallet_name, + xpub_encoded, + // Carry the legacy `wallet` row's password flag/hint straight into + // WalletMeta so the persisted metadata is accurate from cold-start: + // a protected wallet stays `uses_password = true` (Tier-2 keeps the + // password; nothing downgrades it), keeping the metadata and the + // at-rest scheme always in agreement. + uses_password, + password_hint, + }; + + match set(seed_hash, meta) { + Ok(()) => outcome.imported = outcome.imported.saturating_add(1), + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Failed to write wallet-meta entry", + ); + outcome.failed = outcome.failed.saturating_add(1); + } + } + } + + Ok(outcome) +} + +/// Probe whether the legacy `wallet` table still carries +/// `core_wallet_name`. A recent legacy schema migration drops the +/// column, so older installs may still have it while freshly-migrated +/// ones will not. Missing table reads as "column absent" — the caller +/// short-circuits via the typed `legacy_table_exists_named` pre-check +/// before this probe runs. +fn wallet_table_has_core_wallet_name(conn: &Connection) -> Result<bool, MigrationError> { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('wallet') WHERE name = 'core_wallet_name'", + [], + |row| row.get(0), + ) + .map_err(|e| MigrationError::LegacyDbRead { + table: "wallet", + source: e, + })?; + Ok(count > 0) +} + +/// Outcome counters from one [`migrate_wallet_seeds_rows`] pass. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct WalletSeedsMigrationOutcome { + /// Rows whose full envelope was written to the upstream vault. + imported: u32, + /// Rows that would not survive cold-boot hydration (see + /// [`hd_seed_row_is_hydratable`]); non-fatal, logged and counted rather + /// than silently copied. The seed stays in legacy `data.db` — this is + /// exclusion, not data loss. + skipped_malformed: u32, + /// Rows that could not be decoded (seed_hash wrong size, blob + /// length wrong, etc.). Triggers the error path. + failed: u32, +} + +/// Copies each legacy `wallet` row's full encrypted envelope (ciphertext + +/// salt + nonce + flags + xpub) into the upstream vault via +/// [`WalletSeedView`](crate::wallet_backend::WalletSeedView) without +/// decrypting it, so protected and unprotected rows take the same path. +/// Idempotent: re-running overwrites the same envelope under the same +/// `WalletId`. +fn migrate_wallet_seeds_rows(app_context: &Arc<AppContext>) -> Result<(), TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + let Some(path) = app_context.db.db_file_path() else { + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + let path_str = path.to_string_lossy().to_string(); + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path_str, + source: e, + })?; + + let view = backend.wallet_seeds(); + let outcome = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, envelope| view.set(&seed_hash, &envelope), + app_context.network, + )?; + + tracing::info!( + target = "migration::finish_unwire", + imported = outcome.imported, + skipped_malformed = outcome.skipped_malformed, + failed = outcome.failed, + network = ?app_context.network, + "Wallet-seed migration pass complete", + ); + + if outcome.failed > 0 { + return Err(MigrationError::WalletSeedsPartialFailure { + imported: outcome.imported, + failed: outcome.failed, + } + .into()); + } + Ok(()) +} + +/// Pure wallet-seed migration body — readable without an `AppContext`. +/// Walks the `wallet` table at `conn` filtered to `network` and forwards +/// each `(seed_hash, envelope)` pair to `set`. Returns counters; never +/// errors on partial readability so the caller can decide the policy. +/// +/// **Missing table is not an error** — a freshly-installed `data.db` +/// (no legacy rows at all) returns the zero outcome. +fn migrate_wallet_seeds_rows_from_conn<F>( + conn: &Connection, + mut set: F, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<WalletSeedsMigrationOutcome, MigrationError> +where + F: FnMut( + crate::model::wallet::WalletSeedHash, + crate::model::wallet::seed_envelope::StoredSeedEnvelope, + ) -> Result<(), TaskError>, +{ + if !legacy_table_exists_named(conn, "wallet")? { + return Ok(WalletSeedsMigrationOutcome::default()); + } + let sql = "SELECT seed_hash, encrypted_seed, salt, nonce, password_hint, \ + uses_password, master_ecdsa_bip44_account_0_epk \ + FROM wallet WHERE network = ?1"; + + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "wallet", + source: e, + })?; + + let rows = stmt + .query_map(rusqlite::params![network.to_string()], |row| { + let seed_hash: Vec<u8> = row.get(0)?; + let encrypted_seed: Vec<u8> = row.get(1)?; + let salt: Vec<u8> = row.get(2)?; + let nonce: Vec<u8> = row.get(3)?; + let password_hint: Option<String> = row.get(4)?; + let uses_password: bool = row.get(5)?; + let xpub_encoded: Vec<u8> = row.get(6)?; + Ok(( + seed_hash, + encrypted_seed, + salt, + nonce, + password_hint, + uses_password, + xpub_encoded, + )) + }) + .map_err(|e| MigrationError::LegacyDbRead { + table: "wallet", + source: e, + })?; + + let mut outcome = WalletSeedsMigrationOutcome::default(); + for row in rows { + let (seed_hash_bytes, encrypted_seed, salt, nonce, password_hint, uses_password, xpub) = + match row { + Ok(t) => t, + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Skipping unreadable wallet row during seed migration", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + let seed_hash: crate::model::wallet::WalletSeedHash = + match seed_hash_bytes.as_slice().try_into() { + Ok(b) => b, + Err(_) => { + tracing::warn!( + target = "migration::finish_unwire", + blob_len = seed_hash_bytes.len(), + "Skipping wallet row with non-32-byte seed_hash during seed migration", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + }; + + // Salt/nonce length sanity. AES-GCM requires a + // 16-byte Argon2 salt and a 12-byte GCM nonce when the row is + // password-protected; when it isn't, both fields must be + // empty. Anything else is row-level corruption — skip and + // log, do NOT abort the whole migration. + if !crypto_field_lengths_ok(&salt, &nonce, uses_password) { + tracing::warn!( + target = "migration::finish_unwire", + seed_hash = %hex::encode(seed_hash), + salt_len = salt.len(), + nonce_len = nonce.len(), + uses_password, + "Skipping wallet row with corrupted crypto field lengths during seed migration", + ); + outcome.failed = outcome.failed.saturating_add(1); + continue; + } + + // Hydration symmetry. The copy step must not accept a row that + // cold-boot hydration would silently drop, or the migrated wallet would + // be in neither the registered nor the gate-counted set and the sentinel + // would falsely read "done". A row that fails the shared hydratability + // check is genuinely unusable (no derivable xpub / corrupt seed); skip + // and surface it (non-fatal) rather than copy it. The seed stays in + // legacy `data.db`, which the migration never deletes. + if !hd_seed_row_is_hydratable(uses_password, &encrypted_seed, &xpub) { + tracing::warn!( + target = "migration::finish_unwire", + seed_hash = %hex::encode(seed_hash), + uses_password, + seed_len = encrypted_seed.len(), + xpub_len = xpub.len(), + "Skipping wallet row that cold-boot hydration would drop (xpub absent/undecodable or unprotected seed length != 64); seed retained in legacy data.db", + ); + outcome.skipped_malformed = outcome.skipped_malformed.saturating_add(1); + continue; + } + + let envelope = crate::model::wallet::seed_envelope::StoredSeedEnvelope { + encrypted_seed, + salt, + nonce, + password_hint, + uses_password, + xpub_encoded: xpub, + }; + + match set(seed_hash, envelope) { + Ok(()) => outcome.imported = outcome.imported.saturating_add(1), + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Failed to write wallet-seed envelope entry", + ); + outcome.failed = outcome.failed.saturating_add(1); + } + } + } + + Ok(outcome) +} + +/// Public data-loss gate for the future legacy single-key table cleanup +/// (T7). Returns `Ok(())` only when the legacy `single_key_wallet` table +/// for the active network may be safely dropped — i.e. every +/// password-protected row has a matching restored entry in the modern +/// single-key sidecar. Otherwise returns +/// [`TaskError::MigrationFailed`] wrapping +/// [`MigrationError::ProtectedSingleKeysNotRestored`]. +/// +/// **Any cleanup path that removes the legacy single-key table MUST call +/// this first and abort on error** (Smythe S3). The production +/// `is_restored` predicate consults the modern single-key index: a +/// legacy protected address counts as restored once an +/// [`ImportedKey`](crate::model::single_key::ImportedKey) exists at the +/// same address — regardless of whether the user re-protected it with a +/// new passphrase. A key restored without a new passphrase is just as +/// recovered as one with, so keying on address presence (not +/// `has_passphrase`) is what makes the gate eventually release. +pub fn ensure_legacy_single_key_table_droppable( + app_context: &Arc<AppContext>, +) -> Result<(), TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + let Some(path) = app_context.db.db_file_path() else { + // In-memory / headless: no legacy file, nothing to gate. + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + + // Snapshot every restored address once so the per-row predicate is a + // cheap set lookup. Presence in the modern index — not the passphrase + // flag — is the restored signal: the import path always mirrors the + // recovered key into the index whether or not the user chose a new + // passphrase. + let restored: std::collections::BTreeSet<String> = backend + .single_key() + .list() + .into_iter() + .map(|k| k.address) + .collect(); + + guard_single_key_table_droppable(&conn, |addr| restored.contains(addr), app_context.network)?; + Ok(()) +} + +/// The ONE sanctioned production path to remove the legacy +/// `single_key_wallet` table (future T7 cleanup). It drops the table ONLY +/// after the data-loss gate confirms every protected row is restored; the +/// gate is run inside this function, so a future cleanup caller cannot +/// forget it (Smythe S3 / S5). On a blocked drop it returns +/// [`TaskError::MigrationFailed`] wrapping +/// [`MigrationError::ProtectedSingleKeysNotRestored`] and leaves the +/// table — and every key — intact. +/// +/// A re-run after a successful drop is a no-op (`DROP TABLE IF EXISTS`), +/// and an in-memory / absent legacy file is a no-op success. +pub fn drop_legacy_single_key_table_when_safe( + app_context: &Arc<AppContext>, +) -> Result<(), TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + let Some(path) = app_context.db.db_file_path() else { + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + let restored: std::collections::BTreeSet<String> = backend + .single_key() + .list() + .into_iter() + .map(|k| k.address) + .collect(); + drop_legacy_single_key_table(&conn, |addr| restored.contains(addr), app_context.network)?; + Ok(()) +} + +/// Read the completion sentinel for `network` from `det-app.sqlite`. +fn read_sentinel( + app_kv: &crate::wallet_backend::DetKv, + network: Network, +) -> Result<Option<MigrationCompletion>, MigrationError> { + app_kv + .get::<MigrationCompletion>(DetScope::Global, &sentinel_key_for(network)) + .map_err(|e| MigrationError::Sentinel { source: e }) +} + +/// Write the completion sentinel for `network`, marking the migration +/// as finished for this network on this install. +fn write_sentinel( + app_kv: &crate::wallet_backend::DetKv, + network: Network, + network_count: u32, +) -> Result<(), MigrationError> { + let completion = MigrationCompletion { + completed_at: now_epoch_seconds(), + sha: env!("CARGO_PKG_VERSION").to_string(), + network_count, + }; + app_kv + .put(DetScope::Global, &sentinel_key_for(network), &completion) + .map_err(|e| MigrationError::Sentinel { source: e }) +} + +fn now_epoch_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl From<MigrationError> for TaskError { + fn from(source: MigrationError) -> Self { + TaskError::MigrationFailed { + source: Arc::new(source), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + /// TC-MIG-009 — calling the migration when the sentinel for the + /// active network is already present must be a no-op. The + /// orchestrator must not consult legacy `data.db`, must not move + /// state into `Running`, and must leave the sentinel untouched. + #[test] + fn sentinel_short_circuits_run() { + use dash_sdk::dpp::dashcore::Network; + + let kv = kv(); + let original = MigrationCompletion { + completed_at: 1234, + sha: "test-sha".into(), + network_count: 1, + }; + kv.put( + DetScope::Global, + &sentinel_key_for(Network::Testnet), + &original, + ) + .expect("seed sentinel"); + + // Reading the sentinel back via the same path the orchestrator + // uses is the contractual short-circuit hook. If this returns + // `Some`, the orchestrator skips legacy detection entirely. + let observed: Option<MigrationCompletion> = + read_sentinel(&kv, Network::Testnet).expect("read sentinel"); + assert_eq!(observed, Some(original)); + } + + /// Round-trip: writing the sentinel and reading it back yields the + /// same payload. Guards the codec from accidental shape drift. + #[test] + fn sentinel_round_trip() { + use dash_sdk::dpp::dashcore::Network; + + let kv = kv(); + write_sentinel(&kv, Network::Mainnet, 1).expect("write sentinel"); + let completion = read_sentinel(&kv, Network::Mainnet) + .expect("read") + .expect("present"); + assert_eq!(completion.network_count, 1); + assert!(completion.completed_at > 0); + assert_eq!(completion.sha, env!("CARGO_PKG_VERSION")); + } + + /// The sentinel is scoped per network — writing the mainnet sentinel + /// must not satisfy a subsequent testnet read, or a network switch + /// would leave testnet wallets permanently unmigrated behind a + /// stale-looking global sentinel. + #[test] + fn sentinel_is_per_network_mainnet_then_testnet() { + use dash_sdk::dpp::dashcore::Network; + + let kv = kv(); + // Step 1: simulate a successful mainnet migration. + write_sentinel(&kv, Network::Mainnet, 1).expect("write mainnet sentinel"); + assert!( + read_sentinel(&kv, Network::Mainnet) + .expect("read mainnet") + .is_some(), + "mainnet sentinel must be visible to a mainnet read", + ); + // Step 2: switching to testnet must NOT short-circuit. The + // testnet read returns `None` so the orchestrator proceeds to + // detect-and-drain legacy testnet rows. + assert!( + read_sentinel(&kv, Network::Testnet) + .expect("read testnet") + .is_none(), + "mainnet sentinel must not satisfy a testnet read — \ + per-network sentinel regression", + ); + // Step 3: a clean testnet migration writes its own sentinel + // without touching the mainnet one. Both then short-circuit + // their respective networks. + write_sentinel(&kv, Network::Testnet, 1).expect("write testnet sentinel"); + assert!(read_sentinel(&kv, Network::Mainnet).unwrap().is_some()); + assert!(read_sentinel(&kv, Network::Testnet).unwrap().is_some()); + // And the devnet / regtest sentinels are still independent. + assert!(read_sentinel(&kv, Network::Devnet).unwrap().is_none()); + assert!(read_sentinel(&kv, Network::Regtest).unwrap().is_none()); + } + + /// Per-network sentinel keys are stable, distinct, and prefixed — + /// guards against accidental key collisions or `Display` drift on + /// upstream `Network`. + #[test] + fn sentinel_key_format_is_per_network() { + use dash_sdk::dpp::dashcore::Network; + + let mainnet = sentinel_key_for(Network::Mainnet); + let testnet = sentinel_key_for(Network::Testnet); + let devnet = sentinel_key_for(Network::Devnet); + let regtest = sentinel_key_for(Network::Regtest); + + assert_eq!(mainnet, "det:migration:finish_unwire:mainnet:v1"); + assert_eq!(testnet, "det:migration:finish_unwire:testnet:v1"); + assert_eq!(devnet, "det:migration:finish_unwire:devnet:v1"); + assert_eq!(regtest, "det:migration:finish_unwire:regtest:v1"); + // All four are distinct — a misencoded network would collapse + // the sentinels and re-introduce the cross-network leak. + let set: std::collections::HashSet<_> = [&mainnet, &testnet, &devnet, &regtest] + .into_iter() + .collect(); + assert_eq!(set.len(), 4, "every network must get a unique sentinel key"); + } + + // ───────────────────────────────────────────────────────────────── + // Helpers for the single-key migration tests below. + // Mirror the legacy schema shape from `database/single_key_wallet.rs` + // — the migration reads `encrypted_private_key`, `alias`, + // `uses_password`, and `network`. The other columns are seeded so + // the legacy table looks realistic, but the migration ignores them. + // ───────────────────────────────────────────────────────────────── + + fn create_legacy_table(conn: &Connection) { + conn.execute( + "CREATE TABLE single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0, + core_wallet_name TEXT DEFAULT NULL + )", + [], + ) + .expect("create legacy table"); + } + + #[allow(clippy::too_many_arguments)] + fn seed_legacy_row( + conn: &Connection, + key_hash: &[u8; 32], + encrypted_private_key: &[u8], + salt: &[u8], + nonce: &[u8], + address: &str, + alias: Option<&str>, + uses_password: bool, + network: dash_sdk::dpp::dashcore::Network, + ) { + conn.execute( + "INSERT INTO single_key_wallet ( + key_hash, encrypted_private_key, salt, nonce, public_key, + address, alias, uses_password, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + key_hash.as_slice(), + encrypted_private_key, + salt, + nonce, + // The migration does not consult `public_key`; seed an + // empty blob to keep the column NOT NULL constraint + // satisfied without dragging in PublicKey derivation. + Vec::<u8>::new(), + address, + alias, + uses_password as i32, + network.to_string(), + ], + ) + .expect("insert legacy row"); + } + + /// Bare `SingleKeyView`-compatible fixture: no `WalletBackend`, just + /// a file-backed secret store and an in-memory address index. This + /// is what the migration body needs and lets the test assert on the + /// real `SecretStore` writes without standing up an SDK. + fn view_fixture( + dir: &std::path::Path, + network: dash_sdk::dpp::dashcore::Network, + ) -> ( + Arc<platform_wallet_storage::secrets::SecretStore>, + std::sync::RwLock< + std::collections::BTreeMap<String, crate::model::single_key::ImportedKey>, + >, + dash_sdk::dpp::dashcore::Network, + ) { + let store = Arc::new( + crate::wallet_backend::single_key::open_secret_store(&dir.join("secrets.pwsvault")) + .expect("open vault"), + ); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + (store, index, network) + } + + /// TC-SK-001 — a legacy `uses_password=0` row gets imported into the + /// secret store under the canonical `single_key_priv.<addr>` label. + /// This is the post-upgrade "previously imported key still visible" + /// regression guard: if this fails, the user sees nothing on the + /// imported-keys list after the migration. + #[test] + fn tc_sk_001_uses_password_zero_row_migrates_to_secret_store() { + use crate::wallet_backend::single_key::{ + SingleKeyView, label_for_address, single_key_namespace_id, + }; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_table(&conn); + + // Build a known private key + derived address so the test can + // assert on the canonical label. + let mut raw = [0u8; 32]; + raw[31] = 7; + let priv_key = PrivateKey::from_byte_array(&raw, Network::Testnet).expect("priv"); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&Secp256k1::new()), + }; + let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); + let key_hash = crate::model::wallet::single_key::ClosedSingleKey::compute_key_hash(&raw); + seed_legacy_row( + &conn, + &key_hash, + &raw, + &[], + &[], + &address, + Some("paycheque"), + false, + Network::Testnet, + ); + + let (store, index, network) = view_fixture(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + + let outcome = migrate_single_key_rows_from_conn( + &conn, + |wif, alias| view.import_wif(wif, alias).map(|_| ()), + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.skipped_password_protected, 0); + assert_eq!(outcome.failed, 0); + + // The canonical secret-store label is present and decodes as + // an unprotected SingleKeyEntry whose plaintext is 32 bytes + // (with per-key passphrases the in-vault payload is the versioned + // entry shape rather than the bare 32 raw bytes). + let label = label_for_address(&address); + let secret = store + .get(&single_key_namespace_id(), &label) + .expect("read secret") + .expect("secret present"); + let entry = + crate::wallet_backend::single_key_entry::SingleKeyEntry::decode(secret.expose_secret()) + .expect("decode entry"); + assert!(!entry.has_passphrase); + let raw = entry.decrypt(None).expect("plaintext"); + assert_eq!(raw.len(), 32); + + // The view's index reflects the imported key (TC-SK-001's + // "Imported key — <X[0..6]…>" UI surface relies on this). + assert_eq!(view.list().len(), 1); + assert_eq!(view.list()[0].address, address); + assert_eq!(view.list()[0].alias.as_deref(), Some("paycheque")); + } + + /// TC-SK-002 — running the migration twice is a no-op on the second + /// pass. The label collision overwrites the same bytes (cheap + /// idempotency) and the in-memory index entry count stays at 1. + #[test] + fn tc_sk_002_re_run_is_idempotent_and_does_not_duplicate() { + use crate::wallet_backend::single_key::SingleKeyView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_table(&conn); + + let mut raw = [0u8; 32]; + raw[31] = 9; + let key_hash = crate::model::wallet::single_key::ClosedSingleKey::compute_key_hash(&raw); + seed_legacy_row( + &conn, + &key_hash, + &raw, + &[], + &[], + // Address derivation in the migration body is what matters + // — the legacy column is informational. Pass a placeholder + // that the NOT NULL constraint accepts. + "placeholder", + None, + false, + Network::Testnet, + ); + + let (store, index, network) = view_fixture(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + + let first = migrate_single_key_rows_from_conn( + &conn, + |wif, alias| view.import_wif(wif, alias).map(|_| ()), + Network::Testnet, + ) + .expect("first pass"); + let second = migrate_single_key_rows_from_conn( + &conn, + |wif, alias| view.import_wif(wif, alias).map(|_| ()), + Network::Testnet, + ) + .expect("second pass"); + + assert_eq!(first.imported, 1); + assert_eq!(second.imported, 1, "re-import is reported as success"); + // The index does not duplicate — the address key is stable. + assert_eq!(view.list().len(), 1, "re-run must not duplicate index"); + } + + /// TC-SK-006 — partial failures (corrupt blob + password-protected + /// row) do not crash the migration. The good row still imports, the + /// password-protected row counts as deferred, and the corrupt row + /// is the sole `failed`. The fatal-vs-deferred split lets the + /// orchestrator skip the sentinel write when `failed > 0` while + /// pure password-protected runs still make forward progress. + #[test] + fn tc_sk_006_partial_failure_does_not_crash_run() { + use crate::wallet_backend::single_key::SingleKeyView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_table(&conn); + + // Good row. + let mut good = [0u8; 32]; + good[31] = 21; + let good_hash = crate::model::wallet::single_key::ClosedSingleKey::compute_key_hash(&good); + seed_legacy_row( + &conn, + &good_hash, + &good, + &[], + &[], + "good", + None, + false, + Network::Testnet, + ); + + // Corrupt row: 16-byte blob — wrong size, drops into the + // `failed` bucket without aborting the loop. + let mut bad_hash = [0u8; 32]; + bad_hash[0] = 0xCC; + seed_legacy_row( + &conn, + &bad_hash, + &[0u8; 16], + &[], + &[], + "bad", + None, + false, + Network::Testnet, + ); + + // Password-protected row: `uses_password=1` — deferred to + // T-SK-03, does not count as a failure. + let mut pw_hash = [0u8; 32]; + pw_hash[0] = 0xAA; + seed_legacy_row( + &conn, + &pw_hash, + &[0xDE; 48], // ciphertext shape — never decoded here + &[0x01; 16], + &[0x02; 12], + "pw", + Some("locked"), + true, + Network::Testnet, + ); + + let (store, index, network) = view_fixture(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + + let outcome = migrate_single_key_rows_from_conn( + &conn, + |wif, alias| view.import_wif(wif, alias).map(|_| ()), + Network::Testnet, + ) + .expect("partial failure must not abort the loop"); + + assert_eq!(outcome.imported, 1, "the good row must still land"); + assert_eq!( + outcome.skipped_password_protected, 1, + "password-protected rows are deferred, not failed", + ); + assert_eq!( + outcome.failed, 1, + "the 16-byte blob is the only true failure" + ); + } + + /// Missing legacy table is not an error — a fresh install of the + /// app reaches the single-key step with no `single_key_wallet` + /// table at all and must report a clean zero outcome. + #[test] + fn missing_single_key_table_yields_zero_outcome() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open empty db"); + + let outcome = + migrate_single_key_rows_from_conn(&conn, |_wif, _alias| Ok(()), Network::Testnet) + .expect("missing table is benign"); + + assert_eq!(outcome, SingleKeyMigrationOutcome::default()); + } + + // ───────────────────────────────────────────────────────────────── + // T-SK-03 / S3 — legacy single-key table DROP data-loss gate. + // A password-protected (`uses_password=1`) row holds a key encrypted + // under the user's OLD legacy password. Dropping the table before + // that row is restored into the modern vault destroys the key + // permanently. These tests pin the gate that forbids the drop while + // any protected row remains un-restored. + // ───────────────────────────────────────────────────────────────── + + /// Seed one protected and one unprotected legacy single-key row for + /// the same network so the gate tests operate on a realistic table. + fn seed_protected_and_unprotected( + conn: &Connection, + network: dash_sdk::dpp::dashcore::Network, + ) { + create_legacy_table(conn); + // Protected row — encrypted under the legacy password (salt/nonce + // present). The blob contents are irrelevant to the gate, which + // only reads `address` + `uses_password`. + seed_legacy_row( + conn, + &[1u8; 32], + &[0xAB; 48], + &[0x11; 16], + &[0x22; 12], + "yProtectedAddr", + Some("protected"), + true, + network, + ); + // Unprotected row — out of scope for the gate. + seed_legacy_row( + conn, + &[2u8; 32], + &[0xCD; 32], + &[], + &[], + "yOpenAddr", + Some("open"), + false, + network, + ); + } + + /// S3 (must fail before the fix) — with a protected row present and + /// NOT restored, the drop guard refuses, the typed error reports the + /// remaining count, and the legacy rows survive the attempted drop. + #[test] + fn protected_row_blocks_table_drop_and_rows_survive() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + seed_protected_and_unprotected(&conn, Network::Testnet); + + // Nothing restored yet. + let nothing_restored = |_addr: &str| false; + + let err = drop_legacy_single_key_table(&conn, nothing_restored, Network::Testnet) + .expect_err("drop must be blocked while a protected row is un-restored"); + match err { + MigrationError::ProtectedSingleKeysNotRestored { remaining } => { + assert_eq!(remaining, 1, "exactly one protected row outstanding"); + } + other => panic!("expected ProtectedSingleKeysNotRestored, got {other:?}"), + } + + // The table — and crucially the protected row — must still be + // present. A premature drop here would be permanent key loss. + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM single_key_wallet", [], |r| r.get(0)) + .expect("table still exists after blocked drop"); + assert_eq!(row_count, 2, "no rows may be destroyed by a blocked drop"); + let protected_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM single_key_wallet WHERE uses_password = 1", + [], + |r| r.get(0), + ) + .expect("query protected rows"); + assert_eq!(protected_count, 1, "the protected row must survive"); + } + + /// Once every protected row is restored (the predicate returns + /// `true` for its address), the guard permits the drop and the table + /// is removed. + #[test] + fn drop_succeeds_after_all_protected_rows_restored() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + seed_protected_and_unprotected(&conn, Network::Testnet); + + // The protected address is now present in the modern vault. + let restored = |addr: &str| addr == "yProtectedAddr"; + + drop_legacy_single_key_table(&conn, restored, Network::Testnet) + .expect("drop allowed once protected rows are restored"); + + let still_there: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type='table' AND name='single_key_wallet'", + [], + |r| r.get::<_, i64>(0).map(|c| c > 0), + ) + .expect("query sqlite_master"); + assert!(!still_there, "table must be dropped after restore"); + } + + /// A network with only unprotected rows carries no data-loss hazard, + /// so the guard permits the drop even though nothing is "restored". + #[test] + fn unprotected_only_table_is_droppable_without_restore() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + create_legacy_table(&conn); + seed_legacy_row( + &conn, + &[3u8; 32], + &[0xCD; 32], + &[], + &[], + "yOpenOnly", + None, + false, + Network::Testnet, + ); + + assert_eq!( + count_unrestored_protected_single_keys(&conn, |_| false, Network::Testnet) + .expect("count"), + 0, + "unprotected rows never count against the drop guard" + ); + drop_legacy_single_key_table(&conn, |_| false, Network::Testnet) + .expect("unprotected-only table is freely droppable"); + } + + /// A protected row on a DIFFERENT network must not block dropping the + /// active network's table — the gate is per-network, matching the + /// per-network migration scope. + #[test] + fn protected_row_on_other_network_does_not_block_drop() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + create_legacy_table(&conn); + // Protected on mainnet, but we gate testnet. + seed_legacy_row( + &conn, + &[4u8; 32], + &[0xAB; 48], + &[0x11; 16], + &[0x22; 12], + "XMainnetProtected", + Some("mainnet"), + true, + Network::Mainnet, + ); + + assert_eq!( + count_unrestored_protected_single_keys(&conn, |_| false, Network::Testnet) + .expect("count"), + 0, + "a mainnet protected row must not count against a testnet drop" + ); + } + + /// A missing table is not a hazard — the guard reports zero remaining + /// and the drop is a no-op success (fresh install / already cleaned). + #[test] + fn missing_table_is_droppable_no_op() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("empty.db")).expect("open empty db"); + assert_eq!( + count_unrestored_protected_single_keys(&conn, |_| false, Network::Testnet) + .expect("count"), + 0 + ); + drop_legacy_single_key_table(&conn, |_| false, Network::Testnet) + .expect("missing table drop is a benign no-op"); + } + + /// `table_has_rows` returns `false` for a missing table rather than + /// erroring — a fresh install lacks the legacy tables entirely. + #[test] + fn table_has_rows_treats_missing_table_as_empty() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("legacy.db"); + let conn = Connection::open(&path).expect("open empty db"); + // No tables created — every legacy table is "missing". + for &table in LEGACY_TABLES { + assert!( + !table_has_rows(&conn, table).expect("missing table is not an error"), + "missing table `{table}` should report no rows", + ); + } + } + + // Wallet-meta migration fixtures + tests. Mirrors the legacy `wallet` + // schema columns the migrator reads; both pre-drop and post-drop + // shapes (with/without `core_wallet_name`) are covered. + + /// Legacy `wallet` schema including `core_wallet_name` (pre-drop). + /// Matches the columns DET writes in `database/wallet.rs`'s + /// `INSERT INTO wallet`. + fn create_legacy_wallet_table_with_core_name(conn: &Connection) { + conn.execute( + "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, + core_wallet_name TEXT DEFAULT NULL + )", + [], + ) + .expect("create legacy wallet table"); + } + + /// Legacy `wallet` schema without `core_wallet_name` (post-drop — + /// after the recent legacy schema migration removed the column). + fn create_legacy_wallet_table_without_core_name(conn: &Connection) { + conn.execute( + "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 + )", + [], + ) + .expect("create legacy wallet table"); + } + + #[allow(clippy::too_many_arguments)] + fn seed_legacy_wallet_row( + conn: &Connection, + seed_hash: &[u8; 32], + alias: Option<&str>, + is_main: bool, + network: dash_sdk::dpp::dashcore::Network, + core_wallet_name: Option<&str>, + has_core_name_col: bool, + ) { + if has_core_name_col { + 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, core_wallet_name + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + seed_hash.as_slice(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + alias, + is_main as i32, + 0_i32, + Option::<String>::None, + network.to_string(), + core_wallet_name, + ], + ) + .expect("insert legacy wallet row"); + } else { + 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, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + seed_hash.as_slice(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + alias, + is_main as i32, + 0_i32, + Option::<String>::None, + network.to_string(), + ], + ) + .expect("insert legacy wallet row"); + } + } + + /// In-memory wallet-meta view fixture using the same `InMemoryKv` + /// fixture the other migration tests reuse — the wallet-meta sidecar + /// writes through the global `det-app.sqlite` k/v, so a single shared + /// store backs both the migrator and the reader. + fn wallet_meta_view(kv: Arc<DetKv>) -> Arc<DetKv> { + kv + } + + /// TC-W-009 — a legacy `wallet` row's alias / `is_main` / + /// `core_wallet_name` lands in the sidecar verbatim. This is the + /// "name preserved across migration" regression guard: if this + /// fails the wallet picker shows "Unnamed wallet" post-upgrade. + #[test] + fn tc_w_009_legacy_wallet_row_alias_preserved_in_meta() { + use crate::model::wallet::meta::WalletMeta; + use crate::wallet_backend::WalletMetaView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_with_core_name(&conn); + + let seed: crate::model::wallet::WalletSeedHash = [0x11; 32]; + seed_legacy_wallet_row( + &conn, + &seed, + Some("paycheque"), + true, + Network::Testnet, + Some("dev-dashd"), + true, + ); + // Foreign-network row must not bleed into the testnet pass. + let other_seed: crate::model::wallet::WalletSeedHash = [0x22; 32]; + seed_legacy_wallet_row( + &conn, + &other_seed, + Some("mainnet wallet"), + true, + Network::Mainnet, + None, + true, + ); + + let kv = wallet_meta_view(Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default())))); + let view = WalletMetaView::new(&kv); + + let outcome = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 0); + assert_eq!( + view.get(Network::Testnet, &seed), + Some(WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev-dashd".into()), + xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, + }) + ); + // Mainnet row must not be visible on testnet. + assert_eq!(view.get(Network::Testnet, &other_seed), None); + } + + /// TC-W-001 (storage half) — the migrator writes a row that the + /// listing path then surfaces. End-to-end (HD-wallet-visible) is + /// verified after T-W-01 cuts the reader, but this guards the + /// storage half today. + #[test] + fn tc_w_001_storage_half_listing_sees_migrated_row() { + use crate::wallet_backend::WalletMetaView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_with_core_name(&conn); + + let seed: crate::model::wallet::WalletSeedHash = [0x33; 32]; + seed_legacy_wallet_row( + &conn, + &seed, + Some("savings"), + false, + Network::Testnet, + None, + true, + ); + + let kv = wallet_meta_view(Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default())))); + let view = WalletMetaView::new(&kv); + let outcome = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("migrate"); + assert_eq!(outcome.imported, 1); + + let listed = view.list(Network::Testnet); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, seed); + assert_eq!(listed[0].1.alias, "savings"); + } + + /// TC-W-008-adjacent — running the migrator twice is a no-op on the + /// second pass; the alias is upserted with the same bytes (cheap + /// idempotency) and the listing still returns one entry. + #[test] + fn tc_w_008_re_run_is_idempotent_and_does_not_duplicate() { + use crate::wallet_backend::WalletMetaView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_with_core_name(&conn); + + let seed: crate::model::wallet::WalletSeedHash = [0x44; 32]; + seed_legacy_wallet_row( + &conn, + &seed, + Some("paycheque"), + true, + Network::Testnet, + None, + true, + ); + + let kv = wallet_meta_view(Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default())))); + let view = WalletMetaView::new(&kv); + let first = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("first pass"); + let second = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("second pass"); + + assert_eq!(first.imported, 1); + assert_eq!(second.imported, 1, "re-import is reported as success"); + assert_eq!(view.list(Network::Testnet).len(), 1, "no duplicate entry"); + } + + /// Missing legacy `wallet` table is benign — fresh installs that + /// never wrote a wallet must reach the wallet-meta step with no + /// table at all and return the zero outcome. + #[test] + fn missing_legacy_wallet_table_yields_zero_outcome() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open empty db"); + // No table created — the migrator must not error out. + + let outcome = + migrate_wallet_meta_rows_from_conn(&conn, |_seed_hash, _meta| Ok(()), Network::Testnet) + .expect("missing table is benign"); + assert_eq!(outcome, WalletMetaMigrationOutcome::default()); + } + + /// Post-drop schema: a legacy `wallet` table without + /// `core_wallet_name` is the runtime reality after the recent + /// schema migration. The migrator must keep working and store + /// `core_wallet_name = None` in the sidecar. + #[test] + fn post_drop_schema_without_core_wallet_name_falls_back_to_none() { + use crate::wallet_backend::WalletMetaView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + let seed: crate::model::wallet::WalletSeedHash = [0x55; 32]; + seed_legacy_wallet_row( + &conn, + &seed, + Some("paycheque"), + true, + Network::Testnet, + None, + false, + ); + + let kv = wallet_meta_view(Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default())))); + let view = WalletMetaView::new(&kv); + let outcome = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 0); + let meta = view.get(Network::Testnet, &seed).expect("present"); + assert_eq!(meta.alias, "paycheque"); + assert!(meta.is_main); + assert!(meta.core_wallet_name.is_none()); + } + + /// A corrupt row (16-byte `seed_hash` instead of 32) lands in the + /// `failed` bucket without aborting the loop, so a sibling good + /// row still imports. + #[test] + fn partial_failure_does_not_crash_wallet_meta_run() { + use crate::wallet_backend::WalletMetaView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_with_core_name(&conn); + + let good_seed: crate::model::wallet::WalletSeedHash = [0x77; 32]; + seed_legacy_wallet_row( + &conn, + &good_seed, + Some("good"), + false, + Network::Testnet, + None, + true, + ); + // Corrupt: insert directly with a 16-byte seed_hash. SQLite + // doesn't enforce blob length, so this is a legitimate way to + // simulate a wedged row. + 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, core_wallet_name + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + vec![0xCC_u8; 16].as_slice(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Option::<String>::None, + 0_i32, + 0_i32, + Option::<String>::None, + Network::Testnet.to_string(), + Option::<String>::None, + ], + ) + .expect("insert corrupt row"); + + let kv = wallet_meta_view(Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default())))); + let view = WalletMetaView::new(&kv); + let outcome = migrate_wallet_meta_rows_from_conn( + &conn, + |seed_hash, meta| view.set(Network::Testnet, &seed_hash, &meta), + Network::Testnet, + ) + .expect("partial failure does not abort the loop"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 1); + assert!(view.get(Network::Testnet, &good_seed).is_some()); + } + + /// Insert a wallet row with the caller's chosen envelope columns — + /// the full surface T-W-00.5-v2's seed migrator reads. Re-uses the + /// `has_core_name_col = false` legacy schema (the post-drop shape) + /// because the seed migration ignores `core_wallet_name`. + #[allow(clippy::too_many_arguments)] + fn seed_legacy_wallet_seed_row( + conn: &Connection, + seed_hash: &[u8; 32], + encrypted_seed: &[u8], + salt: &[u8], + nonce: &[u8], + master_xpub: &[u8], + password_hint: Option<&str>, + uses_password: bool, + network: dash_sdk::dpp::dashcore::Network, + ) { + 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, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + seed_hash.as_slice(), + encrypted_seed, + salt, + nonce, + master_xpub, + Option::<String>::None, + 0_i32, + uses_password as i32, + password_hint, + network.to_string(), + ], + ) + .expect("insert legacy wallet row"); + } + + /// TC-W-001 storage half — a legacy `wallet` row's full envelope + /// (ciphertext + salt + nonce + flags + xpub) round-trips through + /// the view. The migrator never decrypts; the vault layer wraps + /// the envelope with its own at-rest crypto. + #[test] + fn tc_w_001_envelope_round_trips_through_view() { + use crate::wallet_backend::wallet_seed_store::WalletSeedView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + let seed_hash: crate::model::wallet::WalletSeedHash = [0xAA; 32]; + let ciphertext: [u8; 80] = [0x11; 80]; + let salt: [u8; 16] = [0x01; 16]; + let nonce: [u8; 12] = [0x02; 12]; + let xpub = valid_xpub([0x99u8; 64], Network::Testnet); + seed_legacy_wallet_seed_row( + &conn, + &seed_hash, + &ciphertext, + &salt, + &nonce, + &xpub, + Some("granny's birthday"), + true, + Network::Testnet, + ); + + let store = Arc::new( + crate::wallet_backend::single_key::open_secret_store( + &dir.path().join("secrets.pwsvault"), + ) + .expect("open vault"), + ); + let view = WalletSeedView::new(&store); + + let outcome = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, envelope| view.set(&seed_hash, &envelope), + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 0); + + let got = view.get(&seed_hash).expect("get").expect("entry present"); + assert!(got.uses_password); + assert_eq!(got.encrypted_seed, ciphertext.to_vec()); + assert_eq!(got.salt, salt.to_vec()); + assert_eq!(got.nonce, nonce.to_vec()); + assert_eq!(got.password_hint.as_deref(), Some("granny's birthday")); + assert_eq!(got.xpub_encoded, xpub); + } + + /// TC-W-002 — running the seed migration twice is idempotent: the + /// second pass sees the same import count and the vault still + /// holds the original envelope (upstream `set` upserts). + #[test] + fn tc_w_002_seed_migration_is_idempotent() { + use crate::wallet_backend::wallet_seed_store::WalletSeedView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + let seed_hash: crate::model::wallet::WalletSeedHash = [0xBB; 32]; + let ciphertext: [u8; 64] = [0x22; 64]; + let xpub = valid_xpub([0x88u8; 64], Network::Testnet); + seed_legacy_wallet_seed_row( + &conn, + &seed_hash, + &ciphertext, + &[], + &[], + &xpub, + None, + false, + Network::Testnet, + ); + + let store = Arc::new( + crate::wallet_backend::single_key::open_secret_store( + &dir.path().join("secrets.pwsvault"), + ) + .expect("open vault"), + ); + let view = WalletSeedView::new(&store); + + let first = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, envelope| view.set(&seed_hash, &envelope), + Network::Testnet, + ) + .expect("first pass"); + assert_eq!(first.imported, 1); + + let second = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, envelope| view.set(&seed_hash, &envelope), + Network::Testnet, + ) + .expect("second pass"); + assert_eq!(second.imported, 1); + assert_eq!(second.failed, 0); + + let got = view.get(&seed_hash).unwrap().unwrap(); + assert_eq!(got.encrypted_seed, ciphertext.to_vec()); + assert!(!got.uses_password); + } + + /// A password-protected row travels through the vault verbatim: + /// the migrator never decrypts, the ciphertext lands in the + /// envelope, and `uses_password = true` is preserved so the + /// unlock UI keeps prompting. + #[test] + fn password_protected_envelope_round_trips() { + use crate::wallet_backend::wallet_seed_store::WalletSeedView; + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + let seed_hash: crate::model::wallet::WalletSeedHash = [0xCC; 32]; + let ciphertext: [u8; 80] = [0xFF; 80]; + let xpub = valid_xpub([0x77u8; 64], Network::Testnet); + seed_legacy_wallet_seed_row( + &conn, + &seed_hash, + &ciphertext, + &[0x01; 16], + &[0x02; 12], + &xpub, + Some("locked"), + true, + Network::Testnet, + ); + + let store = Arc::new( + crate::wallet_backend::single_key::open_secret_store( + &dir.path().join("secrets.pwsvault"), + ) + .expect("open vault"), + ); + let view = WalletSeedView::new(&store); + + let outcome = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, envelope| view.set(&seed_hash, &envelope), + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!( + outcome.imported, 1, + "encrypted row migrates without decryption" + ); + assert_eq!(outcome.failed, 0); + + let got = view.get(&seed_hash).expect("get").expect("present"); + assert!(got.uses_password); + assert_eq!(got.encrypted_seed, ciphertext.to_vec()); + assert_eq!(got.password_hint.as_deref(), Some("locked")); + } + + /// A row whose `seed_hash` blob is not 32 bytes counts as a + /// failure rather than a silent overwrite. Catches schema drift / + /// corrupt rows before they reach the vault. + #[test] + fn non_32_byte_seed_hash_is_failed_not_imported() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + // SQLite doesn't enforce blob length, so we can insert a wedged + // 16-byte `seed_hash` to exercise the failed-decode path. + 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, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + vec![0xCC_u8; 16].as_slice(), + vec![0x00_u8; 64].as_slice(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Vec::<u8>::new(), + Option::<String>::None, + 0_i32, + 0_i32, + Option::<String>::None, + Network::Testnet.to_string(), + ], + ) + .expect("insert corrupt row"); + + let outcome = migrate_wallet_seeds_rows_from_conn(&conn, |_, _| Ok(()), Network::Testnet) + .expect("migrate"); + assert_eq!(outcome.imported, 0); + assert_eq!(outcome.failed, 1); + } + + /// Foreign-network rows are partitioned by the `WHERE network = ?` + /// filter. A mainnet row must not leak into a testnet seed + /// migration pass. + #[test] + fn seed_migration_filters_by_network() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + + let testnet_seed: crate::model::wallet::WalletSeedHash = [0xEE; 32]; + let testnet_xpub = valid_xpub([0x55u8; 64], Network::Testnet); + seed_legacy_wallet_seed_row( + &conn, + &testnet_seed, + &[0x33; 64], + &[], + &[], + &testnet_xpub, + None, + false, + Network::Testnet, + ); + let mainnet_seed: crate::model::wallet::WalletSeedHash = [0xEF; 32]; + let mainnet_xpub = valid_xpub([0x66u8; 64], Network::Mainnet); + seed_legacy_wallet_seed_row( + &conn, + &mainnet_seed, + &[0x44; 64], + &[], + &[], + &mainnet_xpub, + None, + false, + Network::Mainnet, + ); + + let mut imported: Vec<crate::model::wallet::WalletSeedHash> = Vec::new(); + let outcome = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, _| { + imported.push(seed_hash); + Ok(()) + }, + Network::Testnet, + ) + .expect("migrate"); + + assert_eq!(outcome.imported, 1); + assert_eq!(imported, vec![testnet_seed]); + } + + /// Missing `wallet` table is the freshly-installed shape — the + /// migrator returns the zero outcome rather than erroring out. + #[test] + fn seed_migration_treats_missing_table_as_empty() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("data.db"); + let conn = Connection::open(&db_path).expect("open legacy db"); + + let outcome = migrate_wallet_seeds_rows_from_conn(&conn, |_, _| Ok(()), Network::Testnet) + .expect("migrate"); + + assert_eq!(outcome.imported, 0); + assert_eq!(outcome.failed, 0); + } + + /// Encode a genuinely-decodable BIP44 master xpub from a seed, so a copy + /// test can feed `hd_seed_row_is_hydratable`'s `ExtendedPubKey::decode` a + /// real value (a random 78-byte blob would not validate). + fn valid_xpub(seed: [u8; 64], network: dash_sdk::dpp::dashcore::Network) -> Vec<u8> { + crate::model::wallet::Wallet::new_from_seed(seed, network, None, None) + .expect("wallet from seed") + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec() + } + + /// Regression: the copy step must reject exactly what cold-boot + /// hydration would drop (empty/undecodable xpub, non-64-byte seed) — + /// counted as `skipped_malformed`, never imported — while a well-formed + /// sibling row still imports. + #[test] + fn qa_001_unhydratable_unprotected_rows_are_skipped_not_copied() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + let network = Network::Testnet; + + // Good row: valid 64-byte seed + decodable xpub → hydrates → imports. + let good_hash: crate::model::wallet::WalletSeedHash = [0x01; 32]; + let good_xpub = valid_xpub([0x42u8; 64], network); + seed_legacy_wallet_seed_row( + &conn, + &good_hash, + &[0x42u8; 64], + &[], + &[], + &good_xpub, + None, + false, + network, + ); + + // Adversarial (a): empty master xpub — copy accepts it today, hydration + // drops it via `reconstruct_from_envelope`. + let empty_xpub_hash: crate::model::wallet::WalletSeedHash = [0x02; 32]; + seed_legacy_wallet_seed_row( + &conn, + &empty_xpub_hash, + &[0x55u8; 64], + &[], + &[], + &[], + None, + false, + network, + ); + + // Adversarial (b): decodable xpub but a 32-byte unprotected seed — + // hydration drops it via `wallet_from_envelope`'s length check. + let short_seed_hash: crate::model::wallet::WalletSeedHash = [0x03; 32]; + let short_xpub = valid_xpub([0x11u8; 64], network); + seed_legacy_wallet_seed_row( + &conn, + &short_seed_hash, + &[0x66u8; 32], + &[], + &[], + &short_xpub, + None, + false, + network, + ); + + let mut imported = Vec::new(); + let outcome = migrate_wallet_seeds_rows_from_conn( + &conn, + |seed_hash, _envelope| { + imported.push(seed_hash); + Ok(()) + }, + network, + ) + .expect("migrate"); + + assert_eq!( + outcome.imported, 1, + "only the well-formed row may be copied" + ); + assert_eq!( + outcome.skipped_malformed, 2, + "both unhydratable rows are skipped, not silently copied", + ); + assert_eq!( + outcome.failed, 0, + "skips are non-fatal — no abort, no per-boot wedge", + ); + assert_eq!( + imported, + vec![good_hash], + "the skipped rows never reached the vault", + ); + } + + /// A protected row with a decodable xpub is NOT seed-length-checked (it + /// hydrates closed from its public xpub), so it copies even with an + /// arbitrary-length ciphertext — mirroring hydration, which only + /// length-checks unprotected seeds. + #[test] + fn qa_001_protected_row_with_valid_xpub_is_not_seed_length_checked() { + use dash_sdk::dpp::dashcore::Network; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("open legacy db"); + create_legacy_wallet_table_without_core_name(&conn); + let network = Network::Testnet; + + let hash: crate::model::wallet::WalletSeedHash = [0x04; 32]; + let xpub = valid_xpub([0x77u8; 64], network); + // Protected: 80-byte ciphertext, 16-byte salt, 12-byte nonce. + seed_legacy_wallet_seed_row( + &conn, + &hash, + &[0xABu8; 80], + &[0x01; 16], + &[0x02; 12], + &xpub, + Some("locked"), + true, + network, + ); + + let outcome = + migrate_wallet_seeds_rows_from_conn(&conn, |_, _| Ok(()), network).expect("migrate"); + + assert_eq!( + outcome.imported, 1, + "a protected row with a valid xpub copies" + ); + assert_eq!(outcome.skipped_malformed, 0); + assert_eq!(outcome.failed, 0); + } + + /// Build a minimal, backend-unwired `AppContext` over a fresh `data.db` + /// (legacy wallet-family tables present but empty). Enough to drive the + /// two `run()` no-op paths, which return before touching the wallet + /// backend. + fn fresh_app_context(dir: &std::path::Path) -> Arc<AppContext> { + use dash_sdk::dpp::dashcore::Network; + + crate::app_dir::ensure_env_file(dir); + let db_file = dir.join("data.db"); + let db = Arc::new(crate::database::Database::new(&db_file).expect("db")); + db.create_tables(true).expect("create tables"); + db.set_default_version().expect("set version"); + + let app_kv = AppContext::open_app_kv(dir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(dir).expect("open secret store"); + AppContext::new( + dir.to_path_buf(), + Network::Testnet, + db, + Default::default(), + Default::default(), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("AppContext") + } + + /// F113 — a launch with no legacy rows must report `did_work = false` + /// and leave the migration state `Idle`, so the per-frame banner + /// reconciler never shows a spurious "storage update complete". + #[tokio::test] + async fn run_with_no_legacy_rows_is_a_silent_noop() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + + let did_work = run(&ctx).await.expect("run"); + + assert!(!did_work, "fresh install moved no data"); + assert!( + matches!(*ctx.migration_status().state(), MigrationState::Idle), + "no-op launch must stay Idle, not publish Success", + ); + } + + /// F113 — once the per-network sentinel exists, a subsequent launch is + /// a no-op: `did_work = false` and the state stays `Idle` (no banner). + #[tokio::test] + async fn run_with_sentinel_present_is_a_silent_noop() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + + // First launch writes the sentinel. + run(&ctx).await.expect("first run"); + // Second launch short-circuits on the sentinel. + let did_work = run(&ctx).await.expect("second run"); + + assert!(!did_work, "sentinel-present launch moved no data"); + assert!( + matches!(*ctx.migration_status().state(), MigrationState::Idle), + "sentinel short-circuit must stay Idle", + ); + } + + /// Fix-2 transient classification: only `WalletBackendUnavailable` is the + /// retryable "backend not yet wired" condition that the cold-start + /// dispatcher auto-retries. Every other variant is terminal — surfaced with + /// a "Retry now" banner and never auto-looped — so a genuinely-failing + /// registration or hydration cannot spin forever. + #[test] + fn only_wallet_backend_unavailable_is_backend_not_ready() { + assert!(MigrationError::WalletBackendUnavailable.is_backend_not_ready()); + assert!( + !MigrationError::RegistrationIncomplete { unregistered: 1 }.is_backend_not_ready(), + "incomplete registration is terminal, not an auto-retry", + ); + assert!( + !MigrationError::Hydration { + source: Box::new(TaskError::WalletNotFound), + } + .is_backend_not_ready(), + "hydration failure is terminal, not an auto-retry", + ); + assert!( + !MigrationError::SingleKeyPartialFailure { + imported: 0, + skipped_password_protected: 0, + failed: 1, + } + .is_backend_not_ready(), + ); + assert!( + !MigrationError::ProtectedSingleKeysNotRestored { remaining: 1 }.is_backend_not_ready(), + ); + } + + /// 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 + /// launch instead of falsely recording "done". This is the structural + /// guarantee that `write_sentinel` runs only after the backend-dependent + /// steps (registration included) succeed. + #[tokio::test] + async fn run_without_wired_backend_does_not_write_sentinel() { + use dash_sdk::dpp::dashcore::Network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + + // Seed a legacy single-key row so detection trips and the run advances + // to the first backend-dependent step. `fresh_app_context` wires no + // backend, so that step aborts with `WalletBackendUnavailable`. The + // fixture's `single_key_wallet` schema matches `create_legacy_table`. + { + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + seed_legacy_row( + &conn, + &[7u8; 32], + &[1u8; 32], + &[], + &[], + "addr", + None, + false, + Network::Testnet, + ); + } + + let result = run(&ctx).await; + assert!( + result.is_err(), + "a run that cannot reach the wallet backend must fail, not complete", + ); + + let app_kv = ctx.app_kv(); + assert!( + read_sentinel(&app_kv, ctx.network) + .expect("read sentinel") + .is_none(), + "the completion sentinel must not be written when the migration aborts", + ); + } +} diff --git a/src/backend_task/migration/mod.rs b/src/backend_task/migration/mod.rs new file mode 100644 index 000000000..36a7e639f --- /dev/null +++ b/src/backend_task/migration/mod.rs @@ -0,0 +1,71 @@ +//! Backend tasks that drain DET's legacy `data.db` into the upstream +//! `platform-wallet-storage` k/v store and `SecretStore`. +//! +//! Today the only variant is [`MigrationTask::FinishUnwire`], which +//! orchestrates the post-PR-#860 cold-start migration. The orchestrator +//! detects whether legacy rows are still present, drains single-key rows, +//! wallet seeds and wallet metadata into the upstream store, registers the +//! migrated wallets, and writes a completion sentinel so subsequent +//! launches short-circuit. See [`finish_unwire`] for the orchestrator body +//! and [`MigrationError`](finish_unwire::MigrationError) for failure shapes. + +use std::sync::Arc; + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::context::migration_status::MigrationState; + +pub mod finish_unwire; +pub mod single_key_restore; + +pub use finish_unwire::MigrationError; + +/// Migration orchestrator dispatch enum. Cheap to clone — every +/// payload is plain data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MigrationTask { + /// Run the post-unwire `data.db` drain. Idempotent: once the + /// completion sentinel exists in `det-app.sqlite`, subsequent calls + /// return `Success` immediately without touching the legacy file. + FinishUnwire, +} + +impl AppContext { + /// Dispatch a [`MigrationTask`]. Always returns + /// [`BackendTaskSuccessResult::Refresh`] on success so the UI can + /// re-poll affected screens once the migration finishes. On + /// failure, publishes [`MigrationState::Failed`] so the per-frame + /// banner reconciliation in `AppState` can surface the error + /// variant with a "Retry now" action — without it the banner + /// would be stuck in `Running` forever. + pub async fn run_migration_task( + self: &Arc<Self>, + task: MigrationTask, + ) -> Result<BackendTaskSuccessResult, TaskError> { + match task { + MigrationTask::FinishUnwire => match finish_unwire::run(self).await { + // `finish_unwire::run` already publishes the terminal state + // (`Success` only when it moved data, `Idle` for a no-op + // launch), so the banner is correct without anything here. + Ok(_did_work) => Ok(BackendTaskSuccessResult::Refresh), + Err(task_error) => { + // Publish a `Failed` state carrying the typed + // `MigrationError` chain so the UI banner can call + // `Display::fmt` at render time and surface the + // wrapped source via the details panel — no + // stringification on the writer side. + if let TaskError::MigrationFailed { source } = &task_error { + // `Arc::clone` is a cheap refcount bump — both + // the returned `Err` and the published `Failed` + // state observe the same typed error chain. + self.migration_status().set_state(MigrationState::Failed { + error: Arc::clone(source), + }); + } + Err(task_error) + } + }, + } + } +} diff --git a/src/backend_task/migration/single_key_restore.rs b/src/backend_task/migration/single_key_restore.rs new file mode 100644 index 000000000..3115c28ab --- /dev/null +++ b/src/backend_task/migration/single_key_restore.rs @@ -0,0 +1,560 @@ +//! T-SK-03 — password-protected single-key restore. +//! +//! The FinishUnwire migration ([`super::finish_unwire`]) preserves but +//! cannot migrate `uses_password=1` legacy `single_key_wallet` rows: each +//! is encrypted under the user's OLD per-wallet password, which the +//! migration code path does not have. Those rows are invisible until the +//! user supplies that password. +//! +//! This module is the bridge. Given the legacy password, it: +//! 1. legacy-decrypts the row's blob (the same AES-GCM + Argon2id scheme +//! `model::wallet::single_key` used to write it), +//! 2. re-imports the recovered key into the MODERN secret-store vault via +//! [`SingleKeyView::import_wif_with_passphrase`] (re-protected under a +//! passphrase the user chooses), and +//! 3. leaves the key visible/usable in the picker. +//! +//! ## Security (Smythe review focus) +//! - **S1** — the recovered 32-byte key and the derived WIF are wrapped in +//! [`Zeroizing`], never logged, never `Debug`-printed, dropped promptly. +//! - **S2** — a wrong legacy password and a corrupt blob both surface the +//! same generic [`TaskError::SingleKeyPassphraseIncorrect`]: no oracle +//! that distinguishes "close" passwords or aids a brute force. +//! - **S4** — re-encryption goes through `import_wif_with_passphrase` → +//! `SingleKeyEntry::protected` → `encrypt_message`, which draws a FRESH +//! random AES-GCM nonce + Argon2 salt every call (never reused). +//! - **S5** — the recovered key is re-imported compressed-only (the import +//! path rejects uncompressed WIFs), so the derived address is identical +//! before and after restore; a stability check enforces it. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; +use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; +use rusqlite::Connection; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::wallet_backend::single_key::ImportPassphrase; + +use super::finish_unwire::MigrationError; + +/// One legacy `uses_password=1` row still awaiting restore. Lists only +/// non-secret display fields — never the ciphertext, salt, or nonce — so +/// it is safe to hold in UI state and render in a banner/dialog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingProtectedRestore { + /// Base58 P2PKH address of the protected key. Stable identifier the + /// UI shows and the restore call routes by. + pub address: String, + /// User-chosen nickname from the legacy row, if any. + pub alias: Option<String>, + /// Network the address is valid on. + pub network: Network, +} + +/// Raw protected-row crypto fields read from the legacy table. Internal +/// only — never leaves this module, never logged. `Debug` is intentionally +/// NOT derived so the encrypted bytes cannot leak through `{:?}`. +struct LegacyProtectedBlob { + address: String, + alias: Option<String>, + encrypted_private_key: Vec<u8>, + salt: Vec<u8>, + nonce: Vec<u8>, +} + +/// List every protected legacy single-key row for the active network that +/// has NOT yet been restored into the modern vault. Restored = a modern +/// single-key sidecar entry exists at the same address. +/// +/// Used by the boot/wallet-screen banner to decide whether to offer the +/// restore flow and how many keys are waiting. +pub fn list_pending_protected_restores( + app_context: &Arc<AppContext>, +) -> Result<Vec<PendingProtectedRestore>, TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + let Some(path) = app_context.db.db_file_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + + // Addresses already present in the modern index are restored and must + // be filtered out, so a re-opened banner does not re-offer them. + let already_present: std::collections::BTreeSet<String> = backend + .single_key() + .list() + .into_iter() + .map(|k| k.address) + .collect(); + + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + let rows = read_pending_protected_rows(&conn, app_context.network)?; + Ok(rows + .into_iter() + .filter(|r| !already_present.contains(&r.address)) + .collect()) +} + +/// Pure read of protected pending rows from `conn` for `network`. Returns +/// only the non-secret display descriptor. A missing table is not an +/// error (fresh install / already cleaned up). +fn read_pending_protected_rows( + conn: &Connection, + network: Network, +) -> Result<Vec<PendingProtectedRestore>, MigrationError> { + if !table_exists(conn, "single_key_wallet")? { + return Ok(Vec::new()); + } + let sql = "SELECT address, alias FROM single_key_wallet \ + WHERE network = ?1 AND uses_password = 1"; + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + let rows = stmt + .query_map(rusqlite::params![network.to_string()], |row| { + let address: String = row.get(0)?; + let alias: Option<String> = row.get(1)?; + Ok(PendingProtectedRestore { + address, + alias, + network, + }) + }) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + let mut out = Vec::new(); + for row in rows { + match row { + Ok(r) => out.push(r), + Err(e) => { + tracing::warn!( + target = "migration::single_key_restore", + error = ?e, + "Skipping unreadable protected single_key_wallet row while listing", + ); + } + } + } + Ok(out) +} + +/// Restore one password-protected single-key row into the modern vault. +/// +/// `legacy_password` decrypts the legacy blob; `new_passphrase` re-protects +/// the key in the modern vault (it may be the same string). On success the +/// key becomes listable/usable for the active session and survives a cold +/// boot. Idempotent failure-safe: nothing is written until the legacy +/// decrypt succeeds, and the legacy row is left untouched so a wrong +/// password leaves it restorable. +/// +/// Returns the derived address (== the legacy address; enforced) so the UI +/// can confirm which key was restored. +/// +/// See the module docs for the S1–S5 security mapping. +pub fn restore_protected_single_key( + app_context: &Arc<AppContext>, + address: &str, + legacy_password: &str, + new_passphrase: ImportPassphrase, +) -> Result<String, TaskError> { + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + let path = app_context + .db + .db_file_path() + .ok_or(TaskError::ProtectedSingleKeyRestoreTargetMissing)?; + if !path.exists() { + return Err(TaskError::ProtectedSingleKeyRestoreTargetMissing); + } + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + + let network = app_context.network; + let blob = read_protected_blob(&conn, address, network)? + .ok_or(TaskError::ProtectedSingleKeyRestoreTargetMissing)?; + + // S1 — recovered key bytes wrapped in `Zeroizing`; they wipe on drop. + // S2 — a wrong password and a corrupt/short blob both collapse to the + // same generic `SingleKeyPassphraseIncorrect`, leaking no oracle. + let wif = legacy_decrypt_to_wif(&blob, legacy_password, network)?; + + // S5 — re-import compressed-only (the import path rejects uncompressed + // WIFs) so the derived address is stable. Confirm it matches the legacy + // address before trusting the row; a mismatch means corruption. + let derived = derive_p2pkh_address(&wif, network)?; + if derived != blob.address { + tracing::warn!( + target = "migration::single_key_restore", + "Restored single-key address does not match the legacy row; aborting", + ); + return Err(TaskError::SingleKeyCryptoFailure); + } + + // S4 — `import_wif_with_passphrase` re-encrypts under a FRESH random + // nonce + salt (via `encrypt_message`). Re-import the recovered key, + // preserving the legacy alias unless the row carried none. + let imported = app_context.import_single_key_wif(&wif, blob.alias.clone(), new_passphrase)?; + debug_assert_eq!(imported.0.address, blob.address); + + tracing::info!( + target = "migration::single_key_restore", + address = %blob.address, + "Restored a password-protected single-key wallet into the modern vault", + ); + let _ = backend; // backend presence already validated above. + Ok(blob.address) +} + +/// Read the full protected-row crypto fields for `address` on `network`. +/// `None` when no matching `uses_password=1` row exists. Secret fields are +/// loaded only here and never logged. +fn read_protected_blob( + conn: &Connection, + address: &str, + network: Network, +) -> Result<Option<LegacyProtectedBlob>, MigrationError> { + if !table_exists(conn, "single_key_wallet")? { + return Ok(None); + } + let sql = "SELECT address, alias, encrypted_private_key, salt, nonce \ + FROM single_key_wallet \ + WHERE network = ?1 AND address = ?2 AND uses_password = 1"; + let mut stmt = conn + .prepare(sql) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + let mut rows = stmt + .query(rusqlite::params![network.to_string(), address]) + .map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })?; + let Some(row) = rows.next().map_err(|e| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + })? + else { + return Ok(None); + }; + let read_err = |e: rusqlite::Error| MigrationError::LegacyDbRead { + table: "single_key_wallet", + source: e, + }; + Ok(Some(LegacyProtectedBlob { + address: row.get(0).map_err(read_err)?, + alias: row.get(1).map_err(read_err)?, + encrypted_private_key: row.get(2).map_err(read_err)?, + salt: row.get(3).map_err(read_err)?, + nonce: row.get(4).map_err(read_err)?, + })) +} + +/// S1/S2 chokepoint: legacy-decrypt the protected blob and return the +/// compressed WIF, wrapped in [`Zeroizing`]. Every decrypt failure +/// (wrong password, malformed AES-GCM, wrong key length) collapses to the +/// generic [`TaskError::SingleKeyPassphraseIncorrect`] — no oracle. +fn legacy_decrypt_to_wif( + blob: &LegacyProtectedBlob, + legacy_password: &str, + network: Network, +) -> Result<Zeroizing<String>, TaskError> { + use crate::model::wallet::single_key::ClosedSingleKey; + + let closed = ClosedSingleKey { + // `key_hash` is not consulted by `decrypt_private_key`; a zero + // placeholder is fine and never persisted. + key_hash: [0u8; 32], + encrypted_private_key: blob.encrypted_private_key.clone(), + salt: blob.salt.clone(), + nonce: blob.nonce.clone(), + }; + // S1 — recovered raw bytes wrapped immediately so they wipe on drop. + // The legacy `decrypt_private_key` returns a bare `[u8; 32]`; box it + // in `Zeroizing` at the boundary and never let a bare copy linger. + let raw = Zeroizing::new(closed.decrypt_private_key(legacy_password).map_err(|_| { + // S2 — do not branch on the failure shape; one generic error. + tracing::debug!( + target = "migration::single_key_restore", + "Legacy single-key decrypt failed (wrong password or corrupt blob)", + ); + TaskError::SingleKeyPassphraseIncorrect + })?); + + let priv_key = PrivateKey::from_byte_array(&raw, network) + .map_err(|_| TaskError::SingleKeyPassphraseIncorrect)?; + // `to_wif()` on a `PrivateKey` from `from_byte_array` is compressed, + // matching the import path's compressed-only requirement (S5). + Ok(Zeroizing::new(priv_key.to_wif())) +} + +/// Derive the P2PKH address for `wif` on `network`. Used to confirm the +/// restored key's address matches the legacy row before trusting it (S5). +fn derive_p2pkh_address(wif: &str, network: Network) -> Result<String, TaskError> { + let priv_key = PrivateKey::from_wif(wif).map_err(|source| TaskError::InvalidWif { + source: Box::new(source), + })?; + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + Ok(Address::p2pkh(&pub_key, network).to_string()) +} + +/// `SELECT 1 FROM sqlite_master` table-existence probe. Missing table is +/// not an error — fresh install / already cleaned up. +fn table_exists(conn: &Connection, name: &str) -> Result<bool, MigrationError> { + 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 { + table: "single_key_wallet", + source: e, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::wallet::single_key::SingleKeyWallet; + + /// Build a legacy table and seed one protected row encrypted under + /// `password`, returning its derived address. Mirrors the legacy + /// writer's protected shape (salt + nonce + AES-GCM ciphertext). + fn seed_protected_row( + conn: &Connection, + raw_key: &[u8; 32], + password: &str, + alias: Option<&str>, + network: Network, + ) -> String { + use crate::model::wallet::single_key::ClosedSingleKey; + conn.execute( + "CREATE TABLE single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL + )", + [], + ) + .expect("create legacy table"); + + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext, + salt, + nonce, + } = ClosedSingleKey::encrypt_private_key(raw_key, password).expect("encrypt"); + let priv_key = PrivateKey::from_byte_array(raw_key, network).expect("priv"); + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + let address = Address::p2pkh(&pub_key, network).to_string(); + let key_hash = ClosedSingleKey::compute_key_hash(raw_key); + conn.execute( + "INSERT INTO single_key_wallet + (key_hash, encrypted_private_key, salt, nonce, public_key, + address, alias, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8)", + rusqlite::params![ + key_hash.as_slice(), + ciphertext, + salt, + nonce, + pub_key.inner.serialize().to_vec(), + address, + alias, + network.to_string(), + ], + ) + .expect("insert protected row"); + address + } + + /// The correct legacy password decrypts the blob to the compressed + /// WIF, and the derived address matches what the legacy row stored — + /// the no-divergence guarantee the restore round-trip depends on (S5). + #[test] + fn correct_password_recovers_compressed_wif_with_stable_address() { + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("db"); + let mut raw = [0u8; 32]; + raw[31] = 7; + let address = seed_protected_row( + &conn, + &raw, + "legacy-secret", + Some("savings"), + Network::Testnet, + ); + + let blob = read_protected_blob(&conn, &address, Network::Testnet) + .expect("read") + .expect("present"); + let wif = legacy_decrypt_to_wif(&blob, "legacy-secret", Network::Testnet).expect("decrypt"); + + // Compressed WIF round-trips to the SAME address (S5). + let parsed = PrivateKey::from_wif(&wif).expect("wif"); + assert!(parsed.compressed, "restored WIF must be compressed"); + assert_eq!( + derive_p2pkh_address(&wif, Network::Testnet).unwrap(), + address, + "restored address must equal the legacy address" + ); + + // And the recovered key bytes equal the original. + let mut buf = [0u8; 32]; + buf.copy_from_slice(&parsed.inner[..]); + assert_eq!(buf, raw, "recovered key must equal the original"); + } + + /// S2 — a wrong legacy password surfaces the generic + /// `SingleKeyPassphraseIncorrect`, identical to the variant a corrupt + /// blob would produce: no oracle that distinguishes the two. + #[test] + fn wrong_password_is_generic_failure_no_oracle() { + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("db"); + let raw = [0x11u8; 32]; + let address = seed_protected_row(&conn, &raw, "right-password", None, Network::Testnet); + + let blob = read_protected_blob(&conn, &address, Network::Testnet) + .expect("read") + .expect("present"); + + let err = legacy_decrypt_to_wif(&blob, "wrong-password", Network::Testnet) + .expect_err("wrong password must fail"); + assert!( + matches!(err, TaskError::SingleKeyPassphraseIncorrect), + "expected generic SingleKeyPassphraseIncorrect, got {err:?}" + ); + + // A blob with corrupted ciphertext yields the SAME variant — the + // failure shape must not let an attacker tell "wrong password" + // from "damaged data". + let mut corrupt = blob; + corrupt.encrypted_private_key[0] ^= 0xFF; + let err2 = legacy_decrypt_to_wif(&corrupt, "right-password", Network::Testnet) + .expect_err("corrupt blob must fail"); + assert!( + matches!(err2, TaskError::SingleKeyPassphraseIncorrect), + "corrupt blob must surface the same generic error, got {err2:?}" + ); + } + + /// Listing pending restores returns only protected rows for the active + /// network and never their secret fields. + #[test] + fn pending_list_returns_protected_rows_for_network_only() { + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("db"); + let raw = [0x22u8; 32]; + let address = seed_protected_row(&conn, &raw, "pw", Some("nick"), Network::Testnet); + + let pending = read_pending_protected_rows(&conn, Network::Testnet).expect("list"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].address, address); + assert_eq!(pending[0].alias.as_deref(), Some("nick")); + assert_eq!(pending[0].network, Network::Testnet); + + // A different network sees nothing. + let other = read_pending_protected_rows(&conn, Network::Mainnet).expect("list mainnet"); + assert!(other.is_empty(), "protected rows are per-network"); + } + + /// Missing table → empty pending list and `None` blob (fresh install). + #[test] + fn missing_table_yields_empty_pending_and_none_blob() { + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("empty.db")).expect("db"); + assert!( + read_pending_protected_rows(&conn, Network::Testnet) + .expect("list") + .is_empty() + ); + assert!( + read_protected_blob(&conn, "yAny", Network::Testnet) + .expect("blob") + .is_none() + ); + } + + /// The recovered WIF re-imports cleanly through the modern import path + /// and the resulting display wallet keeps the same address — closing + /// the legacy→modern bridge end to end at the crypto layer (S4/S5). + /// Exercises the import path against a bare `SingleKeyView` (no full + /// `AppContext`). + #[test] + fn recovered_wif_reimports_with_stable_address() { + use crate::wallet_backend::single_key::{SingleKeyView, open_secret_store}; + + let dir = tempfile::tempdir().expect("tempdir"); + let conn = Connection::open(dir.path().join("data.db")).expect("db"); + let mut raw = [0u8; 32]; + raw[30] = 5; + let address = seed_protected_row(&conn, &raw, "legacy-pw", Some("vault"), Network::Testnet); + + let blob = read_protected_blob(&conn, &address, Network::Testnet) + .expect("read") + .expect("present"); + let wif = legacy_decrypt_to_wif(&blob, "legacy-pw", Network::Testnet).expect("decrypt"); + + let store = + Arc::new(open_secret_store(&dir.path().join("secrets.pwsvault")).expect("vault")); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + let view = SingleKeyView::from_views(&store, &index, Network::Testnet, None); + + // Re-protect under a NEW passphrase — the round-trip must keep the + // address identical (S5) and store an encrypted (not raw) entry (S4). + let imported = view + .import_wif_with_passphrase( + &wif, + blob.alias.clone(), + ImportPassphrase { + passphrase: Some(Zeroizing::new("new-strong-passphrase".into())), + hint: None, + }, + ) + .expect("re-import"); + assert_eq!( + imported.address, address, + "re-imported address must be stable" + ); + assert!( + imported.has_passphrase, + "re-import must be passphrase-protected" + ); + + // And the rebuilt display wallet derives the same address. + let wallet = SingleKeyWallet::from_wif(&wif, None, None).expect("rebuild"); + assert_eq!(wallet.address.to_string(), address); + } +} diff --git a/src/backend_task/mnlist.rs b/src/backend_task/mnlist.rs deleted file mode 100644 index 8448d9687..000000000 --- a/src/backend_task/mnlist.rs +++ /dev/null @@ -1,125 +0,0 @@ -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; -use crate::components::core_p2p_handler::CoreP2PHandler; -use crate::context::AppContext; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{BlockHash, Network}; - -#[derive(Debug, Clone, PartialEq)] -pub enum MnListTask { - FetchEndDmlDiff { - base_block_height: u32, - base_block_hash: BlockHash, - block_height: u32, - block_hash: BlockHash, - validate_quorums: bool, - }, - FetchEndQrInfo { - known_block_hashes: Vec<BlockHash>, - block_hash: BlockHash, - }, - FetchEndQrInfoWithDmls { - known_block_hashes: Vec<BlockHash>, - block_hash: BlockHash, - }, - FetchChainLocks { - base_block_height: u32, - block_height: u32, - }, - /// Fetch a sequence of MNListDiffs for validation purposes - /// Each tuple is (base_height, base_hash, height, hash) - FetchDiffsChain { - chain: Vec<(u32, BlockHash, u32, BlockHash)>, - }, -} - -pub async fn run_mnlist_task( - app: &AppContext, - task: MnListTask, -) -> Result<BackendTaskSuccessResult, TaskError> { - match task { - MnListTask::FetchEndDmlDiff { - base_block_height, - base_block_hash, - block_height, - block_hash, - validate_quorums: _, - } => { - let network = app.network; - let mut p2p = CoreP2PHandler::new(network, None)?; - let diff = p2p.get_dml_diff(base_block_hash, block_hash)?; - Ok(BackendTaskSuccessResult::MnListFetchedDiff { - base_height: base_block_height, - height: block_height, - diff, - }) - } - MnListTask::FetchEndQrInfo { - known_block_hashes, - block_hash, - } => { - let network = app.network; - let mut p2p = CoreP2PHandler::new(network, None)?; - let qr_info = p2p.get_qr_info(known_block_hashes, block_hash)?; - Ok(BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info }) - } - MnListTask::FetchEndQrInfoWithDmls { - known_block_hashes, - block_hash, - } => { - let network = app.network; - let mut p2p = CoreP2PHandler::new(network, None)?; - let qr_info = p2p.get_qr_info(known_block_hashes, block_hash)?; - Ok(BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info }) - } - MnListTask::FetchChainLocks { - base_block_height, - block_height, - } => { - let client = app.core_client.read()?; - let loaded_list_height = match app.network { - Network::Mainnet => 2_227_096, - Network::Testnet => 1_296_600, - _ => 0, - }; - let max_blocks = 2000u32; - let start_height = if base_block_height < loaded_list_height { - block_height.saturating_sub(max_blocks) - } else { - base_block_height - }; - let end_height = start_height.saturating_add(max_blocks).min(block_height); - - let mut out: Vec<((u32, BlockHash), Option<BLSSignature>)> = Vec::new(); - for h in start_height..end_height { - if let Ok(bh2) = client.get_block_hash(h) { - let bh = BlockHash::from_byte_array(bh2.to_byte_array()); - if let Ok(block) = client.get_block(&bh2) { - let sig_opt = block - .coinbase() - .and_then(|cb| cb.special_transaction_payload.as_ref()) - .and_then(|pl| pl.clone().to_coinbase_payload().ok()) - .and_then(|cp| cp.best_cl_signature) - .map(|sig| sig.to_bytes().into()); - out.push(((h, bh), sig_opt)); - } else { - out.push(((h, bh), None)); - } - } - } - Ok(BackendTaskSuccessResult::MnListChainLockSigs { entries: out }) - } - MnListTask::FetchDiffsChain { chain } => { - let network = app.network; - let mut p2p = CoreP2PHandler::new(network, None)?; - let mut items = Vec::with_capacity(chain.len()); - for (base_h, base_hash, h, hash) in chain { - let diff = p2p.get_dml_diff(base_hash, hash)?; - items.push(((base_h, h), diff)); - } - Ok(BackendTaskSuccessResult::MnListFetchedDiffs { items }) - } - } -} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7a88a3e41..e138476c6 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,14 +10,11 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; -use crate::spv::CoreBackendMode; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; -use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; -use dash_sdk::dpp::dashcore::BlockHash; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::{PlatformAddressUpdates, WalletSeedHash}; use crate::model::grovestark_prover::ProofDataOutput; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, IdentityTokenIdentifier, TokenInfo, @@ -25,20 +22,19 @@ use crate::ui::tokens::tokens_screen::{ use crate::utils::egui_mpsc::SenderAsync; use contested_names::ScheduledDPNSVote; use dash_sdk::dpp::balances::credits::TokenAmount; -use dash_sdk::dpp::dashcore::network::message_sml::MnListDiff; use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function::evaluate_interval::IntervalEvaluationExplanation; use dash_sdk::dpp::group::group_action::GroupAction; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::dpp::voting::votes::Vote; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, Identifier}; use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; use std::collections::BTreeMap; use std::sync::Arc; +use migration::MigrationTask; use shielded::ShieldedTask; use tokens::TokenTask; use grovestark::GroveSTARKTask; @@ -53,7 +49,7 @@ pub mod document; pub mod error; pub mod grovestark; pub mod identity; -pub mod mnlist; +pub mod migration; pub mod platform_info; pub mod register_contract; pub mod shielded; @@ -62,23 +58,63 @@ pub mod tokens; pub mod update_data_contract; pub mod wallet; -// TODO: Refactor how we handle errors and messages, and remove it from here -pub(crate) const NO_IDENTITIES_FOUND: &str = "No identities found"; +/// Returns `true` for backend tasks that read or write the +/// `WalletBackend` (and therefore the upstream `SecretStore` / sidecar +/// k/v). These tasks must short-circuit with +/// [`TaskError::WalletStorageNotReady`] while the cold-start migration +/// (`FinishUnwire`) is still running so the user sees the "data is +/// still being updated" banner instead of a misleading SDK timeout. +/// +/// The list mirrors the family check above the `match` in +/// `run_backend_task`: identity / DashPay / Core / wallet / shielded +/// all funnel through `WalletBackend`. The migration task itself is +/// explicitly excluded — that is the work in progress. +fn is_wallet_touching(task: &BackendTask) -> bool { + matches!( + task, + BackendTask::WalletTask(_) + | BackendTask::CoreTask(_) + | BackendTask::IdentityTask(_) + | BackendTask::DashPayTask(_) + | BackendTask::ShieldedTask(_) + ) +} + +/// Whether a wallet-backend build error is terminal (storage written by a +/// newer/incompatible app build). These must surface their actionable +/// message instead of being logged-and-discarded as a transient deferral +/// (F50); every other init error is retried by the cold-boot bridge. +fn is_terminal_storage_open_error(error: &TaskError) -> bool { + matches!( + error, + TaskError::WalletDataTooNew { .. } | TaskError::WalletDataIncompatible { .. } + ) +} -/// Information about fees paid for a platform state transition +/// Information about fees for a platform state transition. #[derive(Debug, Clone, PartialEq)] pub struct FeeResult { - /// The fee that was estimated before the operation + /// The fee that was estimated before the operation. pub estimated_fee: u64, - /// The actual fee that was paid (in credits) - pub actual_fee: u64, + /// The actual fee paid, in credits. `None` when the operation only knows + /// the estimate (the platform did not report a settled fee). + pub actual_fee: Option<u64>, } impl FeeResult { + /// Records both an estimate and the settled actual fee. pub fn new(estimated_fee: u64, actual_fee: u64) -> Self { Self { estimated_fee, - actual_fee, + actual_fee: Some(actual_fee), + } + } + + /// Records only the estimate; no actual fee is known. + pub fn estimated_only(estimated_fee: u64) -> Self { + Self { + estimated_fee, + actual_fee: None, } } } @@ -94,11 +130,13 @@ pub enum BackendTask { BroadcastStateTransition(StateTransition), TokenTask(Box<TokenTask>), SystemTask(SystemTask), - MnListTask(mnlist::MnListTask), PlatformInfo(PlatformInfoTaskRequestType), GroveSTARKTask(GroveSTARKTask), WalletTask(WalletTask), ShieldedTask(ShieldedTask), + /// Cold-start data-migration orchestrator. Drains legacy `data.db` + /// rows into the upstream wallet storage; idempotent across launches. + MigrationTask(MigrationTask), /// Rebuild the Core RPC client and SDK on the current network context. /// Dispatched when the user saves a new RPC password so the reinit /// (which includes DAPI discovery) runs off the UI thread. @@ -146,17 +184,16 @@ pub enum BackendTaskSuccessResult { }, // Specific results - #[allow(dead_code)] // May be used for individual document operations - Document(Document), Documents(Documents), BroadcastedDocument(Document), CoreItem(CoreItem), RegisteredIdentity(QualifiedIdentity, FeeResult), ToppedUpIdentity(QualifiedIdentity, FeeResult), - #[allow(dead_code)] // May be used for reporting successful votes - SuccessfulVotes(Vec<Vote>), - DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), String>)>), + DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), Arc<TaskError>>)>), CastScheduledVote(ScheduledDPNSVote), + /// The scheduled votes that the `CastDueScheduledVotes` sweep is about to + /// cast this cycle, so the Scheduled Votes screen can mark them in progress. + ScheduledVotesInProgress(Vec<ScheduledDPNSVote>), FetchedContract(DataContract), FetchedContractWithTokenPosition( DataContract, @@ -164,8 +201,6 @@ pub enum BackendTaskSuccessResult { ), FetchedContracts(Vec<Option<DataContract>>), PageDocuments(IndexMap<Identifier, Option<Document>>, Option<Start>), - #[allow(dead_code)] // May be used for token search results - TokensByKeyword(Vec<TokenInfo>, Option<Start>), DescriptionsByKeyword(Vec<ContractDescriptionInfo>, Option<Start>), TokenEstimatedNonClaimedPerpetualDistributionAmountWithExplanation( IdentityTokenIdentifier, @@ -200,13 +235,42 @@ pub enum BackendTaskSuccessResult { DashPayContactRequestRejected(Identifier), // Request ID that was rejected DashPayContactAlreadyEstablished(Identifier), // Contact ID that already exists DashPayContactInfoUpdated(Identifier), // Contact ID whose info was updated - DashPayPaymentSent(String, String, f64), // (recipient, address, amount) + DashPayPaymentSent(String, String, u64), // (recipient, address, amount in duffs) + /// Result of a [`FetchAvatar`](crate::backend_task::dashpay::DashPayTask::FetchAvatar): + /// the validated image bytes for `url`, or `None` when the fetch failed. Routed + /// into the screen's avatar fetch cache keyed by `url`. + DashPayAvatar { + url: String, + bytes: Option<Vec<u8>>, + }, + /// Received outputs the `EventBridge` saw on a freshly-detected wallet + /// transaction. The app dispatches `DetectIncomingContactPayments` for + /// these — the backend resolves each against the per-identity DashPay + /// address map and records the ones that match a contact. Non-DashPay + /// outputs are silently ignored, so this carries every received output. + DashPayIncomingDetected(Vec<crate::model::dashpay::DetectedIncomingOutput>), + /// Platform became reachable (masternode list `Synced`), so the wallet + /// backend asked the frame loop to start the automatic all-wallets identity + /// discovery sweep. Emitted once per SPV session from the `CoordinatorGate` + /// fire; the app responds by calling + /// [`queue_all_wallets_identity_discovery`](crate::context::AppContext::queue_all_wallets_identity_discovery). + PlatformReadyDiscoverIdentities, + /// Auto-accept contact QR payload, ready to render. The proof is built + /// through the JIT chokepoint in the backend so the UI never touches a seed. + DashPayAutoAcceptQrCode(String), GeneratedZKProof(ProofDataOutput), VerifiedZKProof(bool, ProofDataOutput), GeneratedReceiveAddress { seed_hash: WalletSeedHash, address: String, }, + /// The wallet's tracked asset locks, read off the UI thread through the + /// upstream `AssetLockManager`. Carries the `seed_hash` so screens cache + /// and match the result per wallet. + TrackedAssetLocks { + seed_hash: WalletSeedHash, + locks: Vec<platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock>, + }, /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, @@ -215,6 +279,20 @@ pub enum BackendTaskSuccessResult { /// Network the balances were fetched from network: Network, }, + /// Pushed by the coordinator after each automatic platform-address sync pass. + /// + /// Carries per-wallet per-address funds as raw 20-byte P2PKH hashes so + /// `EventBridge` (which does not know the network) can populate the result + /// without coupling to the wallet model. `AppState` routes this to + /// `AppContext::apply_platform_address_push` which converts the hashes to + /// `dashcore::Address` and writes them into each wallet's + /// `platform_address_info`, keeping the per-address tab current + /// without a manual Refresh. + PlatformAddressSyncPushed { + /// Per-wallet owned-address balance updates. + /// See [`PlatformAddressUpdates`] for the entry layout. + updates: PlatformAddressUpdates, + }, /// Platform credits transferred between addresses PlatformCreditsTransferred { seed_hash: WalletSeedHash, @@ -227,21 +305,57 @@ pub enum BackendTaskSuccessResult { PlatformAddressWithdrawal { seed_hash: WalletSeedHash, }, - - // MNList-specific results - MnListFetchedDiff { - base_height: u32, - height: u32, - diff: MnListDiff, + /// A private key derived for on-screen display/export, wrapped in + /// [`Secret`](crate::model::secret::Secret) end-to-end. The seed never + /// leaves the backend; only the requested WIF crosses to the UI, which + /// already shows it on screen (same trust boundary). + WalletKeyForDisplay { + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + /// The derived private key as a WIF string, zeroize-on-drop. + wif: crate::model::secret::Secret, }, - MnListFetchedQrInfo { - qr_info: QRInfo, + /// A fresh Platform (DIP-17/18) receive address generated via the JIT + /// chokepoint. The seed never leaves the backend; only the Bech32m-encoded + /// address string crosses to the UI. + GeneratedPlatformReceiveAddress { + seed_hash: WalletSeedHash, + /// The Bech32m-encoded Platform address (DIP-18). + address: String, }, - MnListChainLockSigs { - entries: Vec<((u32, BlockHash), Option<BLSSignature>)>, + /// The identity-authentication public-key cache was warmed for + /// `identity_index` (a completion signal — carries no key material). The + /// chooser re-reads the now-warm cache on the next frame. + IdentityAuthPubkeysWarmed { + identity_index: u32, }, - MnListFetchedDiffs { - items: Vec<((u32, u32), MnListDiff)>, + /// A message signed with a wallet-derived key via the JIT chokepoint. Only + /// the public Base64 signature crosses to the UI — the seed and the derived + /// private key never leave the backend. + WalletMessageSigned { + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + /// The Base64-encoded signature (a public artifact, not a secret). + signature: String, + }, + /// An identity private key derived for on-screen display/export, fetched + /// JIT from the vault (`InVault`). The key bytes never become resident; + /// only the WIF (zeroize-on-drop) crosses to the UI. + IdentityKeyForDisplay { + identity_id: dash_sdk::platform::Identifier, + target: crate::model::qualified_identity::PrivateKeyTarget, + key_id: dash_sdk::dpp::identity::KeyID, + /// The identity private key as a WIF string, zeroize-on-drop. + wif: crate::model::secret::Secret, + }, + /// A message signed with a vault-backed identity key via the JIT + /// chokepoint. Only the public Base64 signature crosses to the UI. + IdentityMessageSigned { + identity_id: dash_sdk::platform::Identifier, + target: crate::model::qualified_identity::PrivateKeyTarget, + key_id: dash_sdk::dpp::identity::KeyID, + /// The Base64-encoded signature (a public artifact, not a secret). + signature: String, }, // Token operation results (replacing string messages) @@ -267,6 +381,21 @@ pub enum BackendTaskSuccessResult { RegisteredDpnsName(FeeResult), RefreshedIdentity(QualifiedIdentity), LoadedIdentity(QualifiedIdentity), + /// This identity's keys were sealed under a password (opt-in). + /// The `count` keys newly sealed (0 when the task was an idempotent re-run + /// over an already-protected identity). + IdentityKeysProtected { + /// The identity whose keys are now password-protected. + identity_id: Identifier, + /// How many keys this run newly sealed Tier-2. + count: usize, + }, + /// This identity's key protection was removed (opt-out); signing + /// is prompt-free again. + IdentityKeysUnprotected { + /// The identity whose key protection was removed. + identity_id: Identifier, + }, // Document operation results (replacing string messages) DeletedDocument(Identifier, FeeResult), @@ -292,12 +421,9 @@ pub enum BackendTaskSuccessResult { // Wallet operation results (replacing string messages) RefreshedWallet { - /// Optional warning message (e.g., Platform sync failed but Core refresh succeeded) - warning: Option<String>, - }, - RecoveredAssetLocks { - recovered_count: usize, - total_amount: u64, + /// Set when Core refresh succeeded but the Platform balance sync + /// failed; carries the typed error for the banner's details panel. + warning: Option<Arc<TaskError>>, }, // DPNS operation results (replacing string messages) @@ -311,19 +437,8 @@ pub enum BackendTaskSuccessResult { // Mining results (dev mode, Regtest/Devnet only) MineBlocksSuccess(u64), - // Core wallet list (async fetch of loaded Core wallets) - CoreWalletsList(Vec<String>), - - // Shielded pool results - ShieldedInitialized { - seed_hash: WalletSeedHash, - balance: u64, - }, - ShieldedNotesSynced { - seed_hash: WalletSeedHash, - new_notes: u32, - balance: u64, - }, + // Shielded pool results (one per surviving op; sync/init/nullifier-scan + // are owned by the upstream coordinator now and carry no DET result). ShieldedCreditsShielded { seed_hash: WalletSeedHash, amount: u64, @@ -336,10 +451,6 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, amount: u64, }, - ShieldedNullifiersChecked { - seed_hash: WalletSeedHash, - spent_count: u32, - }, ShieldedFromAssetLock { seed_hash: WalletSeedHash, amount: u64, @@ -348,7 +459,6 @@ pub enum BackendTaskSuccessResult { seed_hash: WalletSeedHash, amount: u64, }, - ProvingKeyReady, /// Core RPC client and SDK were successfully rebuilt (e.g. after password change). CoreClientReinitialized, @@ -366,9 +476,24 @@ pub enum BackendTaskSuccessResult { count: usize, addresses_csv: String, }, -} -impl BackendTaskSuccessResult {} + /// An asset-lock funding transaction was broadcast successfully. + AssetLockBroadcast { + txid: String, + }, + + /// DashPay contact receiving addresses were registered for an identity. + DashPayAddressesRegistered { + addresses: usize, + contacts: usize, + errors: usize, + }, + + /// Identities were discovered and loaded from a wallet by index search. + IdentitiesLoaded { + count: u32, + }, +} impl AppContext { /// Run backend tasks sequentially @@ -379,10 +504,7 @@ impl AppContext { ) -> Vec<Result<BackendTaskSuccessResult, TaskError>> { let mut results = Vec::new(); for task in tasks { - match self.run_backend_task(task, sender.clone()).await { - Ok(result) => results.push(Ok(result)), - Err(e) => results.push(Err(e)), - }; + results.push(self.run_backend_task(task, sender.clone()).await); } results } @@ -412,6 +534,41 @@ impl AppContext { sender: SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { let sdk = self.sdk.load().as_ref().clone(); + + // Wallet/identity/DashPay/core/shielded flows go through + // `WalletBackend`. Build it lazily on first such task (idempotent) — + // this is where the `AppState`-owned `TaskResult` sender is available. + // Every wallet-touching family wires the backend on first use, so + // this mirrors `is_wallet_touching` directly. + if is_wallet_touching(&task) + && let Err(e) = self.ensure_wallet_backend(sender.clone()).await + { + // A storage-open failure (data written by a newer/incompatible app + // build) is terminal — restarting won't help and the generic + // "deferred" banner is misleading. Surface those variants so the + // user sees the actionable message; every other init error is a + // transient deferral the cold-boot bridge retries. + if is_terminal_storage_open_error(&e) { + return Err(e); + } + tracing::warn!(error = %e, "Wallet backend initialization deferred"); + } + + // Short-circuit wallet-touching tasks while the cold-start + // migration is mid-flight. Reaching the SDK before the legacy + // drain finishes either races on partially-mirrored sidecars + // or produces a misleading SDK timeout. `WalletStorageNotReady` + // is a typed, user-friendly variant whose banner mirrors the + // migration banner ("data is still being updated"). + if is_wallet_touching(&task) && self.migration_status().state().is_running() { + tracing::debug!( + target = "migration::gate", + task = ?task, + "Short-circuiting wallet-touching task — migration in progress", + ); + return Err(TaskError::WalletStorageNotReady); + } + match task { BackendTask::ContractTask(contract_task) => { Ok(self.run_contract_task(*contract_task, &sdk, sender).await?) @@ -438,9 +595,6 @@ impl AppContext { BackendTask::SystemTask(system_task) => { Ok(self.run_system_task(system_task, sender).await?) } - BackendTask::MnListTask(mnlist_task) => { - Ok(mnlist::run_mnlist_task(self, mnlist_task).await?) - } BackendTask::PlatformInfo(platform_info_task) => Ok(self .run_platform_info_task(platform_info_task, &sdk) .await?), @@ -451,6 +605,9 @@ impl AppContext { BackendTask::ShieldedTask(shielded_task) => { Ok(self.run_shielded_task(shielded_task).await?) } + BackendTask::MigrationTask(migration_task) => { + Ok(self.run_migration_task(migration_task).await?) + } BackendTask::ReinitCoreClientAndSdk => { Arc::clone(self).reinit_core_client_and_sdk()?; Ok(BackendTaskSuccessResult::CoreClientReinitialized) @@ -462,31 +619,40 @@ impl AppContext { // and file I/O which would block the async runtime. let data_dir = self.data_dir.clone(); let db = self.db.clone(); - let password_info = self.password_info.clone(); let subtasks = self.subtasks.clone(); let connection_status = self.connection_status.clone(); let egui_ctx = self.egui_ctx().clone(); + let app_kv = self.app_kv(); + let secret_store = self.secret_store(); + // Share the app-global Expert Mode flag so the freshly-switched + // context observes the same value (and live toggles) as the rest + // of the app — never a fresh per-context flag. + let developer_mode = self.developer_mode_handle(); let new_ctx = tokio::task::block_in_place(|| { AppContext::new( data_dir, network, db, - password_info, subtasks, connection_status, egui_ctx, + app_kv, + secret_store, + developer_mode, ) }) - .ok_or(TaskError::NetworkContextCreationFailed { - network, - detail: "AppContext::new() returned None".into(), - })?; + .ok_or(TaskError::NetworkContextCreationFailed { network })?; + // Wire the freshly-built context's wallet backend and then start + // chain sync. The old code called `start_spv()` on an unwired + // context, which fast-failed with `WalletBackendNotYetWired` and + // reported `spv_started=false`. Wiring first removes that race so + // `spv_started` reflects whether sync actually began. let spv_started = if start_spv { - if new_ctx.core_backend_mode() != CoreBackendMode::Spv { - new_ctx.set_core_backend_mode_volatile(CoreBackendMode::Spv); - } - match new_ctx.start_spv() { + match new_ctx + .ensure_wallet_backend_and_start_spv(sender.clone()) + .await + { Ok(()) => { tracing::info!(?network, "SPV started after network switch"); true @@ -534,6 +700,58 @@ impl AppContext { WalletTask::GenerateReceiveAddress { seed_hash } => { self.generate_receive_address(seed_hash).await } + WalletTask::DeriveKeyForDisplay { + seed_hash, + derivation_path, + } => { + self.derive_key_for_display(seed_hash, derivation_path) + .await + } + WalletTask::GeneratePlatformReceiveAddress { seed_hash } => { + self.generate_platform_receive_address(seed_hash).await + } + WalletTask::WarmIdentityAuthPubkeys { + seed_hash, + identity_index, + key_count, + } => { + self.warm_identity_auth_pubkeys(seed_hash, identity_index, key_count) + .await + } + WalletTask::SignMessageWithKey { + seed_hash, + derivation_path, + message, + key_type, + } => { + self.sign_message_with_key(seed_hash, derivation_path, message, key_type) + .await + } + WalletTask::DeriveIdentityKeyForDisplay { + identity_id, + target, + key_id, + } => { + self.derive_identity_key_for_display(identity_id, target, key_id) + .await + } + WalletTask::SignMessageWithIdentityKey { + identity_id, + target, + key_id, + message, + key_type, + } => { + self.sign_message_with_identity_key(identity_id, target, key_id, message, key_type) + .await + } + WalletTask::ListTrackedAssetLocks { seed_hash } => { + let locks = self + .wallet_backend()? + .list_tracked_asset_locks(&seed_hash) + .await?; + Ok(BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks }) + } WalletTask::FetchPlatformAddressBalances { seed_hash } => { self.fetch_platform_address_balances(seed_hash).await } @@ -548,17 +766,11 @@ impl AppContext { } WalletTask::FundPlatformAddressFromAssetLock { seed_hash, - asset_lock_proof, - asset_lock_address, + out_point, outputs, } => { - self.fund_platform_address_from_asset_lock( - seed_hash, - *asset_lock_proof, - asset_lock_address, - outputs, - ) - .await + self.fund_platform_address_from_asset_lock(seed_hash, out_point, outputs) + .await } WalletTask::WithdrawFromPlatformAddress { seed_hash, @@ -593,3 +805,79 @@ impl AppContext { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `is_wallet_touching` covers every task family that funnels + /// through `WalletBackend` — the gate in `run_backend_task` relies + /// on it to short-circuit while the cold-start migration is + /// in-flight. Locking the matrix here prevents the gate from + /// silently letting a future task family race the mirror. + #[test] + fn wallet_touching_matrix_is_stable() { + use crate::backend_task::core::CoreTask; + use crate::backend_task::wallet::WalletTask; + use dash_sdk::dpp::dashcore::Network; + + let seed_hash = crate::model::wallet::WalletSeedHash::default(); + + // Wallet-touching families short-circuit on a running migration. + assert!(is_wallet_touching(&BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + ))); + assert!(is_wallet_touching(&BackendTask::CoreTask( + CoreTask::GetBestChainLocks, + ))); + assert!(is_wallet_touching(&BackendTask::ShieldedTask( + shielded::ShieldedTask::ShieldFromBalance { + seed_hash, + amount: 1, + }, + ))); + assert!(is_wallet_touching(&BackendTask::IdentityTask( + identity::IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, + ))); + assert!(is_wallet_touching(&BackendTask::DashPayTask(Box::new( + dashpay::DashPayTask::SearchProfiles { + search_query: String::new(), + }, + )))); + + // The migration task itself must NOT be gated — that is the + // work that flips the gate back off. + assert!(!is_wallet_touching(&BackendTask::MigrationTask( + MigrationTask::FinishUnwire, + ))); + + // Read-only / network-level tasks are exempt. + assert!(!is_wallet_touching(&BackendTask::ReinitCoreClientAndSdk)); + assert!(!is_wallet_touching(&BackendTask::SwitchNetwork { + network: Network::Testnet, + start_spv: false, + })); + assert!(!is_wallet_touching(&BackendTask::DiscoverDapiNodes { + network: Network::Testnet, + })); + } + + /// Only the storage-open variants (data from a newer/incompatible + /// build) are terminal; every other init error is a transient deferral. + #[test] + fn terminal_storage_open_errors_are_classified() { + assert!(is_terminal_storage_open_error( + &TaskError::WalletDataTooNew { + found: 99, + max_supported: 1, + } + )); + // A transient pre-wire state must NOT be treated as terminal. + assert!(!is_terminal_storage_open_error( + &TaskError::WalletBackendNotYetWired + )); + assert!(!is_terminal_storage_open_error( + &TaskError::WalletStorageNotReady + )); + } +} diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index c53287e66..4e1f1a92a 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -24,10 +24,11 @@ use dash_sdk::dpp::system_data_contracts::load_system_data_contract; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::daily_withdrawal_limit::daily_withdrawal_limit; use dash_sdk::dpp::{dash_to_credits, version::ProtocolVersionVoteCount}; -use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, OrderClause, WhereClause, WhereOperator}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; -use dash_sdk::platform::{DocumentQuery, FetchMany, FetchUnproved}; +use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; +use dash_sdk::platform::{DocumentQuery, FetchMany, FetchUnproved, Identifier}; use dash_sdk::query_types::{ AddressInfo, CurrentQuorumsInfo, NoParamQuery, ProtocolVersionUpgrades, TotalCreditsInPlatform, }; @@ -43,11 +44,49 @@ pub enum PlatformInfoTaskRequestType { CurrentValidatorSetInfo, CurrentWithdrawalsInQueue, RecentlyCompletedWithdrawals, + /// Structured, paginated withdrawal query for programmatic clients (MCP / + /// CLI). Unlike the text variants above, this returns one + /// [`WithdrawalRecord`] per document plus a continuation cursor. + Withdrawals { + /// Query completed/expired withdrawals when `true`, the in-queue set + /// when `false`. + completed: bool, + /// Maximum documents to return. `None` uses the platform default. + limit: Option<u32>, + /// Continuation cursor: the document id to start after, as returned in + /// a prior response's `next_cursor`. + start_after: Option<Identifier>, + }, BasicPlatformInfo, ShieldedPoolState, FetchAddressBalance(String), } +/// One withdrawal document flattened into the fields programmatic clients need. +/// Credits are atomic units (1 Dash = `dash_to_credits!(1)` credits); timestamps +/// are Unix milliseconds straight from the document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WithdrawalRecord { + /// Withdrawal document id (base58-encodable handle, also the page cursor). + pub document_id: Identifier, + /// Identity that requested the withdrawal. + pub owner_id: Identifier, + /// Amount in credits (atomic units). + pub amount_credits: u64, + /// Withdrawal status: `"queued"`, `"pooled"`, `"broadcasted"`, + /// `"complete"`, or `"expired"`. + pub status: String, + /// Destination Dash address decoded from the output script, or `None` when + /// the script does not map to a standard address on this network. + pub address: Option<String>, + /// Sequential on-chain transaction index, when present on the document. + pub transaction_index: Option<u64>, + /// Document creation time (Unix ms), when present. + pub created_at_ms: Option<u64>, + /// Document last-update time (Unix ms), when present. + pub updated_at_ms: Option<u64>, +} + #[derive(Debug, Clone)] pub enum PlatformInfoTaskResult { BasicPlatformInfo { @@ -61,6 +100,14 @@ pub enum PlatformInfoTaskResult { balance: u64, nonce: u32, }, + Withdrawals { + records: Vec<WithdrawalRecord>, + /// Sum of all returned records' `amount_credits`. + total_amount_credits: u64, + /// Pass as the next request's `start_after` to fetch the following + /// page. `None` when this page was not full (no more results). + next_cursor: Option<Identifier>, + }, } impl PartialEq for PlatformInfoTaskResult { @@ -94,11 +141,48 @@ impl PartialEq for PlatformInfoTaskResult { nonce: n2, }, ) => addr1 == addr2 && bal1 == bal2 && n1 == n2, + ( + PlatformInfoTaskResult::Withdrawals { + records: r1, + total_amount_credits: t1, + next_cursor: c1, + }, + PlatformInfoTaskResult::Withdrawals { + records: r2, + total_amount_credits: t2, + next_cursor: c2, + }, + ) => r1 == r2 && t1 == t2 && c1 == c2, _ => false, } } } +/// Why a withdrawal document could not be parsed for display or into a +/// [`WithdrawalRecord`]. +/// +/// Carried as the `#[source]` of [`TaskError::WithdrawalDocumentParsingError`]: +/// the user sees that variant's message while this enum preserves the technical +/// cause for logs and the collapsible details panel. +#[derive(Debug, thiserror::Error)] +pub enum WithdrawalParseError { + /// A document field was missing or had an unexpected type. + #[error("A withdrawal document field could not be read")] + Field(#[from] dash_sdk::dpp::platform_value::Error), + /// The document lacked a required created/updated timestamp. + #[error("A withdrawal document is missing a required timestamp")] + MissingTimestamp, + /// A timestamp value fell outside the representable range. + #[error("A withdrawal document has an out-of-range timestamp")] + InvalidTimestamp, + /// The status field held a value outside the known withdrawal states. + #[error("A withdrawal document has an unrecognized status value {value}")] + InvalidStatus { value: u8 }, + /// The daily withdrawal limit could not be computed for this platform version. + #[error("The daily withdrawal limit could not be computed")] + DailyLimit(#[source] Box<dash_sdk::dpp::ProtocolError>), +} + // Helper functions for formatting platform data fn format_extended_epoch_info( epoch_info: ExtendedEpochInfo, @@ -205,83 +289,27 @@ fn format_withdrawal_documents_with_daily_limit( withdrawal_documents: &[Document], total_credits_on_platform: Credits, network: Network, -) -> Result<String, TaskError> { +) -> Result<String, WithdrawalParseError> { let total_amount: Credits = withdrawal_documents .iter() .map(|document| { document .properties() .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - }) + .map_err(WithdrawalParseError::Field) }) - .collect::<Result<Vec<Credits>, TaskError>>()? + .collect::<Result<Vec<Credits>, WithdrawalParseError>>()? .into_iter() .sum(); - let amounts: Vec<String> = - withdrawal_documents - .iter() - .map(|document| { - let index = document.created_at().ok_or_else(|| { - TaskError::WithdrawalDocumentParsingError { - detail: "Withdrawal document missing created_at timestamp".to_string(), - } - })?; - let utc_datetime = DateTime::<Utc>::from_timestamp_millis(index as i64) - .ok_or_else(|| TaskError::WithdrawalDocumentParsingError { - detail: "Invalid withdrawal created_at timestamp".to_string(), - })?; - let local_datetime: DateTime<Local> = utc_datetime.with_timezone(&Local); - - let amount = document - .properties() - .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - })?; - let status_u8: u8 = - document - .properties() - .get_integer::<u8>(STATUS) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal status: {}", e), - })?; - let status: WithdrawalStatus = status_u8.try_into().map_err(|_| { - TaskError::WithdrawalDocumentParsingError { - detail: format!("Invalid withdrawal status value: {}", status_u8), - } - })?; - let owner_id = document.owner_id(); - let address_bytes = - document - .properties() - .get_bytes(OUTPUT_SCRIPT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal output script: {}", e), - })?; - let output_script = ScriptBuf::from_bytes(address_bytes); - let address = Address::from_script(&output_script, network) - .map(|addr| addr.to_string()) - .unwrap_or_else(|e| format!("Invalid Address: {}", e)); - Ok(format!( - "{}: {:.8} Dash for {} towards {} ({})", - local_datetime.format("%Y-%m-%d %H:%M:%S"), - amount as f64 / (dash_to_credits!(1) as f64), - owner_id, - address, - status, - )) - }) - .collect::<Result<Vec<String>, TaskError>>()?; + let amounts: Vec<String> = withdrawal_documents + .iter() + .map(|document| format_withdrawal_line(document, network)) + .collect::<Result<Vec<String>, WithdrawalParseError>>()?; let daily_withdrawal_limit = - daily_withdrawal_limit(total_credits_on_platform, PlatformVersion::latest()).map_err( - |e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to calculate daily withdrawal limit: {}", e), - }, - )?; + daily_withdrawal_limit(total_credits_on_platform, PlatformVersion::latest()) + .map_err(|e| WithdrawalParseError::DailyLimit(Box::new(e)))?; Ok(format!( "Withdrawal Information:\n\n\ @@ -298,76 +326,23 @@ fn format_withdrawal_documents_with_daily_limit( fn format_withdrawal_documents_to_bare_info( withdrawal_documents: &[Document], network: Network, -) -> Result<String, TaskError> { +) -> Result<String, WithdrawalParseError> { let total_amount: Credits = withdrawal_documents .iter() .map(|document| { document .properties() .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - }) + .map_err(WithdrawalParseError::Field) }) - .collect::<Result<Vec<Credits>, TaskError>>()? + .collect::<Result<Vec<Credits>, WithdrawalParseError>>()? .into_iter() .sum(); - let amounts: Vec<String> = - withdrawal_documents - .iter() - .map(|document| { - let index = document.created_at().ok_or_else(|| { - TaskError::WithdrawalDocumentParsingError { - detail: "Withdrawal document missing created_at timestamp".to_string(), - } - })?; - let utc_datetime = DateTime::<Utc>::from_timestamp_millis(index as i64) - .ok_or_else(|| TaskError::WithdrawalDocumentParsingError { - detail: "Invalid withdrawal created_at timestamp".to_string(), - })?; - let local_datetime: DateTime<Local> = utc_datetime.with_timezone(&Local); - - let amount = document - .properties() - .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - })?; - let status_u8: u8 = - document - .properties() - .get_integer::<u8>(STATUS) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal status: {}", e), - })?; - let status: WithdrawalStatus = status_u8.try_into().map_err(|_| { - TaskError::WithdrawalDocumentParsingError { - detail: format!("Invalid withdrawal status value: {}", status_u8), - } - })?; - let owner_id = document.owner_id(); - let address_bytes = - document - .properties() - .get_bytes(OUTPUT_SCRIPT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal output script: {}", e), - })?; - let output_script = ScriptBuf::from_bytes(address_bytes); - let address = Address::from_script(&output_script, network) - .map(|addr| addr.to_string()) - .unwrap_or_else(|e| format!("Invalid Address: {}", e)); - Ok(format!( - "{}: {:.8} Dash for {} towards {} ({})", - local_datetime.format("%Y-%m-%d %H:%M:%S"), - amount as f64 / (dash_to_credits!(1) as f64), - owner_id, - address, - status, - )) - }) - .collect::<Result<Vec<String>, TaskError>>()?; + let amounts: Vec<String> = withdrawal_documents + .iter() + .map(|document| format_withdrawal_line(document, network)) + .collect::<Result<Vec<String>, WithdrawalParseError>>()?; Ok(format!( "Withdrawal Information:\n\n\ @@ -378,6 +353,172 @@ fn format_withdrawal_documents_to_bare_info( )) } +/// Format one queued/in-flight withdrawal document as a single human-readable +/// line: `"<created-at>: <amount> Dash for <owner> towards <address> (<status>)"`. +fn format_withdrawal_line( + document: &Document, + network: Network, +) -> Result<String, WithdrawalParseError> { + let index = document + .created_at() + .ok_or(WithdrawalParseError::MissingTimestamp)?; + let utc_datetime = DateTime::<Utc>::from_timestamp_millis(index as i64) + .ok_or(WithdrawalParseError::InvalidTimestamp)?; + let local_datetime: DateTime<Local> = utc_datetime.with_timezone(&Local); + + let amount = document + .properties() + .get_integer::<Credits>(AMOUNT) + .map_err(WithdrawalParseError::Field)?; + let status_u8: u8 = document + .properties() + .get_integer::<u8>(STATUS) + .map_err(WithdrawalParseError::Field)?; + let status: WithdrawalStatus = status_u8 + .try_into() + .map_err(|_| WithdrawalParseError::InvalidStatus { value: status_u8 })?; + let owner_id = document.owner_id(); + let address_bytes = document + .properties() + .get_bytes(OUTPUT_SCRIPT) + .map_err(WithdrawalParseError::Field)?; + let output_script = ScriptBuf::from_bytes(address_bytes); + let address = Address::from_script(&output_script, network) + .map(|addr| addr.to_string()) + .unwrap_or_else(|e| format!("Invalid Address: {}", e)); + Ok(format!( + "{}: {:.8} Dash for {} towards {} ({})", + local_datetime.format("%Y-%m-%d %H:%M:%S"), + amount as f64 / (dash_to_credits!(1) as f64), + owner_id, + address, + status, + )) +} + +/// Format one completed/expired withdrawal document as a single line keyed by +/// on-chain transaction index and last-update time: +/// `"TX #<index>: <amount> Dash for <owner> to <address> (<status>) at <time>"`. +fn format_completed_withdrawal_line( + document: &Document, + network: Network, +) -> Result<String, WithdrawalParseError> { + let index = document + .updated_at() + .ok_or(WithdrawalParseError::MissingTimestamp)?; + let utc_datetime = DateTime::<Utc>::from_timestamp_millis(index as i64) + .ok_or(WithdrawalParseError::InvalidTimestamp)?; + let local_datetime: DateTime<Local> = utc_datetime.with_timezone(&Local); + + let amount = document + .properties() + .get_integer::<Credits>(AMOUNT) + .map_err(WithdrawalParseError::Field)?; + let status_u8: u8 = document + .properties() + .get_integer::<u8>(STATUS) + .map_err(WithdrawalParseError::Field)?; + let status: WithdrawalStatus = status_u8 + .try_into() + .map_err(|_| WithdrawalParseError::InvalidStatus { value: status_u8 })?; + let owner_id = document.owner_id(); + let address_bytes = document + .properties() + .get_bytes(OUTPUT_SCRIPT) + .map_err(WithdrawalParseError::Field)?; + let transaction_index = document + .properties() + .get_integer::<u64>(TRANSACTION_INDEX) + .map_err(WithdrawalParseError::Field)?; + let output_script = ScriptBuf::from_bytes(address_bytes); + let address = Address::from_script(&output_script, network) + .map(|addr| addr.to_string()) + .unwrap_or_else(|e| format!("Invalid Address: {}", e)); + Ok(format!( + "TX #{}: {:.8} Dash for {} to {} ({}) at {}", + transaction_index, + amount as f64 / (dash_to_credits!(1) as f64), + owner_id, + address, + status, + local_datetime.format("%Y-%m-%d %H:%M:%S"), + )) +} + +/// Stable, lowercase status string for programmatic clients. Distinct from the +/// human-facing `Display` ("Queued", …) so machine consumers can match on it. +fn withdrawal_status_str(status: WithdrawalStatus) -> &'static str { + match status { + WithdrawalStatus::QUEUED => "queued", + WithdrawalStatus::POOLED => "pooled", + WithdrawalStatus::BROADCASTED => "broadcasted", + WithdrawalStatus::COMPLETE => "complete", + WithdrawalStatus::EXPIRED => "expired", + } +} + +/// Flatten one withdrawal [`Document`] into a [`WithdrawalRecord`]. +fn extract_withdrawal_record( + document: &Document, + network: Network, +) -> Result<WithdrawalRecord, WithdrawalParseError> { + let amount_credits = document + .properties() + .get_integer::<Credits>(AMOUNT) + .map_err(WithdrawalParseError::Field)?; + let status_u8 = document + .properties() + .get_integer::<u8>(STATUS) + .map_err(WithdrawalParseError::Field)?; + let status: WithdrawalStatus = status_u8 + .try_into() + .map_err(|_| WithdrawalParseError::InvalidStatus { value: status_u8 })?; + let address = document + .properties() + .get_bytes(OUTPUT_SCRIPT) + .ok() + .and_then(|bytes| Address::from_script(&ScriptBuf::from_bytes(bytes), network).ok()) + .map(|addr| addr.to_string()); + let transaction_index = document + .properties() + .get_integer::<u64>(TRANSACTION_INDEX) + .ok(); + + Ok(WithdrawalRecord { + document_id: document.id(), + owner_id: document.owner_id(), + amount_credits, + status: withdrawal_status_str(status).to_string(), + address, + transaction_index, + created_at_ms: document.created_at(), + updated_at_ms: document.updated_at(), + }) +} + +/// Build the structured, paginated withdrawal result. `documents` are the page +/// already fetched; `page_limit` is the limit that was requested so the cursor +/// is only emitted when the page came back full (more results may exist). +fn build_withdrawals_result( + documents: &[Document], + page_limit: usize, + network: Network, +) -> Result<PlatformInfoTaskResult, TaskError> { + let records = documents + .iter() + .map(|doc| extract_withdrawal_record(doc, network)) + .collect::<Result<Vec<_>, _>>()?; + let total_amount_credits = records.iter().map(|r| r.amount_credits).sum(); + let next_cursor = (documents.len() == page_limit) + .then(|| records.last().map(|r| r.document_id)) + .flatten(); + Ok(PlatformInfoTaskResult::Withdrawals { + records, + total_amount_credits, + next_cursor, + }) +} + impl AppContext { pub async fn run_platform_info_task( self: &Arc<Self>, @@ -490,9 +631,12 @@ impl AppContext { .map_err(|e| TaskError::from(SdkError::Protocol(e)))?; let queued_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: Arc::new(withdrawal_contract), document_type_name: "withdrawal".to_string(), where_clauses: vec![], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 50, start: None, @@ -535,6 +679,7 @@ impl AppContext { .map_err(|e| TaskError::from(SdkError::Protocol(e)))?; let completed_document_query = DocumentQuery { + select: SelectProjection::documents(), data_contract: Arc::new(withdrawal_contract), document_type_name: "withdrawal".to_string(), where_clauses: vec![WhereClause { @@ -545,6 +690,8 @@ impl AppContext { Value::U8(WithdrawalStatus::EXPIRED as u8), ]), }], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![ OrderClause { field: "status".to_string(), @@ -587,81 +734,16 @@ impl AppContext { document .properties() .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - }) + .map_err(WithdrawalParseError::Field) }) - .collect::<Result<Vec<Credits>, TaskError>>()? + .collect::<Result<Vec<Credits>, WithdrawalParseError>>()? .into_iter() .sum(); let amounts: Vec<String> = withdrawal_docs .iter() - .map(|document| { - let index = document.updated_at().ok_or_else(|| { - TaskError::WithdrawalDocumentParsingError { - detail: "Withdrawal document missing updated_at timestamp" - .to_string(), - } - })?; - let utc_datetime = DateTime::<Utc>::from_timestamp_millis(index as i64) - .ok_or_else(|| TaskError::WithdrawalDocumentParsingError { - detail: "Invalid withdrawal updated_at timestamp".to_string(), - })?; - let local_datetime: DateTime<Local> = - utc_datetime.with_timezone(&Local); - - let amount = document - .properties() - .get_integer::<Credits>(AMOUNT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal amount: {}", e), - })?; - let status_u8: u8 = document - .properties() - .get_integer::<u8>(STATUS) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get withdrawal status: {}", e), - })?; - let status: WithdrawalStatus = status_u8.try_into().map_err(|_| { - TaskError::WithdrawalDocumentParsingError { - detail: format!( - "Invalid withdrawal status value: {}", - status_u8 - ), - } - })?; - let owner_id = document.owner_id(); - let address_bytes = document - .properties() - .get_bytes(OUTPUT_SCRIPT) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!( - "Failed to get withdrawal output script: {}", - e - ), - })?; - let transaction_index = document - .properties() - .get_integer::<u64>(TRANSACTION_INDEX) - .map_err(|e| TaskError::WithdrawalDocumentParsingError { - detail: format!("Failed to get transaction index: {}", e), - })?; - let output_script = ScriptBuf::from_bytes(address_bytes); - let address = Address::from_script(&output_script, self.network) - .map(|addr| addr.to_string()) - .unwrap_or_else(|e| format!("Invalid Address: {}", e)); - Ok(format!( - "TX #{}: {:.8} Dash for {} to {} ({}) at {}", - transaction_index, - amount as f64 / (dash_to_credits!(1) as f64), - owner_id, - address, - status, - local_datetime.format("%Y-%m-%d %H:%M:%S"), - )) - }) - .collect::<Result<Vec<String>, TaskError>>()?; + .map(|document| format_completed_withdrawal_line(document, self.network)) + .collect::<Result<Vec<String>, WithdrawalParseError>>()?; let formatted = format!( "Recently Completed Withdrawals:\n\n\ @@ -678,6 +760,72 @@ impl AppContext { )) } } + PlatformInfoTaskRequestType::Withdrawals { + completed, + limit, + start_after, + } => { + let withdrawal_contract = load_system_data_contract( + SystemDataContract::Withdrawals, + PlatformVersion::latest(), + ) + .map_err(|e| TaskError::from(SdkError::Protocol(e)))?; + + // `0` is the upstream sentinel for "default limit"; clamp the + // requested page so the cursor heuristic has a known bound. + let page_limit = limit.unwrap_or(50).clamp(1, 100); + let start = start_after.map(|id| Start::StartAfter(id.to_buffer().to_vec())); + + let statuses = if completed { + vec![ + Value::U8(WithdrawalStatus::COMPLETE as u8), + Value::U8(WithdrawalStatus::EXPIRED as u8), + ] + } else { + vec![ + Value::U8(WithdrawalStatus::QUEUED as u8), + Value::U8(WithdrawalStatus::POOLED as u8), + Value::U8(WithdrawalStatus::BROADCASTED as u8), + ] + }; + let where_clauses = vec![WhereClause { + field: "status".to_string(), + operator: WhereOperator::In, + value: Value::Array(statuses), + }]; + let order_by_clauses = vec![ + OrderClause { + field: "status".to_string(), + ascending: true, + }, + OrderClause { + field: "transactionIndex".to_string(), + ascending: true, + }, + ]; + + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: Arc::new(withdrawal_contract), + document_type_name: "withdrawal".to_string(), + where_clauses, + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses, + limit: page_limit, + start, + }; + + let documents = Document::fetch_many(sdk, query) + .await + .map_err(TaskError::from)?; + let withdrawal_docs: Vec<Document> = + documents.values().filter_map(|a| a.clone()).collect(); + + let result = + build_withdrawals_result(&withdrawal_docs, page_limit as usize, self.network)?; + Ok(BackendTaskSuccessResult::PlatformInfo(result)) + } PlatformInfoTaskRequestType::ShieldedPoolState => { use dash_sdk::query_types::ShieldedPoolState; @@ -695,9 +843,7 @@ impl AppContext { PlatformInfoTaskResult::TextResult(formatted), )) } - Err(e) => Err(TaskError::ShieldedSyncFailed { - detail: format!("Failed to fetch shielded pool state: {}", e), - }), + Err(e) => Err(TaskError::ShieldedSyncFailed(Box::new(e))), } } PlatformInfoTaskRequestType::FetchAddressBalance(address_string) => { diff --git a/src/backend_task/register_contract.rs b/src/backend_task/register_contract.rs index 40d2652f3..ee964599d 100644 --- a/src/backend_task/register_contract.rs +++ b/src/backend_task/register_contract.rs @@ -1,25 +1,13 @@ -use std::time::Duration; - use dash_sdk::{ Error, Sdk, - dpp::{dashcore::Network, data_contract::accessors::v0::DataContractV0Getters}, - platform::{DataContract, Fetch, IdentityPublicKey, transition::put_contract::PutContract}, + dpp::data_contract::accessors::v0::DataContractV0Getters, + platform::{DataContract, IdentityPublicKey, transition::put_contract::PutContract}, }; -use tokio::time::sleep; use super::{BackendTaskSuccessResult, FeeResult}; use crate::backend_task::error::TaskError; -use crate::backend_task::update_data_contract::extract_contract_id_from_error; -use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::{ - app::TaskResult, - context::AppContext, - model::{proof_log_item::RequestType, qualified_identity::QualifiedIdentity}, -}; -use crate::{ - database::contracts::InsertTokensToo::AllTokensShouldBeAdded, - model::proof_log_item::ProofLogItem, -}; +use crate::model::qualified_contract::InsertTokensToo::AllTokensShouldBeAdded; +use crate::{app::TaskResult, context::AppContext, model::qualified_identity::QualifiedIdentity}; impl AppContext { pub async fn register_data_contract( @@ -31,8 +19,8 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { - // Estimate fee for contract creation - let estimated_fee = PlatformFeeEstimator::new().estimate_contract_create_base(); + let estimated_fee = self.fee_estimator().estimate_contract_create_base(); + let contract_id = data_contract.id(); match data_contract .put_to_platform_and_wait_for_response(sdk, signing_key.clone(), &identity, None) @@ -43,73 +31,38 @@ impl AppContext { true => None, false => Some(alias), }; - self.db.insert_contract_if_not_exists( + self.insert_contract_if_not_exists( &returned_contract, optional_alias.as_deref(), AllTokensShouldBeAdded, - self, )?; - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::RegisteredContract(fee_result)) + Ok(BackendTaskSuccessResult::RegisteredContract( + FeeResult::estimated_only(estimated_fee), + )) } - Err(e) => match e { - Error::DriveProofError(proof_error, proof_bytes, block_info) => { - let proof_error_str = proof_error.to_string(); - // Log the proof error first, before any other operations - self.db - .insert_proof_log_item(ProofLogItem { - request_type: RequestType::BroadcastStateTransition, - request_bytes: vec![], - verification_path_query_bytes: vec![], - height: block_info.height, - time_ms: block_info.time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(proof_error_str.clone()), - }) + Err(e @ Error::DriveProofError(..)) => { + self.recover_contract_after_proof_error( + sdk, + contract_id, + e, + &sender, + |ctx, contract| { + let optional_alias = ctx + .get_contract_by_id(&contract.id()) + .ok() + .flatten() + .and_then(|c| c.alias); + ctx.insert_contract_if_not_exists( + contract, + optional_alias.as_deref(), + AllTokensShouldBeAdded, + ) .ok(); - - // Reconstruct the SDK error to preserve as source - let source_error = - Box::new(Error::DriveProofError(proof_error, proof_bytes, block_info)); - - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::ProofErrorLogged, - ))) - .await - .map_err(|_| TaskError::InternalSendError)?; - - // Try to extract contract ID and fetch the contract if it exists - // This handles the case where the contract was actually created despite the proof error - if let Ok(id) = extract_contract_id_from_error(&proof_error_str) { - match self.network { - Network::Regtest => sleep(Duration::from_secs(3)).await, - _ => sleep(Duration::from_secs(10)).await, - } - if let Ok(Some(contract)) = DataContract::fetch(sdk, id).await { - let optional_alias = self - .get_contract_by_id(&contract.id()) - .ok() - .flatten() - .and_then(|c| c.alias); - - self.db - .insert_contract_if_not_exists( - &contract, - optional_alias.as_deref(), - AllTokensShouldBeAdded, - self, - ) - .ok(); - - return Ok(BackendTaskSuccessResult::ContractSavedAfterProofError); - } - } - - Err(TaskError::ProofError { source_error }) - } - e => Err(TaskError::from(e)), - }, + }, + ) + .await + } + Err(e) => Err(TaskError::from(e)), } } } diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs deleted file mode 100644 index 864dbe6a5..000000000 --- a/src/backend_task/shielded/bundle.rs +++ /dev/null @@ -1,884 +0,0 @@ -use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error}; -use crate::context::AppContext; -use crate::context::shielded::get_proving_key; -use crate::model::fee_estimation::{format_credits_as_dash, shielded_fee_for_actions}; -use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; -use dash_sdk::dpp::address_funds::{ - AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, -}; -use dash_sdk::dpp::dashcore::Address; -use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::shielded::builder::{ - OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, - build_shielded_withdrawal_transition, build_unshield_transition, -}; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::withdrawal::Pooling; -use dash_sdk::grovedb_commitment_tree::{ - Anchor, ClientPersistentCommitmentTree, Nullifier, PaymentAddress, ProvingKey, -}; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, MutexGuard}; - -/// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. -struct CachedProver { - key: &'static ProvingKey, -} - -impl OrchardProver for CachedProver { - fn proving_key(&self) -> &ProvingKey { - self.key - } -} - -/// Progress stage for a shield credits operation (used by batch UI). -#[derive(Clone, Debug)] -pub enum ShieldStage { - Queued, - BuildingProof { - nonce: u32, - }, - WaitingToBroadcast, - Broadcasting, - Complete, - Failed { - error: String, - st_json: Option<String>, - }, -} - -impl ShieldStage { - pub fn is_terminal(&self) -> bool { - matches!(self, ShieldStage::Complete | ShieldStage::Failed { .. }) - } - - pub fn progress_fraction(&self) -> f32 { - match self { - ShieldStage::Queued => 0.0, - ShieldStage::BuildingProof { .. } => 0.4, - ShieldStage::WaitingToBroadcast => 0.6, - ShieldStage::Broadcasting => 0.8, - ShieldStage::Complete => 1.0, - ShieldStage::Failed { .. } => 1.0, - } - } - - pub fn label(&self) -> String { - match self { - ShieldStage::Queued => "Queued".to_string(), - ShieldStage::BuildingProof { nonce } => { - format!("Building proof... (nonce: {})", nonce) - } - ShieldStage::WaitingToBroadcast => "Waiting to broadcast...".to_string(), - ShieldStage::Broadcasting => { - "Broadcasting & waiting for nonce confirmation...".to_string() - } - ShieldStage::Complete => "Complete".to_string(), - ShieldStage::Failed { error, .. } => format!("Failed: {}", error), - } - } -} - -/// Build a Shield transition without broadcasting (for batch parallel mode). -/// -/// Returns the built `StateTransition` so the caller can broadcast in nonce order. -pub fn build_shield_credit( - app_context: &Arc<AppContext>, - seed_hash: &WalletSeedHash, - recipient_payment_address: &PaymentAddress, - amount: u64, - from_address: PlatformAddress, - nonce: u32, -) -> Result<dash_sdk::dpp::state_transition::StateTransition, TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let prover = CachedProver { - key: get_proving_key(), - }; - let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; - - let wallet_arc = { - let wallets = app_context.wallets.read()?; - wallets - .get(seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut inputs = BTreeMap::new(); - inputs.insert(from_address, (nonce, amount)); - - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - - let wallet = wallet_arc.read()?; - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - build_shield_transition( - &recipient_addr, - amount, - inputs, - fee_strategy, - &*wallet, - 0, - &prover, - [0u8; 36], - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string())) -} - -/// Build and broadcast a Shield transition (transparent -> shielded pool). -/// -/// Uses the DPP builder which handles Orchard bundle construction internally -/// (including Halo 2 proof generation and RedPallas signature application). -pub async fn shield_credits( - app_context: &Arc<AppContext>, - seed_hash: &WalletSeedHash, - recipient_payment_address: &PaymentAddress, - amount: u64, - from_address: PlatformAddress, - nonce_override: Option<u32>, - stage: Option<Arc<Mutex<ShieldStage>>>, -) -> Result<(), TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let prover = CachedProver { - key: get_proving_key(), - }; - - let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; - - let wallet_arc = { - let wallets = app_context.wallets.read()?; - wallets - .get(seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let nonce: u32 = if let Some(n) = nonce_override { - n - } else { - let wallet = wallet_arc.read()?; - wallet - .platform_address_info - .iter() - .find_map(|(addr, info)| { - let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; - if platform_addr == from_address { - Some(info.nonce + 1) - } else { - None - } - }) - .ok_or(TaskError::PlatformAddressNotFound)? - }; - - let mut inputs = BTreeMap::new(); - inputs.insert(from_address, (nonce, amount)); - - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - - tracing::info!( - "Shield credits: {} ({} credits), nonce={}, building proof...", - format_credits_as_dash(amount), - amount, - nonce, - ); - - if let Some(s) = &stage { - *s.lock()? = ShieldStage::BuildingProof { nonce }; - } - - let state_transition = { - let wallet = wallet_arc.read()?; - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - build_shield_transition( - &recipient_addr, - amount, - inputs, - fee_strategy, - &*wallet, - 0, - &prover, - [0u8; 36], - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string()))? - }; - - if let Some(s) = &stage { - *s.lock()? = ShieldStage::Broadcasting; - } - - tracing::trace!("Shield credits: state transition built, broadcasting..."); - - state_transition - .broadcast(&sdk, None) - .await - .map_err(shielded_broadcast_error)?; - - state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - .map_err(|e| { - tracing::warn!("Shield credits broadcast succeeded but confirmation wait failed: {e}"); - }) - .ok(); - - tracing::info!( - "Shield credits broadcast succeeded: {}", - format_credits_as_dash(amount), - ); - - Ok(()) -} - -/// Build and broadcast a ShieldedTransfer transition (pool -> pool). -/// -/// Returns the nullifiers of the notes that were spent. -pub async fn shielded_transfer( - app_context: &Arc<AppContext>, - _seed_hash: &WalletSeedHash, - shielded_state: &ShieldedWalletState, - amount: u64, - recipient_address_bytes: &[u8], -) -> Result<Vec<Nullifier>, TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let prover = CachedProver { - key: get_proving_key(), - }; - - let recipient_bytes: [u8; 43] = recipient_address_bytes - .try_into() - .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) - .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - - let (spendable_notes, total_input_value, exact_fee) = - select_notes_with_fee(shielded_state, amount, 2, sdk.version())?; - let change_amount = total_input_value - .saturating_sub(amount) - .saturating_sub(exact_fee); - - tracing::info!( - "Shielded transfer: sending {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", - format_credits_as_dash(amount), - amount, - format_credits_as_dash(exact_fee), - exact_fee, - spendable_notes.len(), - format_credits_as_dash(total_input_value), - total_input_value, - format_credits_as_dash(change_amount), - change_amount, - ); - - let spent_nullifiers: Vec<Nullifier> = spendable_notes.iter().map(|n| n.nullifier).collect(); - - let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock()?; - extract_spends_and_anchor(&tree, &spendable_notes)? - }; - - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; - - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - let state_transition = build_shielded_transfer_transition( - spends, - &recipient_addr, - amount, - &change_addr, - &shielded_state.keys.fvk, - &shielded_state.keys.ask, - anchor, - &prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string()))?; - - tracing::trace!("Shielded transfer: state transition built, broadcasting..."); - - state_transition - .broadcast(&sdk, None) - .await - .map_err(shielded_broadcast_error)?; - - state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - .map_err(|e| { - tracing::warn!( - "Shielded transfer broadcast succeeded but confirmation wait failed: {e}" - ); - }) - .ok(); - - tracing::info!( - "Shielded transfer broadcast succeeded: {} nullifiers created, change={}", - spent_nullifiers.len(), - change_amount > 0, - ); - - Ok(spent_nullifiers) -} - -/// Build and broadcast an Unshield transition (shielded pool -> platform address). -/// -/// Returns the nullifiers of the notes that were spent. -pub async fn unshield_credits( - app_context: &Arc<AppContext>, - _seed_hash: &WalletSeedHash, - shielded_state: &ShieldedWalletState, - amount: u64, - to_platform_address: PlatformAddress, -) -> Result<Vec<Nullifier>, TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let prover = CachedProver { - key: get_proving_key(), - }; - - let (spendable_notes, total_input_value, exact_fee) = - select_notes_with_fee(shielded_state, amount, 1, sdk.version())?; - let change_amount = total_input_value - .saturating_sub(amount) - .saturating_sub(exact_fee); - - tracing::info!( - "Unshield credits: {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", - format_credits_as_dash(amount), - amount, - format_credits_as_dash(exact_fee), - exact_fee, - spendable_notes.len(), - format_credits_as_dash(total_input_value), - total_input_value, - format_credits_as_dash(change_amount), - change_amount, - ); - - let spent_nullifiers: Vec<Nullifier> = spendable_notes.iter().map(|n| n.nullifier).collect(); - - let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock()?; - extract_spends_and_anchor(&tree, &spendable_notes)? - }; - - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; - - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - let state_transition = build_unshield_transition( - spends, - to_platform_address, - amount, - &change_addr, - &shielded_state.keys.fvk, - &shielded_state.keys.ask, - anchor, - &prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string()))?; - - tracing::trace!("Unshield credits: state transition built, broadcasting..."); - - state_transition - .broadcast(&sdk, None) - .await - .map_err(shielded_broadcast_error)?; - - state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - .map_err(|e| { - tracing::warn!( - "Unshield credits broadcast succeeded but confirmation wait failed: {e}" - ); - }) - .ok(); - - tracing::info!( - "Unshield credits broadcast succeeded: {} nullifiers created, change={}", - spent_nullifiers.len(), - change_amount > 0, - ); - - Ok(spent_nullifiers) -} - -/// Build and broadcast a ShieldFromAssetLock transition (core DASH -> shielded pool via asset lock). -/// -/// Creates an asset lock transaction from wallet UTXOs, broadcasts it, waits for -/// an InstantLock/ChainLock proof, then builds and broadcasts a Type 18 -/// ShieldFromAssetLock state transition that deposits credits directly into the -/// shielded pool. -pub async fn shield_from_asset_lock( - app_context: &Arc<AppContext>, - seed_hash: &WalletSeedHash, - shielded_state: &ShieldedWalletState, - amount_duffs: u64, - source_address: Option<&Address>, -) -> Result<u64, TaskError> { - use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; - use dash_sdk::dpp::prelude::AssetLockProof; - use dash_sdk::dpp::shielded::builder::build_shield_from_asset_lock_transition; - use std::time::Duration; - - let proving_key = crate::context::shielded::get_proving_key(); - - let (platform_fee_duffs, _l1_fee_duffs) = app_context - .fee_estimator() - .estimate_shield_from_core_fees_duffs(); - let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs); - - // Step 1: Create the asset lock transaction - let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { - let wallet_arc = { - let wallets = app_context.wallets.read()?; - wallets - .get(seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc - .write() - .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - - let first_result = wallet.generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - source_address, - ); - - let (tx, private_key, address, _change, utxos) = match first_result { - Ok(ok) => ok, - Err(_) => { - wallet - .reload_utxos(app_context.as_ref()) - .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; - wallet - .generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - source_address, - ) - .map_err(shielded_build_error)? - } - }; - - (tx, private_key, address, utxos) - }; - - let tx_id = asset_lock_transaction.txid(); - - // Step 2: Register this transaction as waiting for finality - { - let mut proofs = app_context.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - // Step 3: Broadcast the transaction (routes through SPV or RPC per - // `core_backend_mode()`). On failure, drop the finality tracking entry - // we just inserted so it does not leak across retries. - if let Err(e) = app_context - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - match app_context.transactions_waiting_for_finality.lock() { - Ok(mut proofs) => { - proofs.remove(&tx_id); - } - Err(poisoned) => { - tracing::warn!( - %tx_id, - "transactions_waiting_for_finality lock is poisoned after broadcast failure; \ - recovering through poisoned guard so the finality tracking entry is cleared" - ); - let mut proofs = poisoned.into_inner(); - proofs.remove(&tx_id); - } - } - return Err(e); - } - - // Step 4: Remove used UTXOs from wallet - { - let wallet_arc = { - let wallets = app_context.wallets.read()?; - wallets - .get(seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc - .write() - .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - wallet.utxos.retain(|_, utxo_map| { - utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); - !utxo_map.is_empty() - }); - - for utxo in used_utxos.keys() { - app_context - .db - .drop_utxo(utxo, &app_context.network.to_string())?; - } - - wallet - .recalculate_affected_address_balances(&used_utxos, app_context.as_ref()) - .map_err(|detail| TaskError::WalletBalanceRecalculationFailed { detail })?; - } - - // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout - let asset_lock_proof: AssetLockProof; - let timeout = tokio::time::sleep(Duration::from_secs(300)); - tokio::pin!(timeout); - - loop { - tokio::select! { - _ = &mut timeout => { - // Block briefly to guarantee cleanup; the critical section is a - // small BTreeMap remove. Mirrors the broadcast-failure branch - // above so a timeout cannot leak a finality tracking entry - // that the finality listener would otherwise keep servicing. - match app_context.transactions_waiting_for_finality.lock() { - Ok(mut proofs) => { - proofs.remove(&tx_id); - } - Err(poisoned) => { - tracing::warn!( - %tx_id, - "transactions_waiting_for_finality lock is poisoned on timeout; \ - recovering through poisoned guard so the finality tracking entry is cleared" - ); - let mut proofs = poisoned.into_inner(); - proofs.remove(&tx_id); - } - } - - if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc - && let Some(wallet_arc) = app_context.wallets.read().ok() - .and_then(|w| w.get(seed_hash).cloned()) - { - let ctx = Arc::clone(app_context); - tokio::task::spawn_blocking(move || { - if let Err(e) = ctx.refresh_wallet_info(wallet_arc) { - tracing::warn!("Failed to auto-refresh wallet after timeout: {}", e); - } - }); - } - - return Err(TaskError::ShieldedAssetLockTimeout); - } - _ = tokio::time::sleep(Duration::from_millis(200)) => { - let proofs = app_context.transactions_waiting_for_finality.lock()?; - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; - } - } - } - } - - // Step 6: Clean up the finality tracking - { - let mut proofs = app_context.transactions_waiting_for_finality.lock()?; - proofs.remove(&tx_id); - } - - // Step 7: Build and broadcast the shield-from-asset-lock transition - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let recipient = payment_address_to_orchard(&shielded_state.keys.default_address)?; - let prover = CachedProver { key: proving_key }; - - let shield_amount_credits = - amount_duffs - .checked_mul(CREDITS_PER_DUFF) - .ok_or(TaskError::CreditCalculationOverflow { - amount: amount_duffs, - credits_per_duff: CREDITS_PER_DUFF, - })?; - - tracing::info!( - "Shield from asset lock: building state transition for {} ({} credits)", - format_credits_as_dash(shield_amount_credits), - shield_amount_credits, - ); - - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - let state_transition = build_shield_from_asset_lock_transition( - &recipient, - shield_amount_credits, - asset_lock_proof, - asset_lock_private_key.inner.as_ref(), - &prover, - [0u8; 36], - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string()))?; - - tracing::trace!("Shield from asset lock: state transition built, broadcasting..."); - - state_transition - .broadcast(&sdk, None) - .await - .map_err(shielded_broadcast_error)?; - - state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - .map_err(|e| { - tracing::warn!( - "Shield from asset lock broadcast succeeded but confirmation wait failed: {e}" - ); - }) - .ok(); - - tracing::info!( - "Shield from asset lock broadcast succeeded: {}", - format_credits_as_dash(shield_amount_credits), - ); - - Ok(shield_amount_credits) -} - -/// Build and broadcast a ShieldedWithdrawal transition (shielded pool -> core L1 address). -/// -/// Returns the nullifiers of the notes that were spent. -pub async fn shielded_withdrawal( - app_context: &Arc<AppContext>, - _seed_hash: &WalletSeedHash, - shielded_state: &ShieldedWalletState, - amount: u64, - to_core_address: Address, -) -> Result<Vec<Nullifier>, TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let prover = CachedProver { - key: get_proving_key(), - }; - - let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - - let (spendable_notes, total_input_value, exact_fee) = - select_notes_with_fee(shielded_state, amount, 1, sdk.version())?; - let change_amount = total_input_value - .saturating_sub(amount) - .saturating_sub(exact_fee); - - tracing::info!( - "Shielded withdrawal: {} ({} credits) to core address, fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", - format_credits_as_dash(amount), - amount, - format_credits_as_dash(exact_fee), - exact_fee, - spendable_notes.len(), - format_credits_as_dash(total_input_value), - total_input_value, - format_credits_as_dash(change_amount), - change_amount, - ); - - let spent_nullifiers: Vec<Nullifier> = spendable_notes.iter().map(|n| n.nullifier).collect(); - - let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock()?; - extract_spends_and_anchor(&tree, &spendable_notes)? - }; - - let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; - - // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo - let state_transition = build_shielded_withdrawal_transition( - spends, - amount, - output_script, - 1, // core_fee_per_byte - Pooling::Standard, - &change_addr, - &shielded_state.keys.fvk, - &shielded_state.keys.ask, - anchor, - &prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), - ) - .map_err(|e| shielded_build_error(e.to_string()))?; - - tracing::trace!("Shielded withdrawal: state transition built, broadcasting..."); - - state_transition - .broadcast(&sdk, None) - .await - .map_err(shielded_broadcast_error)?; - - state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - .map_err(|e| { - tracing::warn!( - "Shielded withdrawal broadcast succeeded but confirmation wait failed: {e}" - ); - }) - .ok(); - - tracing::info!( - "Shielded withdrawal broadcast succeeded: {} nullifiers created, change={}", - spent_nullifiers.len(), - change_amount > 0, - ); - - Ok(spent_nullifiers) -} - -/// Select notes sufficient to cover `amount` plus the exact shielded fee. -/// -/// Uses an iterative approach: -/// 1. Estimate fee for `min_actions` (the builder's minimum action count) -/// 2. Select notes for amount + estimated fee -/// 3. Compute exact fee from actual note count -/// 4. If insufficient, re-select with exact fee; repeat (converges in 2-3 iterations) -/// -/// Returns the selected notes, total input value, and the exact fee. -fn select_notes_with_fee<'a>( - shielded_state: &'a ShieldedWalletState, - amount: u64, - min_actions: usize, - platform_version: &PlatformVersion, -) -> Result< - ( - Vec<&'a crate::model::wallet::shielded::ShieldedNote>, - u64, - u64, - ), - TaskError, -> { - let mut fee_estimate = shielded_fee_for_actions(min_actions, platform_version); - - for _ in 0..5 { - let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?; - let num_actions = notes.len().max(min_actions); - let exact_fee = shielded_fee_for_actions(num_actions, platform_version); - - if total >= amount.saturating_add(exact_fee) { - return Ok((notes, total, exact_fee)); - } - - fee_estimate = exact_fee; - } - - // Final attempt with last computed fee - let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?; - let num_actions = notes.len().max(min_actions); - let exact_fee = shielded_fee_for_actions(num_actions, platform_version); - if total < amount.saturating_add(exact_fee) { - return Err(TaskError::ShieldedInsufficientBalance { - available: total, - required: amount.saturating_add(exact_fee), - }); - } - Ok((notes, total, exact_fee)) -} - -/// Select unspent notes to cover `amount + fee_headroom` using a greedy algorithm. -/// -/// The `fee_headroom` ensures selected inputs cover both the send amount -/// and the transition fee. Without it, sending the full balance fails -/// because the DPP builder adds fees on top of the selected amount. -/// -/// The `required` amount in error messages includes the fee so the user -/// understands the total cost. -fn select_notes_for_amount( - shielded_state: &ShieldedWalletState, - amount: u64, - fee_headroom: u64, -) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), TaskError> { - let unspent: Vec<_> = shielded_state.unspent_notes(); - - if unspent.is_empty() { - return Err(TaskError::ShieldedNoUnspentNotes); - } - - let required = amount.saturating_add(fee_headroom); - let total_available: u64 = unspent.iter().map(|n| n.value).sum(); - if total_available < required { - return Err(TaskError::ShieldedInsufficientBalance { - available: total_available, - required, - }); - } - - let mut sorted: Vec<_> = unspent; - sorted.sort_by(|a, b| b.value.cmp(&a.value)); - - let mut selected = Vec::new(); - let mut accumulated = 0u64; - - for note in sorted { - selected.push(note); - accumulated += note.value; - if accumulated >= required { - break; - } - } - - Ok((selected, accumulated)) -} - -/// Extract spendable notes with Merkle witnesses and the tree anchor. -/// -/// Locks the commitment tree, computes a Merkle path for each selected note, -/// and returns them alongside the current tree anchor for proof construction. -fn extract_spends_and_anchor( - tree: &MutexGuard<'_, ClientPersistentCommitmentTree>, - notes: &[&ShieldedNote], -) -> Result<(Vec<SpendableNote>, Anchor), TaskError> { - let spends = notes - .iter() - .map(|note| { - let merkle_path = tree - .witness(note.position, 0) - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })? - .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { - detail: "No Merkle path available for note".into(), - })?; - Ok(SpendableNote { - note: note.note, - merkle_path, - }) - }) - .collect::<Result<Vec<_>, TaskError>>()?; - - let anchor = tree - .anchor() - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })?; - Ok((spends, anchor)) -} - -/// Convert a PaymentAddress to an OrchardAddress for the builder functions. -fn payment_address_to_orchard(addr: &PaymentAddress) -> Result<OrchardAddress, TaskError> { - let raw = addr.to_raw_address_bytes(); - OrchardAddress::from_raw_bytes(&raw).map_err(|_| TaskError::ShieldedInvalidRecipientAddress) -} diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index 971d88363..66eba3b1b 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -1,61 +1,215 @@ -pub mod bundle; -pub mod nullifiers; -pub mod sync; - +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; -use dash_sdk::dpp::address_funds::PlatformAddress; +use crate::wallet_backend::PlatformPathIndex; +use dash_sdk::dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::dashcore::Address; +use std::sync::Arc; +/// The shielded-pool operations DET dispatches into the upstream +/// `platform-wallet` coordinator. +/// +/// Sync, nullifier scanning, key derivation and the commitment tree are all +/// owned by the upstream coordinator, so only the five fund-moving operations +/// remain here. Balance/notes/activity are read through the push snapshot and +/// the coordinator store, not through a task. #[derive(Debug, Clone, PartialEq)] pub enum ShieldedTask { - /// Initialize shielded state for a wallet (ZIP32 key derivation, load tree from DB) - InitializeShieldedWallet { seed_hash: WalletSeedHash }, - - /// Sync encrypted notes from platform (trial decrypt, update tree) - SyncNotes { seed_hash: WalletSeedHash }, + /// Shield core DASH directly into the shielded pool via asset lock (Type 18). + ShieldFromAssetLock { + seed_hash: WalletSeedHash, + amount_duffs: u64, + }, - /// Shield credits from platform address into the shielded pool (Type 15) - ShieldCredits { + /// Shield credits from the wallet's platform balance into the shielded + /// pool (Type 15). Upstream selects the input addresses; DET does not pick + /// a `from_address` or manage nonces. + ShieldFromBalance { seed_hash: WalletSeedHash, amount: u64, - from_address: PlatformAddress, - /// When set, use this nonce instead of reading from wallet (for batch parallel mode). - nonce_override: Option<u32>, }, - /// Private transfer within the shielded pool (Type 16) + /// Private transfer within the shielded pool (Type 16). ShieldedTransfer { seed_hash: WalletSeedHash, amount: u64, - /// Serialized Orchard PaymentAddress bytes + /// Serialized Orchard payment address (43 raw bytes). recipient_address_bytes: Vec<u8>, }, - /// Unshield credits to a platform address (Type 17) + /// Unshield credits to a platform address (Type 17). UnshieldCredits { seed_hash: WalletSeedHash, amount: u64, to_platform_address: PlatformAddress, }, - /// Check nullifiers to detect spent notes - CheckNullifiers { seed_hash: WalletSeedHash }, - - /// Shield core DASH directly into the shielded pool via asset lock (Type 18) - ShieldFromAssetLock { - seed_hash: WalletSeedHash, - amount_duffs: u64, - /// If set, restrict UTXO selection to this Core address. - source_address: Option<Address>, - }, - - /// Withdraw from the shielded pool directly to a core L1 address (Type 19) + /// Withdraw from the shielded pool directly to a core L1 address (Type 19). ShieldedWithdrawal { seed_hash: WalletSeedHash, amount: u64, to_core_address: Address, }, +} + +impl AppContext { + /// Run a shielded-pool task by forwarding to the upstream coordinator + /// through the [`WalletBackend`](crate::wallet_backend::WalletBackend) + /// shielded ops. Each op already maps upstream errors via + /// `map_shielded_op_error`, so this layer is a thin adapter: it shapes the + /// task payload into the backend call and refreshes the frame-safe balance + /// snapshot once the op confirms. + /// + /// Binding is handled at unlock time (`bootstrap_wallet_addresses_jit` → + /// `ensure_shielded_bound`); an operation on a still-unbound wallet surfaces + /// [`TaskError::ShieldedNotBound`]. + pub async fn run_shielded_task( + self: &Arc<Self>, + task: ShieldedTask, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let backend = self.wallet_backend()?; + match task { + ShieldedTask::ShieldFromAssetLock { + seed_hash, + amount_duffs, + } => { + use platform_wallet::wallet::asset_lock::AssetLockFunding; + + // The asset lock must also cover the shielded fee; upstream + // passes the whole lock value (minus that flat fee) to the + // recipient, so we size the lock to `amount + fee`. + let (platform_fee_duffs, _l1_fee_duffs) = + self.fee_estimator().estimate_shield_from_core_fees_duffs(); + let lock_amount_duffs = amount_duffs.saturating_add(platform_fee_duffs); + + // Deposit into this wallet's own default Orchard address. The + // keys are bound at unlock; an unbound wallet has no address. + let raw = backend + .shielded_default_address(&seed_hash, 0) + .await? + .ok_or(TaskError::ShieldedNotBound)?; + let recipient = OrchardAddress::from_raw_bytes(&raw) + .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; + + let funding = AssetLockFunding::FromWalletBalance { + amount_duffs: lock_amount_duffs, + account_index: 0, + }; + + backend + .shield_from_asset_lock(&seed_hash, funding, recipient, 0, None) + .await?; + + self.refresh_shielded_balance_snapshot(&seed_hash).await; + + let credits = amount_duffs.saturating_mul(CREDITS_PER_DUFF); + Ok(BackendTaskSuccessResult::ShieldedFromAssetLock { + seed_hash, + amount: credits, + }) + } + + ShieldedTask::ShieldFromBalance { seed_hash, amount } => { + // The upstream op selects the funding platform addresses; DET + // only supplies the wallet's address→path index for signing. + let path_index = self.platform_path_index(&seed_hash)?; + backend + .shield_from_balance(&seed_hash, &path_index, 0, 0, amount) + .await?; + + self.refresh_shielded_balance_snapshot(&seed_hash).await; + + Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) + } + + ShieldedTask::ShieldedTransfer { + seed_hash, + amount, + recipient_address_bytes, + } => { + let recipient_raw: [u8; 43] = recipient_address_bytes + .as_slice() + .try_into() + .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; + + backend + .shielded_transfer(&seed_hash, 0, &recipient_raw, amount, [0u8; 36]) + .await?; + + self.refresh_shielded_balance_snapshot(&seed_hash).await; + + Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount }) + } + + ShieldedTask::UnshieldCredits { + seed_hash, + amount, + to_platform_address, + } => { + let to_bech32m = to_platform_address.to_bech32m_string(self.network); + backend + .shielded_unshield(&seed_hash, 0, &to_bech32m, amount) + .await?; + + self.refresh_shielded_balance_snapshot(&seed_hash).await; + + Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) + } + + ShieldedTask::ShieldedWithdrawal { + seed_hash, + amount, + to_core_address, + } => { + let to_core = to_core_address.to_string(); + // `core_fee_per_byte = 1`: the historical DET default for + // shielded withdrawals; upstream prices the withdrawal document. + backend + .shielded_withdraw(&seed_hash, 0, &to_core, amount, 1) + .await?; + + self.refresh_shielded_balance_snapshot(&seed_hash).await; + + Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) + } + } + } + + /// Build the address→path index for `seed_hash`'s in-memory wallet, used to + /// sign platform-address spends in `shield_from_balance`. The read guard is + /// dropped before returning so no wallet lock is held across an await. + fn platform_path_index( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<PlatformPathIndex, TaskError> { + let wallet_arc = self.wallet_arc(seed_hash)?; + let wallet = wallet_arc.read()?; + Ok(PlatformPathIndex::from_wallet(&wallet, self.network)) + } - /// Warm up the proving key in background (~30s) - WarmUpProvingKey, + /// Refresh the frame-safe shielded balance snapshot for `seed_hash` from the + /// coordinator store after a confirmed operation. + /// + /// Keeps the UI balance current immediately after a spend, without waiting + /// for the next `on_shielded_sync_completed` push from the 60-second sync + /// loop. Best-effort — a failed read leaves the previous snapshot in place. + async fn refresh_shielded_balance_snapshot(self: &Arc<Self>, seed_hash: &WalletSeedHash) { + let backend = match self.wallet_backend() { + Ok(backend) => backend, + Err(_) => return, + }; + match backend.shielded_balances(seed_hash).await { + Ok(balances) => { + let total: u64 = balances.values().copied().sum(); + if let Ok(mut map) = self.shielded_balances.lock() { + map.insert(*seed_hash, total); + } + } + Err(error) => { + tracing::debug!(?error, "post-operation shielded balance refresh failed"); + } + } + } } diff --git a/src/backend_task/shielded/nullifiers.rs b/src/backend_task/shielded/nullifiers.rs deleted file mode 100644 index 611892b27..000000000 --- a/src/backend_task/shielded/nullifiers.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::shielded::ShieldedWalletState; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; -use std::sync::Arc; - -/// Check which unspent notes have been spent on-chain using the SDK's -/// privacy-preserving nullifier sync. -/// -/// The SDK handles full tree scan vs incremental catch-up internally based -/// on the provided `last_sync_height` and `last_sync_timestamp`. -pub async fn check_nullifiers( - app_context: &Arc<AppContext>, - seed_hash: &WalletSeedHash, - shielded_state: &mut ShieldedWalletState, - network: Network, -) -> Result<u32, TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let network_str = network.to_string(); - - // Collect unspent nullifier bytes for the provider - let unspent_nullifiers: Vec<[u8; 32]> = shielded_state - .notes - .iter() - .filter(|n| !n.is_spent) - .map(|n| n.nullifier.to_bytes()) - .collect(); - - if unspent_nullifiers.is_empty() { - return Ok(0); - } - - let last_height = shielded_state.last_nullifier_sync_height; - let last_timestamp = shielded_state.last_nullifier_sync_timestamp; - - let last_sync = if last_height > 0 || last_timestamp > 0 { - Some(NullifierSyncCheckpoint { - height: last_height, - timestamp: last_timestamp, - }) - } else { - None - }; - - let result = sdk - .sync_nullifiers(&unspent_nullifiers, None::<NullifierSyncConfig>, last_sync) - .await - .map_err(|e| TaskError::ShieldedNullifierSyncFailed { - detail: e.to_string(), - })?; - - // Mark found (spent) nullifiers - let mut spent_count = 0u32; - for nf_bytes in &result.found { - for note in &mut shielded_state.notes { - if !note.is_spent && note.nullifier.to_bytes() == *nf_bytes { - note.is_spent = true; - spent_count += 1; - let _ = app_context - .db - .mark_shielded_note_spent(seed_hash, nf_bytes, &network_str); - } - } - } - - // Persist sync height and timestamp - shielded_state.last_nullifier_sync_height = result.new_sync_height; - shielded_state.last_nullifier_sync_timestamp = result.new_sync_timestamp; - let _ = app_context.db.set_nullifier_sync_info( - seed_hash, - &network_str, - result.new_sync_height, - result.new_sync_timestamp, - ); - - if spent_count > 0 { - shielded_state.recalculate_balance(); - } - - Ok(spent_count) -} diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs deleted file mode 100644 index 96a512fc3..000000000 --- a/src/backend_task/shielded/sync.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::grovedb_commitment_tree::{Position, Retention}; -use dash_sdk::platform::shielded::sync_shielded_notes; -use std::sync::Arc; - -/// Server-enforced chunk size — start_index must be a multiple of this. -const CHUNK_SIZE: u64 = 2048; - -/// Sync encrypted notes from the platform using the SDK's parallel chunk fetcher. -/// -/// On resume the raw `last_synced_index` may fall mid-chunk; we round down to -/// the nearest chunk boundary and re-fetch that partial chunk, skipping any -/// positions already present in the local tree. -pub async fn sync_notes( - app_context: &Arc<AppContext>, - seed_hash: &WalletSeedHash, - shielded_state: &mut ShieldedWalletState, - network: Network, -) -> Result<(u32, u64), TaskError> { - let sdk = { app_context.sdk.load().as_ref().clone() }; - - let network_str = network.to_string(); - let prepared_ivk = shielded_state.keys.ivk.prepare(); - - // Round down to nearest chunk boundary so the server accepts the query. - let already_have = shielded_state.last_synced_index; - let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; - - tracing::info!( - "Starting shielded note sync: last_synced={}, aligned_start={}", - already_have, - aligned_start, - ); - - let result = sync_shielded_notes(&sdk, &prepared_ivk, aligned_start, None) - .await - .map_err(|e| TaskError::ShieldedSyncFailed { - detail: e.to_string(), - })?; - - tracing::info!( - "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", - result.total_notes_scanned, - result.decrypted_notes.len(), - result.next_start_index, - ); - - if result.next_start_index == 0 && result.total_notes_scanned > 0 { - tracing::warn!( - "Shielded sync: next_start_index is 0 after scanning {} notes — next sync will rescan everything from the beginning", - result.total_notes_scanned, - ); - } - - // Append notes to the local commitment tree, skipping positions already present. - // all_notes is ordered: aligned_start + i == global position of all_notes[i]. - let mut appended = 0u32; - for (i, raw_note) in result.all_notes.iter().enumerate() { - let global_pos = aligned_start + i as u64; - if global_pos < already_have { - continue; // already appended in a previous sync - } - - let cmx_bytes: [u8; 32] = - raw_note - .cmx - .as_slice() - .try_into() - .map_err(|_| TaskError::ShieldedSyncFailed { - detail: "Invalid cmx length".into(), - })?; - - let is_ours = result - .decrypted_notes - .iter() - .any(|dn| dn.position == global_pos); - let retention = if is_ours { - Retention::Marked - } else { - Retention::Ephemeral - }; - - shielded_state - .commitment_tree - .lock() - .unwrap() - .append(cmx_bytes, retention) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - - appended += 1; - } - - if appended > 0 { - let checkpoint_id = result.next_start_index as u32; - shielded_state - .commitment_tree - .lock() - .unwrap() - .checkpoint(checkpoint_id) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - } - - // Persist and record decrypted notes that are new (position >= already_have). - // Also skip notes already in memory (loaded from DB during init) to prevent - // double-counting when the commitment tree resets but persisted notes remain. - // Build a HashMap of position->value for O(1) lookups and divergence detection. - let existing_notes: std::collections::HashMap<u64, u64> = shielded_state - .notes - .iter() - .map(|n| (u64::from(n.position), n.note.value().inner())) - .collect(); - let mut new_note_count = 0u32; - for dn in result.decrypted_notes { - if dn.position < already_have { - continue; // already stored in a previous sync - } - if let Some(&existing_value) = existing_notes.get(&dn.position) { - let new_value = dn.note.value().inner(); - if new_value != existing_value { - tracing::warn!( - position = dn.position, - existing_value, - new_value, - "Shielded note dedup: value divergence at existing position" - ); - } - continue; // already loaded from DB during init - } - - // Compute the spending nullifier from our FVK (dn.nullifier is the rho/nf - // field from the compact action, not the spending nullifier). - let nullifier = dn.note.nullifier(&shielded_state.keys.fvk); - let value = dn.note.value().inner(); - - tracing::info!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); - - let note_data = crate::model::wallet::shielded::serialize_note(&dn.note); - let nullifier_bytes = nullifier.to_bytes(); - - let _ = app_context.db.insert_shielded_note( - seed_hash, - &crate::database::shielded::InsertShieldedNote { - note_data: &note_data, - position: dn.position, - cmx: &dn.cmx, - nullifier: &nullifier_bytes, - block_height: 0, - value, - network: &network_str, - }, - ); - - shielded_state.notes.push(ShieldedNote { - note: dn.note, - position: Position::from(dn.position), - cmx: dn.cmx, - nullifier, - block_height: 0, - is_spent: false, - value, - }); - - new_note_count += 1; - } - - // Store the actual number of notes seen, not the chunk-rounded next_start_index. - // The SDK rounds next_start_index UP to the next 2048 boundary, which would - // make the display show 2048 even when only e.g. 150 notes exist. - // On the next sync we round down again, re-fetch the partial chunk, and skip - // positions already in the tree — so correctness is maintained. - shielded_state.last_synced_index = aligned_start + result.total_notes_scanned; - shielded_state.recalculate_balance(); - - let notes_total = shielded_state.notes.len(); - let notes_unspent = shielded_state.unspent_notes().len(); - - tracing::info!( - "Shielded sync finished: {} new note(s), total notes={} (unspent={}), spendable balance: {} ({} credits)", - new_note_count, - notes_total, - notes_unspent, - format_credits_as_dash(shielded_state.shielded_balance), - shielded_state.shielded_balance, - ); - - Ok((new_note_count, shielded_state.shielded_balance)) -} diff --git a/src/backend_task/system_task/mod.rs b/src/backend_task/system_task/mod.rs index cfef89c32..0d47bfb62 100644 --- a/src/backend_task/system_task/mod.rs +++ b/src/backend_task/system_task/mod.rs @@ -20,32 +20,33 @@ impl AppContext { match task { SystemTask::WipePlatformData => self.wipe_devnet(), SystemTask::UpdateThemePreference(theme_mode) => { - self.update_theme_preference(theme_mode) + self.handle_update_theme_preference(theme_mode) } } } pub fn wipe_devnet(self: &Arc<Self>) -> Result<BackendTaskSuccessResult, TaskError> { - self.db - .delete_all_local_qualified_identities_in_devnet(self)?; + self.delete_all_local_qualified_identities_in_devnet()?; + self.delete_all_local_tokens_in_devnet()?; - self.db.delete_all_local_tokens_in_devnet(self)?; + // Asset-lock state lives in the upstream `AssetLockManager`; the + // legacy `asset_lock_transaction` DET table and its module were + // deleted, so there is no DET-side mirror to clear here. - self.db - .remove_all_asset_locks_identity_id_for_devnet(self)?; - - self.db.remove_all_contracts_in_devnet(self)?; + self.clear_user_contracts()?; Ok(BackendTaskSuccessResult::Refresh) } - pub fn update_theme_preference( + /// Backend-task handler for `SystemTask::UpdateThemePreference`. + /// Wraps [`AppContext::update_theme_preference`] (the k/v writer) in + /// the `BackendTaskSuccessResult` envelope the dispatcher expects. + pub fn handle_update_theme_preference( self: &Arc<Self>, theme_mode: ThemeMode, ) -> Result<BackendTaskSuccessResult, TaskError> { - let _guard = self.invalidate_settings_cache(); - - self.db.update_theme_preference(theme_mode)?; + self.update_theme_preference(theme_mode) + .map_err(|source| TaskError::AppSettingsWrite { source })?; Ok(BackendTaskSuccessResult::UpdatedThemePreference(theme_mode)) } diff --git a/src/backend_task/tokens/burn_tokens.rs b/src/backend_task/tokens/burn_tokens.rs index 636ea91d0..ff3aa36d1 100644 --- a/src/backend_task/tokens/burn_tokens.rs +++ b/src/backend_task/tokens/burn_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::document::DocumentV0Getters; @@ -50,84 +50,83 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_burn(builder, &signing_key, owner_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Using the result, update the balance of the owner identity - if let Some(token_id) = data_contract.token_id(token_position) { - match result { - // Standard burn result - direct balance update - BurnResult::TokenBalance(identity_id, amount) => { - if let Err(e) = - self.insert_token_identity_balance(&token_id, &identity_id, amount) - { - tracing::error!("Failed to update token balance: {}", e); - } - } + self.execute_token_op( + async { + sdk.token_burn(builder, &signing_key, owner_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| { + // Using the result, update the balance of the owner identity + if let Some(token_id) = data_contract.token_id(token_position) { + match result { + // Standard burn result - direct balance update + BurnResult::TokenBalance(identity_id, amount) => { + if let Err(e) = + self.insert_token_identity_balance(&token_id, &identity_id, amount) + { + tracing::error!("Failed to update token balance: {}", e); + } + } - // Historical document - extract owner and amount from document - BurnResult::HistoricalDocument(document) => { - if let (Some(owner_value), Some(amount_value)) = - (document.get("ownerId"), document.get("amount")) - && let (Value::Identifier(owner_bytes), Value::U64(amount)) = - (owner_value, amount_value) - && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &owner_id, *amount) - { - tracing::error!( - "Failed to update token balance from historical document: {}", - e - ); - } - } + // Historical document - extract owner and amount from document + BurnResult::HistoricalDocument(document) => { + if let (Some(owner_value), Some(amount_value)) = + (document.get("ownerId"), document.get("amount")) + && let (Value::Identifier(owner_bytes), Value::U64(amount)) = + (owner_value, amount_value) + && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) + && let Err(e) = self + .insert_token_identity_balance(&token_id, &owner_id, *amount) + { + tracing::error!( + "Failed to update token balance from historical document: {}", + e + ); + } + } - // Group action with document - assume completed if document exists - BurnResult::GroupActionWithDocument(_, Some(document)) => { - if let (Some(owner_value), Some(amount_value)) = - (document.get("ownerId"), document.get("amount")) - && let (Value::Identifier(owner_bytes), Value::U64(amount)) = - (owner_value, amount_value) - && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &owner_id, *amount) - { - tracing::error!( - "Failed to update token balance from group action document: {}", - e - ); - } - } + // Group action with document - assume completed if document exists + BurnResult::GroupActionWithDocument(_, Some(document)) => { + if let (Some(owner_value), Some(amount_value)) = + (document.get("ownerId"), document.get("amount")) + && let (Value::Identifier(owner_bytes), Value::U64(amount)) = + (owner_value, amount_value) + && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) + && let Err(e) = self + .insert_token_identity_balance(&token_id, &owner_id, *amount) + { + tracing::error!( + "Failed to update token balance from group action document: {}", + e + ); + } + } - // Group action with balance - only update if action is closed - BurnResult::GroupActionWithBalance(_, status, Some(amount)) => { - if matches!(status, GroupActionStatus::ActionClosed) { - let owner_id = owner_identity.identity.id(); - if let Err(e) = - self.insert_token_identity_balance(&token_id, &owner_id, amount) - { - tracing::error!( - "Failed to update token balance from group action: {}", - e - ); + // Group action with balance - only update if action is closed + BurnResult::GroupActionWithBalance(_, status, Some(amount)) => { + if matches!(status, GroupActionStatus::ActionClosed) { + let owner_id = owner_identity.identity.id(); + if let Err(e) = + self.insert_token_identity_balance(&token_id, &owner_id, amount) + { + tracing::error!( + "Failed to update token balance from group action: {}", + e + ); + } + } } + + // Other variants don't require balance updates + _ => {} } } - - // Other variants don't require balance updates - _ => {} - } - } - - // Return success with fee result - // For token operations, we use the estimated fee as a placeholder - // TODO: Add proper fee tracking when SDK provides this information - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::BurnedTokens(fee_result)) + }, + BackendTaskSuccessResult::BurnedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/claim_tokens.rs b/src/backend_task/tokens/claim_tokens.rs index ca5542630..f9df054df 100644 --- a/src/backend_task/tokens/claim_tokens.rs +++ b/src/backend_task/tokens/claim_tokens.rs @@ -1,8 +1,8 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; @@ -43,55 +43,62 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_claim(builder, &signing_key, actor_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; + self.execute_token_op( + async { + sdk.token_claim(builder, &signing_key, actor_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| { + // Using the result, update the balance of the claimer identity + if let Some(token_id) = data_contract.token_id(token_position) { + match result { + // Standard claim result - extract claimer and amount from document + ClaimResult::Document(document) => { + if let (Some(claimer_value), Some(amount_value)) = + (document.get("claimerId"), document.get("amount")) + && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = + (claimer_value, amount_value) + && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &claimer_id, + *amount, + ) + { + tracing::error!( + "Failed to update token balance from claim document: {}", + e + ); + } + } - // Using the result, update the balance of the claimer identity - if let Some(token_id) = data_contract.token_id(token_position) { - match result { - // Standard claim result - extract claimer and amount from document - ClaimResult::Document(document) => { - if let (Some(claimer_value), Some(amount_value)) = - (document.get("claimerId"), document.get("amount")) - && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = - (claimer_value, amount_value) - && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &claimer_id, *amount) - { - tracing::error!( - "Failed to update token balance from claim document: {}", - e - ); + // Group action with document - assume completed if document exists + ClaimResult::GroupActionWithDocument(_, document) => { + if let (Some(claimer_value), Some(amount_value)) = + (document.get("claimerId"), document.get("amount")) + && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = + (claimer_value, amount_value) + && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &claimer_id, + *amount, + ) + { + tracing::error!( + "Failed to update token balance from group action document: {}", + e + ); + } + } } } - - // Group action with document - assume completed if document exists - ClaimResult::GroupActionWithDocument(_, document) => { - if let (Some(claimer_value), Some(amount_value)) = - (document.get("claimerId"), document.get("amount")) - && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = - (claimer_value, amount_value) - && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &claimer_id, *amount) - { - tracing::error!( - "Failed to update token balance from group action document: {}", - e - ); - } - } - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::ClaimedTokens(fee_result)) + }, + BackendTaskSuccessResult::ClaimedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/destroy_frozen_funds.rs b/src/backend_task/tokens/destroy_frozen_funds.rs index d9400fbde..d53905480 100644 --- a/src/backend_task/tokens/destroy_frozen_funds.rs +++ b/src/backend_task/tokens/destroy_frozen_funds.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -46,25 +46,22 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log proof result for audit trail - tracing::info!("DestroyFrozenFunds proof result: {}", proof_result); - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result)) + self.execute_token_op( + async { + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version()) + .await + .map_err(TaskError::from)?; + state_transition + .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |proof_result| tracing::info!("DestroyFrozenFunds proof result: {}", proof_result), + BackendTaskSuccessResult::DestroyedFrozenFunds, + ) + .await } } diff --git a/src/backend_task/tokens/freeze_tokens.rs b/src/backend_task/tokens/freeze_tokens.rs index 8c8578341..44e484039 100644 --- a/src/backend_task/tokens/freeze_tokens.rs +++ b/src/backend_task/tokens/freeze_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; @@ -47,44 +47,42 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_freeze(builder, &signing_key, actor_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log the proof-verified freeze result - match result { - FreezeResult::IdentityInfo(identity_id, info) => { - tracing::info!( - "FreezeTokens: identity {} frozen={}", - identity_id, - info.frozen() - ); - } - FreezeResult::HistoricalDocument(document) => { - tracing::info!("FreezeTokens: historical document id={}", document.id()); - } - FreezeResult::GroupActionWithDocument(power, doc) => { - tracing::info!( - "FreezeTokens: group action power={}, has_doc={}", - power, - doc.is_some() - ); - } - FreezeResult::GroupActionWithIdentityInfo(power, info) => { - tracing::info!( - "FreezeTokens: group action power={}, frozen={}", - power, - info.frozen() - ); - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::FrozeTokens(fee_result)) + self.execute_token_op( + async { + sdk.token_freeze(builder, &signing_key, actor_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| match result { + FreezeResult::IdentityInfo(identity_id, info) => { + tracing::info!( + "FreezeTokens: identity {} frozen={}", + identity_id, + info.frozen() + ); + } + FreezeResult::HistoricalDocument(document) => { + tracing::info!("FreezeTokens: historical document id={}", document.id()); + } + FreezeResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "FreezeTokens: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + FreezeResult::GroupActionWithIdentityInfo(power, info) => { + tracing::info!( + "FreezeTokens: group action power={}, frozen={}", + power, + info.frozen() + ); + } + }, + BackendTaskSuccessResult::FrozeTokens, + ) + .await } } diff --git a/src/backend_task/tokens/mint_tokens.rs b/src/backend_task/tokens/mint_tokens.rs index 8cdc44355..7fa3f77d2 100644 --- a/src/backend_task/tokens/mint_tokens.rs +++ b/src/backend_task/tokens/mint_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::document::DocumentV0Getters; @@ -57,84 +57,93 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_mint(builder, &signing_key, sending_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Using the result, update the balance of the recipient identity - if let Some(token_id) = data_contract.token_id(token_position) { - match result { - // Standard mint result - direct balance update - MintResult::TokenBalance(identity_id, amount) => { - if let Err(e) = - self.insert_token_identity_balance(&token_id, &identity_id, amount) - { - tracing::error!("Failed to update token balance: {}", e); - } - } + self.execute_token_op( + async { + sdk.token_mint(builder, &signing_key, sending_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| { + // Using the result, update the balance of the recipient identity + if let Some(token_id) = data_contract.token_id(token_position) { + match result { + // Standard mint result - direct balance update + MintResult::TokenBalance(identity_id, amount) => { + if let Err(e) = + self.insert_token_identity_balance(&token_id, &identity_id, amount) + { + tracing::error!("Failed to update token balance: {}", e); + } + } - // Historical document - extract recipient and amount from document - MintResult::HistoricalDocument(document) => { - if let (Some(recipient_value), Some(amount_value)) = - (document.get("recipientId"), document.get("amount")) - && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = - (recipient_value, amount_value) - && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &recipient_id, *amount) - { - tracing::error!( - "Failed to update token balance from historical document: {}", - e - ); - } - } + // Historical document - extract recipient and amount from document + MintResult::HistoricalDocument(document) => { + if let (Some(recipient_value), Some(amount_value)) = + (document.get("recipientId"), document.get("amount")) + && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = + (recipient_value, amount_value) + && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &recipient_id, + *amount, + ) + { + tracing::error!( + "Failed to update token balance from historical document: {}", + e + ); + } + } - // Group action with document - assume completed if document exists - MintResult::GroupActionWithDocument(_, Some(document)) => { - if let (Some(recipient_value), Some(amount_value)) = - (document.get("recipientId"), document.get("amount")) - && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = - (recipient_value, amount_value) - && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &recipient_id, *amount) - { - tracing::error!( - "Failed to update token balance from group action document: {}", - e - ); - } - } + // Group action with document - assume completed if document exists + MintResult::GroupActionWithDocument(_, Some(document)) => { + if let (Some(recipient_value), Some(amount_value)) = + (document.get("recipientId"), document.get("amount")) + && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = + (recipient_value, amount_value) + && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &recipient_id, + *amount, + ) + { + tracing::error!( + "Failed to update token balance from group action document: {}", + e + ); + } + } - // Group action with balance - only update if action is closed - MintResult::GroupActionWithBalance(_, status, Some(amount)) => { - if matches!(status, GroupActionStatus::ActionClosed) { - // Get the recipient identity (either optional_recipient or sending_identity) - let recipient_id = - optional_recipient.unwrap_or_else(|| sending_identity.identity.id()); - if let Err(e) = - self.insert_token_identity_balance(&token_id, &recipient_id, amount) - { - tracing::error!( - "Failed to update token balance from group action: {}", - e - ); + // Group action with balance - only update if action is closed + MintResult::GroupActionWithBalance(_, status, Some(amount)) => { + if matches!(status, GroupActionStatus::ActionClosed) { + // Get the recipient identity (either optional_recipient or sending_identity) + let recipient_id = optional_recipient + .unwrap_or_else(|| sending_identity.identity.id()); + if let Err(e) = self.insert_token_identity_balance( + &token_id, + &recipient_id, + amount, + ) { + tracing::error!( + "Failed to update token balance from group action: {}", + e + ); + } + } } + + // Other variants don't require balance updates + _ => {} } } - - // Other variants don't require balance updates - _ => {} - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::MintedTokens(fee_result)) + }, + BackendTaskSuccessResult::MintedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index 50073dbe1..f1288fce4 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -1,4 +1,4 @@ -use super::BackendTaskSuccessResult; +use super::{BackendTaskSuccessResult, FeeResult}; use crate::backend_task::error::TaskError; use crate::ui::tokens::tokens_screen::{IdentityTokenIdentifier, IdentityTokenInfo, TokenInfo}; use crate::{app::TaskResult, context::AppContext, model::qualified_identity::QualifiedIdentity}; @@ -43,6 +43,7 @@ use dash_sdk::{ proto::get_documents_request::get_documents_request_v0::Start, }, }; +use std::future::Future; use std::{collections::BTreeMap, sync::Arc}; mod burn_tokens; @@ -62,45 +63,54 @@ mod transfer_tokens; mod unfreeze_tokens; mod update_token_config; +/// All token-configuration inputs for +/// [`AppContext::build_data_contract_v1_with_one_token`], grouped to keep the +/// builder and its task variant from carrying two dozen positional fields. +#[derive(Debug, Clone, PartialEq)] +pub struct TokenContractParams { + pub token_names: Vec<(String, String, String)>, + pub contract_keywords: Vec<String>, + pub token_description: Option<String>, + pub should_capitalize: bool, + pub decimals: u8, + pub base_supply: TokenAmount, + pub max_supply: Option<TokenAmount>, + pub start_paused: bool, + pub allow_transfers_to_frozen_identities: bool, + pub keeps_history: TokenKeepsHistoryRules, + pub main_control_group: Option<GroupContractPosition>, + + pub manual_minting_rules: ChangeControlRules, + pub manual_burning_rules: ChangeControlRules, + pub freeze_rules: ChangeControlRules, + pub unfreeze_rules: ChangeControlRules, + pub destroy_frozen_funds_rules: ChangeControlRules, + pub emergency_action_rules: ChangeControlRules, + pub max_supply_change_rules: ChangeControlRules, + pub conventions_change_rules: ChangeControlRules, + + pub main_control_group_change_authorized: AuthorizedActionTakers, + + pub distribution_rules: TokenDistributionRules, + pub groups: BTreeMap<GroupContractPosition, Group>, + pub document_schemas: Option<BTreeMap<String, serde_json::Value>>, + pub marketplace_rules: ChangeControlRules, +} + #[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum TokenTask { RegisterTokenContract { identity: QualifiedIdentity, signing_key: Box<IdentityPublicKey>, - token_names: Vec<(String, String, String)>, - contract_keywords: Vec<String>, - token_description: Option<String>, - should_capitalize: bool, - decimals: u8, - base_supply: TokenAmount, - max_supply: Option<TokenAmount>, - start_paused: bool, - allow_transfers_to_frozen_identities: bool, - keeps_history: TokenKeepsHistoryRules, - main_control_group: Option<GroupContractPosition>, - - // Manual Mint - manual_minting_rules: ChangeControlRules, - manual_burning_rules: ChangeControlRules, - freeze_rules: ChangeControlRules, - unfreeze_rules: Box<ChangeControlRules>, - destroy_frozen_funds_rules: Box<ChangeControlRules>, - emergency_action_rules: Box<ChangeControlRules>, - max_supply_change_rules: Box<ChangeControlRules>, - conventions_change_rules: Box<ChangeControlRules>, - - // Main Control Group Change - main_control_group_change_authorized: AuthorizedActionTakers, - - distribution_rules: TokenDistributionRules, - groups: BTreeMap<GroupContractPosition, Group>, - document_schemas: Option<BTreeMap<String, serde_json::Value>>, - marketplace_trade_mode: u8, - marketplace_rules: ChangeControlRules, + params: Box<TokenContractParams>, }, 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. + StopTrackingTokenBalance(IdentityTokenIdentifier), QueryDescriptionsByKeyword(String, Option<Start>), FetchTokenByContractId(Identifier), FetchTokenByTokenId(Identifier), @@ -216,78 +226,46 @@ pub enum TokenTask { } impl AppContext { + /// Awaits a token state-transition SDK call, applies its post-broadcast + /// side effects (balance updates, audit logging), and returns the success + /// result carrying an estimated-only fee. + /// + /// The platform does not report a settled fee for token ops, so the fee is + /// always the pre-flight estimate (see [`FeeResult::estimated_only`]). + async fn execute_token_op<R>( + &self, + call: impl Future<Output = Result<R, TaskError>>, + post_broadcast: impl FnOnce(R), + make_success: fn(FeeResult) -> BackendTaskSuccessResult, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let result = call.await?; + post_broadcast(result); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + Ok(make_success(FeeResult::estimated_only(estimated_fee))) + } + pub async fn run_token_task( self: &Arc<Self>, task: TokenTask, sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { - match &task { + match task { TokenTask::RegisterTokenContract { identity, signing_key, - token_names, - contract_keywords, - token_description, - should_capitalize, - decimals, - base_supply, - max_supply, - start_paused, - allow_transfers_to_frozen_identities, - keeps_history, - main_control_group, - manual_minting_rules, - manual_burning_rules, - freeze_rules, - unfreeze_rules, - destroy_frozen_funds_rules, - emergency_action_rules, - max_supply_change_rules, - conventions_change_rules, - main_control_group_change_authorized, - distribution_rules, - groups, - document_schemas, - marketplace_trade_mode, - marketplace_rules, + params, } => { + let alias = params.token_names[0].0.clone(); let data_contract = self - .build_data_contract_v1_with_one_token( - identity.identity.id(), - token_names.clone(), - contract_keywords.to_vec(), - token_description.clone(), - *should_capitalize, - *decimals, - *base_supply, - *max_supply, - *start_paused, - *allow_transfers_to_frozen_identities, - *keeps_history, - *main_control_group, - manual_minting_rules.clone(), - manual_burning_rules.clone(), - freeze_rules.clone(), - unfreeze_rules.as_ref().clone(), - destroy_frozen_funds_rules.as_ref().clone(), - emergency_action_rules.as_ref().clone(), - max_supply_change_rules.as_ref().clone(), - conventions_change_rules.as_ref().clone(), - *main_control_group_change_authorized, - distribution_rules.clone(), - groups.clone(), - document_schemas.clone(), - *marketplace_trade_mode, - marketplace_rules.clone(), - ) + .build_data_contract_v1_with_one_token(identity.identity.id(), *params) .map_err(|e| TaskError::from(dash_sdk::Error::Protocol(e)))?; self.register_data_contract( data_contract, - token_names[0].0.clone(), - identity.clone(), - signing_key.as_ref().clone(), + alias, + identity, + *signing_key, sdk, sender, ) @@ -306,21 +284,21 @@ impl AppContext { group_info, } => { self.mint_tokens( - sending_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *amount, - *recipient_id, - *group_info, + &sending_identity, + data_contract, + token_position, + signing_key, + public_note, + amount, + recipient_id, + group_info, sdk, sender, ) .await } TokenTask::QueryDescriptionsByKeyword(keyword, cursor) => { - self.query_descriptions_by_keyword(keyword, cursor, sdk) + self.query_descriptions_by_keyword(&keyword, &cursor, sdk) .await } TokenTask::TransferTokens { @@ -333,13 +311,13 @@ impl AppContext { public_note, } => { self.transfer_tokens( - sending_identity, - *recipient_id, - *amount, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), + &sending_identity, + recipient_id, + amount, + data_contract, + token_position, + signing_key, + public_note, sdk, sender, ) @@ -355,13 +333,13 @@ impl AppContext { group_info, } => { self.burn_tokens( - owner_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *amount, - *group_info, + &owner_identity, + data_contract, + token_position, + signing_key, + public_note, + amount, + group_info, sdk, sender, ) @@ -377,13 +355,13 @@ impl AppContext { group_info, } => { self.destroy_frozen_funds( - actor_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *frozen_identity, - *group_info, + &actor_identity, + data_contract, + token_position, + signing_key, + public_note, + frozen_identity, + group_info, sdk, sender, ) @@ -399,13 +377,13 @@ impl AppContext { group_info, } => { self.freeze_tokens( - actor_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *freeze_identity, - *group_info, + &actor_identity, + data_contract, + token_position, + signing_key, + public_note, + freeze_identity, + group_info, sdk, sender, ) @@ -421,13 +399,13 @@ impl AppContext { group_info, } => { self.unfreeze_tokens( - actor_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *unfreeze_identity, - *group_info, + &actor_identity, + data_contract, + token_position, + signing_key, + public_note, + unfreeze_identity, + group_info, sdk, sender, ) @@ -442,12 +420,12 @@ impl AppContext { group_info, } => { self.pause_tokens( - actor_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *group_info, + &actor_identity, + data_contract, + token_position, + signing_key, + public_note, + group_info, sdk, sender, ) @@ -462,12 +440,12 @@ impl AppContext { group_info, } => { self.resume_tokens( - actor_identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - *group_info, + &actor_identity, + data_contract, + token_position, + signing_key, + public_note, + group_info, sdk, sender, ) @@ -482,12 +460,12 @@ impl AppContext { public_note, } => { self.claim_tokens( - data_contract.clone(), - *token_position, - actor_identity, - *distribution_type, - signing_key.clone(), - public_note.clone(), + data_contract, + token_position, + &actor_identity, + distribution_type, + signing_key, + public_note, sdk, ) .await @@ -497,8 +475,8 @@ impl AppContext { token_id, } => { self.query_token_non_claimed_perpetual_distribution_rewards_with_explanation( - *identity_id, - *token_id, + identity_id, + token_id, sdk, ) .await @@ -512,8 +490,16 @@ impl AppContext { ) .await } + TokenTask::StopTrackingTokenBalance(identity_token_pair) => { + self.stop_tracking_token_balance( + identity_token_pair.identity_id, + identity_token_pair.token_id, + sender, + ) + .await + } TokenTask::FetchTokenByContractId(contract_id) => { - match DataContract::fetch_by_identifier(sdk, *contract_id).await { + match DataContract::fetch_by_identifier(sdk, contract_id).await { Ok(Some(data_contract)) => { Ok(BackendTaskSuccessResult::FetchedContract(data_contract)) } @@ -525,7 +511,7 @@ impl AppContext { use dash_sdk::dpp::tokens::contract_info::TokenContractInfo; use dash_sdk::dpp::tokens::contract_info::v0::TokenContractInfoV0Accessors; - match TokenContractInfo::fetch(sdk, *token_id).await { + match TokenContractInfo::fetch(sdk, token_id).await { Ok(Some(token_contract_info)) => { // Extract the contract ID and token position from token_contract_info let (contract_id, token_position) = match &token_contract_info { @@ -552,21 +538,12 @@ impl AppContext { } } TokenTask::SaveTokenLocally(token_info) => { - let token_config_bytes = bincode::encode_to_vec( - &token_info.token_configuration, - bincode::config::standard(), - ) - .map_err(|e| TaskError::SerializationError { - detail: e.to_string(), - })?; - - self.db.insert_token( + self.insert_token( &token_info.token_id, &token_info.token_name, - &token_config_bytes, + token_info.token_configuration, &token_info.data_contract_id, token_info.token_position, - self, )?; Ok(BackendTaskSuccessResult::SavedToken) @@ -579,11 +556,11 @@ impl AppContext { group_info, } => { self.update_token_config( - *identity_token_info.clone(), - change_item.clone(), - signing_key, - public_note.clone(), - *group_info, + *identity_token_info, + change_item, + &signing_key, + public_note, + group_info, sdk, ) .await @@ -597,12 +574,12 @@ impl AppContext { total_agreed_price, } => { self.purchase_tokens( - identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - *amount, - *total_agreed_price, + &identity, + data_contract, + token_position, + signing_key, + amount, + total_agreed_price, sdk, sender, ) @@ -618,20 +595,20 @@ impl AppContext { group_info, } => { self.set_direct_purchase_price( - identity, - data_contract.clone(), - *token_position, - signing_key.clone(), - public_note.clone(), - token_pricing_schedule.clone(), - *group_info, + &identity, + data_contract, + token_position, + signing_key, + public_note, + token_pricing_schedule, + group_info, sdk, sender, ) .await } TokenTask::QueryTokenPricing(token_id) => { - self.query_token_pricing(*token_id, sdk, sender).await + self.query_token_pricing(token_id, sdk, sender).await } } } @@ -642,37 +619,39 @@ impl AppContext { /// - the specified owner_id /// - an empty set of documents, groups, schema_defs /// - a single token in tokens[0] with fields derived from your parameters. - #[allow(clippy::too_many_arguments)] #[allow(clippy::result_large_err)] pub fn build_data_contract_v1_with_one_token( &self, owner_id: Identifier, - token_names: Vec<(String, String, String)>, - contract_keywords: Vec<String>, - token_description: Option<String>, - should_capitalize: bool, - decimals: u8, - base_supply: u64, - max_supply: Option<u64>, - start_as_paused: bool, - allow_transfer_to_frozen_balance: bool, - keeps_history: TokenKeepsHistoryRules, - main_control_group: Option<u16>, - manual_minting_rules: ChangeControlRules, - manual_burning_rules: ChangeControlRules, - freeze_rules: ChangeControlRules, - unfreeze_rules: ChangeControlRules, - destroy_frozen_funds_rules: ChangeControlRules, - emergency_action_rules: ChangeControlRules, - max_supply_change_rules: ChangeControlRules, - conventions_change_rules: ChangeControlRules, - main_control_group_change_authorized: AuthorizedActionTakers, - distribution_rules: TokenDistributionRules, - groups: BTreeMap<u16, Group>, - document_schemas: Option<BTreeMap<String, serde_json::Value>>, - marketplace_trade_mode: u8, - marketplace_rules: ChangeControlRules, + params: TokenContractParams, ) -> Result<DataContract, ProtocolError> { + let TokenContractParams { + token_names, + contract_keywords, + token_description, + should_capitalize, + decimals, + base_supply, + max_supply, + start_paused: start_as_paused, + allow_transfers_to_frozen_identities: allow_transfer_to_frozen_balance, + keeps_history, + main_control_group, + manual_minting_rules, + manual_burning_rules, + freeze_rules, + unfreeze_rules, + destroy_frozen_funds_rules, + emergency_action_rules, + max_supply_change_rules, + conventions_change_rules, + main_control_group_change_authorized, + distribution_rules, + groups, + document_schemas, + marketplace_rules, + } = params; + // 1) Create the V1 struct first to get the contract ID let contract_id = Identifier::random(); let mut contract_v1 = DataContractV1 { @@ -762,15 +741,9 @@ impl AppContext { token_config_v0.distribution_rules = distribution_rules; token_config_v0.description = token_description; - // Set marketplace rules - // Map the u8 value to TokenTradeMode (0 = NotTradeable) - let trade_mode = match marketplace_trade_mode { - 0 => TokenTradeMode::NotTradeable, - _ => TokenTradeMode::NotTradeable, // Default to NotTradeable for any unknown value - }; - + // All tokens are created NotTradeable; a future SDK will add more modes. token_config_v0.marketplace_rules = TokenMarketplaceRules::V0(TokenMarketplaceRulesV0 { - trade_mode, + trade_mode: TokenTradeMode::NotTradeable, trade_mode_change_rules: marketplace_rules, }); diff --git a/src/backend_task/tokens/pause_tokens.rs b/src/backend_task/tokens/pause_tokens.rs index e923b8f2c..812e86282 100644 --- a/src/backend_task/tokens/pause_tokens.rs +++ b/src/backend_task/tokens/pause_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -45,25 +45,22 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log proof result for audit trail - tracing::info!("PauseTokens proof result: {}", proof_result); - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::PausedTokens(fee_result)) + self.execute_token_op( + async { + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version()) + .await + .map_err(TaskError::from)?; + state_transition + .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |proof_result| tracing::info!("PauseTokens proof result: {}", proof_result), + BackendTaskSuccessResult::PausedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/purchase_tokens.rs b/src/backend_task/tokens/purchase_tokens.rs index 6346f75e7..0648803d3 100644 --- a/src/backend_task/tokens/purchase_tokens.rs +++ b/src/backend_task/tokens/purchase_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::balances::credits::TokenAmount; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -41,80 +41,93 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_purchase(builder, &signing_key, sending_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; + self.execute_token_op( + async { + sdk.token_purchase(builder, &signing_key, sending_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| { + // Update token balance from the proof-verified result + if let Some(token_id) = data_contract.token_id(token_position) { + match result { + // Standard purchase result - update purchaser's balance + DirectPurchaseResult::TokenBalance(identity_id, balance) => { + tracing::info!( + "PurchaseTokens: identity {} new balance {}", + identity_id, + balance + ); + if let Err(e) = + self.insert_token_identity_balance(&token_id, &identity_id, balance) + { + tracing::warn!("Failed to update token balance: {}", e); + } + } - // Update token balance from the proof-verified result - if let Some(token_id) = data_contract.token_id(token_position) { - match result { - // Standard purchase result - update purchaser's balance - DirectPurchaseResult::TokenBalance(identity_id, balance) => { - tracing::info!( - "PurchaseTokens: identity {} new balance {}", - identity_id, - balance - ); - if let Err(e) = - self.insert_token_identity_balance(&token_id, &identity_id, balance) - { - tracing::warn!("Failed to update token balance: {}", e); - } - } + // Historical document - extract purchaser and balance from document + DirectPurchaseResult::HistoricalDocument(document) => { + tracing::info!( + "PurchaseTokens: historical document id={}", + document.id() + ); + if let (Some(purchaser_value), Some(balance_value)) = + (document.get("purchaserId"), document.get("balance")) + && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = + (purchaser_value, balance_value) + && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &purchaser_id, + *balance, + ) + { + tracing::warn!( + "Failed to update token balance from historical document: {}", + e + ); + } + } - // Historical document - extract purchaser and balance from document - DirectPurchaseResult::HistoricalDocument(document) => { - tracing::info!("PurchaseTokens: historical document id={}", document.id()); - if let (Some(purchaser_value), Some(balance_value)) = - (document.get("purchaserId"), document.get("balance")) - && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = - (purchaser_value, balance_value) - && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &purchaser_id, *balance) - { - tracing::warn!( - "Failed to update token balance from historical document: {}", - e - ); - } - } + // Group action with document + DirectPurchaseResult::GroupActionWithDocument(power, Some(document)) => { + tracing::info!( + "PurchaseTokens: group action power={}, doc_id={}", + power, + document.id() + ); + if let (Some(purchaser_value), Some(balance_value)) = + (document.get("purchaserId"), document.get("balance")) + && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = + (purchaser_value, balance_value) + && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) + && let Err(e) = self.insert_token_identity_balance( + &token_id, + &purchaser_id, + *balance, + ) + { + tracing::warn!( + "Failed to update token balance from group action document: {}", + e + ); + } + } - // Group action with document - DirectPurchaseResult::GroupActionWithDocument(power, Some(document)) => { - tracing::info!( - "PurchaseTokens: group action power={}, doc_id={}", - power, - document.id() - ); - if let (Some(purchaser_value), Some(balance_value)) = - (document.get("purchaserId"), document.get("balance")) - && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = - (purchaser_value, balance_value) - && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) - && let Err(e) = - self.insert_token_identity_balance(&token_id, &purchaser_id, *balance) - { - tracing::warn!( - "Failed to update token balance from group action document: {}", - e - ); + // Group action without document - no balance to update + DirectPurchaseResult::GroupActionWithDocument(power, None) => { + tracing::info!( + "PurchaseTokens: group action power={}, no document", + power + ); + } } } - - // Group action without document - no balance to update - DirectPurchaseResult::GroupActionWithDocument(power, None) => { - tracing::info!("PurchaseTokens: group action power={}, no document", power); - } - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::PurchasedTokens(fee_result)) + }, + BackendTaskSuccessResult::PurchasedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs index 3473d6224..ef63b92dd 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -1,21 +1,23 @@ -//! Query token balances from Platform +//! 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. use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::platform::tokens::identity_token_balances::{ - IdentityTokenBalances, IdentityTokenBalancesQuery, -}; -use dash_sdk::platform::{FetchMany, Identifier}; -use dash_sdk::{Sdk, dpp::balances::credits::TokenAmount}; +use dash_sdk::platform::Identifier; use crate::app::TaskResult; impl AppContext { pub async fn query_my_token_balances( &self, - sdk: &Sdk, + _sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { let identities = self.load_local_qualified_identities()?; @@ -24,101 +26,86 @@ impl AppContext { return Err(TaskError::NoIdentitiesFound); } - for identity in identities { - let identity_id = identity.identity.id(); - let token_infos = self - .identity_token_balances()? - .values() - .filter(|t| t.identity_id == identity_id) - .map(|t| (t.token_id, t.data_contract_id, t.token_position)) - .collect::<Vec<_>>(); + // 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<Identifier> = identities.iter().map(|qi| qi.identity.id()).collect(); - let token_ids: Vec<Identifier> = token_infos - .iter() - .map(|(token_id, _, _)| *token_id) - .collect(); + self.refresh_upstream_token_balances(identity_ids, token_ids, &sender) + .await?; - if token_ids.is_empty() { - continue; - } - - let query = IdentityTokenBalancesQuery { - identity_id, - token_ids, - }; - - let balances_result: Result<IdentityTokenBalances, _> = - TokenAmount::fetch_many(sdk, query).await; + Ok(BackendTaskSuccessResult::FetchedTokenBalances) + } - match balances_result { - Ok(token_balances) => { - for balance in token_balances.iter() { - let token_id = balance.0; - let balance = match balance.1 { - Some(b) => *b, - None => 0, - }; - self.db.insert_identity_token_balance( - token_id, - &identity_id, - balance, - self, - )?; - sender - .send(TaskResult::Refresh) - .await - .map_err(|_| TaskError::InternalSendError)?; - } - } - Err(e) => { - return Err(TaskError::TokenQueryError { - detail: format!("Failed to query token balances: {}", e), - }); - } - } - } + pub async fn query_token_balance( + &self, + _sdk: &Sdk, + identity_id: Identifier, + _token_id: Identifier, + sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + // 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) + .await?; Ok(BackendTaskSuccessResult::FetchedTokenBalances) } - pub async fn query_token_balance( + /// 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). + pub async fn stop_tracking_token_balance( &self, - sdk: &Sdk, identity_id: Identifier, token_id: Identifier, sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, TaskError> { - let query = IdentityTokenBalancesQuery { - identity_id, - token_ids: vec![token_id], - }; + self.wallet_backend()? + .unwatch_identity_token(identity_id, token_id) + .await; + self.remove_token_balance(token_id, identity_id)?; + sender + .send(TaskResult::Refresh) + .await + .map_err(|_| TaskError::InternalSendError)?; + Ok(BackendTaskSuccessResult::FetchedTokenBalances) + } - let balances_result: Result<IdentityTokenBalances, _> = - TokenAmount::fetch_many(sdk, query).await; + /// Token ids in DET's local registry — the watch set every local identity + /// tracks upstream. + fn known_token_ids(&self) -> Result<Vec<Identifier>, TaskError> { + Ok(self.get_all_known_tokens()?.keys().copied().collect()) + } - match balances_result { - Ok(token_balances) => { - for balance in token_balances.iter() { - let token_id = balance.0; - let balance = match balance.1 { - Some(b) => *b, - None => 0, - }; - self.db - .insert_identity_token_balance(token_id, &identity_id, balance, self)?; - sender - .send(TaskResult::Refresh) - .await - .map_err(|_| TaskError::InternalSendError)?; - } - } - Err(e) => { - return Err(TaskError::TokenQueryError { - detail: format!("Failed to query token balances: {}", e), - }); - } + /// 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. + async fn refresh_upstream_token_balances( + &self, + identity_ids: Vec<Identifier>, + token_ids: Vec<Identifier>, + sender: &crate::utils::egui_mpsc::SenderAsync<TaskResult>, + ) -> Result<(), TaskError> { + let backend = self.wallet_backend()?; + for identity_id in identity_ids { + backend + .register_identity_tokens(identity_id, token_ids.clone()) + .await; } - - Ok(BackendTaskSuccessResult::FetchedTokenBalances) + backend.sync_token_balances_now().await; + sender + .send(TaskResult::Refresh) + .await + .map_err(|_| TaskError::InternalSendError)?; + Ok(()) } } diff --git a/src/backend_task/tokens/query_token_non_claimed_perpetual_distribution_rewards.rs b/src/backend_task/tokens/query_token_non_claimed_perpetual_distribution_rewards.rs index fb13291bf..c99512c7e 100644 --- a/src/backend_task/tokens/query_token_non_claimed_perpetual_distribution_rewards.rs +++ b/src/backend_task/tokens/query_token_non_claimed_perpetual_distribution_rewards.rs @@ -73,11 +73,11 @@ impl AppContext { token_id: Identifier, sdk: &Sdk, ) -> Result<BackendTaskSuccessResult, TaskError> { - let token_config = self.db.get_token_config_for_id(&token_id, self)?.ok_or( - TaskError::TokenQueryError { - detail: "Token config not found in database".to_string(), - }, - )?; + let token_config = + self.get_token_config_for_id(&token_id)? + .ok_or(TaskError::TokenQueryError { + detail: "Token config not found in database".to_string(), + })?; let perpetual_distribution = token_config .distribution_rules() .perpetual_distribution() diff --git a/src/backend_task/tokens/resume_tokens.rs b/src/backend_task/tokens/resume_tokens.rs index 05903bf0a..698b99102 100644 --- a/src/backend_task/tokens/resume_tokens.rs +++ b/src/backend_task/tokens/resume_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -45,25 +45,22 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(TaskError::from)?; - - // Broadcast - let proof_result = state_transition - .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log proof result for audit trail - tracing::info!("ResumeTokens proof result: {}", proof_result); - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::ResumedTokens(fee_result)) + self.execute_token_op( + async { + let state_transition = builder + .sign(sdk, &signing_key, actor_identity, self.platform_version()) + .await + .map_err(TaskError::from)?; + state_transition + .broadcast_and_wait::<StateTransitionProofResult>(sdk, None) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |proof_result| tracing::info!("ResumeTokens proof result: {}", proof_result), + BackendTaskSuccessResult::ResumedTokens, + ) + .await } } diff --git a/src/backend_task/tokens/set_token_price.rs b/src/backend_task/tokens/set_token_price.rs index 97f9cc588..a6ebcb0a4 100644 --- a/src/backend_task/tokens/set_token_price.rs +++ b/src/backend_task/tokens/set_token_price.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; @@ -50,48 +50,46 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_set_price_for_direct_purchase(builder, &signing_key, sending_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log the proof-verified set price result - match result { - SetPriceResult::PricingSchedule(owner_id, schedule) => { - tracing::info!( - "SetDirectPurchasePrice: owner {} has_schedule={}", - owner_id, - schedule.is_some() - ); - } - SetPriceResult::HistoricalDocument(document) => { - tracing::info!( - "SetDirectPurchasePrice: historical document id={}", - document.id() - ); - } - SetPriceResult::GroupActionWithDocument(power, doc) => { - tracing::info!( - "SetDirectPurchasePrice: group action power={}, has_doc={}", - power, - doc.is_some() - ); - } - SetPriceResult::GroupActionWithPricingSchedule(power, status, schedule) => { - tracing::info!( - "SetDirectPurchasePrice: group action power={}, status={:?}, has_schedule={}", - power, - status, - schedule.is_some() - ); - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::SetTokenPrice(fee_result)) + self.execute_token_op( + async { + sdk.token_set_price_for_direct_purchase(builder, &signing_key, sending_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| match result { + SetPriceResult::PricingSchedule(owner_id, schedule) => { + tracing::info!( + "SetDirectPurchasePrice: owner {} has_schedule={}", + owner_id, + schedule.is_some() + ); + } + SetPriceResult::HistoricalDocument(document) => { + tracing::info!( + "SetDirectPurchasePrice: historical document id={}", + document.id() + ); + } + SetPriceResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "SetDirectPurchasePrice: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + SetPriceResult::GroupActionWithPricingSchedule(power, status, schedule) => { + tracing::info!( + "SetDirectPurchasePrice: group action power={}, status={:?}, has_schedule={}", + power, + status, + schedule.is_some() + ); + } + }, + BackendTaskSuccessResult::SetTokenPrice, + ) + .await } } diff --git a/src/backend_task/tokens/transfer_tokens.rs b/src/backend_task/tokens/transfer_tokens.rs index 86c327540..e0543c843 100644 --- a/src/backend_task/tokens/transfer_tokens.rs +++ b/src/backend_task/tokens/transfer_tokens.rs @@ -4,8 +4,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::document::DocumentV0Getters; @@ -47,14 +47,18 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_transfer(builder, &signing_key, sending_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Using the result, update the balance of both sender and recipient identities - if let Some(token_id) = data_contract.token_id(token_position) { - match result { + self.execute_token_op( + async { + sdk.token_transfer(builder, &signing_key, sending_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| { + // Using the result, update the balance of both sender and recipient identities + if let Some(token_id) = data_contract.token_id(token_position) { + match result { // Standard transfer result - update balances from map TransferResult::IdentitiesBalances(balances_map) => { for (identity_id, balance) in balances_map { @@ -168,16 +172,13 @@ impl AppContext { } } - // Other variants don't require balance updates - _ => {} - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::TransferredTokens(fee_result)) + // Other variants don't require balance updates + _ => {} + } + } + }, + BackendTaskSuccessResult::TransferredTokens, + ) + .await } } diff --git a/src/backend_task/tokens/unfreeze_tokens.rs b/src/backend_task/tokens/unfreeze_tokens.rs index 36b813943..a78a383af 100644 --- a/src/backend_task/tokens/unfreeze_tokens.rs +++ b/src/backend_task/tokens/unfreeze_tokens.rs @@ -2,8 +2,8 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; @@ -47,44 +47,42 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let result = sdk - .token_unfreeze_identity(builder, &signing_key, actor_identity) - .await - .map_err(|e| self.log_drive_proof_error(e, RequestType::BroadcastStateTransition))?; - - // Log the proof-verified unfreeze result - match result { - UnfreezeResult::IdentityInfo(identity_id, info) => { - tracing::info!( - "UnfreezeTokens: identity {} frozen={}", - identity_id, - info.frozen() - ); - } - UnfreezeResult::HistoricalDocument(document) => { - tracing::info!("UnfreezeTokens: historical document id={}", document.id()); - } - UnfreezeResult::GroupActionWithDocument(power, doc) => { - tracing::info!( - "UnfreezeTokens: group action power={}, has_doc={}", - power, - doc.is_some() - ); - } - UnfreezeResult::GroupActionWithIdentityInfo(power, info) => { - tracing::info!( - "UnfreezeTokens: group action power={}, frozen={}", - power, - info.frozen() - ); - } - } - - // Return success with fee result - use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::UnfrozeTokens(fee_result)) + self.execute_token_op( + async { + sdk.token_unfreeze_identity(builder, &signing_key, actor_identity) + .await + .map_err(|e| { + self.log_drive_proof_error(e, RequestType::BroadcastStateTransition) + }) + }, + |result| match result { + UnfreezeResult::IdentityInfo(identity_id, info) => { + tracing::info!( + "UnfreezeTokens: identity {} frozen={}", + identity_id, + info.frozen() + ); + } + UnfreezeResult::HistoricalDocument(document) => { + tracing::info!("UnfreezeTokens: historical document id={}", document.id()); + } + UnfreezeResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "UnfreezeTokens: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + UnfreezeResult::GroupActionWithIdentityInfo(power, info) => { + tracing::info!( + "UnfreezeTokens: group action power={}, frozen={}", + power, + info.frozen() + ); + } + }, + BackendTaskSuccessResult::UnfrozeTokens, + ) + .await } } diff --git a/src/backend_task/tokens/update_token_config.rs b/src/backend_task/tokens/update_token_config.rs index 1d25964d1..c81668bc2 100644 --- a/src/backend_task/tokens/update_token_config.rs +++ b/src/backend_task/tokens/update_token_config.rs @@ -1,7 +1,7 @@ use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::proof_log_item::RequestType; +use crate::model::request_type::RequestType; use crate::ui::tokens::tokens_screen::IdentityTokenInfo; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -118,9 +118,8 @@ impl AppContext { // Return success with fee result use crate::backend_task::FeeResult; - use crate::model::fee_estimation::PlatformFeeEstimator; - let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); - let fee_result = FeeResult::new(estimated_fee, estimated_fee); + let estimated_fee = self.fee_estimator().estimate_document_batch(1); + let fee_result = FeeResult::estimated_only(estimated_fee); Ok(BackendTaskSuccessResult::UpdatedTokenConfig( change_item.to_string(), fee_result, diff --git a/src/backend_task/update_data_contract.rs b/src/backend_task/update_data_contract.rs index c9df7bbf4..09f55e2f6 100644 --- a/src/backend_task/update_data_contract.rs +++ b/src/backend_task/update_data_contract.rs @@ -1,21 +1,13 @@ use super::{BackendTaskSuccessResult, FeeResult}; use crate::{ - app::TaskResult, - backend_task::error::TaskError, - context::AppContext, - model::{ - fee_estimation::PlatformFeeEstimator, - proof_log_item::{ProofLogItem, RequestType}, - qualified_identity::QualifiedIdentity, - }, + app::TaskResult, backend_task::error::TaskError, context::AppContext, + model::qualified_identity::QualifiedIdentity, }; use dash_sdk::{ Error, Sdk, dpp::{ - dashcore::Network, data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}, identity::{SecurityLevel, accessors::IdentityGettersV0}, - platform_value::string_encoding::Encoding, state_transition::{ StateTransition, StateTransitionSigningOptions, data_contract_update_transition::DataContractUpdateTransition, @@ -23,36 +15,10 @@ use dash_sdk::{ version::TryIntoPlatformVersioned, }, platform::{ - DataContract, Fetch, Identifier, IdentityPublicKey, + DataContract, Identifier, IdentityPublicKey, transition::broadcast::BroadcastStateTransition, }, }; -use std::time::Duration; -use tokio::time::sleep; - -/// Extracts the contract ID from a formatted error message string that contains: -/// "... with id <contract_id>: ..." -pub fn extract_contract_id_from_error(error: &str) -> Result<Identifier, String> { - // Find the start of "with id " - let prefix = "with id "; - let start_index = error - .find(prefix) - .ok_or("Missing 'with id ' prefix in error message")? - + prefix.len(); - - // Slice from after "with id " and find the next colon - let rest = &error[start_index..]; - let end_index = rest.find(':').ok_or("Missing ':' after contract ID")?; - - let id_str = &rest[..end_index].trim(); - - Identifier::from_string(id_str, Encoding::Base58).map_err(|e| { - format!( - "Failed to convert contract ID from string to Identifier: {}", - e - ) - }) -} impl AppContext { pub async fn update_data_contract( @@ -64,10 +30,11 @@ impl AppContext { sender: crate::utils::egui_mpsc::SenderAsync<TaskResult>, ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { // Estimate fee for contract update - let estimated_fee = PlatformFeeEstimator::new().estimate_contract_update(); + let estimated_fee = self.fee_estimator().estimate_contract_update(); // Increment the version of the data contract data_contract.increment_version(); + let contract_id = data_contract.id(); // Fetch the identity contract nonce let identity_contract_nonce = sdk @@ -104,62 +71,29 @@ impl AppContext { allow_signing_with_any_purpose: false, }, ) + .await .map_err(|e| TaskError::from(dash_sdk::Error::Protocol(e)))?; match state_transition.broadcast_and_wait(sdk, None).await { Ok(returned_contract) => { - self.db - .replace_contract(data_contract.id(), &returned_contract, self)?; - let fee_result = FeeResult::new(estimated_fee, estimated_fee); - Ok(BackendTaskSuccessResult::UpdatedContract(fee_result)) + self.replace_contract(data_contract.id(), &returned_contract)?; + Ok(BackendTaskSuccessResult::UpdatedContract( + FeeResult::estimated_only(estimated_fee), + )) } - Err(e) => match e { - Error::DriveProofError(proof_error, proof_bytes, block_info) => { - let proof_error_str = proof_error.to_string(); - // Log the proof error first, before any other operations - self.db - .insert_proof_log_item(ProofLogItem { - request_type: RequestType::BroadcastStateTransition, - request_bytes: vec![], - verification_path_query_bytes: vec![], - height: block_info.height, - time_ms: block_info.time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(proof_error_str.clone()), - }) - .ok(); - - // Reconstruct the SDK error to preserve as source - let source_error = - Box::new(Error::DriveProofError(proof_error, proof_bytes, block_info)); - - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::ProofErrorLogged, - ))) - .await - .map_err(|_| crate::backend_task::error::TaskError::InternalSendError)?; - - // Try to extract contract ID and fetch the contract if it exists - // This handles the case where the contract was actually updated despite the proof error - if let Ok(id) = extract_contract_id_from_error(&proof_error_str) { - match self.network { - Network::Regtest => sleep(Duration::from_secs(3)).await, - _ => sleep(Duration::from_secs(10)).await, - } - if let Ok(Some(contract)) = DataContract::fetch(sdk, id).await { - self.db - .replace_contract(contract.id(), &contract, self) - .ok(); - - return Ok(BackendTaskSuccessResult::ContractSavedAfterProofError); - } - } - - Err(crate::backend_task::error::TaskError::ProofError { source_error }) - } - e => Err(crate::backend_task::error::TaskError::from(e)), - }, + Err(e @ Error::DriveProofError(..)) => { + self.recover_contract_after_proof_error( + sdk, + contract_id, + e, + &sender, + |ctx, contract| { + ctx.replace_contract(contract.id(), contract).ok(); + }, + ) + .await + } + Err(e) => Err(crate::backend_task::error::TaskError::from(e)), } } } diff --git a/src/backend_task/wallet/derive_identity_key_for_display.rs b/src/backend_task/wallet/derive_identity_key_for_display.rs new file mode 100644 index 000000000..1d7efe1f2 --- /dev/null +++ b/src/backend_task/wallet/derive_identity_key_for_display.rs @@ -0,0 +1,43 @@ +//! Backend task: derive a vault-backed identity key for on-screen display. +//! Fetches the raw key JIT through the secret chokepoint (`InVault` route); +//! only the WIF crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::secret::Secret; +use dash_sdk::dpp::dashcore::PrivateKey; +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::platform::Identifier; +use std::sync::Arc; + +impl AppContext { + /// Derive an identity private key for on-screen display/export. + /// + /// The raw key is fetched just-in-time from the vault through the chokepoint + /// (`SecretScope::IdentityKey`, prompt-free) and borrowed only inside the + /// closure; it zeroizes when the closure returns. Only the WIF — wrapped in + /// [`Secret`] — crosses back to the UI. + pub(crate) async fn derive_identity_key_for_display( + self: &Arc<Self>, + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let network = self.network; + let wif = self + .with_identity_secret_key(identity_id, target.clone(), key_id, |secret_key| { + let private_key = PrivateKey::new(secret_key, network); + Ok(Secret::new(private_key.to_wif())) + }) + .await?; + + Ok(BackendTaskSuccessResult::IdentityKeyForDisplay { + identity_id, + target, + key_id, + wif, + }) + } +} diff --git a/src/backend_task/wallet/derive_key_for_display.rs b/src/backend_task/wallet/derive_key_for_display.rs new file mode 100644 index 000000000..b4953e90f --- /dev/null +++ b/src/backend_task/wallet/derive_key_for_display.rs @@ -0,0 +1,40 @@ +//! Backend task: derive a wallet HD key for on-screen display or export. +//! Fetches the seed JIT through the secret chokepoint; only the WIF crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::secret::Secret; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use std::sync::Arc; + +impl AppContext { + /// Derive a private key for on-screen display/export. + /// + /// The HD seed is fetched just-in-time through the JIT chokepoint and + /// borrowed only for the single derivation inside the closure; it zeroizes + /// when the closure returns. Only the resulting WIF — wrapped in + /// [`Secret`] — crosses back to the UI. This is the seam the sync UI key + /// viewers use instead of reading the wallet's parked seed. + pub(crate) async fn derive_key_for_display( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let wif = self + .with_wallet_derived_key( + seed_hash, + &derivation_path, + TaskError::WalletKeyLookupFailed, + |private_key| Ok(Secret::new(private_key.to_wif())), + ) + .await?; + + Ok(BackendTaskSuccessResult::WalletKeyForDisplay { + seed_hash, + derivation_path, + wif, + }) + } +} diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 86a4ac217..b8882f9c4 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -1,13 +1,9 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; -use crate::model::wallet::{ - DerivationPathHelpers, DerivationPathReference, DerivationPathType, WalletAddressProvider, - WalletSeedHash, -}; +use crate::model::wallet::{WalletAddressProvider, WalletSeedHash}; use dash_sdk::RequestSettings; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::address_sync::AddressSyncConfig; use dash_sdk::platform::address_sync::AddressSyncResult; use std::sync::Arc; @@ -20,35 +16,42 @@ impl AppContext { tracing::info!("Platform address sync start"); let start_time = std::time::Instant::now(); - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - - // Get last sync timestamp from database - let (last_sync_timestamp, last_sync_height) = - self.db.get_platform_sync_info(&seed_hash).unwrap_or((0, 0)); - - // Create provider (requires wallet to be open for address derivation) - let mut provider = { - let wallet = wallet_arc.read()?; - match WalletAddressProvider::new(&wallet, self.network) { - Ok(provider) => provider.with_stored_state(&wallet, self.network, last_sync_height), - Err(_) if !wallet.is_open() => { - return Err(crate::backend_task::error::TaskError::WalletLocked); - } - Err(e) => { - return Err( + let wallet_arc = self.wallet_arc(&seed_hash)?; + + // Create provider. Address derivation needs the DIP-17 account-level + // xpub, which is derived once from the HD seed fetched just-in-time + // through the chokepoint. The seed is borrowed for that single + // derivation inside the closure and zeroizes on return — the provider + // then derives every gap-limit child from the public xpub alone. + // + // This explicit refresh does a full tree scan: with no DET-side cursor + // it always re-derives from scratch and returns every funded address. + // Steady-state freshness comes from the coordinator's background pass. + let network = self.network; + let backend = self.wallet_backend()?; + let mut provider = backend + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(crate::backend_task::error::TaskError::WalletLocked)?; + // Backfill the platform-payment account xpub while the seed + // is borrowed, so later seedless coordinator pushes can + // reconcile addresses for signing. No-op once cached. + wallet_arc + .write()? + .ensure_platform_payment_account_xpub(seed, network); + let wallet = wallet_arc.read()?; + WalletAddressProvider::new(&wallet, network, seed).map_err(|source| { crate::backend_task::error::TaskError::WalletAddressProviderSetupFailed { - detail: e, - }, - ); - } - } - }; + source, + } + }) + }, + ) + .await?; // Sync using SDK's privacy-preserving method (handles both full and incremental) let sdk = self.sdk.load().as_ref().clone(); @@ -65,20 +68,15 @@ impl AppContext { None }; - let last_ts = if last_sync_timestamp > 0 { - Some(last_sync_timestamp) - } else { - None - }; - - let result = match sdk - .sync_address_balances(&mut provider, config, last_ts) - .await - { + let result = match sdk.sync_address_balances(&mut provider, config, None).await { Ok(res) => res, - // TODO: Replace with structural match when the SDK exposes a typed - // variant for empty-tree proof responses. - Err(e) if e.to_string().contains("empty tree") => { + // A never-funded wallet has no platform-balance tree to prove + // against, so the proof layer reports an empty tree. That is the + // expected first-sync state, not a failure — treat it as an empty + // result. Matched structurally against the typed proof variants + // (see `is_empty_tree_proof`); the leaf marker is the only string + // the upstream proof error exposes for this case. + Err(e) if crate::backend_task::error::is_empty_tree_proof(&e) => { tracing::debug!( "Platform address balance tree is empty. Returning empty sync result." ); @@ -88,85 +86,30 @@ impl AppContext { }; tracing::info!( - "Sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint={}, new_sync_height={}, new_sync_timestamp={}", + "Sync complete: duration={:?}, found={}, absent={}, checkpoint={}, new_sync_height={}, new_sync_timestamp={}", start_time.elapsed(), result.found.len(), result.absent.len(), - result.highest_found_index, result.checkpoint_height, result.new_sync_height, result.new_sync_timestamp, ); - // Log the found balances from provider - for (addr, funds) in provider.found_balances() { - use dash_sdk::dpp::address_funds::PlatformAddress; - let platform_addr_str = PlatformAddress::try_from(addr.clone()) - .map(|p| p.to_bech32m_string(self.network)) - .unwrap_or_else(|_| addr.to_string()); - tracing::info!( - "Sync found address: {} with balance: {}, nonce: {}", - platform_addr_str, - funds.balance, - funds.nonce - ); - } - - // Persist sync state - if let Err(e) = self.db.set_platform_sync_info( - &seed_hash, - result.new_sync_timestamp, - result.new_sync_height, - ) { - tracing::warn!("Failed to save platform sync info: {}", e); - } + // Per-address balances/nonces are intentionally not logged: the summary + // line above carries the aggregate counts, and per-address financial + // detail does not belong in plaintext logs at the default level. - // Apply results to wallet and persist + // Apply results to the in-memory wallet. Persistence is the upstream + // coordinator's job: it owns the `platform_addresses` rows and re-pushes + // them on its next pass, so DET keeps no parallel at-rest copy. let balances = { let mut wallet = wallet_arc.write()?; - // Update wallet with synced balances provider.apply_results_to_wallet(&mut wallet); - // Persist addresses and balances to database - for (index, (address, funds)) in provider.found_balances_with_indices() { - // Persist the address to wallet_addresses table if not already there - let derivation_path = DerivationPath::platform_payment_path( - self.network, - 0, // account - 0, // key_class - index, - ); - if let Err(e) = self.db.add_address_if_not_exists( - &seed_hash, - address, - &self.network, - &derivation_path, - DerivationPathReference::PlatformPayment, - DerivationPathType::CLEAR_FUNDS, - None, - ) { - tracing::warn!("Failed to persist Platform address: {}", e); - } - - // Persist balance to platform_address_balances table - if let Err(e) = self.db.set_platform_address_info( - &seed_hash, - address, - funds.balance, - funds.nonce, - &self.network, - ) { - tracing::warn!("Failed to persist Platform address info: {}", e); - } - } - // Return the wallet's complete platform_address_info, not just - // found_balances. The SDK's incremental sync only reports addresses - // whose balance changed; unchanged addresses are absent from - // found_balances but still have valid nonces in the wallet. - // Returning only found_balances would cause the UI to lose nonce - // values for stable-balance addresses (issue #652). + // found_balances, so a wallet whose balances were already current + // keeps its full per-address set with valid nonces (issue #652). wallet .platform_address_info .iter() diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index a06d7b74a..637dc14a0 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -1,144 +1,250 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; -use dash_sdk::dpp::dashcore::Address; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::dashcore::OutPoint; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; use std::collections::BTreeMap; use std::sync::Arc; +/// Routing rule for the tracked-lock funding dispatch: the orchestrated path is +/// eligible only when EVERY recipient is in the wallet's upstream +/// platform-payment pool (the orchestrator's pre-flight rejects mixed maps +/// outright), so a single non-pool recipient routes the whole map to the manual +/// path. `memberships` is each recipient's pre-resolved pool membership. +fn route_to_orchestrator(memberships: impl IntoIterator<Item = bool>) -> bool { + memberships.into_iter().all(|in_pool| in_pool) +} + impl AppContext { - /// Fund Platform addresses from an asset lock + /// Fund Platform addresses from a tracked asset lock. + /// + /// Branches on upstream pool membership. When every recipient is already + /// revealed in this wallet's upstream platform-payment pool — the exact + /// recipient set the orchestrator's pre-flight accepts — the funding routes + /// through the orchestrated `fund_from_asset_lock` pipeline, which resumes + /// the tracked lock, submits with CL-height retry and IS→CL fallback, and + /// consumes the lock on acceptance. Any other destination — an advanced + /// footgun users are trusted to take — keeps the manual `TopUpAddress` path. pub(crate) async fn fund_platform_address_from_asset_lock( self: &Arc<Self>, seed_hash: WalletSeedHash, - asset_lock_proof: AssetLockProof, - asset_lock_address: Address, + out_point: OutPoint, outputs: BTreeMap<PlatformAddress, Option<Credits>>, - ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { - use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; - use dash_sdk::dpp::dashcore::OutPoint; - use dash_sdk::platform::transition::top_up_address::TopUpAddress; + ) -> Result<BackendTaskSuccessResult, TaskError> { + let backend = self.wallet_backend()?; + + // The UI caller always supplies one `None` remainder recipient. A future + // programmatic caller passing an empty map would otherwise route to the + // orchestrator (vacuously all-in-pool) and rely on its pre-flight to + // catch it before broadcast — reject it directly here instead, so the + // precondition is enforced in every build, not just debug ones. + if outputs.is_empty() { + return Err(TaskError::NoFundingRecipients); + } - // Clone wallet and SDK before the async operation to avoid holding guards across await - let (wallet, sdk, asset_lock_private_key) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - let wallet = wallet_arc.read()?.clone(); - let sdk = self.sdk.load().as_ref().clone(); + // Resolve each recipient's pool membership (short-circuiting on the first + // miss to skip remaining network-touching queries), then apply the pure + // routing rule. Splitting the async query from the decision keeps the + // mixed-ownership rule unit-testable. + let mut memberships: Vec<bool> = Vec::with_capacity(outputs.len()); + for addr in outputs.keys() { + let in_pool = backend.platform_address_in_pool(&seed_hash, addr).await?; + memberships.push(in_pool); + if !in_pool { + break; + } + } - // Get the private key for the asset lock address - let private_key = wallet - .private_key_for_address(&asset_lock_address, self.network) - .map_err( - |e| crate::backend_task::error::TaskError::WalletKeyLookupFailed { detail: e }, - )? - .ok_or(crate::backend_task::error::TaskError::AssetLockAddressNotFound)?; + if route_to_orchestrator(memberships) { + return self + .fund_platform_address_from_asset_lock_orchestrated(seed_hash, out_point, outputs) + .await; + } - (wallet, sdk, private_key) - }; + self.fund_platform_address_from_asset_lock_manual(seed_hash, out_point, outputs) + .await + } - // Check if we need to convert an old instant lock proof to a chain lock proof - use crate::context::get_transaction_info; - use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; - use dash_sdk::platform::Fetch; + /// Orchestrated tracked-lock funding for wallet-owned destinations: resumes + /// the lock by outpoint, submits the address-funding transition with + /// CL-height retry and IS→CL fallback, then consumes the lock on + /// acceptance. + async fn fund_platform_address_from_asset_lock_orchestrated( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + out_point: OutPoint, + outputs: BTreeMap<PlatformAddress, Option<Credits>>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + use crate::wallet_backend::PlatformPathIndex; + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use platform_wallet::wallet::asset_lock::AssetLockFunding; - let asset_lock_proof = - if let AssetLockProof::Instant(instant_asset_lock_proof) = &asset_lock_proof { - // Get the transaction ID from the instant lock proof - let tx_id = instant_asset_lock_proof.transaction().txid(); + let wallet_arc = self.wallet_arc(&seed_hash)?; + let network = self.network; + let path_index = { + let wallet = wallet_arc.read()?; + PlatformPathIndex::from_wallet(&wallet, network) + }; + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; - // Query DAPI to check if the transaction has been chain-locked - let tx_info = get_transaction_info(&sdk, &tx_id).await?; + let backend = self.wallet_backend()?; + backend + .fund_platform_address( + &seed_hash, + AssetLockFunding::FromExistingAssetLock { out_point }, + 0, + outputs, + fee_strategy, + &path_index, + None, + ) + .await?; - if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { - // Transaction has been chain-locked with sufficient confirmations - let tx_block_height = tx_info.height; + self.fetch_platform_address_balances(seed_hash).await?; - // Check if the platform has caught up to this block height - let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) - .await - .map_err(crate::backend_task::error::TaskError::from)?; - - if tx_block_height <= metadata.core_chain_locked_height { - // Platform has synced past this block, use chain lock proof - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: tx_block_height, - out_point: OutPoint::new(tx_id, 0), - }) - } else { - // Platform hasn't verified this Core block yet - can't use chain lock proof - // and instant lock is stale. User needs to wait. - return Err(crate::backend_task::error::TaskError::AssetLockExpired { - tx_block_height, - platform_height: metadata.core_chain_locked_height, - }); - } - } else { - // Use the instant lock proof as-is (transaction is recent) - asset_lock_proof - } - } else { - // Already a chain lock proof, use as-is - asset_lock_proof - }; - - // Simple fee strategy: reduce from first output - let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) + } - // Get the transaction ID before consuming the asset lock proof - let tx_id = match &asset_lock_proof { - AssetLockProof::Instant(instant) => instant.transaction().txid(), - AssetLockProof::Chain(chain) => chain.out_point.txid, + /// Manual tracked-lock funding: derive the credit-output key and submit the + /// `TopUpAddress` transition directly. Used for non-owned destinations. A + /// submit failure propagates via `?`, so the flow never reports a false + /// success. + async fn fund_platform_address_from_asset_lock_manual( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + out_point: OutPoint, + outputs: BTreeMap<PlatformAddress, Option<Credits>>, + ) -> Result<BackendTaskSuccessResult, TaskError> { + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::dpp::dashcore::Address; + use dash_sdk::platform::transition::top_up_address::TopUpAddress; + + let backend = self.wallet_backend()?; + let tracked = backend + .list_tracked_asset_locks(&seed_hash) + .await? + .into_iter() + .find(|t| t.out_point == out_point) + .ok_or(TaskError::AssetLockAddressNotFound)?; + + let asset_lock_proof = tracked + .proof + .clone() + .ok_or(TaskError::AssetLockAddressNotFound)?; + + // Recover the credit-output address from the asset-lock transaction + // payload — the first credit output is the funded address. + let payload = tracked + .transaction + .special_transaction_payload + .as_ref() + .ok_or(TaskError::AssetLockAddressNotFound)?; + let asset_lock_payload = match payload { + TransactionPayload::AssetLockPayloadType(p) => p, + _ => return Err(TaskError::AssetLockAddressNotFound), + }; + let credit_output = asset_lock_payload + .credit_outputs + .first() + .ok_or(TaskError::AssetLockAddressNotFound)?; + let asset_lock_address = Address::from_script(&credit_output.script_pubkey, self.network) + .map_err(|_| TaskError::AssetLockAddressNotFound)?; + + let (wallet, sdk) = { + let wallet = self.wallet_arc(&seed_hash)?.read()?.clone(); + let sdk = self.sdk.load().as_ref().clone(); + (wallet, sdk) }; - // Use the SDK to top up Platform addresses from asset lock - let _result = outputs - .top_up( - &sdk, - asset_lock_proof, - asset_lock_private_key, - fee_strategy, - &wallet, - None, + // Resolve the HD seed once through the chokepoint and, inside that same + // scope, both derive the asset-lock address's private key AND build the + // JIT platform signer that authorises each funded-output witness. The + // seed is borrowed for the whole top-up and zeroizes when the closure + // returns — it never enters this layer by value. The pure path index is + // built before the scope. + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; + let network = self.network; + let asset_lock_address_for_lookup = asset_lock_address.clone(); + let path_index = PlatformPathIndex::from_wallet(&wallet, network); + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + + let _result = backend + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let asset_lock_private_key = wallet + .private_key_for_address_with_seed( + seed, + &asset_lock_address_for_lookup, + network, + ) + .map_err(|detail| { + tracing::warn!(error = %detail, "Asset-lock key derivation failed"); + TaskError::WalletKeyLookupFailed + })? + .ok_or(TaskError::AssetLockAddressNotFound)?; + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + // Non-owned destination: the manual `TopUpAddress` submit + // has no orchestrated consume/retry. A failure propagates via + // `?`, so the flow never reports a false success; the + // orchestrated recovery is reserved for wallet-owned + // destinations (see the owned branch above). + outputs + .top_up( + &sdk, + asset_lock_proof, + asset_lock_private_key, + fee_strategy, + &signer, + None, + ) + .await + .map_err(TaskError::from) + }, ) - .await - .map_err(crate::backend_task::error::TaskError::from)?; - - // Remove the used asset lock from the wallet and database - { - let wallet_arc = { - self.wallets - .read() - .ok() - .and_then(|w| w.get(&seed_hash).cloned()) - }; - if let Some(wallet_arc) = wallet_arc { - let mut wallet = wallet_arc.write()?; - wallet - .unused_asset_locks - .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); - } - // Also remove from database - if let Err(e) = self - .db - .delete_asset_lock_transaction(&tx_id.to_byte_array()) - { - tracing::warn!("Failed to delete asset lock from database: {}", e); - } - } + .await?; - // Trigger a balance refresh self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } } + +#[cfg(test)] +mod tests { + use super::route_to_orchestrator; + + /// All recipients in-pool routes to the orchestrator. + #[test] + fn all_in_pool_routes_to_orchestrator() { + assert!(route_to_orchestrator([true, true, true])); + assert!(route_to_orchestrator([true])); + } + + /// A mixed map (one in-pool, one foreign) routes to the manual path — the + /// orchestrator's pre-flight would reject the foreign recipient, so the + /// whole funding falls back rather than half-succeeding. + #[test] + fn mixed_ownership_routes_to_manual() { + assert!(!route_to_orchestrator([true, false])); + assert!(!route_to_orchestrator([false, true])); + assert!(!route_to_orchestrator([false])); + } + + /// An empty map (no recipients) is vacuously all-in-pool, so it routes to + /// the orchestrator. Unreachable via the current UI caller, which always + /// inserts exactly one `None` remainder recipient; a future programmatic / + /// MCP caller passing an empty map would route here and be rejected by the + /// orchestrator's own `validate_recipient_addresses` pre-flight before + /// broadcast. The rule is pinned so the helper's contract is explicit. + #[test] + fn empty_is_vacuously_in_pool() { + assert!(route_to_orchestrator(std::iter::empty())); + } +} diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index e786620e1..6a6b4c320 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -2,16 +2,71 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::AddressFundsFeeStrategy; +use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::platform::transition::top_up_address::TopUpAddress; +use platform_wallet::AssetLockFundingType; +use std::collections::BTreeMap; use std::sync::Arc; +/// Output map for a Platform address top-up: each entry maps a recipient to an +/// explicit credit amount, or `None` to absorb the remainder after fees. +type FundingOutputs = BTreeMap<PlatformAddress, Option<u64>>; + +/// Build the output map and fee strategy for the fee-from-wallet funding branch. +/// +/// The `destination` receives exactly `amount` converted to credits, and a +/// separate `change_address` absorbs the asset-lock surplus minus the Platform +/// fee. The returned `ReduceOutput` step indexes the change output's position in +/// the (sorted) map, so the fee is always deducted from change, never from the +/// destination amount. +fn build_fee_from_wallet_outputs( + amount: u64, + destination: PlatformAddress, + change_address: PlatformAddress, +) -> Result<(FundingOutputs, AddressFundsFeeStrategy), TaskError> { + let amount_credits = + amount + .checked_mul(CREDITS_PER_DUFF) + .ok_or(TaskError::CreditCalculationOverflow { + amount, + credits_per_duff: CREDITS_PER_DUFF, + })?; + + let mut outputs: FundingOutputs = BTreeMap::new(); + outputs.insert(destination, Some(amount_credits)); + outputs.insert(change_address, None); + + let change_index = outputs.keys().position(|k| *k == change_address).ok_or( + TaskError::ChangeAddressUnavailable { + reason: "change address not found in outputs map", + }, + )? as u16; + + Ok(( + outputs, + vec![AddressFundsFeeStrategyStep::ReduceOutput(change_index)], + )) +} + impl AppContext { - /// Fund a platform address directly from wallet UTXOs. - /// Creates an asset lock, broadcasts it, waits for confirmation, then funds the destination. + /// Fund a Platform (DIP-17) address directly from wallet UTXOs. /// - /// If `fee_deduct_from_output` is true, fees are deducted from the amount (recipient receives less). - /// If `fee_deduct_from_output` is false, fees are paid from extra wallet balance (recipient receives exact amount). + /// Branches on upstream pool membership. When the destination is already + /// revealed in this wallet's upstream platform-payment pool — the exact + /// recipient set the orchestrator's pre-flight accepts — the funding routes + /// through the orchestrated `fund_from_asset_lock` pipeline (build/broadcast + /// the lock, CL-height retry, InstantSend → ChainLock fallback, consume on + /// acceptance). Any other destination — an advanced footgun users are + /// trusted to take — keeps the manual asset-lock + `TopUpAddress` path. + /// + /// The fee-from-wallet case needs a second (change) recipient, and the + /// orchestrator's pre-flight requires *every* recipient to be in-pool. So it + /// reaches the orchestrator only when a distinct in-pool, watched + /// platform-payment address is available to absorb the change; otherwise it + /// falls back to the manual path (which derives a fresh change address). pub(crate) async fn fund_platform_address_from_wallet_utxos( self: &Arc<Self>, seed_hash: WalletSeedHash, @@ -19,220 +74,362 @@ impl AppContext { destination: PlatformAddress, fee_deduct_from_output: bool, ) -> Result<BackendTaskSuccessResult, TaskError> { - use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; - use dash_sdk::platform::transition::top_up_address::TopUpAddress; - - // When fee_deduct_from_output is false, we need to create a larger asset lock - // that includes the estimated platform fee, so the recipient receives the exact amount. - let (asset_lock_amount, allow_take_fee_from_amount) = if fee_deduct_from_output { - // Fees deducted from output: use the requested amount, allow core fee to be taken from it - (amount, true) - } else { - // Fees paid from wallet: add estimated platform fee to asset lock amount. - // We use 2 outputs: the destination (explicit amount) and a change address - // (remainder recipient that absorbs the fee). - let estimated_platform_fee_duffs = self - .fee_estimator() - .estimate_address_funding_from_asset_lock_duffs(2); - let asset_lock_amount = amount.saturating_add(estimated_platform_fee_duffs); - (asset_lock_amount, false) - }; + let backend = self.wallet_backend()?; + let destination_in_pool = backend + .platform_address_in_pool(&seed_hash, &destination) + .await?; - // Step 1: Create the asset lock transaction (UTXOs are selected but NOT yet removed) - let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc.write()?; - - // Try to create the asset lock transaction, reload UTXOs if needed - match wallet.generic_asset_lock_transaction( - self, - self.network, - asset_lock_amount, - allow_take_fee_from_amount, - None, - ) { - Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), - Err(e) => { - // Reload UTXOs (RPC: fetches from Core; SPV: no-op). - // Only retry if something actually changed. - if !wallet - .reload_utxos(self) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })? - { - return Err(TaskError::AssetLockTransactionBuildFailed { detail: e }); - } - let (tx, private_key, address, _change, utxos) = wallet - .generic_asset_lock_transaction( - self, - self.network, - asset_lock_amount, - allow_take_fee_from_amount, - None, - ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; - (tx, private_key, address, utxos) - } + if destination_in_pool { + if fee_deduct_from_output { + // Fee-from-output: a single in-pool destination is the lone + // `None` recipient, satisfying the orchestrator's one-`None` + // invariant with no change recipient. + return self + .fund_platform_address_from_wallet_utxos_orchestrated( + seed_hash, + amount, + destination, + ) + .await; } + + // Fee-from-wallet: the orchestrator accepts this only when the + // change recipient is also in-pool. Source one from the wallet's + // own watched platform-payment addresses; if found, route through + // the orchestrator for the same recovery guarantees. + if let Some(change) = self + .select_in_pool_platform_change(&seed_hash, &destination) + .await? + { + return self + .fund_platform_address_from_wallet_utxos_orchestrated_with_change( + seed_hash, + amount, + destination, + change, + ) + .await; + } + } + + self.fund_platform_address_from_wallet_utxos_manual( + seed_hash, + amount, + destination, + fee_deduct_from_output, + ) + .await + } + + /// Find a watched platform-payment address (distinct from `destination`) + /// that is already in this wallet's upstream pool, to absorb fee-from-wallet + /// change. + /// + /// Candidates are the wallet's watched platform-payment addresses — every + /// one is inside DET's synced provider window, so its change credits are + /// visible and spendable (the funds-safety invariant from `0a64be55`). Each + /// is gated through [`WalletBackend::platform_address_in_pool`], the exact + /// membership check the orchestrator's pre-flight runs, so a returned + /// address is guaranteed to pass `validate_recipient_addresses`. No reveal + /// or pool advance happens — only already-revealed addresses are inspected. + /// `None` means no distinct in-pool candidate exists; the caller falls back + /// to the manual path. + async fn select_in_pool_platform_change( + self: &Arc<Self>, + seed_hash: &WalletSeedHash, + destination: &PlatformAddress, + ) -> Result<Option<PlatformAddress>, TaskError> { + let candidates: Vec<PlatformAddress> = { + let wallet_arc = self.wallet_arc(seed_hash)?; + let wallet = wallet_arc.read()?; + wallet + .platform_addresses(self.network) + .into_iter() + .map(|(_, platform_address)| platform_address) + .collect() }; - // Step 2–4: Store → broadcast → remove UTXOs (atomic pattern). - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? + // This loop owns ordering and the async in-pool membership gate: it walks + // candidates in order and returns the first that is both distinct from the + // destination and confirmed in-pool. The orchestrator needs two distinct + // recipients, so the destination can never be its own change. + let backend = self.wallet_backend()?; + for candidate in candidates { + if &candidate == destination { + continue; + } + if backend + .platform_address_in_pool(seed_hash, &candidate) + .await? + { + return Ok(Some(candidate)); + } + } + + Ok(None) + } + + /// Orchestrated wallet-UTXO funding for a wallet-owned destination: the + /// upstream `fund_from_asset_lock` builds and broadcasts the asset lock, + /// submits the address-funding transition with CL-height retry and IS→CL + /// fallback, then consumes the lock on acceptance. + async fn fund_platform_address_from_wallet_utxos_orchestrated( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + amount: u64, + destination: PlatformAddress, + ) -> Result<BackendTaskSuccessResult, TaskError> { + use crate::wallet_backend::PlatformPathIndex; + use platform_wallet::wallet::asset_lock::AssetLockFunding; + + let wallet_arc = self.wallet_arc(&seed_hash)?; + let network = self.network; + + let mut outputs: FundingOutputs = BTreeMap::new(); + outputs.insert(destination, None); + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + + let path_index = { + let wallet = wallet_arc.read()?; + PlatformPathIndex::from_wallet(&wallet, network) }; - let tx_id = self - .broadcast_and_commit_asset_lock( - &asset_lock_transaction, - asset_lock_amount, + let backend = self.wallet_backend()?; + backend + .fund_platform_address( &seed_hash, - &wallet_arc, - &used_utxos, + AssetLockFunding::FromWalletBalance { + amount_duffs: amount, + account_index: 0, + }, + 0, + outputs, + fee_strategy, + &path_index, + None, ) .await?; - // Step 5: Wait for asset lock proof (InstantLock or ChainLock) via shared helper. - // On timeout the helper cleans up the finality tracking entry. - // Post-timeout recovery is mode-dependent: - // RPC — fire-and-forget refresh_wallet_info to reconcile spent UTXOs - // SPV — spent UTXOs are reconciled automatically on the next sync cycle - let asset_lock_proof = match self.wait_for_asset_lock_proof(tx_id).await { - Ok(proof) => proof, - Err(timeout_err) => { - use crate::spv::CoreBackendMode; - - match self.core_backend_mode() { - CoreBackendMode::Rpc => { - if let Some(wallet_arc) = self - .wallets - .read() - .ok() - .and_then(|w| w.get(&seed_hash).cloned()) - { - let ctx = Arc::clone(self); - // Fire-and-forget — don't block the error return on refresh - tokio::task::spawn_blocking(move || { - if let Err(e) = ctx.refresh_wallet_info(wallet_arc) { - tracing::warn!( - "Failed to auto-refresh wallet after timeout: {}", - e - ); - } - }); - } - } - CoreBackendMode::Spv => { - tracing::warn!( - "Asset lock proof timed out in SPV mode (tx {}). \ - Spent UTXOs will be reconciled automatically during \ - the next SPV sync cycle when a new block arrives.", - tx_id - ); - } - } - - return Err(timeout_err); - } - }; + self.fetch_platform_address_balances(seed_hash).await?; - // Step 6: Get wallet, SDK, and derive a fresh change address if needed - let (wallet, sdk, change_platform_address) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - // Derive a fresh change address from the BIP44 internal (change) path - // while we have write access (only needed when fees are NOT deducted - // from the output). Using change_address() ensures proper BIP44 - // separation between receive and change addresses. - let change_platform_address = if !fee_deduct_from_output { - let mut wallet_w = wallet_arc.write()?; - let addr = wallet_w - .change_address(self.network, Some(self)) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; - Some(PlatformAddress::try_from(addr).map_err(|e| { - TaskError::AddressConversionFailed { - source: Box::new(e), - } - })?) - } else { - None - }; - - let wallet = wallet_arc.read()?.clone(); - let sdk = self.sdk.load().as_ref().clone(); - (wallet, sdk, change_platform_address) - }; + Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) + } + + /// Orchestrated fee-from-wallet funding for a wallet-owned destination with + /// an in-pool change recipient. + /// + /// The `destination` receives exactly `amount` credits; the `change` + /// recipient (the lone `None` entry) absorbs the asset-lock surplus, from + /// which the Platform fee is deducted via `ReduceOutput`. Both recipients are + /// in-pool, so the orchestrator's pre-flight accepts the map and the full + /// recovery pipeline (CL-height retry, IS→CL fallback, consume-on-accept) + /// applies. The lock is sized to cover `amount` plus the estimated fee so the + /// surplus the change absorbs is exactly the fee budget. + async fn fund_platform_address_from_wallet_utxos_orchestrated_with_change( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + amount: u64, + destination: PlatformAddress, + change: PlatformAddress, + ) -> Result<BackendTaskSuccessResult, TaskError> { + use crate::wallet_backend::PlatformPathIndex; + use platform_wallet::wallet::asset_lock::AssetLockFunding; - // Step 7: Fund the destination platform address - let mut outputs = std::collections::BTreeMap::new(); + let wallet_arc = self.wallet_arc(&seed_hash)?; + let network = self.network; - let fee_strategy = if fee_deduct_from_output { - // Fee deducted from output: destination is the remainder recipient (gets - // asset lock value minus fee). ReduceOutput(0) tells Platform to deduct - // the fee from the single output. - outputs.insert(destination, None); - vec![AddressFundsFeeStrategyStep::ReduceOutput(0)] - } else { - // Fee NOT deducted from output: destination receives the exact requested - // amount. We use a fresh wallet-controlled change address to absorb the - // fee estimate surplus, keeping it spendable. - let amount_credits = amount.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { - TaskError::CreditCalculationOverflow { - amount, - credits_per_duff: CREDITS_PER_DUFF, - } - })?; + let (outputs, fee_strategy) = build_fee_from_wallet_outputs(amount, destination, change)?; - if let Some(change_address) = change_platform_address { - outputs.insert(destination, Some(amount_credits)); - outputs.insert(change_address, None); // Remainder recipient - - // Determine the BTreeMap index of the change address to target it - // with the fee strategy (BTreeMap iterates in key order). - let change_index = outputs - .keys() - .position(|k| *k == change_address) - .ok_or_else(|| TaskError::ChangeAddressUnavailable { - reason: "change address not found in outputs map", - })? as u16; - vec![AddressFundsFeeStrategyStep::ReduceOutput(change_index)] - } else { - return Err(TaskError::ChangeAddressUnavailable { - reason: "no change address was derived for platform funding", - }); - } + // The lock must also cover the Platform fee, since fees are paid from the + // wallet (the surplus the change output absorbs), not from `amount`. + let estimated_platform_fee_duffs = self + .fee_estimator() + .estimate_address_funding_from_asset_lock_duffs(2); + let asset_lock_amount = amount.saturating_add(estimated_platform_fee_duffs); + + let path_index = { + let wallet = wallet_arc.read()?; + PlatformPathIndex::from_wallet(&wallet, network) }; - outputs - .top_up( - &sdk, - asset_lock_proof, - asset_lock_private_key, + let backend = self.wallet_backend()?; + backend + .fund_platform_address( + &seed_hash, + AssetLockFunding::FromWalletBalance { + amount_duffs: asset_lock_amount, + account_index: 0, + }, + 0, + outputs, fee_strategy, - &wallet, + &path_index, None, ) - .await - .map_err(TaskError::from)?; + .await?; - // Step 9: Refresh platform address balances self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } + + /// Manual wallet-UTXO funding: create the asset lock, then submit the + /// `TopUpAddress` transition directly. The fallback path — used for non-owned + /// destinations, and for the fee-from-wallet case only when no distinct + /// in-pool change address is available (it then derives a fresh one). A + /// submit failure propagates via `?`, so the flow never reports a false + /// success. + async fn fund_platform_address_from_wallet_utxos_manual( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + amount: u64, + destination: PlatformAddress, + fee_deduct_from_output: bool, + ) -> Result<BackendTaskSuccessResult, TaskError> { + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; + + // When fees are paid from the wallet (not the output), the asset lock + // must be large enough to also cover the estimated Platform fee. + let asset_lock_amount = if fee_deduct_from_output { + amount + } else { + let estimated_platform_fee_duffs = self + .fee_estimator() + .estimate_address_funding_from_asset_lock_duffs(2); + amount.saturating_add(estimated_platform_fee_duffs) + }; + + let backend = self.wallet_backend()?; + let (asset_lock_proof, asset_lock_private_key, _tx_id) = backend + .create_asset_lock_proof( + &seed_hash, + asset_lock_amount, + AssetLockFundingType::AssetLockAddressTopUp, + 0, + ) + .await?; + + let wallet_arc = self.wallet_arc(&seed_hash)?; + let sdk = self.sdk.load().as_ref().clone(); + + // Derive the change address, build the outputs, and sign — all inside + // one held-seed scope so the operation prompts at most once and the + // seed zeroizes on return. The change must be a watched DIP-17 + // platform-payment address (so its credits are synced and spendable), + // not a BIP-44 change address — hence it is derived and registered via + // the platform-payment path, then the signer index is rebuilt to cover + // it. + let network = self.network; + backend + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + + let (outputs, fee_strategy) = + if fee_deduct_from_output { + let mut outputs: FundingOutputs = BTreeMap::new(); + outputs.insert(destination, None); + (outputs, vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]) + } else { + let change_core_addr = { + let mut wallet_w = wallet_arc.write()?; + wallet_w + .generate_platform_receive_address_with_seed(seed, network) + .map_err(|_| TaskError::WalletPlatformReceiveAddressFailed)? + }; + let change_address = PlatformAddress::try_from(change_core_addr) + .map_err(|e| TaskError::AddressConversionFailed { + source: Box::new(e), + })?; + build_fee_from_wallet_outputs(amount, destination, change_address)? + }; + + let path_index = { + let wallet = wallet_arc.read()?; + PlatformPathIndex::from_wallet(&wallet, network) + }; + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + outputs + .top_up( + &sdk, + asset_lock_proof, + asset_lock_private_key, + fee_strategy, + &signer, + None, + ) + .await + .map_err(TaskError::from) + }, + ) + .await?; + + self.fetch_platform_address_balances(seed_hash).await?; + + Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p2pkh(byte: u8) -> PlatformAddress { + PlatformAddress::P2pkh([byte; 20]) + } + + /// The destination receives exactly `amount * CREDITS_PER_DUFF`, and the + /// `ReduceOutput` index resolves to the change output — never the + /// destination — regardless of how the addresses sort in the map. + fn assert_outputs(destination: PlatformAddress, change: PlatformAddress) { + let amount = 12_345u64; + let (outputs, fee_strategy) = + build_fee_from_wallet_outputs(amount, destination, change).expect("builds outputs"); + + assert_eq!( + outputs.get(&destination).copied(), + Some(Some(amount * CREDITS_PER_DUFF)), + "destination receives the exact credit amount", + ); + assert_eq!( + outputs.get(&change).copied(), + Some(None), + "change output absorbs the remainder", + ); + + assert_eq!(fee_strategy.len(), 1); + let AddressFundsFeeStrategyStep::ReduceOutput(index) = fee_strategy[0] else { + panic!("fee-from-wallet branch reduces an output"); + }; + let reduced = outputs + .keys() + .nth(index as usize) + .expect("fee index points at an output"); + assert_eq!( + *reduced, change, + "the fee is deducted from the change output" + ); + } + + #[test] + fn fee_index_resolves_to_change_when_change_sorts_first() { + // change (0x01) sorts before destination (0x02) + assert_outputs(p2pkh(0x02), p2pkh(0x01)); + } + + #[test] + fn fee_index_resolves_to_change_when_change_sorts_last() { + // change (0x02) sorts after destination (0x01) + assert_outputs(p2pkh(0x01), p2pkh(0x02)); + } + + #[test] + fn amount_overflow_is_rejected() { + let err = build_fee_from_wallet_outputs(u64::MAX, p2pkh(0x01), p2pkh(0x02)) + .expect_err("overflows"); + assert!(matches!(err, TaskError::CreditCalculationOverflow { .. })); + } } diff --git a/src/backend_task/wallet/generate_platform_receive_address.rs b/src/backend_task/wallet/generate_platform_receive_address.rs new file mode 100644 index 000000000..c388320e7 --- /dev/null +++ b/src/backend_task/wallet/generate_platform_receive_address.rs @@ -0,0 +1,52 @@ +//! Backend task: generate a fresh Platform (DIP-17/18) receive address for a wallet. +//! Fetches the seed JIT through the secret chokepoint; only the Bech32m address crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use std::sync::Arc; + +impl AppContext { + /// Generate a fresh Platform (DIP-17/18) receive address for a wallet. + /// + /// The HD seed is fetched just-in-time through the JIT chokepoint and + /// borrowed only for the single derivation inside the closure; it zeroizes + /// when the closure returns. The new address is derived, registered on the + /// in-memory wallet, and only the Bech32m-encoded address string crosses + /// back to the UI — the seed never leaves the backend. This is the seam the + /// sync receive-address UI uses instead of reading the wallet's parked seed. + pub(crate) async fn generate_platform_receive_address( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let wallet_arc = self.wallet_arc(&seed_hash)?; + + let network = self.network; + let backend = self.wallet_backend()?; + let address = backend + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let mut guard = wallet_arc.write()?; + let address = guard + .generate_platform_receive_address_with_seed(seed, network) + .map_err(|detail| { + tracing::warn!(error = %detail, "Platform receive-address derivation failed"); + TaskError::WalletPlatformReceiveAddressFailed + })?; + let platform_address = PlatformAddress::try_from(address).map_err(|detail| { + tracing::warn!(error = %detail, "Derived address is not a valid Platform address"); + TaskError::WalletPlatformReceiveAddressFailed + })?; + Ok(platform_address.to_bech32m_string(network)) + }, + ) + .await?; + + Ok(BackendTaskSuccessResult::GeneratedPlatformReceiveAddress { seed_hash, address }) + } +} diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs index 977cbb8ac..babe68793 100644 --- a/src/backend_task/wallet/generate_receive_address.rs +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -1,57 +1,73 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::wallet::{DerivationPathReference, DerivationPathType, WalletSeedHash}; -use crate::spv::CoreBackendMode; +use crate::model::wallet::WalletSeedHash; use std::sync::Arc; impl AppContext { + /// Generate a fresh receive address via the wallet backend. pub(crate) async fn generate_receive_address( self: &Arc<Self>, seed_hash: WalletSeedHash, - ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - - let address_string = if self.core_backend_mode() == CoreBackendMode::Spv { - let derived = self - .spv_manager - .next_bip44_receive_address(seed_hash, 0) - .await - .map_err(|e| { - crate::backend_task::error::TaskError::WalletAddressDerivationFailed { - detail: e, - } - })?; + ) -> Result<BackendTaskSuccessResult, TaskError> { + // A seed hash that matches no wallet in the local store is a genuine + // "not found". This is distinct from a known wallet whose backend is + // still loading: the backend reports the latter as the transient, + // retryable `WalletNotLoaded`. Resolving the existence question here, + // where the DET-side wallet store lives, keeps that distinction honest + // instead of collapsing both cases into `WalletNotLoaded`. + if !self.wallets.read()?.contains_key(&seed_hash) { + return Err(TaskError::WalletNotFound); + } + let backend = self.wallet_backend()?; + let address = backend.next_receive_address(&seed_hash).await?; + Ok(BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address }) + } +} - let _ = self.register_spv_address( - &wallet_arc, - derived.address.clone(), - derived.derivation_path.clone(), - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP44, - )?; +#[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::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; - derived.address.to_string() - } else { - let mut wallet = wallet_arc.write()?; - wallet - .receive_address(self.network, true, Some(self)) - .map_err(|e| { - crate::backend_task::error::TaskError::WalletAddressDerivationFailed { - detail: e, - } - })? - .to_string() - }; + /// Regression for #860: a receive-address request for a seed hash that + /// matches no locally-stored wallet must return `WalletNotFound`, NOT the + /// transient `WalletNotLoaded`. The existence check runs before the wallet + /// backend is consulted, so this holds even with no backend wired. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unknown_seed_hash_returns_wallet_not_found() { + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); - Ok(BackendTaskSuccessResult::GeneratedReceiveAddress { - seed_hash, - address: address_string, - }) + // No wallets are loaded, so any seed hash is genuinely unknown. + let unknown: WalletSeedHash = [0xAB; 32]; + let err = ctx + .generate_receive_address(unknown) + .await + .expect_err("an unknown seed hash must fail, not succeed"); + assert!( + matches!(err, TaskError::WalletNotFound), + "expected WalletNotFound, got {err:?}" + ); } } diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 83a227fd7..a4c841414 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -1,23 +1,193 @@ +mod derive_identity_key_for_display; +mod derive_key_for_display; mod fetch_platform_address_balances; mod fund_platform_address_from_asset_lock; mod fund_platform_address_from_wallet_utxos; +mod generate_platform_receive_address; mod generate_receive_address; +mod sign_message_with_identity_key; +mod sign_message_with_key; mod transfer_platform_credits; +mod warm_identity_auth_pubkeys; mod withdraw_from_platform_address; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; -use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; +use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; +use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey}; use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::platform::Identifier; use std::collections::BTreeMap; +use std::sync::Arc; + +/// Build the Base64-encoded Dash signed-message envelope for `message` signed +/// with `secret_key`. The envelope is a recoverable signature: a header byte +/// (`27 + recId`, `+4` when `compressed`) followed by the 64-byte signature, so +/// a verifier can recover the signer's public key from the signature alone. +/// Shared by the wallet-key and identity-key message-signing tasks. +pub(crate) fn dash_signed_message( + message: &str, + secret_key: &SecretKey, + compressed: bool, +) -> String { + let secp = Secp256k1::new(); + let message_hash = signed_msg_hash(message); + let digest = Message::from_digest(*message_hash.as_byte_array()); + let recoverable = secp.sign_ecdsa_recoverable(&digest, secret_key); + MessageSignature::new(recoverable, compressed).to_base64() +} + +impl AppContext { + /// Resolve `seed_hash`'s wallet, derive its private key at + /// `derivation_path` through the HD-seed JIT chokepoint, and hand it to + /// `f`. The seed and derived key zeroize when the closure returns — only + /// `f`'s result crosses back to the caller. + /// + /// `derivation_failed` is the `TaskError` reported when derivation itself + /// fails; callers pass the variant matching their user-facing wording + /// (message-signing vs. key-display differ). Shared by the wallet-key + /// sign and display tasks. + async fn with_wallet_derived_key<T>( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + derivation_path: &DerivationPath, + derivation_failed: TaskError, + f: impl FnOnce(PrivateKey) -> Result<T, TaskError>, + ) -> Result<T, TaskError> { + let wallet = self.wallet_arc(&seed_hash)?.read()?.clone(); + + let network = self.network; + let backend = self.wallet_backend()?; + backend + .secret_access() + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let private_key = wallet + .private_key_at_derivation_path_with_seed(seed, derivation_path, network) + .map_err(|detail| { + tracing::warn!(error = %detail, "Wallet-key derivation failed"); + derivation_failed + })?; + f(private_key) + }, + ) + .await + } + + /// Resolve the vault-backed identity key at `(identity_id, target, + /// key_id)` through the JIT chokepoint and hand its `SecretKey` to `f`. + /// The raw key zeroizes when the closure returns — only `f`'s result + /// crosses back to the caller. Shared by the identity-key sign and + /// display tasks. + async fn with_identity_secret_key<T>( + self: &Arc<Self>, + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + f: impl FnOnce(SecretKey) -> Result<T, TaskError>, + ) -> Result<T, TaskError> { + let scope = crate::wallet_backend::SecretScope::IdentityKey { + identity_id: identity_id.to_buffer(), + target, + key_id, + }; + let backend = self.wallet_backend()?; + backend + .secret_access() + .with_secret(&scope, |plaintext| { + let key = plaintext + .expose_identity_key() + .ok_or(TaskError::IdentityKeyMissing)?; + // Present-but-malformed key bytes are distinct from a + // genuinely absent key and from a signing/derivation failure. + let secret_key = SecretKey::from_byte_array(key).map_err(|detail| { + tracing::warn!(error = %detail, "Identity-key secret construction failed"); + TaskError::IdentityKeyMalformed + })?; + f(secret_key) + }) + .await + } +} #[derive(Debug, Clone, PartialEq)] pub enum WalletTask { GenerateReceiveAddress { seed_hash: WalletSeedHash, }, + /// Derive a private key for on-screen display/export. The HD seed is + /// fetched just-in-time through the JIT chokepoint, the key is derived in + /// the backend, and only the WIF (wrapped in `Secret`) is returned — the + /// seed never crosses into the UI layer. + DeriveKeyForDisplay { + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + }, + /// Generate a fresh Platform (DIP-17/18) receive address. The HD seed is + /// fetched just-in-time through the JIT chokepoint, the address is derived + /// and registered in the backend, and only the resulting address crosses + /// back to the UI — the seed never leaves the backend. + GeneratePlatformReceiveAddress { + seed_hash: WalletSeedHash, + }, + /// Warm the identity-authentication public-key cache for one identity + /// index so the identity-key chooser can read its public keys without the + /// seed. The HD seed is fetched just-in-time through the JIT chokepoint, + /// the first `key_count` auth keys are derived and persisted to the cache + /// in the backend, and only a completion signal crosses back to the UI — + /// the seed never leaves the backend. + WarmIdentityAuthPubkeys { + seed_hash: WalletSeedHash, + identity_index: u32, + /// Number of auth keys to warm (master at index 0 plus the default + /// additional keys), so the chooser's cache reads all hit. + key_count: u32, + }, + /// Sign a message with a wallet-derived key at `derivation_path`. The HD + /// seed is fetched just-in-time through the JIT chokepoint, the key is + /// derived and the message signed entirely in the backend, and only the + /// Base64 signature (public) is returned — the seed and the derived private + /// key never cross into the UI layer. + SignMessageWithKey { + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + /// The message to sign (the user-entered plaintext, not a secret). + message: String, + /// The key type that determines the signing scheme. + key_type: KeyType, + }, + /// Derive an identity private key for on-screen display/export. The raw + /// key is fetched just-in-time from the vault through the JIT chokepoint + /// (`InVault` route) and only the WIF (wrapped in `Secret`) crosses back to + /// the UI — the key bytes never become resident. + DeriveIdentityKeyForDisplay { + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + }, + /// Sign a message with a vault-backed identity key. The raw key is fetched + /// just-in-time through the chokepoint, the message signed in the backend, + /// and only the public Base64 signature crosses back — the key never + /// becomes resident. + SignMessageWithIdentityKey { + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + /// The message to sign (the user-entered plaintext, not a secret). + message: String, + /// The key type that determines the signing scheme. + key_type: KeyType, + }, /// Fetch Platform address balances and nonces from Platform for a wallet FetchPlatformAddressBalances { seed_hash: WalletSeedHash, @@ -33,13 +203,20 @@ pub enum WalletTask { /// Should be the input with the highest balance to ensure sufficient funds for fees. fee_payer_index: u16, }, - /// Fund Platform addresses from an asset lock + /// List the wallet's tracked asset locks. Read through the upstream + /// `AssetLockManager` (the single source of truth) off the UI thread, so + /// screens never drive the async accessor from the egui frame loop. + ListTrackedAssetLocks { + seed_hash: WalletSeedHash, + }, + /// Fund Platform addresses from a tracked asset lock identified by its + /// credit-output outpoint. The proof and credit-output key are recovered + /// from the upstream `AssetLockManager` and the wallet's funding + /// account; DET no longer stages the asset lock itself. FundPlatformAddressFromAssetLock { seed_hash: WalletSeedHash, - /// Asset lock proof - asset_lock_proof: Box<AssetLockProof>, - /// Address to fund (the asset lock address is the source) - asset_lock_address: Address, + /// Credit-output outpoint of the tracked asset lock. + out_point: OutPoint, /// Platform addresses and optional amounts to fund (None = distribute evenly) outputs: BTreeMap<PlatformAddress, Option<Credits>>, }, @@ -68,3 +245,41 @@ pub enum WalletTask { fee_deduct_from_output: bool, }, } + +#[cfg(test)] +mod tests { + use super::dash_signed_message; + use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; + + /// The shared signed-message envelope round-trips: the signer's public key + /// recovers from the produced signature for both compression flags. A + /// hardcoded recovery header would fail ~50% of the time here. Both the + /// wallet-key and identity-key signers call this one helper. + fn assert_recovers(compressed: bool) { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_byte_array(&[0x42u8; 32]).expect("valid secret"); + let expected_pubkey = PublicKey::from_secret_key(&secp, &secret_key); + let message = "Bilby was here"; + + let base64 = dash_signed_message(message, &secret_key, compressed); + let parsed = MessageSignature::from_base64(&base64).expect("valid envelope"); + assert_eq!(parsed.compressed, compressed); + + let recovered = parsed + .recover_pubkey(&secp, signed_msg_hash(message)) + .expect("recovers a public key"); + assert_eq!(recovered.inner, expected_pubkey); + assert_eq!(recovered.compressed, compressed); + } + + #[test] + fn recovers_signer_pubkey_compressed() { + assert_recovers(true); + } + + #[test] + fn recovers_signer_pubkey_uncompressed() { + assert_recovers(false); + } +} diff --git a/src/backend_task/wallet/sign_message_with_identity_key.rs b/src/backend_task/wallet/sign_message_with_identity_key.rs new file mode 100644 index 000000000..9de62fb4b --- /dev/null +++ b/src/backend_task/wallet/sign_message_with_identity_key.rs @@ -0,0 +1,48 @@ +//! Backend task: sign a message with a vault-backed identity key. +//! Fetches the raw key JIT through the chokepoint (`InVault` route); only the +//! Base64 signature crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::backend_task::wallet::dash_signed_message; +use crate::context::AppContext; +use crate::model::qualified_identity::PrivateKeyTarget; +use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::platform::Identifier; +use std::sync::Arc; + +impl AppContext { + /// Sign a message with a vault-backed identity key. + /// + /// The raw key is fetched just-in-time through the chokepoint and borrowed + /// only for the single sign inside the closure; it zeroizes on return. Only + /// the public Base64 signature crosses back to the UI. Identity keys are + /// compressed by convention, so the recoverable envelope uses `compressed`. + pub(crate) async fn sign_message_with_identity_key( + self: &Arc<Self>, + identity_id: Identifier, + target: PrivateKeyTarget, + key_id: KeyID, + message: String, + key_type: KeyType, + ) -> Result<BackendTaskSuccessResult, TaskError> { + // Reject non-ECDSA before touching the vault. + if !matches!(key_type, KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160) { + return Err(TaskError::WalletMessageSignUnsupportedKeyType); + } + + let signature = self + .with_identity_secret_key(identity_id, target.clone(), key_id, |secret_key| { + // Identity keys are compressed by convention. + Ok(dash_signed_message(message.as_str(), &secret_key, true)) + }) + .await?; + + Ok(BackendTaskSuccessResult::IdentityMessageSigned { + identity_id, + target, + key_id, + signature, + }) + } +} diff --git a/src/backend_task/wallet/sign_message_with_key.rs b/src/backend_task/wallet/sign_message_with_key.rs new file mode 100644 index 000000000..c0f4cf28c --- /dev/null +++ b/src/backend_task/wallet/sign_message_with_key.rs @@ -0,0 +1,63 @@ +//! Backend task: sign a message with a wallet-derived key at a given derivation path. +//! Fetches the seed JIT through the secret chokepoint; only the Base64 signature crosses back to the UI. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::backend_task::wallet::dash_signed_message; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::dashcore::secp256k1::SecretKey; +use dash_sdk::dpp::identity::KeyType; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use std::sync::Arc; + +impl AppContext { + /// Sign a message with a wallet-derived key at `derivation_path`. + /// + /// The HD seed is fetched just-in-time through the JIT chokepoint and + /// borrowed only for the single derivation inside the closure; both the + /// seed and the derived private key zeroize when the closure returns. Only + /// the resulting public Base64 signature crosses back to the UI — no secret + /// material leaves the backend. This is the seam the on-screen "sign + /// message" feature uses for wallet-derived keys instead of deriving the key + /// in the UI from the wallet's parked seed. + pub(crate) async fn sign_message_with_key( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + derivation_path: DerivationPath, + message: String, + key_type: KeyType, + ) -> Result<BackendTaskSuccessResult, TaskError> { + // Only ECDSA key types support message signing here; reject others + // before touching the seed so no prompt fires for an unsupported key. + if !matches!(key_type, KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160) { + return Err(TaskError::WalletMessageSignUnsupportedKeyType); + } + + let signature = self + .with_wallet_derived_key( + seed_hash, + &derivation_path, + TaskError::WalletMessageSigningFailed, + |private_key| { + let secret_key = SecretKey::from_byte_array(&private_key.inner.secret_bytes()) + .map_err(|detail| { + tracing::warn!(error = %detail, "Sign-message secret key construction failed"); + TaskError::WalletMessageSigningFailed + })?; + Ok(dash_signed_message( + message.as_str(), + &secret_key, + private_key.compressed, + )) + }, + ) + .await?; + + Ok(BackendTaskSuccessResult::WalletMessageSigned { + seed_hash, + derivation_path, + signature, + }) + } +} diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs index 941db65de..3028561b9 100644 --- a/src/backend_task/wallet/transfer_platform_credits.rs +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -15,19 +15,13 @@ impl AppContext { outputs: BTreeMap<PlatformAddress, Credits>, fee_payer_index: u16, ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; // Clone wallet and SDK before the async operation to avoid holding guards across await let (wallet, sdk) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - let wallet = wallet_arc.read()?.clone(); + let wallet = self.wallet_arc(&seed_hash)?.read()?.clone(); let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk) }; @@ -37,21 +31,39 @@ impl AppContext { fee_payer_index, )]; + // Per-input address/amount detail is intentionally not logged: the + // summary line carries the aggregate counts, and per-input financial + // detail does not belong in plaintext logs at the default level. tracing::info!( "transfer_platform_credits: fee_payer_index={}, inputs={}, outputs={}", fee_payer_index, inputs.len(), outputs.len() ); - for (idx, (addr, amount)) in inputs.iter().enumerate() { - tracing::info!(" Input {}: {:?} -> {}", idx, addr, amount); - } - // Use the SDK to transfer - returns proof-verified updated address infos - let address_infos = sdk - .transfer_address_funds(inputs, outputs, fee_strategy, &wallet, None) - .await - .map_err(crate::backend_task::error::TaskError::from)?; + // Build the pure address→path index before entering the secret scope, + // then sign each input through a JIT platform signer that borrows the + // HD seed for the duration of the SDK call only. The seed zeroizes when + // the scope returns — it never enters this layer by value. + let network = self.network; + let path_index = PlatformPathIndex::from_wallet(&wallet, network); + let backend = self.wallet_backend()?; + let (address_infos, _height) = backend + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(crate::backend_task::error::TaskError::WalletLocked)?; + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + sdk.transfer_address_funds(inputs, outputs, fee_strategy, &signer, None) + .await + .map_err(crate::backend_task::error::TaskError::from) + }, + ) + .await?; // Update wallet balances from the proof-verified response (no extra fetch needed) self.update_wallet_platform_address_info_from_sdk(seed_hash, &address_infos)?; diff --git a/src/backend_task/wallet/warm_identity_auth_pubkeys.rs b/src/backend_task/wallet/warm_identity_auth_pubkeys.rs new file mode 100644 index 000000000..70f0aeda2 --- /dev/null +++ b/src/backend_task/wallet/warm_identity_auth_pubkeys.rs @@ -0,0 +1,77 @@ +//! Backend task: warm the identity-authentication public-key cache for one identity index. +//! Fetches the seed JIT through the secret chokepoint; only derived public keys are persisted — no secret leaves the backend. + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use crate::wallet_backend::SecretScope; +use std::sync::Arc; + +impl AppContext { + /// Warm the identity-authentication public-key cache for one identity index. + /// + /// The identity-key chooser runs in synchronous `ui()` and cannot `await`, + /// so it reads the public keys it shows from the + /// [`AuthPubkeyCache`](crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache). + /// On a cold cache this task fills it: the HD seed is fetched just-in-time + /// through the JIT chokepoint, the first `key_count` auth keys are derived + /// and persisted, and only a completion signal returns. The seed never + /// crosses into the UI. + /// + /// Best-effort and idempotent: keys already cached are skipped; a single + /// `with_secret` scope covers the whole range (one prompt for a protected + /// wallet, though at the chooser the wallet is already open so the scope + /// resolves from the session cache without prompting). + pub(crate) async fn warm_identity_auth_pubkeys( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + identity_index: u32, + key_count: u32, + ) -> Result<BackendTaskSuccessResult, TaskError> { + let network = self.network; + + let wallet = self.wallet_arc(&seed_hash)?; + + let backend = self.wallet_backend()?; + let view = backend.auth_pubkey_cache(); + let mut cache = view.get(network, &seed_hash); + + let missing: Vec<u32> = (0..key_count) + .filter(|&key_index| cache.get(network, identity_index, key_index).is_none()) + .collect(); + + if missing.is_empty() { + return Ok(BackendTaskSuccessResult::IdentityAuthPubkeysWarmed { identity_index }); + } + + backend + .secret_access() + .with_secret(&SecretScope::HdSeed { seed_hash }, |plaintext| { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let guard = wallet.read()?; + let mut changed = false; + for &key_index in &missing { + let public_key = guard + .identity_authentication_ecdsa_public_key_from_seed( + seed, + network, + identity_index, + key_index, + ) + .map_err(|detail| { + tracing::warn!(error = %detail, "Identity-auth pubkey warm derivation failed"); + TaskError::WalletKeyLookupFailed + })?; + changed |= cache.insert(network, identity_index, key_index, &public_key); + } + if changed { + view.put(network, &seed_hash, &cache)?; + } + Ok(()) + }) + .await?; + + Ok(BackendTaskSuccessResult::IdentityAuthPubkeysWarmed { identity_index }) + } +} diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index 38dcd89b3..0cacca187 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -3,6 +3,7 @@ use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::identity::core_script::CoreScript; use std::collections::BTreeMap; use std::sync::Arc; @@ -17,43 +18,70 @@ impl AppContext { core_fee_per_byte: u32, fee_payer_index: u16, ) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> { + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; - // Clone wallet and SDK before the async operation to avoid holding guards across await - let (wallet, sdk) = { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(crate::backend_task::error::TaskError::WalletNotFound)? - }; - let wallet = wallet_arc.read()?.clone(); - let sdk = self.sdk.load().as_ref().clone(); - (wallet, sdk) - }; + // Resolve the shared wallet handle and SDK before the async operation. + let network = self.network; + let wallet_arc = self.wallet_arc(&seed_hash)?; + let sdk = self.sdk.load().as_ref().clone(); // Deduct fee from the specified input (should be the one with highest balance) let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( fee_payer_index, )]; - // Use the SDK to withdraw - let _result = sdk - .withdraw_address_funds( - inputs, - None, // No change output - fee_strategy, - core_fee_per_byte, - Pooling::Never, - output_script, - &wallet, - None, + // Core-address form of each spent input, for signer-path reconciliation. + let input_addresses: Vec<Address> = inputs + .keys() + .map(|pa| pa.to_address_with_network(network)) + .collect(); + + // Sign each withdrawal input through a JIT platform signer that borrows + // the HD seed only for the duration of the SDK call. The seed zeroizes + // on return. + let backend = self.wallet_backend()?; + let _result = backend + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(crate::backend_task::error::TaskError::WalletLocked)?; + + // Self-heal before signing: cache the xpub and register each + // input's derivation path if the coordinator reported its + // balance without one, so a visible balance is always + // signable. Guard drops before the SDK await below. + let path_index = { + let mut wallet = wallet_arc.write()?; + wallet.ensure_platform_payment_account_xpub(seed, network); + for addr in &input_addresses { + wallet.reconcile_platform_address(addr, network); + } + PlatformPathIndex::from_wallet(&wallet, network) + }; + + let signer = DetPlatformSigner::from_held(seed, network, &path_index); + sdk.withdraw_address_funds( + inputs, + None, // No change output + fee_strategy, + core_fee_per_byte, + Pooling::Never, + output_script, + &signer, + None, + ) + .await + .map_err(crate::backend_task::error::TaskError::from) + }, ) - .await - .map_err(crate::backend_task::error::TaskError::from)?; + .await?; // Trigger a balance refresh self.fetch_platform_address_balances(seed_hash).await?; diff --git a/src/bin/det_cli/connect.rs b/src/bin/det_cli/connect.rs index d834d0aea..3824f953f 100644 --- a/src/bin/det_cli/connect.rs +++ b/src/bin/det_cli/connect.rs @@ -13,7 +13,13 @@ pub(super) fn format_service_error(e: rmcp::service::ServiceError) -> String { } /// Run as a standalone MCP stdio server (replaces the separate dash-evo-tool-mcp binary). -pub(super) fn run_stdio_server() -> Result<(), Box<dyn std::error::Error>> { +/// +/// Always terminates via [`std::process::exit`] rather than returning — this +/// bypasses Tokio runtime teardown and prevents coordinator OS threads +/// (`identity-sync`, `platform-address-sync`, `shielded-sync`) from panicking +/// when they poll `tokio::time::sleep` against a shutting-down timer wheel. +/// See `DashMcpService::shutdown_wallet_backend` for the full race analysis. +pub(super) fn run_stdio_server() -> ! { use dash_evo_tool::logging::initialize_logger; initialize_logger(); @@ -25,11 +31,28 @@ pub(super) fn run_stdio_server() -> Result<(), Box<dyn std::error::Error>> { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(4) .enable_all() - .build()?; + .build() + .expect("failed to build Tokio runtime"); - runtime - .block_on(dash_evo_tool::mcp::start_stdio()) - .map_err(|e| -> Box<dyn std::error::Error> { e }) + // `start_stdio` drains the wallet backend's persister (quiesce) before + // returning. We do NOT call `runtime.shutdown_timeout` afterwards — + // instead we hard-exit below so coordinator threads cannot race the + // timer-wheel teardown. + let result = runtime.block_on(dash_evo_tool::mcp::start_stdio()); + + let exit_code: i32 = match result { + Ok(()) => 0, + Err(ref e) => { + eprintln!("MCP server error: {e}"); + 1 + } + }; + + use std::io::Write as _; + let _ = std::io::stdout().lock().flush(); + let _ = std::io::stderr().lock().flush(); + // TODO(graceful-teardown): replace with normal return once WalletBackend::quiesce() joins coordinator threads. + std::process::exit(exit_code); } pub(super) async fn connect_in_process() -> Result<McpClient, Box<dyn std::error::Error>> { @@ -64,11 +87,13 @@ pub(super) async fn connect_http( StreamableHttpClientTransport, StreamableHttpClientTransportConfig, }; - let config = StreamableHttpClientTransportConfig { - uri: addr.into(), - auth_header: bearer.map(|token| format!("Bearer {token}")), - ..Default::default() - }; + let mut config = StreamableHttpClientTransportConfig::with_uri(addr); + if let Some(token) = bearer { + // rmcp's `auth_header` takes the raw token and prepends `Bearer ` itself + // (via reqwest's `bearer_auth`). Passing a pre-prefixed value would put + // `Bearer Bearer <token>` on the wire and fail server-side auth. + config = config.auth_header(token.to_string()); + } let transport = StreamableHttpClientTransport::from_config(config); let client = ().serve(transport).await?; Ok(client) diff --git a/src/bin/det_cli/headless.rs b/src/bin/det_cli/headless.rs index dcee11d41..d92153410 100644 --- a/src/bin/det_cli/headless.rs +++ b/src/bin/det_cli/headless.rs @@ -1,9 +1,12 @@ //! Headless HTTP MCP server daemon. -use std::sync::Arc; - /// Run det-cli as a headless HTTP MCP server. +/// /// Eagerly initializes AppContext, starts SPV, serves MCP tools over HTTP. +/// Terminates via [`std::process::exit`] on clean shutdown — bypassing Tokio +/// runtime teardown to prevent coordinator OS threads from panicking against a +/// shutting-down timer wheel. See `DashMcpService::shutdown_wallet_backend` +/// for the race analysis. pub(super) fn run_headless() -> Result<(), Box<dyn std::error::Error>> { use dash_evo_tool::logging::initialize_logger; use dash_evo_tool::mcp::server::init_app_context; @@ -25,11 +28,11 @@ pub(super) fn run_headless() -> Result<(), Box<dyn std::error::Error>> { .enable_all() .build()?; - runtime.block_on(async { + let result: Result<(), Box<dyn std::error::Error>> = runtime.block_on(async { let ctx = init_app_context() .await .map_err(|e| format!("Failed to initialize: {}", e.message))?; - let swappable = Arc::new(arc_swap::ArcSwap::new(ctx)); + let swappable = std::sync::Arc::new(arc_swap::ArcSwap::new(ctx)); let cancel = tokio_util::sync::CancellationToken::new(); let cancel_on_signal = cancel.clone(); @@ -39,8 +42,35 @@ pub(super) fn run_headless() -> Result<(), Box<dyn std::error::Error>> { cancel_on_signal.cancel(); }); - start_http_server(swappable, config, cancel) + let result = start_http_server(swappable.clone(), config, cancel) .await - .map_err(|e| -> Box<dyn std::error::Error> { e }) - }) + .map_err(|e| -> Box<dyn std::error::Error> { e }); + + // Drain the wallet backend's persister. `swappable.load_full()` yields + // the CURRENTLY active context; network_switch already drained the + // outgoing backend at swap time (see NetworkSwitch::invoke in + // src/mcp/tools/network.rs), so only the current context needs + // draining here. + let current_ctx = swappable.load_full(); + if let Ok(backend) = current_ctx.wallet_backend() { + backend.shutdown().await; + } + + result + }); + + // Hard-exit: bypass runtime teardown to prevent coordinator OS threads from + // panicking against the shutting-down timer wheel. + let exit_code: i32 = match result { + Ok(()) => 0, + Err(ref e) => { + eprintln!("Headless server error: {e}"); + 1 + } + }; + use std::io::Write as _; + let _ = std::io::stdout().lock().flush(); + let _ = std::io::stderr().lock().flush(); + // TODO(graceful-teardown): replace with normal return once WalletBackend::quiesce() joins coordinator threads. + std::process::exit(exit_code); } diff --git a/src/bin/det_cli/main.rs b/src/bin/det_cli/main.rs index a675804a4..4014a5685 100644 --- a/src/bin/det_cli/main.rs +++ b/src/bin/det_cli/main.rs @@ -104,7 +104,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { } if matches!(cli.command, Some(Commands::Serve)) { - return connect::run_stdio_server(); + connect::run_stdio_server(); } #[cfg(feature = "headless")] @@ -126,16 +126,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { .enable_all() .build()?; - if let Err(e) = runtime.block_on(run(cli)) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - Ok(()) + let exit_code: i32 = match runtime.block_on(run(cli)) { + Ok(()) => 0, + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + }; + + // Hard-exit: bypass Tokio runtime teardown to prevent coordinator OS threads + // (identity-sync, platform-address-sync, shielded-sync) from panicking when + // they poll `tokio::time::sleep` against a shutting-down timer wheel. + // The tool result has already been printed by this point; any SQLite writes + // issued before the tool returned are transaction-committed. + // See `DashMcpService::shutdown_wallet_backend` for the full race analysis. + use std::io::Write as _; + let _ = std::io::stdout().lock().flush(); + let _ = std::io::stderr().lock().flush(); + // TODO(graceful-teardown): replace with normal return once WalletBackend::quiesce() joins coordinator threads. + std::process::exit(exit_code); } async fn run(cli: Cli) -> Result<(), String> { + // An empty/whitespace bearer means "no key": the default .env ships + // `MCP_API_KEY=` (empty), which dotenvy sets as an empty string, so clap's + // env binding yields `Some("")`. Treat that exactly like an unset key — + // matching the HTTP server, which also disables auth on an empty key. + let bearer = cli + .bearer + .as_deref() + .map(str::trim) + .filter(|b| !b.is_empty()); + // Mode selection: --standalone or no bearer -> stdio; bearer present -> HTTP. - let use_stdio = cli.standalone || cli.bearer.is_none(); + let use_stdio = cli.standalone || bearer.is_none(); let client: McpClient = if use_stdio { connect::connect_in_process() @@ -143,7 +167,7 @@ async fn run(cli: Cli) -> Result<(), String> { .map_err(|e| e.to_string())? } else { let addr = resolve_addr(cli.addr); - connect::connect_http(&addr, cli.bearer.as_deref()) + connect::connect_http(&addr, bearer) .await .map_err(|e| e.to_string())? }; diff --git a/src/boot.rs b/src/boot.rs new file mode 100644 index 000000000..60f754036 --- /dev/null +++ b/src/boot.rs @@ -0,0 +1,341 @@ +//! GUI boot wrapper that un-bricks a passphrase-protected legacy seed vault. +//! +//! The seed vault is normally opened keyless at boot (obfuscation, not +//! confidentiality — see [`open_secret_store`](crate::wallet_backend::single_key::open_secret_store)). +//! A vault an older build sealed with a real passphrase fails that keyless +//! open with `SecretStoreError::WrongPassphrase`, which previously propagated +//! out of `AppState::new` and aborted startup before any window appeared. +//! +//! [`BootApp`] wraps the eframe app: when the keyless open fails *specifically* +//! with a wrong-passphrase, it renders the shared +//! [`passphrase_modal`](crate::ui::components::passphrase_modal) (proper masked +//! input, zeroized) in the SAME event loop (a second `eframe::run_native` is +//! not portable — winit forbids a second event loop on macOS) and re-opens the +//! SAME vault in place with the supplied passphrase. The open is +//! non-destructive — the vault is never deleted, recreated, or rekeyed — so +//! wallet seeds are never at risk. Every other boot failure stays fatal exactly +//! as before. +//! +//! Only the GUI binary boots through here; the headless MCP/CLI path keeps the +//! keyless open and surfaces the typed error instead of popping a dialog. + +use std::path::PathBuf; +use std::sync::Arc; + +use eframe::egui; +use platform_wallet_storage::secrets::{SecretStore, SecretString}; + +use crate::app::AppState; +use crate::context::AppContext; +use crate::database::Database; +use crate::ui::components::passphrase_modal::{ + PassphraseModalConfig, PassphraseModalOutcome, passphrase_modal, +}; + +type BootError = Box<dyn std::error::Error + Send + Sync>; + +/// Sole owner of the production boot environment: resolve the app data +/// directory (creating it if needed), ensure the `.env` file exists, and +/// initialize logging. Returns the resolved data directory. +/// +/// Called from `main` before the tokio runtime starts and again from +/// [`AppState::boot_inputs`](crate::app::AppState::boot_inputs) inside the +/// eframe boot; the logger's `Once` guard makes the second call a no-op, so +/// this stays the single place the sequence is written. +pub fn prepare_environment() -> Result<PathBuf, std::io::Error> { + use crate::app_dir::{app_user_data_dir_path, ensure_data_dir_exists, ensure_env_file}; + let data_dir = app_user_data_dir_path()?; + ensure_data_dir_exists(&data_dir)?; + ensure_env_file(&data_dir); + crate::logging::initialize_logger(); + Ok(data_dir) +} + +/// The eframe application during boot: either still collecting the legacy +/// vault passphrase, or the fully built [`AppState`]. +pub enum BootApp { + /// The vault is passphrase-protected; collecting the passphrase. + Unlocking(UnlockState), + /// The vault is open and the app is running normally. + Running(Box<AppState>), + /// Unlock succeeded but app assembly failed (fatal, e.g. no network could + /// be initialized). Renders nothing; a viewport close has been requested. + Failed, +} + +impl BootApp { + /// Build the boot app, opening the seed vault keyless first. + /// + /// On success the full [`AppState`] is built immediately. If the keyless + /// open fails *specifically* with a wrong vault passphrase, this returns + /// [`BootApp::Unlocking`] so the frame loop can prompt for it. Any other + /// failure (including app assembly) propagates as an error, aborting boot + /// exactly as before. + pub fn new(ctx: egui::Context) -> Result<Self, BootError> { + let (data_dir, db) = AppState::boot_inputs()?; + match classify_open(&data_dir)? { + BootDecision::Ready(store) => Ok(BootApp::Running(Box::new(AppState::new_inner( + ctx, db, data_dir, store, + )?))), + BootDecision::Unlock => { + tracing::warn!( + "Seed vault is protected by a passphrase from an earlier version; \ + prompting to unlock instead of aborting boot" + ); + Ok(BootApp::Unlocking(UnlockState::new(ctx, db, data_dir))) + } + } + } + + /// Attempt to open the vault with the supplied passphrase and, on success, + /// build the full app. A wrong passphrase re-arms the prompt; a genuine + /// assembly failure is fatal (the viewport is closed). + fn try_unlock(&mut self, passphrase: SecretString, ctx: &egui::Context) { + let data_dir = match self { + BootApp::Unlocking(state) => state.data_dir.clone(), + _ => return, + }; + match AppContext::open_secret_store_with_passphrase(&data_dir, passphrase) { + Ok(store) => { + let BootApp::Unlocking(state) = std::mem::replace(self, BootApp::Failed) else { + return; + }; + match AppState::new_inner(state.ctx, state.db, state.data_dir, store) { + Ok(app) => *self = BootApp::Running(Box::new(app)), + Err(e) => { + tracing::error!(error = ?e, "Could not start the app after unlocking the vault"); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + Err(e) if e.is_secret_store_wrong_passphrase() => { + if let BootApp::Unlocking(state) = self { + state.error = Some(UnlockError::WrongPassphrase); + } + } + // Any non-passphrase failure on the keyed open is genuinely fatal — + // mirror BootApp::new's classification. Re-prompting can never + // resolve it, so abort cleanly instead of trapping the user behind + // a futile prompt (the inverse of the bug this fix addresses). + Err(e) => { + tracing::error!(error = ?e, "Vault open failed with a non-passphrase error; aborting"); + *self = BootApp::Failed; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } +} + +impl eframe::App for BootApp { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if let BootApp::Running(app) = self { + app.ui(ui, frame); + return; + } + if matches!(self, BootApp::Failed) { + return; + } + // Unlocking: render the prompt, then act on the outcome. Split from the + // transition so the prompt's borrow of `self` is released first. + let ctx = ui.ctx().clone(); + let outcome = match self { + BootApp::Unlocking(state) => state.show_modal(&ctx), + _ => return, + }; + match outcome { + UnlockOutcome::Pending => {} + UnlockOutcome::Cancel => { + tracing::info!("User chose to quit at the legacy-vault unlock prompt"); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + UnlockOutcome::Submit(passphrase) => self.try_unlock(passphrase, &ctx), + } + } + + fn on_exit(&mut self) { + if let BootApp::Running(app) = self { + app.on_exit(); + } + } +} + +/// State of the legacy-vault unlock prompt. +/// +/// The masked input buffer and focus tracking live inside the reused +/// [`passphrase_modal`] (egui data cache), so this carries only the domain +/// state needed to re-open the vault and the re-prompt reason. +pub struct UnlockState { + ctx: egui::Context, + db: Arc<Database>, + data_dir: PathBuf, + error: Option<UnlockError>, +} + +impl UnlockState { + fn new(ctx: egui::Context, db: Arc<Database>, data_dir: PathBuf) -> Self { + Self { + ctx, + db, + data_dir, + error: None, + } + } + + /// Render the shared masked unlock prompt for one frame and report the + /// user's action. The passphrase is masked, zeroized, and extracted by + /// [`passphrase_modal`]; a blank entry re-prompts rather than calling the + /// vault. + fn show_modal(&mut self, ctx: &egui::Context) -> UnlockOutcome { + let config = PassphraseModalConfig { + window_title: "Unlock your saved keys", + body: "Your saved keys are protected by a passphrase set in an earlier version. \ + Enter it to open them. The app asks for this passphrase every time it starts.", + hint: None, + error: self.error.map(UnlockError::message), + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + remember_label: None, + }; + + match passphrase_modal(ctx, &config, |_ui| {}) { + PassphraseModalOutcome::Pending => UnlockOutcome::Pending, + PassphraseModalOutcome::Cancel => UnlockOutcome::Cancel, + PassphraseModalOutcome::Submit(text) => { + let passphrase = SecretString::new(text.to_string()); + if passphrase.is_blank() { + self.error = Some(UnlockError::Blank); + UnlockOutcome::Pending + } else { + self.error = None; + UnlockOutcome::Submit(passphrase) + } + } + } + } +} + +/// One frame's outcome of the unlock prompt. +enum UnlockOutcome { + /// No actionable input this frame. + Pending, + /// The user submitted a non-blank passphrase. + Submit(SecretString), + /// The user chose to quit. + Cancel, +} + +/// User-facing reason the unlock prompt is re-shown. Only recoverable reasons +/// live here — a non-passphrase open failure aborts (see [`BootApp::try_unlock`]) +/// rather than re-prompting, so it is never surfaced through this enum. +#[derive(Clone, Copy)] +enum UnlockError { + WrongPassphrase, + Blank, +} + +impl UnlockError { + fn message(self) -> &'static str { + match self { + UnlockError::WrongPassphrase => "That passphrase is not correct. Try again.", + UnlockError::Blank => "Enter your passphrase to continue.", + } + } +} + +/// The keyless-open classification at boot. +enum BootDecision { + /// The vault opened keyless and is ready to use. + Ready(Arc<SecretStore>), + /// The vault is passphrase-protected (legacy); the user must unlock it. + Unlock, +} + +/// Open the seed vault keyless and classify the result. +/// +/// A wrong vault passphrase (a legacy passphrase vault) is the ONLY recoverable +/// case and routes to [`BootDecision::Unlock`]; every other failure is fatal and +/// propagates, matching the original abort-on-error boot behavior. The same +/// predicate gates the unlock-retry loop in [`BootApp::try_unlock`], so both +/// open paths classify fatality identically. +fn classify_open(data_dir: &std::path::Path) -> Result<BootDecision, BootError> { + match AppContext::open_secret_store(data_dir) { + Ok(store) => Ok(BootDecision::Ready(store)), + Err(e) if e.is_secret_store_wrong_passphrase() => Ok(BootDecision::Unlock), + Err(e) => Err(Box::new(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::{open_secret_store, open_secret_store_with_passphrase}; + + /// A normal keyless vault classifies as ready — the happy path never gates. + #[test] + fn classify_open_ready_for_keyless_vault() { + let tmp = tempfile::tempdir().expect("tempdir"); + // Seed a keyless vault at the data dir's secret path, then drop the + // handle so its exclusive lock is released before re-opening. + AppContext::open_secret_store(tmp.path()).expect("create keyless vault"); + + let decision = classify_open(tmp.path()).expect("classify"); + assert!( + matches!(decision, BootDecision::Ready(_)), + "keyless vault must classify as Ready" + ); + } + + /// A vault an older build sealed with a passphrase classifies as Unlock — + /// the gate fires only here. + #[test] + fn classify_open_unlock_for_passphrase_vault() { + let tmp = tempfile::tempdir().expect("tempdir"); + let vault = tmp.path().join("secrets").join("det-secrets.pwsvault"); + open_secret_store_with_passphrase(&vault, SecretString::new("legacy")) + .expect("create passphrase vault"); + + let decision = classify_open(tmp.path()).expect("classify"); + assert!( + matches!(decision, BootDecision::Unlock), + "passphrase vault must classify as Unlock" + ); + } + + /// A non-passphrase open failure (here a malformed vault) is FATAL — it + /// propagates rather than gating to Unlock, mirroring try_unlock's policy. + #[test] + fn classify_open_propagates_non_passphrase_failure() { + let tmp = tempfile::tempdir().expect("tempdir"); + let secrets = tmp.path().join("secrets"); + std::fs::create_dir_all(&secrets).expect("mkdir"); + std::fs::write(secrets.join("det-secrets.pwsvault"), b"not a vault") + .expect("write garbage"); + + assert!( + classify_open(tmp.path()).is_err(), + "a malformed vault must be fatal, never gated to Unlock" + ); + } + + /// Sanity: the keyless open of a passphrase vault is precisely the failure + /// the gate keys on — `open_secret_store` (keyless) fails it, while + /// `open_secret_store_with_passphrase` recovers it non-destructively. (The + /// data round-trip is pinned in single_key.rs; here we only assert the + /// classification both helpers feed.) + #[test] + fn keyless_open_of_passphrase_vault_is_the_gated_failure() { + let tmp = tempfile::tempdir().expect("tempdir"); + let vault = tmp.path().join("secrets").join("det-secrets.pwsvault"); + open_secret_store_with_passphrase(&vault, SecretString::new("legacy")) + .expect("create passphrase vault"); + + let err = open_secret_store(&vault).expect_err("keyless open must fail"); + let task_err = crate::backend_task::error::TaskError::SecretStore { + source: Box::new(err), + }; + assert!( + task_err.is_secret_store_wrong_passphrase(), + "the keyless failure must be exactly the wrong-passphrase signal the gate keys on" + ); + } +} diff --git a/src/components/core_p2p_handler.rs b/src/components/core_p2p_handler.rs deleted file mode 100644 index f929005e3..000000000 --- a/src/components/core_p2p_handler.rs +++ /dev/null @@ -1,450 +0,0 @@ -use chrono::Utc; -use dash_sdk::dpp::dashcore::BlockHash; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::consensus::{deserialize, encode, serialize}; -use dash_sdk::dpp::dashcore::network::constants::ServiceFlags; -use dash_sdk::dpp::dashcore::network::message::{NetworkMessage, RawNetworkMessage}; -use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; -use dash_sdk::dpp::dashcore::network::message_sml::{GetMnListDiff, MnListDiff}; -use dash_sdk::dpp::dashcore::network::{Address, message_network, message_qrinfo}; -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; -use sha2::{Digest, Sha256}; -use std::io::{ErrorKind, Read, Write}; -use std::net::TcpStream; -use std::thread; -use std::time::Duration; -use thiserror::Error; - -/// Errors from peer-to-peer communication with Dash Core. -#[derive(Debug, Error)] -pub enum P2PError { - /// TCP connection or socket I/O failed. - #[error( - "Could not communicate with Dash Core over the local network. Check that Dash Core is running." - )] - Io { - #[from] - source: std::io::Error, - }, - - /// Failed to deserialize a P2P protocol message. - #[error("Received an unreadable message from Dash Core. The node may need to be updated.")] - Deserialization { - #[from] - source: encode::Error, - }, - - /// Timed out waiting for a response from Dash Core. - #[error("Dash Core did not respond in time. Please retry.")] - Timeout, - - /// Received an unexpected message type. - #[error("Received an unexpected response from Dash Core. Please retry.")] - UnexpectedMessage { - /// The command name that was received instead of the expected one. - received: String, - }, - - /// Network type is not supported for P2P connections. - #[error("This network type does not support direct peer connections.")] - UnsupportedNetwork, - - /// A protocol-level error (bad checksum, oversized message, unexpected format). - #[error("Received a malformed message from Dash Core. The node may need to be updated.")] - ProtocolError { detail: String }, -} - -#[derive(Debug)] -pub struct CoreP2PHandler { - pub network: Network, - pub port: u16, - pub stream: TcpStream, - pub handshake_success: bool, -} - -/// Dash P2P header length in bytes -const HEADER_LENGTH: usize = 24; - -/// Maximum message payload size (e.g. 0x02000000 bytes) -const MAX_MSG_LENGTH: usize = 0x02000000; - -/// Compute double-SHA256 on the given data. -fn double_sha256(data: &[u8]) -> [u8; 32] { - let hash1 = Sha256::digest(data); - let hash2 = Sha256::digest(hash1); - let mut result = [0u8; 32]; - result.copy_from_slice(&hash2); - result -} - -/// Internal marker for non-fatal (retryable) read errors. -#[derive(Debug)] -enum ReadMessageError { - Transient, - Fatal(P2PError), -} - -impl CoreP2PHandler { - pub fn new(network: Network, use_port: Option<u16>) -> Result<CoreP2PHandler, P2PError> { - let port = use_port.unwrap_or(match network { - Network::Mainnet => 9999, - Network::Testnet => 19999, - Network::Devnet => 29999, - Network::Regtest => 29999, - }); - let stream = TcpStream::connect_timeout( - &format!("127.0.0.1:{port}") - .parse() - .map_err(|_| P2PError::Io { - source: std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid socket address", - ), - })?, - Duration::from_secs(5), - )?; - stream.set_read_timeout(Some(Duration::from_secs(5)))?; - stream.set_write_timeout(Some(Duration::from_secs(5)))?; - tracing::info!("Connected to Dash Core at 127.0.0.1:{}", port); - Ok(CoreP2PHandler { - network, - port, - stream, - handshake_success: false, - }) - } - - /// Sends a network message over the provided stream and waits for a response. - pub fn send_dml_request_message( - &mut self, - network_message: NetworkMessage, - ) -> Result<MnListDiff, P2PError> { - if !self.handshake_success { - self.handshake()?; - } - let stream = &mut self.stream; - let raw_message = RawNetworkMessage { - magic: self.network.magic(), - payload: network_message, - }; - let encoded_message = serialize(&raw_message); - stream.write_all(&encoded_message)?; - tracing::debug!("Sent getmnlistdiff message to Dash Core"); - - let (mut command, mut payload); - let start_time = std::time::Instant::now(); - let timeout = Duration::from_secs(5); - loop { - if start_time.elapsed() > timeout { - return Err(P2PError::Timeout); - } - match self.read_message() { - Ok((c, p)) => { - command = c; - payload = p; - } - Err(ReadMessageError::Transient) => { - thread::sleep(Duration::from_millis(10)); - continue; - } - Err(ReadMessageError::Fatal(e)) => return Err(e), - } - if command == "mnlistdiff" { - tracing::debug!("Got mnlistdiff message"); - break; - } else { - thread::sleep(Duration::from_millis(10)); - } - } - - let response_message: RawNetworkMessage = deserialize(&payload)?; - - match response_message.payload { - NetworkMessage::MnListDiff(diff) => Ok(diff), - msg => Err(P2PError::UnexpectedMessage { - received: format!("{msg:?}"), - }), - } - } - - /// Sends a network message over the provided stream and waits for a response. - pub fn send_qr_info_request_message( - &mut self, - network_message: NetworkMessage, - ) -> Result<QRInfo, P2PError> { - if !self.handshake_success { - self.handshake()?; - } - let stream = &mut self.stream; - let raw_message = RawNetworkMessage { - magic: self.network.magic(), - payload: network_message, - }; - let encoded_message = serialize(&raw_message); - stream.write_all(&encoded_message)?; - tracing::debug!("Sent qr info request message to Dash Core"); - - let (mut command, mut payload); - // QRInfo on mainnet can take noticeably longer to prepare. - // Temporarily increase socket read timeout and our overall wait. - let (socket_timeout, overall_timeout) = match self.network { - Network::Mainnet => (Duration::from_secs(60), Duration::from_secs(60)), - _ => (Duration::from_secs(15), Duration::from_secs(15)), - }; - let previous_socket_timeout = self.stream.read_timeout()?; - self.stream.set_read_timeout(Some(socket_timeout))?; - let start_time = std::time::Instant::now(); - let timeout = overall_timeout; - loop { - if start_time.elapsed() > timeout { - self.stream.set_read_timeout(previous_socket_timeout)?; - return Err(P2PError::Timeout); - } - match self.read_message() { - Ok((c, p)) => { - command = c; - payload = p; - } - Err(ReadMessageError::Transient) => { - thread::sleep(Duration::from_millis(10)); - continue; - } - Err(ReadMessageError::Fatal(e)) => return Err(e), - } - if command == "qrinfo" { - tracing::debug!("Got qrinfo message"); - self.stream.set_read_timeout(previous_socket_timeout)?; - break; - } else { - thread::sleep(Duration::from_millis(10)); - } - } - - let response_message: RawNetworkMessage = deserialize(&payload)?; - - match response_message.payload { - NetworkMessage::QRInfo(qr_info) => Ok(qr_info), - msg => Err(P2PError::UnexpectedMessage { - received: format!("{msg:?}"), - }), - } - } - - // Note: get_dml_diff and get_qr_info are already defined above (lines ~351 and ~364) - /// Perform the handshake (version/verack exchange) with the peer. - pub fn handshake(&mut self) -> Result<(), P2PError> { - let mut rng = StdRng::from_os_rng(); - - // Build a version message. - let version_msg = NetworkMessage::Version(message_network::VersionMessage { - version: 70235, - services: ServiceFlags::NONE, - timestamp: Utc::now().timestamp(), - receiver: Address { - services: ServiceFlags::BLOOM, - address: Default::default(), - port: self.stream.peer_addr()?.port(), - }, - sender: Address { - services: ServiceFlags::NONE, - address: Default::default(), - port: self.stream.local_addr()?.port(), - }, - nonce: rng.random(), - user_agent: "/dash-evo-tool:0.9/".to_string(), - start_height: 0, - relay: false, - mn_auth_challenge: rng.random(), - masternode_connection: false, - }); - - // Wrap it in a raw message. - let raw_version = RawNetworkMessage { - magic: self.network.magic(), - payload: version_msg, - }; - let encoded_version = serialize(&raw_version); - self.stream.write_all(&encoded_version)?; - tracing::debug!("Sent version message"); - - thread::sleep(Duration::from_millis(50)); - - // Read and process incoming messages until handshake is complete. - self.run_handshake_loop()?; - self.handshake_success = true; - Ok(()) - } - - fn read_message(&mut self) -> Result<(String, Vec<u8>), ReadMessageError> { - let mut header_buf = [0u8; HEADER_LENGTH]; - // Read the header. - self.stream - .read_exact(&mut header_buf) - .map_err(|e| match e.kind() { - ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, - _ => ReadMessageError::Fatal(P2PError::Io { source: e }), - })?; - - // If the first 4 bytes don't match our network magic, shift until we do. - const MAX_SYNC_ATTEMPTS: usize = 1024; // Prevent reading more than 1KB looking for magic - let mut sync_attempts = 0; - while u32::from_le_bytes(header_buf[0..4].try_into().unwrap()) != self.network.magic() { - sync_attempts += 1; - if sync_attempts > MAX_SYNC_ATTEMPTS { - return Err(ReadMessageError::Fatal(P2PError::ProtocolError { - detail: "failed to find network magic in stream".to_string(), - })); - } - // Shift left by one byte. - for i in 0..HEADER_LENGTH - 1 { - header_buf[i] = header_buf[i + 1]; - } - // Read one more byte. - let mut one_byte = [0u8; 1]; - self.stream - .read_exact(&mut one_byte) - .map_err(|e| match e.kind() { - ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, - _ => ReadMessageError::Fatal(P2PError::Io { source: e }), - })?; - header_buf[HEADER_LENGTH - 1] = one_byte[0]; - } - - // Extract the command. - let command_bytes = &header_buf[4..16]; - let command = String::from_utf8_lossy(command_bytes) - .trim_matches('\0') - .to_string(); - - // Payload length (little-endian u32) - let payload_len_u32 = u32::from_le_bytes(header_buf[16..20].try_into().unwrap()); - if payload_len_u32 > MAX_MSG_LENGTH as u32 { - return Err(ReadMessageError::Fatal(P2PError::ProtocolError { - detail: format!("payload length {payload_len_u32} exceeds maximum"), - })); - } - let payload_len = payload_len_u32 as usize; - - // Expected checksum. - let expected_checksum = &header_buf[20..24]; - - // Read the payload. - let mut payload_buf = vec![0u8; payload_len]; - self.stream - .read_exact(&mut payload_buf) - .map_err(|e| match e.kind() { - ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, - _ => ReadMessageError::Fatal(P2PError::Io { source: e }), - })?; - - // Compute and verify checksum. - let computed_checksum = &double_sha256(&payload_buf)[0..4]; - if computed_checksum != expected_checksum { - return Err(ReadMessageError::Fatal(P2PError::ProtocolError { - detail: format!( - "checksum mismatch for {command}: computed {computed_checksum:x?}, expected {expected_checksum:x?}" - ), - })); - } - let mut total_buf = header_buf.to_vec(); - total_buf.append(&mut payload_buf); - Ok((command, total_buf)) - } - - /// The handshake loop: read messages until we complete the version/verack exchange. - fn run_handshake_loop(&mut self) -> Result<(), P2PError> { - // Expect a version message from the peer, with a timeout. - let start_time = std::time::Instant::now(); - let timeout = Duration::from_secs(5); - let (command, payload) = loop { - if start_time.elapsed() > timeout { - return Err(P2PError::Timeout); - } - match self.read_message() { - Ok(res) => break res, - Err(ReadMessageError::Transient) => { - thread::sleep(Duration::from_millis(10)); - continue; - } - Err(ReadMessageError::Fatal(e)) => return Err(e), - } - }; - if command != "version" { - return Err(P2PError::UnexpectedMessage { received: command }); - } - // Deserialize the version message payload. - let raw: RawNetworkMessage = deserialize(&payload)?; - match raw.payload { - NetworkMessage::Version(peer_version) => { - tracing::debug!("Received peer version: {:?}", peer_version); - } - msg => { - return Err(P2PError::UnexpectedMessage { - received: format!("{msg:?}"), - }); - } - } - - let start_time = std::time::Instant::now(); - let timeout = Duration::from_secs(5); - loop { - if start_time.elapsed() > timeout { - return Err(P2PError::Timeout); - } - let (command, _) = match self.read_message() { - Ok(res) => res, - Err(ReadMessageError::Transient) => { - thread::sleep(Duration::from_millis(10)); - continue; - } - Err(ReadMessageError::Fatal(e)) => return Err(e), - }; - if command == "verack" { - tracing::debug!("Got verack message"); - break; - } else { - thread::sleep(Duration::from_millis(10)); - } - } - - // Send verack. - let verack_msg = NetworkMessage::Verack; - let raw_verack = RawNetworkMessage { - magic: self.network.magic(), - payload: verack_msg, - }; - let encoded_verack = serialize(&raw_verack); - self.stream.write_all(&encoded_verack)?; - - tracing::debug!("Sent verack message"); - Ok(()) - } - - /// Sends a `GetMnListDiff` request after completing the handshake. - pub fn get_dml_diff( - &mut self, - base_block_hash: BlockHash, - block_hash: BlockHash, - ) -> Result<MnListDiff, P2PError> { - let get_mnlist_diff_msg = NetworkMessage::GetMnListD(GetMnListDiff { - base_block_hash, - block_hash, - }); - self.send_dml_request_message(get_mnlist_diff_msg) - } - - /// Sends a `GetMnListDiff` request after completing the handshake. - pub fn get_qr_info( - &mut self, - known_block_hashes: Vec<BlockHash>, - block_request_hash: BlockHash, - ) -> Result<QRInfo, P2PError> { - let get_mnlist_diff_msg = NetworkMessage::GetQRInfo(message_qrinfo::GetQRInfo { - base_block_hashes: known_block_hashes, - block_request_hash, - extra_share: true, - }); - self.send_qr_info_request_message(get_mnlist_diff_msg) - } -} diff --git a/src/components/core_zmq_listener.rs b/src/components/core_zmq_listener.rs deleted file mode 100644 index 2fb5cf905..000000000 --- a/src/components/core_zmq_listener.rs +++ /dev/null @@ -1,464 +0,0 @@ -use crossbeam_channel::Sender; -use dash_sdk::dpp::dashcore::consensus::Decodable; -use dash_sdk::dpp::dashcore::{Block, ChainLock, InstantLock, Network, Transaction}; -use dash_sdk::dpp::prelude::CoreBlockHeight; -use std::error::Error; -use std::io::Cursor; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -use std::thread; -use std::time::Duration; - -#[cfg(not(target_os = "windows"))] -use image::EncodableLayout; -#[cfg(not(target_os = "windows"))] -use zmq::Context; - -#[cfg(target_os = "windows")] -use futures::StreamExt; -#[cfg(target_os = "windows")] -use tokio::runtime::Runtime; -#[cfg(target_os = "windows")] -use tokio::time::timeout; -#[cfg(target_os = "windows")] -use zeromq::{Socket, SocketRecv, SubSocket}; - -pub struct CoreZMQListener { - #[allow(dead_code)] // Used for stopping the listener - should_stop: Arc<AtomicBool>, - #[allow(dead_code)] // Handle to the background thread - handle: Option<thread::JoinHandle<()>>, -} - -pub enum ZMQMessage { - ISLockedTransaction(Transaction, InstantLock), - ChainLockedBlock(Block, ChainLock), - ChainLockedLockedTransaction(Transaction, CoreBlockHeight), -} - -#[derive(Debug)] -pub enum ZMQConnectionEvent { - Connected, - Disconnected, -} - -#[cfg(not(target_os = "windows"))] -pub const IS_LOCK_SIG_MSG: &[u8; 12] = b"rawtxlocksig"; -#[cfg(not(target_os = "windows"))] -pub const CHAIN_LOCKED_BLOCK_MSG: &[u8; 12] = b"rawchainlock"; - -#[cfg(target_os = "windows")] -pub const IS_LOCK_SIG_MSG: &str = "rawtxlocksig"; -#[cfg(target_os = "windows")] -pub const CHAIN_LOCKED_BLOCK_MSG: &str = "rawchainlock"; - -impl CoreZMQListener { - #[cfg(not(target_os = "windows"))] - pub fn spawn_listener( - network: Network, - endpoint: &str, - sender: crate::utils::egui_mpsc::SenderSync<(ZMQMessage, Network)>, - tx_zmq_status: Option<Sender<ZMQConnectionEvent>>, - ) -> Result<Self, Box<dyn Error>> { - let should_stop = Arc::new(AtomicBool::new(false)); - let endpoint = endpoint.to_string(); - let should_stop_clone = Arc::clone(&should_stop); - - let handle = thread::spawn(move || { - // Create the socket inside the thread. - let context = Context::new(); - let socket = context.socket(zmq::SUB).expect("Failed to create socket"); - - // Set heartbeat options - socket - .set_heartbeat_ivl(5000) - .expect("Failed to set heartbeat interval"); // Send a heartbeat every 5000 ms - socket - .set_heartbeat_timeout(10000) - .expect("Failed to set heartbeat timeout"); // Timeout after 10000 ms without response - - let monitor_addr = "inproc://socket-monitor"; - socket - .monitor(monitor_addr, zmq::SocketEvent::ALL as i32) - .expect("Failed to monitor socket"); - - // Create the PAIR socket for monitoring - let monitor_socket = context - .socket(zmq::PAIR) - .expect("Failed to create monitor socket"); - monitor_socket - .connect(monitor_addr) - .expect("Failed to connect monitor socket"); - - // Connect to the zmqpubhashtxlock endpoint. - socket.connect(&endpoint).expect("Failed to connect"); - - // Subscribe to the "rawtxlocksig" events. - socket - .set_subscribe(IS_LOCK_SIG_MSG) - .expect("Failed to subscribe to rawtxlocksig"); - - // Subscribe to the "rawtxlocksig" events. - socket - .set_subscribe(CHAIN_LOCKED_BLOCK_MSG) - .expect("Failed to subscribe to rawchainlock"); - - tracing::info!("Subscribed to ZMQ at {}", endpoint); - - let mut items = [ - socket.as_poll_item(zmq::POLLIN), - monitor_socket.as_poll_item(zmq::POLLIN), - ]; - - while !should_stop_clone.load(Ordering::SeqCst) { - zmq::poll(&mut items, -1).expect("Failed to poll sockets"); - - if items[0].is_readable() { - // Handle messages from the SUB socket - // Receive the topic part of the message - let mut topic_message = zmq::Message::new(); - - // Use non-blocking receive with DONTWAIT. - match socket.recv(&mut topic_message, zmq::DONTWAIT) { - Ok(_) => { - let topic = topic_message.as_str().unwrap_or(""); - let has_more = socket.get_rcvmore().unwrap_or(false); - - if has_more { - // Receive the data part of the message - let mut data_message = zmq::Message::new(); - if let Err(e) = socket.recv(&mut data_message, 0) { - tracing::error!("Error receiving data part: {}", e); - continue; - } - - let data_bytes = data_message.as_bytes(); - - match topic { - "rawchainlocksig" => { - // println!("Received raw chain locked block:"); - // println!("Data (hex): {}", hex::encode(data_bytes)); - - // Create a cursor over the data_bytes - let mut cursor = Cursor::new(data_bytes); - - // Deserialize the LLMQChainLock - match Block::consensus_decode(&mut cursor) { - Ok(block) => { - match ChainLock::consensus_decode(&mut cursor) { - Ok(chain_lock) => { - // Send the ChainLock and Network back to the main thread - if let Err(e) = sender.send(( - ZMQMessage::ChainLockedBlock( - block, chain_lock, - ), - network, - )) { - tracing::error!( - "Error sending data to main thread: {}", - e - ); - } - } - Err(e) => { - tracing::error!( - "Error deserializing InstantLock: {}", - e - ); - } - } - } - Err(e) => { - tracing::error!( - "Error deserializing chain locked block: bytes({}) error: {}", - hex::encode(data_bytes), - e - ); - } - } - } - "rawtxlocksig" => { - // println!("Received rawtxlocksig for InstantSend:"); - // println!("Data (hex): {}", hex::encode(data_bytes)); - - // Create a cursor over the data_bytes - let mut cursor = Cursor::new(data_bytes); - - // Deserialize the transaction - match Transaction::consensus_decode(&mut cursor) { - Ok(tx) => { - // Deserialize the InstantLock from the remaining bytes - match InstantLock::consensus_decode(&mut cursor) { - Ok(islock) => { - // Send the Transaction, InstantLock, and Network back to the main thread - if let Err(e) = sender.send(( - ZMQMessage::ISLockedTransaction( - tx, islock, - ), - network, - )) { - tracing::error!( - "Error sending data to main thread: {}", - e - ); - } - } - Err(e) => { - tracing::error!( - "Error deserializing InstantLock: {}", - e - ); - } - } - } - Err(e) => { - tracing::error!( - "Error deserializing transaction: {}", - e - ); - } - } - } - _ => { - tracing::warn!("Received unknown topic: {}", topic); - } - } - } - } - Err(e) => { - if e == zmq::Error::EAGAIN { - // No message received, sleep briefly. - thread::sleep(Duration::from_millis(100)); - continue; - } else { - tracing::error!("Error receiving message: {}", e); - break; - } - } - } - } - - if items[1].is_readable() { - let mut event_msg = zmq::Message::new(); - monitor_socket - .recv(&mut event_msg, 0) - .expect("Failed to receive event message"); - - let mut addr_msg = zmq::Message::new(); - monitor_socket - .recv(&mut addr_msg, 0) - .expect("Failed to receive address message"); - - let data: &[u8] = event_msg.as_ref(); // Explicitly annotate the type - if data.len() >= 6 { - let event_number = u16::from_le_bytes([data[0], data[1]]); - let endpoint = addr_msg.as_str().unwrap_or(""); - - match zmq::SocketEvent::from_raw(event_number) { - zmq::SocketEvent::CONNECTED => { - if let Some(ref tx) = tx_zmq_status { - tracing::info!("ZMQ Socket connected to {}", endpoint); - tx.send(ZMQConnectionEvent::Connected) - .expect("Failed to send connected event"); - } - // Connection is successful - } - zmq::SocketEvent::DISCONNECTED => { - if let Some(ref tx) = tx_zmq_status { - tracing::info!("ZMQ Socket disconnected from {}", endpoint); - tx.send(ZMQConnectionEvent::Disconnected) - .expect("Failed to send connected event"); - } - // Connection is lost - } - // Handle other events as needed - _ => {} - } - } else { - tracing::warn!("Invalid event message received"); - } - } - } - - tracing::info!("Listener is stopping."); - // Clean up socket (optional, as it will be dropped here). - drop(socket); - }); - - Ok(CoreZMQListener { - should_stop, - handle: Some(handle), - }) - } - - #[cfg(target_os = "windows")] - pub fn spawn_listener( - network: Network, - endpoint: &str, - sender: crate::utils::egui_mpsc::SenderSync<(ZMQMessage, Network)>, - tx_zmq_status: Option<Sender<ZMQConnectionEvent>>, - ) -> Result<Self, Box<dyn Error>> { - let should_stop = Arc::new(AtomicBool::new(false)); - let endpoint = endpoint.to_string(); - let should_stop_clone = Arc::clone(&should_stop); - - let handle = thread::spawn(move || { - // Create the runtime inside the thread. - let rt = Runtime::new().unwrap(); - rt.block_on(async move { - // Create the socket inside the async context. - let mut socket = SubSocket::new(); - - // Connect to the endpoint - socket - .connect(&endpoint) - .await - .expect("Failed to connect"); - - // Subscribe to the "rawtxlocksig" events. - socket - .subscribe(IS_LOCK_SIG_MSG) - .await - .expect("Failed to subscribe to rawtxlocksig"); - - // Subscribe to the "rawchainlock" events. - socket - .subscribe(CHAIN_LOCKED_BLOCK_MSG) - .await - .expect("Failed to subscribe to rawchainlock"); - - tracing::info!("Subscribed to ZMQ at {}", endpoint); - while !should_stop_clone.load(Ordering::SeqCst) { - match timeout(Duration::from_secs(30), socket.recv()).await { - Ok(Ok(msg)) => { - // Process the message - // Access frames using msg.get(n) - if let Some(topic_frame) = msg.get(0) { - let topic = String::from_utf8_lossy(topic_frame).to_string(); - - if let Some(data_frame) = msg.get(1) { - let data_bytes = data_frame; - - match topic.as_str() { - "rawchainlock" => { - // Deserialize the Block - let mut cursor = Cursor::new(data_bytes); - match Block::consensus_decode(&mut cursor) { - Ok(block) => { - match ChainLock::consensus_decode(&mut cursor) { - Ok(chain_lock) => { - // Send the ChainLock and Network back to the main thread - if let Err(e) = sender.send(( - ZMQMessage::ChainLockedBlock( - block, chain_lock, - ), - network, - )) { - tracing::error!( - "Error sending data to main thread: {}", - e - ); - } - } - Err(e) => { - tracing::error!( - "Error deserializing ChainLock: {}", - e - ); - } - } - } - Err(e) => { - tracing::error!( - "Error deserializing chain locked block: {}", - e - ); - } - } - } - "rawtxlocksig" => { - // Deserialize the Transaction and InstantLock - let mut cursor = Cursor::new(data_bytes); - match Transaction::consensus_decode(&mut cursor) { - Ok(tx) => { - match InstantLock::consensus_decode(&mut cursor) - { - Ok(islock) => { - if let Some(ref tx) = tx_zmq_status { - // ZMQ refresh socket connected status - tx.send(ZMQConnectionEvent::Connected) - .expect("Failed to send connected event"); - } - if let Err(e) = sender.send(( - ZMQMessage::ISLockedTransaction( - tx, islock, - ), - network, - )) { - tracing::error!( - "Error sending data to main thread: {}", - e - ); - } - } - Err(e) => { - tracing::error!( - "Error deserializing InstantLock: {}", - e - ); - } - } - } - Err(e) => { - tracing::error!( - "Error deserializing transaction: {}", - e - ); - } - } - } - _ => { - tracing::warn!("Received unknown topic: {}", topic); - } - } - } - } - }, - Ok(Err(e)) => { - // Handle recv error - tracing::error!("Error receiving message: {}", e); - // Sleep briefly before retrying - tokio::time::sleep(Duration::from_millis(100)).await; - }, - Err(_) => { - // Timeout occurred, handle disconnection - if let Some(ref tx) = tx_zmq_status { - tx.send(ZMQConnectionEvent::Disconnected) - .expect("Failed to send connected event"); - } - }, - } - } - - tracing::info!("Listener is stopping."); - // The socket will be dropped here - }); - }); - - Ok(CoreZMQListener { - should_stop, - handle: Some(handle), - }) - } - - /// Stops the listener by signaling the thread and waiting for it to finish. - #[allow(dead_code)] // May be used for clean shutdown - pub fn stop(&mut self) { - self.should_stop.store(true, Ordering::SeqCst); - if let Some(handle) = self.handle.take() { - handle.join().expect("Failed to join listener thread"); - } - } -} diff --git a/src/components/mod.rs b/src/components/mod.rs deleted file mode 100644 index 63b1335f0..000000000 --- a/src/components/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod core_p2p_handler; -pub mod core_zmq_listener; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 8d6222604..48627d0a6 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -1,15 +1,14 @@ use crate::app::AppAction; use crate::app::TaskResult; -use crate::backend_task::BackendTask; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::core::{CoreItem, CoreTask}; -use crate::components::core_zmq_listener::ZMQConnectionEvent; -use crate::spv::{CoreBackendMode, SpvStatus}; +use crate::backend_task::core::CoreItem; +use crate::model::spv_status::{SpvStatus, SpvStatusSnapshot}; use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; -use dash_sdk::dpp::dashcore::{ChainLock, Network}; +use dash_sdk::dpp::dashcore::Network; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, Ordering}; use std::time::{Duration, Instant}; +use tokio::sync::watch; const REFRESH_CONNECTED: Duration = Duration::from_secs(4); const REFRESH_DISCONNECTED: Duration = Duration::from_secs(1); @@ -50,16 +49,42 @@ impl From<u8> for OverallConnectionState { /// Supports Dash Core and SPV. #[derive(Debug)] pub struct ConnectionStatus { - rpc_online: AtomicBool, - zmq_status: Mutex<ZMQConnectionEvent>, spv_status: AtomicU8, - backend_mode: AtomicU8, - disable_zmq: AtomicBool, + /// Event-driven mirror of `spv_status` for async waiters. Every transition + /// funnelled through [`Self::set_spv_status`] is broadcast here. + /// [`Self::begin_spv_stop`] bypasses this (it writes the atomic directly) — + /// `Stopping` is not a waited-on state, so that is intentional. + /// [`Self::reset`] explicitly sends `Idle` to keep the watch coherent across + /// network switches. + spv_status_tx: watch::Sender<SpvStatus>, overall_state: AtomicU8, + /// `true` once the SPV masternode list has finished syncing, so quorum + /// public keys are resolvable. Platform/identity sync must wait on this: + /// firing proof-verifying DAPI calls before quorums exist makes every + /// queried node fail locally and get banned by the SDK. Push-based from + /// the wallet-backend `EventBridge` `on_progress` callback. Reset by + /// [`Self::reset`] on disconnect / network switch. + /// + /// ADVISORY MIRROR: read only by the `get_quorum_public_key` defense + /// backstop. `Relaxed` is sufficient because it guards no other memory — + /// the authoritative coordinator-start ordering lives in `CoordinatorGate`, + /// whose own `masternodes_ready`/`fired` swap is `SeqCst`. + masternodes_ready: AtomicBool, // NOTE: Mutex (not RwLock) is intentional — single reader (tooltip hover), // single writer (poll cycle), minimal contention. RwLock overhead not justified. spv_last_error: Mutex<Option<String>>, - rpc_last_error: Mutex<Option<String>>, + /// Latest per-phase chain-sync progress pushed by the wallet-backend + /// `EventBridge` `on_progress` callback. Drives the determinate progress + /// bars in the network and wallet screens. + spv_sync_progress: Mutex<Option<SpvSyncProgress>>, + /// Latest downloaded-notes progress for an in-flight shielded sync pass, + /// pushed by the wallet-backend `EventBridge` `on_shielded_sync_progress` + /// callback. `None` between passes. Cleared by [`Self::reset`] and on pass + /// completion. + shielded_sync_progress: Mutex<Option<ShieldedSyncProgress>>, + /// Latest committed-to-tree progress for an in-flight shielded sync pass, + /// pushed by `on_shielded_tree_progress`. `None` between passes. + shielded_tree_progress: Mutex<Option<ShieldedTreeProgress>>, last_update: Mutex<Instant>, spv_connected_peers: AtomicU16, /// When SPV first entered an active state (`Starting`/`Syncing`) with zero @@ -69,17 +94,42 @@ pub struct ConnectionStatus { dapi_available_endpoints: AtomicU16, } +/// Downloaded-notes progress for an in-flight shielded sync pass (DET-shaped — +/// no upstream type crosses the seam). Network-scoped: a single pass covers +/// every viewing key on the coordinator. `None` when no pass is in flight. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShieldedSyncProgress { + /// Cumulative encrypted notes scanned so far in the current pass. + pub cumulative_scanned: u64, + /// Latest block height observed during the pass. + pub block_height: u64, +} + +/// Committed-to-tree progress for an in-flight shielded sync pass (DET-shaped). +/// Distinct from [`ShieldedSyncProgress`]: this counts commitments appended to +/// the local Orchard tree (the "checked" signal). `total_target == 0` means the +/// on-chain leaf count was unavailable, so progress is indeterminate (render a +/// spinner, not a determinate bar). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShieldedTreeProgress { + /// Cumulative tree leaf count committed so far. + pub leaves_committed: u64, + /// On-chain MMR total leaf count fetched at pass start; `0` = indeterminate. + pub total_target: u64, +} + impl ConnectionStatus { pub fn new() -> Self { + let (spv_status_tx, _) = watch::channel(SpvStatus::Idle); Self { - rpc_online: AtomicBool::new(false), - zmq_status: Mutex::new(ZMQConnectionEvent::Disconnected), spv_status: AtomicU8::new(SpvStatus::Idle as u8), - backend_mode: AtomicU8::new(CoreBackendMode::Spv.as_u8()), - disable_zmq: AtomicBool::new(false), + spv_status_tx, overall_state: AtomicU8::new(OverallConnectionState::Disconnected as u8), + masternodes_ready: AtomicBool::new(false), spv_last_error: Mutex::new(None), - rpc_last_error: Mutex::new(None), + spv_sync_progress: Mutex::new(None), + shielded_sync_progress: Mutex::new(None), + shielded_tree_progress: Mutex::new(None), last_update: Mutex::new(Instant::now()), spv_connected_peers: AtomicU16::new(0), spv_no_peers_since: Mutex::new(None), @@ -90,19 +140,20 @@ impl ConnectionStatus { /// Reset all connection state. Called when switching the active network /// so the status reflects the new network from a clean slate. - /// - /// `backend_mode` should be the new network's current backend mode so that - /// `overall_state()` and `tooltip_text()` read the correct mode immediately. - pub fn reset(&self, backend_mode: CoreBackendMode) { - self.rpc_online.store(false, Ordering::Relaxed); - if let Ok(mut status) = self.zmq_status.lock() { - *status = ZMQConnectionEvent::Disconnected; - } + pub fn reset(&self) { self.spv_status .store(SpvStatus::Idle as u8, Ordering::Relaxed); - self.backend_mode - .store(backend_mode.as_u8(), Ordering::Relaxed); - self.disable_zmq.store(false, Ordering::Relaxed); + // Keep the watch coherent: any waiter sleeping across a network switch + // sees `Idle` and continues to wait for the next `Running`/`Error`. + let _ = self.spv_status_tx.send_if_modified(|cur| { + if *cur != SpvStatus::Idle { + *cur = SpvStatus::Idle; + true + } else { + false + } + }); + self.masternodes_ready.store(false, Ordering::Relaxed); self.spv_connected_peers.store(0, Ordering::Relaxed); *self .spv_no_peers_since @@ -115,58 +166,40 @@ impl ConnectionStatus { if let Ok(mut err) = self.spv_last_error.lock() { *err = None; } - if let Ok(mut err) = self.rpc_last_error.lock() { - *err = None; - } + *self + .spv_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + *self + .shielded_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + *self + .shielded_tree_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; // Set last_update to epoch so the next trigger_refresh fires immediately *self.last_update.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now() - REFRESH_CONNECTED; } - pub fn rpc_online(&self) -> bool { - self.rpc_online.load(Ordering::Relaxed) - } - - pub fn set_rpc_online(&self, online: bool) { - self.rpc_online.store(online, Ordering::Relaxed); - if online { - self.set_rpc_last_error(None); - } - } - - /// Set the last RPC error message (from chain lock polling). - pub fn set_rpc_last_error(&self, error: Option<String>) { - let mut err = self - .rpc_last_error - .lock() - .unwrap_or_else(|e| e.into_inner()); - *err = error; - } - - /// Get the last RPC error message, if any. - pub fn rpc_last_error(&self) -> Option<String> { - self.rpc_last_error.lock().ok().and_then(|g| g.clone()) - } - - pub fn zmq_connected(&self) -> bool { - self.zmq_status - .lock() - .map(|status| matches!(*status, ZMQConnectionEvent::Connected)) - .unwrap_or(false) - } - - pub fn set_zmq_status(&self, event: ZMQConnectionEvent) { - if let Ok(mut status) = self.zmq_status.lock() { - *status = event; - } - } - pub fn spv_status(&self) -> SpvStatus { SpvStatus::from(self.spv_status.load(Ordering::Relaxed)) } pub fn set_spv_status(&self, status: SpvStatus) { self.spv_status.store(status as u8, Ordering::Relaxed); + // Notify async waiters (e.g. ensure_spv_synced in mcp/resolve.rs). + // send_if_modified avoids a spurious wakeup when the caller sets the + // same status twice in a row (common during steady-state Running). + let _ = self.spv_status_tx.send_if_modified(|cur| { + if *cur != status { + *cur = status; + true + } else { + false + } + }); // Clear the "no peers" timer when SPV leaves an active state to avoid // stale peer-degraded warnings in tooltips. if !status.is_active() { @@ -178,6 +211,58 @@ impl ConnectionStatus { } } + /// Subscribe to SPV-status changes. Each call creates a new + /// [`watch::Receiver`] that receives the current value immediately via + /// [`watch::Receiver::borrow_and_update`] and then wakes on every + /// [`set_spv_status`] transition (deduped — same-value writes are + /// suppressed). The sender is app-lifetime, so `changed()` never returns + /// `Err` during normal operation. + pub fn subscribe_spv_status(&self) -> watch::Receiver<SpvStatus> { + self.spv_status_tx.subscribe() + } + + /// Atomically claim a disconnect: transition the SPV indicator to + /// [`SpvStatus::Stopping`] iff it is currently in a stoppable state + /// (`Starting`/`Syncing`/`Running`), returning `true` for the single caller + /// that won the transition. + /// + /// Returns `false` when no stop is needed or one is already in flight (the + /// status is already `Stopping`, or it is `Idle`/`Stopped`/`Error`). This is + /// the synchronous dedupe guard the Disconnect button relies on: the winning + /// dispatch flips the indicator to `Stopping` on the same frame as the + /// click — so the button disables immediately — and a fast second click + /// loses the race and spawns no second teardown. + pub fn begin_spv_stop(&self) -> bool { + for current in [SpvStatus::Starting, SpvStatus::Syncing, SpvStatus::Running] { + if self + .spv_status + .compare_exchange( + current as u8, + SpvStatus::Stopping as u8, + Ordering::AcqRel, + Ordering::Relaxed, + ) + .is_ok() + { + return true; + } + } + false + } + + /// Whether the SPV masternode list has finished syncing and quorum public + /// keys are resolvable. Platform/identity sync gates on this to avoid the + /// DAPI self-ban storm (see [`Self::masternodes_ready`] field docs). + pub fn masternodes_ready(&self) -> bool { + self.masternodes_ready.load(Ordering::Relaxed) + } + + /// Record whether the SPV masternode list has finished syncing. Push-based + /// from the wallet-backend `EventBridge` `on_progress` callback. + pub fn set_masternodes_ready(&self, ready: bool) { + self.masternodes_ready.store(ready, Ordering::Relaxed); + } + /// Set the last SPV error message (push-based from SpvManager event handlers). pub fn set_spv_last_error(&self, error: Option<String>) { let mut err = self @@ -192,6 +277,69 @@ impl ConnectionStatus { self.spv_last_error.lock().ok().and_then(|g| g.clone()) } + /// Store the latest chain-sync progress (push-based from the wallet-backend + /// `EventBridge` `on_progress` callback). `None` clears it (e.g. on stop). + pub fn set_spv_sync_progress(&self, progress: Option<SpvSyncProgress>) { + *self + .spv_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = progress; + } + + /// Latest chain-sync progress, if any has been reported. + pub fn spv_sync_progress(&self) -> Option<SpvSyncProgress> { + self.spv_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + /// Store the latest shielded downloaded-notes progress (push-based from the + /// `EventBridge` `on_shielded_sync_progress` callback). `None` clears it. + pub fn set_shielded_sync_progress(&self, progress: Option<ShieldedSyncProgress>) { + *self + .shielded_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = progress; + } + + /// Latest shielded downloaded-notes progress, if a pass is in flight. + pub fn shielded_sync_progress(&self) -> Option<ShieldedSyncProgress> { + *self + .shielded_sync_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + /// Store the latest shielded committed-to-tree progress (push-based from + /// the `EventBridge` `on_shielded_tree_progress` callback). `None` clears it. + pub fn set_shielded_tree_progress(&self, progress: Option<ShieldedTreeProgress>) { + *self + .shielded_tree_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) = progress; + } + + /// Latest shielded committed-to-tree progress, if a pass is in flight. + pub fn shielded_tree_progress(&self) -> Option<ShieldedTreeProgress> { + *self + .shielded_tree_progress + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + /// Build a [`SpvStatusSnapshot`] for UI rendering from the live + /// push-based state. This is the single source of truth the network and + /// wallet screens read instead of an inert default snapshot. + pub fn spv_status_snapshot(&self) -> SpvStatusSnapshot { + SpvStatusSnapshot { + status: self.spv_status(), + sync_progress: self.spv_sync_progress(), + last_error: self.spv_last_error(), + connected_peers: self.spv_connected_peers() as usize, + } + } + /// Update SPV connected peer count and maintain `spv_no_peers_since` tracking. /// /// Called from SpvManager's network event handler when peer count changes. @@ -208,26 +356,10 @@ impl ConnectionStatus { } } - pub fn backend_mode(&self) -> CoreBackendMode { - self.backend_mode.load(Ordering::Relaxed).into() - } - - pub fn set_backend_mode(&self, mode: CoreBackendMode) { - self.backend_mode.store(mode.as_u8(), Ordering::Relaxed); - } - - pub fn disable_zmq(&self) -> bool { - self.disable_zmq.load(Ordering::Relaxed) - } - - pub fn set_disable_zmq(&self, disable: bool) { - self.disable_zmq.store(disable, Ordering::Relaxed); - } - - /// Reset the throttle timer so the next `trigger_refresh()` fires immediately. - pub fn reset_timer(&self) { - *self.last_update.lock().unwrap_or_else(|e| e.into_inner()) = - Instant::now() - REFRESH_CONNECTED; + /// Current SPV connected peer count (push-based from the wallet-backend + /// `EventBridge` `on_network_event` callback). + pub fn spv_connected_peers(&self) -> u16 { + self.spv_connected_peers.load(Ordering::Relaxed) } pub fn dapi_total_endpoints(&self) -> u16 { @@ -272,14 +404,19 @@ impl ConnectionStatus { .is_some_and(|since| since.elapsed() >= SPV_PEER_DEGRADED_TIMEOUT) } - pub fn spv_connected(status: SpvStatus) -> bool { - status.is_active() - } - pub fn overall_state(&self) -> OverallConnectionState { self.overall_state.load(Ordering::Relaxed).into() } + /// Test seam: force the overall connection state directly. Bypasses + /// [`Self::refresh_state`] so a test that drives a consumer in isolation (not + /// the throttled frame loop) sees a stable state. Compiled only under the + /// `testing` feature. + #[cfg(feature = "testing")] + pub fn set_overall_state(&self, state: OverallConnectionState) { + self.overall_state.store(state as u8, Ordering::Relaxed); + } + /// Recompute the overall connection state from the individual subsystem /// flags. /// @@ -289,174 +426,74 @@ impl ConnectionStatus { /// frame (1-4 s cadence) and any transient inconsistency self-corrects on /// the next poll. pub fn refresh_state(&self) { - let backend_mode = self.backend_mode(); - let disable_zmq = self.disable_zmq(); let spv_status = self.spv_status(); let dapi_available = self.dapi_available(); - let state = match backend_mode { - CoreBackendMode::Rpc => { - // RPC mode: no intermediate syncing state exposed, so red or green only. - if self.rpc_online() && (disable_zmq || self.zmq_connected()) && dapi_available { - OverallConnectionState::Synced - } else { - OverallConnectionState::Disconnected - } - } - CoreBackendMode::Spv => { - if !dapi_available { - OverallConnectionState::Disconnected - } else { - let has_peers = self.spv_connected_peers.load(Ordering::Relaxed) > 0; - match spv_status { - SpvStatus::Running if has_peers => OverallConnectionState::Synced, - SpvStatus::Running - | SpvStatus::Starting - | SpvStatus::Syncing - | SpvStatus::Stopping => { - if has_peers { - OverallConnectionState::Syncing - } else { - OverallConnectionState::Connecting - } - } - SpvStatus::Error => OverallConnectionState::Error, - _ => OverallConnectionState::Disconnected, + // An SPV start/sync failure surfaces as `Error` regardless of DAPI + // availability — the DAPI gate below must not mask it, otherwise a + // failed chain-sync start would silently read as `Disconnected`. + let state = if spv_status == SpvStatus::Error { + OverallConnectionState::Error + } else if !dapi_available { + OverallConnectionState::Disconnected + } else { + let has_peers = self.spv_connected_peers.load(Ordering::Relaxed) > 0; + match spv_status { + SpvStatus::Running if has_peers => OverallConnectionState::Synced, + SpvStatus::Running + | SpvStatus::Starting + | SpvStatus::Syncing + | SpvStatus::Stopping => { + if has_peers { + OverallConnectionState::Syncing + } else { + OverallConnectionState::Connecting } } + _ => OverallConnectionState::Disconnected, } }; self.overall_state.store(state as u8, Ordering::Relaxed); } /// Build the tooltip string for the connection indicator. - /// - /// In SPV mode, fetches sync progress from the [`SpvManager`] to display - /// a detailed phase summary (e.g. `"SPV: Headers: 12345 / 27000 (45%)"`) - /// instead of the bare `"SPV: Syncing"`. - // TODO: decouple from AppContext — accept a struct with the needed fields - // (spv_manager status, settings) instead of the full context reference. - pub fn tooltip_text(&self, app_context: &crate::context::AppContext) -> String { - let backend_mode = self.backend_mode(); - let disable_zmq = self.disable_zmq(); + pub fn tooltip_text(&self) -> String { let spv_status = self.spv_status(); let overall = self.overall_state(); let dapi_status = format!("DAPI: {}", self.dapi_status_label()); - match backend_mode { - CoreBackendMode::Rpc => { - let rpc_status = if self.rpc_online() { - "RPC: Connected" - } else { - "RPC: Disconnected" - }; - let zmq_status = if disable_zmq { - "ZMQ: Disabled" - } else if self.zmq_connected() { - "ZMQ: Connected" - } else { - "ZMQ: Disconnected" - }; - - let header = match overall { - OverallConnectionState::Synced => "Connected to Dash Core Wallet", - // RPC mode doesn't currently produce Connecting/Syncing/Error, but kept for forward-compat. - OverallConnectionState::Connecting | OverallConnectionState::Syncing => { - "Syncing to Dash Core Wallet" - } - OverallConnectionState::Error => "Connection error", - OverallConnectionState::Disconnected if self.rpc_online() => { - "Dash Core connection incomplete" - } - OverallConnectionState::Disconnected => { - "Disconnected from Dash Core Wallet. Click to start it." - } - }; - format!("{header}\n{rpc_status}\n{zmq_status}\n{dapi_status}") - } - CoreBackendMode::Spv => { - let header: std::borrow::Cow<'_, str> = match overall { - OverallConnectionState::Synced => "Ready".into(), - OverallConnectionState::Connecting => "Connecting...".into(), - OverallConnectionState::Syncing => "Syncing".into(), - OverallConnectionState::Error => { - let detail = self - .spv_last_error() - .unwrap_or_else(|| "unknown error".to_string()); - format!("SPV sync error: {detail}").into() - } - OverallConnectionState::Disconnected => "Disconnected".into(), - }; - let spv_label = if spv_status == SpvStatus::Running { - "SPV: Synced".to_string() - } else if spv_status == SpvStatus::Error { - "SPV: Error".to_string() - } else { - app_context - .spv_manager() - .status() - .sync_progress - .as_ref() - .map(|p| format!("SPV: {}", spv_phase_summary(p))) - .unwrap_or_else(|| format!("SPV: {:?}", spv_status)) - }; - let degraded_warning = if self.spv_peer_degraded() { - "\nHaving trouble finding peers. Check your connection." - } else { - "" - }; - format!("{header}\n{spv_label}{degraded_warning}\n{dapi_status}") + let header: std::borrow::Cow<'_, str> = match overall { + OverallConnectionState::Synced => "Ready".into(), + OverallConnectionState::Connecting => "Connecting...".into(), + OverallConnectionState::Syncing => "Syncing".into(), + OverallConnectionState::Error => { + let detail = self + .spv_last_error() + .unwrap_or_else(|| "unknown error".to_string()); + format!("SPV sync error: {detail}").into() } - } - } - - pub fn update_from_chainlocks( - &self, - network: Network, - mainnet_chainlock: &Option<ChainLock>, - testnet_chainlock: &Option<ChainLock>, - devnet_chainlock: &Option<ChainLock>, - local_chainlock: &Option<ChainLock>, - ) { - let online = match network { - Network::Mainnet => mainnet_chainlock.is_some(), - Network::Testnet => testnet_chainlock.is_some(), - Network::Devnet => devnet_chainlock.is_some(), - Network::Regtest => local_chainlock.is_some(), + OverallConnectionState::Disconnected => "Disconnected".into(), }; - self.set_rpc_online(online); + let spv_label = match spv_status { + SpvStatus::Running => "SPV: Synced".to_string(), + SpvStatus::Error => "SPV: Error".to_string(), + other => format!("SPV: {other:?}"), + }; + let degraded_warning = if self.spv_peer_degraded() { + "\nHaving trouble finding peers. Check your connection." + } else { + "" + }; + format!("{header}\n{spv_label}{degraded_warning}\n{dapi_status}") } /// Updates internal connection state from a task result. pub fn handle_task_result(&self, task_result: &TaskResult, active_network: Network) { - if let TaskResult::Success(message) = task_result { - match message.as_ref() { - BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( - mainnet_chainlock, - testnet_chainlock, - devnet_chainlock, - local_chainlock, - rpc_error, - )) => { - self.update_from_chainlocks( - active_network, - mainnet_chainlock, - testnet_chainlock, - devnet_chainlock, - local_chainlock, - ); - // INTENTIONAL(SEC-005): RPC error strings shown as-is in network - // status tooltip. Acceptable for desktop app — helps debugging. - self.set_rpc_last_error(rpc_error.clone()); - self.refresh_state(); - } - BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock(_, network)) => { - if *network == active_network { - self.set_rpc_online(true); - self.refresh_state(); - } - } - _ => {} - } + if let TaskResult::Success(message) = task_result + && let BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock(_, network)) = + message.as_ref() + && *network == active_network + { + self.refresh_state(); } } @@ -481,40 +518,15 @@ impl ConnectionStatus { } *last_update = now; - self.refresh_zmq_and_spv(app_context); - // SPV mode does not use RPC chain lock polling. - if self.backend_mode() == CoreBackendMode::Spv { - return AppAction::None; - } - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::GetBestChainLocks)) + self.refresh_dapi(app_context); + // SPV status is push-based; no RPC chain lock polling. + AppAction::None } - fn refresh_zmq_and_spv(&self, app_context: &crate::context::AppContext) { - // Get current backend mode - let backend_mode = app_context.core_backend_mode(); - self.set_backend_mode(backend_mode); - - match backend_mode { - CoreBackendMode::Spv => { - // SPV status is push-based: SpvManager event handlers call - // set_spv_status / set_spv_connected_peers / set_spv_last_error - // directly, so no polling is needed here. - } - CoreBackendMode::Rpc => { - // Update ZMQ status if there's a new event - let disable_zmq = app_context - .get_settings() - .ok() - .flatten() - .map(|s| s.disable_zmq) - .unwrap_or(false); - self.set_disable_zmq(disable_zmq); - - if let Ok(event) = app_context.rx_zmq_status.try_recv() { - self.set_zmq_status(event); - } - } - } + fn refresh_dapi(&self, app_context: &crate::context::AppContext) { + // SPV status is push-based: chain sync (owned by upstream + // platform-wallet) feeds set_spv_status / set_spv_connected_peers / + // set_spv_last_error, so no polling is needed here. // Update DAPI endpoint status { @@ -584,6 +596,82 @@ pub fn spv_phase_summary(progress: &SpvSyncProgress) -> String { "syncing...".to_string() } +/// Number of phases in the SPV sync pipeline — the total in the blocking +/// overlay's "Step N of {total}" counter. Single source of truth: shared with +/// the overlay adopter so the displayed total can never drift from the phase +/// count [`spv_phase_step`] actually walks. +pub const SPV_SYNC_PHASE_COUNT: u32 = 5; + +/// The currently-active SPV sync phase as `(1-based step, current height)`, or +/// `None` when no phase is actively syncing yet. Mirrors the pipeline order of +/// [`spv_phase_summary`]; the height is the phase's processed tip (Blocks reports +/// `last_processed`, every other phase its `current_height`). Shared by +/// [`spv_phase_step`] (the shown counter) and [`spv_progress_token`] (the hidden +/// watchdog liveness signal) so the two can never disagree on which phase is live. +fn active_spv_phase(progress: &SpvSyncProgress) -> Option<(u32, u32)> { + let is_syncing = |state: SyncState| state == SyncState::Syncing; + if let Ok(p) = progress.headers() + && is_syncing(p.state()) + { + Some((1, p.current_height())) + } else if let Ok(p) = progress.masternodes() + && is_syncing(p.state()) + { + Some((2, p.current_height())) + } else if let Ok(p) = progress.filter_headers() + && is_syncing(p.state()) + { + Some((3, p.current_height())) + } else if let Ok(p) = progress.filters() + && is_syncing(p.state()) + { + Some((4, p.current_height())) + } else if let Ok(p) = progress.blocks() + && is_syncing(p.state()) + { + Some((SPV_SYNC_PHASE_COUNT, p.last_processed())) + } else { + None + } +} + +/// Map the currently-active SPV sync phase to a 1-based step number for the +/// blocking overlay's "Step N of {total}" counter — Headers=1, Masternodes=2, +/// Filter Headers=3, Filters=4, Blocks=[`SPV_SYNC_PHASE_COUNT`] — or `None` when +/// no phase is actively syncing yet. Mirrors the pipeline order of +/// [`spv_phase_summary`]. +/// +/// The step is always in range by construction (every arm in +/// [`active_spv_phase`] returns a literal `<= SPV_SYNC_PHASE_COUNT`), but that +/// invariant lives across two hand-maintained sites — a phase added or removed +/// from one without updating the other would drift. This is checked at runtime +/// (not `debug_assert!`, which compiles out in release, silently hiding the +/// drift from every production build) so a regression is visible in logs +/// instead of just a vanished "Step N of M" counter downstream. +pub fn spv_phase_step(progress: &SpvSyncProgress) -> Option<u32> { + let step = active_spv_phase(progress)?.0; + if step > SPV_SYNC_PHASE_COUNT { + tracing::error!( + step, + SPV_SYNC_PHASE_COUNT, + "SPV phase step exceeds SPV_SYNC_PHASE_COUNT — active_spv_phase and the constant have drifted; bump SPV_SYNC_PHASE_COUNT" + ); + } + Some(step) +} + +/// A **hidden, monotonic** liveness token for the blocking overlay's no-progress +/// watchdog (A-1). It strictly increases as SPV sync advances — within a phase the +/// active phase's height climbs (low 32 bits), and across phases the 1-based step +/// climbs (high 32 bits) — so a slow-but-advancing phase (e.g. Headers on a slow +/// link) keeps resetting the watchdog even though the shown "Step N of 5" copy is +/// unchanged. NEVER rendered; no height or number leaks into user-facing copy. +/// `None` when no phase is actively syncing yet. +pub fn spv_progress_token(progress: &SpvSyncProgress) -> Option<u64> { + let (step, height) = active_spv_phase(progress)?; + Some(((step as u64) << 32) | height as u64) +} + fn pct(current: u32, target: u32) -> u32 { if target == 0 { 0 @@ -601,8 +689,161 @@ impl Default for ConnectionStatus { #[cfg(test)] mod tests { use super::*; + use dash_sdk::dash_spv::sync::{BlockHeadersProgress, MasternodesProgress}; + use std::sync::Arc; use std::time::Duration; + /// A `SyncProgress` mid-headers-download: 5000 / 10000 (50%). + fn syncing_progress() -> SpvSyncProgress { + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Syncing); + headers.update_target_height(10_000); + headers.update_tip_height(5_000); + let mut progress = SpvSyncProgress::default(); + progress.update_headers(headers); + progress + } + + #[test] + fn spv_sync_progress_round_trips() { + let status = ConnectionStatus::new(); + assert!(status.spv_sync_progress().is_none()); + + status.set_spv_sync_progress(Some(syncing_progress())); + let got = status.spv_sync_progress().expect("progress stored"); + let headers = got.headers().expect("headers phase present"); + assert_eq!(headers.target_height(), 10_000); + assert_eq!(headers.current_height(), 5_000); + + status.set_spv_sync_progress(None); + assert!(status.spv_sync_progress().is_none()); + } + + /// F-SPV-2 — the blocking overlay's "Step N of 5" counter maps to the active + /// phase, and the summary reflects the live headers phase. + #[test] + fn spv_phase_step_and_summary_track_active_phase() { + let progress = syncing_progress(); + assert_eq!(spv_phase_step(&progress), Some(1), "headers phase → step 1"); + assert!( + spv_phase_summary(&progress).starts_with("Headers: 5000 / 10000"), + "summary reflects the live headers phase" + ); + + // No phase actively syncing → no step (the overlay shows the generic line). + assert_eq!(spv_phase_step(&SpvSyncProgress::default()), None); + } + + /// Item B — the hidden watchdog liveness token advances as the active phase's + /// height climbs (so a slow-but-advancing phase resets the no-progress + /// watchdog), is monotonic across the step transition, and is `None` when idle. + #[test] + fn spv_progress_token_advances_with_height_and_is_monotonic() { + // Idle progress → no token. + assert_eq!(spv_progress_token(&SpvSyncProgress::default()), None); + + // Headers at 5000: a token exists. + let progress = syncing_progress(); + let t1 = spv_progress_token(&progress).expect("syncing → token"); + + // Same phase, higher tip (7000) → strictly larger token. + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Syncing); + headers.update_target_height(10_000); + headers.update_tip_height(7_000); + let mut advanced = SpvSyncProgress::default(); + advanced.update_headers(headers); + let t2 = spv_progress_token(&advanced).expect("syncing → token"); + assert!( + t2 > t1, + "an advancing height yields a strictly larger token" + ); + + // The step lives in the high 32 bits, so a later phase always out-ranks an + // earlier one regardless of height — the token is monotonic across phases. + assert_eq!(t1 >> 32, 1, "headers maps to step 1 in the high bits"); + + // Cross-phase, high-bits-dominate: a LATER phase at the LOWEST height must + // out-rank an EARLIER phase at a near-maximal height. Headers (step 1) near + // the top of the u32 range still loses to masternodes (step 2) at height 0, + // because the step in the high 32 bits dominates the height in the low bits — + // the exact invariant this test's name claims. + let mut headers_high = BlockHeadersProgress::default(); + headers_high.set_state(SyncState::Syncing); + headers_high.update_target_height(4_000_000_000); + headers_high.update_tip_height(4_000_000_000); + let mut early_high = SpvSyncProgress::default(); + early_high.update_headers(headers_high); + let early_high_token = spv_progress_token(&early_high).expect("syncing → token"); + assert_eq!(early_high_token >> 32, 1, "headers is step 1"); + + let mut masternodes_low = MasternodesProgress::default(); + masternodes_low.set_state(SyncState::Syncing); + // current_height stays at its default 0 — the lowest possible. + let mut late_low = SpvSyncProgress::default(); + late_low.update_masternodes(masternodes_low); + let late_low_token = spv_progress_token(&late_low).expect("syncing → token"); + assert_eq!(late_low_token >> 32, 2, "masternodes is step 2"); + + assert!( + late_low_token > early_high_token, + "a later phase at height 0 out-ranks an earlier phase near the u32 ceiling \ + — the high-bit step dominates the low-bit height" + ); + } + + #[test] + fn spv_status_snapshot_reflects_live_state() { + let status = ConnectionStatus::new(); + status.set_spv_status(SpvStatus::Syncing); + status.set_spv_connected_peers(3); + status.set_spv_sync_progress(Some(syncing_progress())); + + let snap = status.spv_status_snapshot(); + assert_eq!(snap.status, SpvStatus::Syncing); + assert_eq!(snap.connected_peers, 3); + let progress = snap.sync_progress.expect("snapshot carries live progress"); + assert_eq!(progress.headers().unwrap().target_height(), 10_000); + } + + #[test] + fn reset_clears_sync_progress() { + let status = ConnectionStatus::new(); + status.set_spv_sync_progress(Some(syncing_progress())); + assert!(status.spv_sync_progress().is_some()); + + status.reset(); + assert!(status.spv_sync_progress().is_none()); + } + + #[test] + fn masternodes_ready_defaults_false_and_toggles() { + let status = ConnectionStatus::new(); + assert!( + !status.masternodes_ready(), + "a fresh status must report masternodes not ready" + ); + + status.set_masternodes_ready(true); + assert!(status.masternodes_ready(), "setter must flip the flag"); + + status.set_masternodes_ready(false); + assert!(!status.masternodes_ready(), "setter must clear the flag"); + } + + #[test] + fn reset_clears_masternodes_ready() { + let status = ConnectionStatus::new(); + status.set_masternodes_ready(true); + assert!(status.masternodes_ready()); + + status.reset(); + assert!( + !status.masternodes_ready(), + "reset (disconnect / network switch) must re-arm the gate" + ); + } + #[test] fn spv_peer_degraded_returns_false_when_none() { let status = ConnectionStatus::new(); @@ -636,7 +877,222 @@ mod tests { assert!(status.spv_peer_degraded()); // After reset the timestamp should be cleared. - status.reset(CoreBackendMode::Spv); + status.reset(); assert!(!status.spv_peer_degraded()); } + + /// An SPV `Error` must surface as the `Error` overall state even + /// when DAPI is unavailable. The DAPI gate previously short-circuited to + /// `Disconnected` first, masking a failed chain-sync start. + #[test] + fn spv_error_not_masked_by_unavailable_dapi() { + let status = ConnectionStatus::new(); + status.set_dapi_status(0, 0); // DAPI unavailable + assert!(!status.dapi_available(), "precondition: DAPI unavailable"); + + status.set_spv_status(SpvStatus::Error); + status.refresh_state(); + + assert_eq!( + status.overall_state(), + OverallConnectionState::Error, + "SPV error must not be masked by the DAPI gate" + ); + } + + /// `begin_spv_stop` is the single-winner dedupe guard behind the Disconnect + /// button: from an active state it claims the stop (flips to `Stopping`, + /// returns `true`) exactly once; an immediate second call loses (returns + /// `false`) so a fast double-click spawns no second teardown. + #[test] + fn begin_spv_stop_claims_once_from_active() { + for active in [SpvStatus::Starting, SpvStatus::Syncing, SpvStatus::Running] { + let status = ConnectionStatus::new(); + status.set_spv_status(active); + + assert!( + status.begin_spv_stop(), + "first stop from {active:?} must win the claim" + ); + assert_eq!( + status.spv_status(), + SpvStatus::Stopping, + "winning claim must flip the indicator to Stopping synchronously" + ); + assert!( + !status.begin_spv_stop(), + "second stop from {active:?} must lose while one is in flight" + ); + } + } + + /// `begin_spv_stop` is a no-op (returns `false`, leaves status untouched) + /// when there is nothing to stop: already inactive or already stopping. + #[test] + fn begin_spv_stop_noop_when_not_stoppable() { + for inactive in [SpvStatus::Idle, SpvStatus::Stopped, SpvStatus::Error] { + let status = ConnectionStatus::new(); + status.set_spv_status(inactive); + assert!( + !status.begin_spv_stop(), + "no stop to claim from {inactive:?}" + ); + assert_eq!(status.spv_status(), inactive, "status must be untouched"); + } + + let status = ConnectionStatus::new(); + status.set_spv_status(SpvStatus::Stopping); + assert!( + !status.begin_spv_stop(), + "a stop already in flight must not be re-claimed" + ); + assert_eq!(status.spv_status(), SpvStatus::Stopping); + } + + /// Without an SPV error, an unavailable DAPI still reads as `Disconnected` + /// — the new error-precedence branch must not change the DAPI gate for the + /// non-error path. + #[test] + fn unavailable_dapi_reads_disconnected_without_spv_error() { + let status = ConnectionStatus::new(); + status.set_dapi_status(0, 0); + status.set_spv_status(SpvStatus::Syncing); + status.refresh_state(); + + assert_eq!( + status.overall_state(), + OverallConnectionState::Disconnected, + "unavailable DAPI without an SPV error should read Disconnected" + ); + } + + #[test] + fn shielded_sync_progress_round_trips_and_clears() { + let status = ConnectionStatus::new(); + assert!(status.shielded_sync_progress().is_none()); + + status.set_shielded_sync_progress(Some(ShieldedSyncProgress { + cumulative_scanned: 4_096, + block_height: 123_456, + })); + let got = status.shielded_sync_progress().expect("progress stored"); + assert_eq!(got.cumulative_scanned, 4_096); + assert_eq!(got.block_height, 123_456); + + status.set_shielded_sync_progress(None); + assert!(status.shielded_sync_progress().is_none()); + } + + #[test] + fn shielded_tree_progress_round_trips_and_clears() { + let status = ConnectionStatus::new(); + assert!(status.shielded_tree_progress().is_none()); + + status.set_shielded_tree_progress(Some(ShieldedTreeProgress { + leaves_committed: 2_048, + total_target: 10_000, + })); + let got = status.shielded_tree_progress().expect("progress stored"); + assert_eq!(got.leaves_committed, 2_048); + assert_eq!(got.total_target, 10_000); + + status.set_shielded_tree_progress(None); + assert!(status.shielded_tree_progress().is_none()); + } + + // ── watch channel tests ─────────────────────────────────────────────────── + + /// subscribe_spv_status() returns a receiver whose first borrow yields the + /// current (Idle) value, and a subsequent set_spv_status(Running) wakes it. + #[tokio::test] + async fn subscribe_spv_status_wakes_on_running() { + let status = ConnectionStatus::new(); + let mut rx = status.subscribe_spv_status(); + + // First borrow: current value is Idle (channel was created at Idle). + assert_eq!(*rx.borrow_and_update(), SpvStatus::Idle); + + // Drive a transition from a separate task. + let status_arc = Arc::new(status); + let status_clone = Arc::clone(&status_arc); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + status_clone.set_spv_status(SpvStatus::Running); + }); + + // changed() must wake when Running is set. + rx.changed().await.expect("sender is alive"); + assert_eq!(*rx.borrow_and_update(), SpvStatus::Running); + } + + /// Lost-wakeup guard: if Running is set BEFORE subscribing, the first + /// borrow_and_update() reads Running immediately (no wait needed). + #[test] + fn subscribe_after_running_reads_immediately() { + let status = ConnectionStatus::new(); + status.set_spv_status(SpvStatus::Running); + + let mut rx = status.subscribe_spv_status(); + // borrow_and_update on a freshly subscribed receiver returns the + // current (Running) value even though we subscribed after the set. + assert_eq!(*rx.borrow_and_update(), SpvStatus::Running); + } + + /// set_spv_status(Error) wakes a waiter and the value is Error. + #[tokio::test] + async fn subscribe_spv_status_wakes_on_error() { + let status = ConnectionStatus::new(); + let mut rx = status.subscribe_spv_status(); + assert_eq!(*rx.borrow_and_update(), SpvStatus::Idle); + + let status_arc = Arc::new(status); + let status_clone = Arc::clone(&status_arc); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + status_clone.set_spv_status(SpvStatus::Error); + }); + + rx.changed().await.expect("sender is alive"); + assert_eq!(*rx.borrow_and_update(), SpvStatus::Error); + } + + /// reset() mirrors Idle into the watch; a waiter sleeping across a network + /// switch sees Idle and resumes waiting (it does not spuriously return Ok). + #[tokio::test] + async fn reset_mirrors_idle_to_watch() { + let status = ConnectionStatus::new(); + status.set_spv_status(SpvStatus::Running); + + let mut rx = status.subscribe_spv_status(); + assert_eq!(*rx.borrow_and_update(), SpvStatus::Running); + + status.reset(); + rx.changed().await.expect("sender is alive"); + assert_eq!(*rx.borrow_and_update(), SpvStatus::Idle); + } + + #[test] + fn reset_clears_shielded_progress() { + let status = ConnectionStatus::new(); + status.set_shielded_sync_progress(Some(ShieldedSyncProgress { + cumulative_scanned: 1, + block_height: 2, + })); + status.set_shielded_tree_progress(Some(ShieldedTreeProgress { + leaves_committed: 3, + total_target: 4, + })); + assert!(status.shielded_sync_progress().is_some()); + assert!(status.shielded_tree_progress().is_some()); + + status.reset(); + assert!( + status.shielded_sync_progress().is_none(), + "reset must clear in-flight shielded sync progress" + ); + assert!( + status.shielded_tree_progress().is_none(), + "reset must clear in-flight shielded tree progress" + ); + } } diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs new file mode 100644 index 000000000..44630a9d9 --- /dev/null +++ b/src/context/contested_names_db.rs @@ -0,0 +1,543 @@ +//! DPNS contest cache, persisted in the per-network wallet k/v store. +//! +//! One [`StoredContestedName`] entry per normalized name, keyed at +//! `det:contested_name:<normalized_name>` in the global scope (contests +//! are network-scoped, not per-wallet). Contender rows are nested inside +//! the record so a single k/v read returns the whole contest — there is +//! no relational join. + +use super::AppContext; +use crate::backend_task::error::TaskError; +use crate::model::contested_name::{ContestState, Contestant, ContestedName}; +use crate::wallet_backend::{DetScope, KvAdapterError}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::TimestampMillis; +use dash_sdk::dpp::prelude::{BlockHeight, CoreBlockHeight}; +use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use dash_sdk::platform::Identifier; +use dash_sdk::query_types::Contenders; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::time::Duration; + +/// Key prefix for DPNS contest cache entries in the per-network wallet +/// k/v store. Each contest occupies one record at +/// `det:contested_name:<normalized_name>` in the global scope. +const CONTESTED_NAME_KEY_PREFIX: &str = "det:contested_name:"; + +fn contested_name_key(normalized_name: &str) -> String { + format!("{CONTESTED_NAME_KEY_PREFIX}{normalized_name}") +} + +/// Persisted shape of a single DPNS contest. Contenders are nested so +/// the whole record reads atomically — pre-C6 stored them in a separate +/// `contestant` table joined at load time. +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoredContestedName { + normalized_contested_name: String, + locked_votes: Option<u32>, + abstain_votes: Option<u32>, + awarded_to: Option<[u8; 32]>, + end_time: Option<TimestampMillis>, + /// `true` once the contest has been resolved to "locked" + /// (no winner). Distinct from `awarded_to`. + locked: bool, + last_updated: Option<TimestampMillis>, + contestants: Vec<StoredContestant>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredContestant { + id: [u8; 32], + name: String, + info: String, + votes: u32, + created_at: Option<TimestampMillis>, + created_at_block_height: Option<BlockHeight>, + created_at_core_block_height: Option<CoreBlockHeight>, + document_id: [u8; 32], +} + +/// How long a DPNS name contest stays open before it locks or resolves. +/// Mainnet runs the two-week production window; every other network uses a +/// 90-minute fast-cycle window for testing. Mirrors platform's DPNS +/// contested-name governance parameters (`ACTIVE_VOTE_DURATION`). +const MAINNET_CONTEST_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 14); +const NON_MAINNET_CONTEST_DURATION: Duration = Duration::from_secs(60 * 90); + +fn contest_duration_for_network(network: Network) -> Duration { + if network == Network::Mainnet { + MAINNET_CONTEST_DURATION + } else { + NON_MAINNET_CONTEST_DURATION + } +} + +impl StoredContestedName { + fn to_contested_name(&self, network: Network) -> ContestedName { + let contest_duration = contest_duration_for_network(network); + let awarded_to_id = self.awarded_to.map(Identifier::from); + + // Match pre-C6 semantics: state is computed from the latest + // contestant's `created_at`, falling back to `Unknown` when no + // contestant timestamps are available. + let latest_created_at = self.contestants.iter().rev().find_map(|c| c.created_at); + let state = if self.locked { + ContestState::Locked + } else if let Some(id) = awarded_to_id { + ContestState::WonBy(id) + } else if let Some(created_at) = latest_created_at { + let elapsed = Duration::from_millis( + (std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64) + .saturating_sub(created_at), + ); + // New contenders may join only during the first half of the + // contest window; the second half is vote-only. + if elapsed <= contest_duration / 2 { + ContestState::Joinable + } else { + ContestState::Ongoing + } + } else { + ContestState::Unknown + }; + + let contestants = self + .contestants + .iter() + .map(|c| Contestant { + id: Identifier::from(c.id), + name: c.name.clone(), + info: c.info.clone(), + votes: c.votes, + created_at: c.created_at, + created_at_block_height: c.created_at_block_height, + created_at_core_block_height: c.created_at_core_block_height, + document_id: Identifier::from(c.document_id), + }) + .collect(); + + ContestedName { + normalized_contested_name: self.normalized_contested_name.clone(), + contestants: Some(contestants), + locked_votes: self.locked_votes, + abstain_votes: self.abstain_votes, + awarded_to: awarded_to_id, + end_time: self.end_time, + state, + last_updated: self.last_updated, + my_votes: BTreeMap::new(), + } + } +} + +/// Map a k/v adapter failure to the DPNS-contest storage error. +fn contest_err(source: KvAdapterError) -> TaskError { + TaskError::ContestStorage { source } +} + +impl AppContext { + /// Fetches every DPNS contest cached in the per-network k/v store. + pub fn all_contested_names(&self) -> std::result::Result<Vec<ContestedName>, TaskError> { + let kv = self.det_kv()?; + let keys = kv + .list(DetScope::Global, Some(CONTESTED_NAME_KEY_PREFIX)) + .map_err(contest_err)?; + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + match kv.get::<StoredContestedName>(DetScope::Global, &key) { + Ok(Some(stored)) => out.push(stored.to_contested_name(self.network)), + Ok(None) => {} + Err(e) => tracing::warn!( + key = %key, + error = ?e, + "Skipping unreadable contested-name entry", + ), + } + } + Ok(out) + } + + /// Fetches every DPNS contest cached in the per-network k/v store whose + /// `end_time` is in the future (or unknown). + pub fn ongoing_contested_names(&self) -> std::result::Result<Vec<ContestedName>, TaskError> { + let current_timestamp = std::time::UNIX_EPOCH + .elapsed() + .unwrap_or_default() + .as_millis() as u64; + let kv = self.det_kv()?; + let keys = kv + .list(DetScope::Global, Some(CONTESTED_NAME_KEY_PREFIX)) + .map_err(contest_err)?; + let mut out = Vec::new(); + for key in keys { + match kv.get::<StoredContestedName>(DetScope::Global, &key) { + Ok(Some(stored)) => match stored.end_time { + Some(t) if t <= current_timestamp => {} + _ => out.push(stored.to_contested_name(self.network)), + }, + Ok(None) => {} + Err(e) => tracing::warn!( + key = %key, + error = ?e, + "Skipping unreadable contested-name entry", + ), + } + } + Ok(out) + } + + /// Summarise a masternode/evonode node's DPNS voting position for its card. + /// + /// `voter_id` is the node's voter-identity id (`associated_voter_identity`); + /// pass `None` for a node with no voting key loaded — it can vote on + /// nothing, so the summary is empty. The open count reads the ongoing + /// contest cache and the scheduled-vote flag reuses the existing DPNS + /// Scheduled Votes state (no new backend concept — §10.1). + pub fn masternode_contest_summary( + &self, + voter_id: Option<Identifier>, + ) -> std::result::Result<crate::model::contested_name::MasternodeContestSummary, TaskError> + { + let Some(voter_id) = voter_id else { + return Ok(crate::model::contested_name::MasternodeContestSummary::default()); + }; + + let open_contest_count = self + .ongoing_contested_names()? + .iter() + .filter(|contest| contest.is_open_for_voter(&voter_id)) + .count(); + + let has_scheduled_vote = self + .get_scheduled_votes()? + .iter() + .any(|vote| vote.voter_id == voter_id && !vote.executed_successfully); + + Ok(crate::model::contested_name::MasternodeContestSummary { + open_contest_count, + has_scheduled_vote, + }) + } + + /// Apply a batch of newly-seen normalized names. New names are stored + /// as empty contest skeletons; existing names whose `last_updated` is + /// older than 30 s are returned alongside new names for the caller to + /// refresh — matching pre-C6 staleness gating. + pub fn insert_name_contests_as_normalized_names( + &self, + name_contests: Vec<String>, + ) -> std::result::Result<Vec<String>, TaskError> { + let kv = self.det_kv()?; + let stale_threshold = chrono::Utc::now().timestamp() - 30; + let mut new_names: Vec<String> = Vec::new(); + let mut stale: Vec<(String, Option<i64>)> = Vec::new(); + + for name in name_contests { + let key = contested_name_key(&name); + match kv + .get::<StoredContestedName>(DetScope::Global, &key) + .map_err(contest_err)? + { + None => { + let stored = StoredContestedName { + normalized_contested_name: name.clone(), + ..Default::default() + }; + kv.put(DetScope::Global, &key, &stored) + .map_err(contest_err)?; + new_names.push(name); + } + Some(stored) => { + let last_updated = stored.last_updated.map(|t| t as i64); + if last_updated.is_none_or(|t| t < stale_threshold) { + stale.push((name, last_updated)); + } + } + } + } + + // Combine new and stale names (oldest first), preserving the + // pre-C6 ordering callers may rely on. + stale.extend(new_names.into_iter().map(|name| (name, None))); + stale.sort_by(|a, b| a.1.unwrap_or(0).cmp(&b.1.unwrap_or(0))); + Ok(stale.into_iter().map(|(name, _)| name).collect()) + } + + /// Update a single contest record with the latest set of contenders. + /// Mirrors the pre-C6 `insert_or_update_contenders` behavior: when a + /// winner is decided, only the resolution fields are written; otherwise + /// vote tallies and the contender list are refreshed. + pub fn insert_or_update_contenders( + &self, + normalized_contested_name: &str, + contenders: &Contenders, + dpns_domain_document_type: DocumentTypeRef, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let key = contested_name_key(normalized_contested_name); + let last_updated = chrono::Utc::now().timestamp() as u64; + + let mut stored = kv + .get::<StoredContestedName>(DetScope::Global, &key) + .map_err(contest_err)? + .unwrap_or_else(|| StoredContestedName { + normalized_contested_name: normalized_contested_name.to_string(), + ..Default::default() + }); + + if let Some((winner, block_info)) = contenders.winner { + match winner { + ContestedDocumentVotePollWinnerInfo::NoWinner => {} + ContestedDocumentVotePollWinnerInfo::WonByIdentity(won_by) => { + stored.awarded_to = Some(won_by.to_buffer()); + stored.last_updated = Some(last_updated); + stored.end_time = Some(block_info.time_ms); + kv.put(DetScope::Global, &key, &stored) + .map_err(contest_err)?; + } + ContestedDocumentVotePollWinnerInfo::Locked => { + stored.locked = true; + stored.last_updated = Some(last_updated); + stored.end_time = Some(block_info.time_ms); + kv.put(DetScope::Global, &key, &stored) + .map_err(contest_err)?; + } + } + return Ok(()); + } + + stored.locked_votes = Some(contenders.lock_vote_tally.unwrap_or(0)); + stored.abstain_votes = Some(contenders.abstain_vote_tally.unwrap_or(0)); + stored.last_updated = Some(last_updated); + + let mut existing: HashMap<[u8; 32], StoredContestant> = + stored.contestants.drain(..).map(|c| (c.id, c)).collect(); + + for (identity_id, contender) in &contenders.contenders { + let deserialized_contender = contender + .try_to_contender(dpns_domain_document_type, self.platform_version()) + .map_err(|source| TaskError::ContractEncoding { + source: Box::new(source), + })?; + let Some(document) = deserialized_contender.document().as_ref() else { + tracing::warn!( + %identity_id, + "Skipping contender with missing document while updating cache", + ); + continue; + }; + let Some(name) = document.get("label").and_then(|v| v.as_str()) else { + tracing::warn!( + %identity_id, + "Skipping contender with missing label while updating cache", + ); + continue; + }; + let buf = identity_id.to_buffer(); + let votes = contender.vote_tally().unwrap_or(0); + match existing.remove(&buf) { + Some(mut prev) => { + prev.votes = votes; + prev.name = name.to_string(); + prev.document_id = document.id().to_buffer(); + prev.created_at = document.created_at(); + prev.created_at_block_height = document.created_at_block_height(); + prev.created_at_core_block_height = document.created_at_core_block_height(); + stored.contestants.push(prev); + } + None => stored.contestants.push(StoredContestant { + id: buf, + name: name.to_string(), + info: String::new(), + votes, + created_at: document.created_at(), + created_at_block_height: document.created_at_block_height(), + created_at_core_block_height: document.created_at_core_block_height(), + document_id: document.id().to_buffer(), + }), + } + } + + kv.put(DetScope::Global, &key, &stored) + .map_err(contest_err)?; + Ok(()) + } + + /// Patch the `end_time` for a batch of contests. Mirrors pre-C6: + /// the new ending time is only written when it advances past any + /// previously stored one, or when no ending time was recorded yet. + pub fn update_contested_name_ending_times<I>( + &self, + name_contests: I, + ) -> std::result::Result<(), TaskError> + where + I: IntoIterator<Item = (String, TimestampMillis)>, + { + let kv = self.det_kv()?; + for (name, new_end_time) in name_contests { + let key = contested_name_key(&name); + let Some(mut stored) = kv + .get::<StoredContestedName>(DetScope::Global, &key) + .map_err(contest_err)? + else { + continue; + }; + match stored.end_time { + Some(t) if t >= new_end_time => continue, + _ => { + stored.end_time = Some(new_end_time); + kv.put(DetScope::Global, &key, &stored) + .map_err(contest_err)?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::Arc; + + fn empty_kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + fn contestant(id: u8, created_at: Option<TimestampMillis>) -> StoredContestant { + StoredContestant { + id: [id; 32], + name: format!("name-{id}"), + info: String::new(), + votes: u32::from(id), + created_at, + created_at_block_height: None, + created_at_core_block_height: None, + document_id: [id; 32], + } + } + + // ---------------------------------------------------------------- + // Decode: contest state branches the cache resolves at read time. + // ---------------------------------------------------------------- + + #[test] + fn to_contested_name_reports_won_by_when_awarded() { + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + awarded_to: Some([7u8; 32]), + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + assert_eq!(cn.state, ContestState::WonBy(Identifier::from([7u8; 32]))); + assert_eq!(cn.awarded_to, Some(Identifier::from([7u8; 32]))); + } + + #[test] + fn to_contested_name_reports_locked_when_locked_flag_set() { + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + locked: true, + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + assert_eq!(cn.state, ContestState::Locked); + assert!(cn.awarded_to.is_none()); + } + + #[test] + fn to_contested_name_locked_wins_over_awarded() { + // `locked` is checked before `awarded_to`; a record carrying both + // resolves to `Locked`. + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + locked: true, + awarded_to: Some([9u8; 32]), + ..Default::default() + }; + assert_eq!( + stored.to_contested_name(Network::Testnet).state, + ContestState::Locked + ); + } + + #[test] + fn to_contested_name_is_unknown_without_winner_or_timestamps() { + // No winner, not locked, and no contestant timestamps to date the + // contest against — the state stays `Unknown`. + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + contestants: vec![contestant(1, None), contestant(2, None)], + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + assert_eq!(cn.state, ContestState::Unknown); + assert_eq!(cn.contestants.as_ref().map(Vec::len), Some(2)); + } + + #[test] + fn to_contested_name_preserves_contestant_fields() { + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + locked: true, + contestants: vec![contestant(3, Some(100))], + ..Default::default() + }; + let cn = stored.to_contested_name(Network::Testnet); + let mapped = &cn.contestants.unwrap()[0]; + assert_eq!(mapped.id, Identifier::from([3u8; 32])); + assert_eq!(mapped.name, "name-3"); + assert_eq!(mapped.votes, 3); + assert_eq!(mapped.created_at, Some(100)); + } + + // ---------------------------------------------------------------- + // Storage: Global-scoped k/v round-trip of a contest record. + // ---------------------------------------------------------------- + + #[test] + fn contest_record_round_trips_in_global_scope() { + let kv = empty_kv(); + let key = contested_name_key("dash"); + let stored = StoredContestedName { + normalized_contested_name: "dash".to_string(), + locked_votes: Some(4), + abstain_votes: Some(2), + end_time: Some(1_700), + last_updated: Some(1_600), + contestants: vec![contestant(1, Some(10))], + ..Default::default() + }; + kv.put(DetScope::Global, &key, &stored).unwrap(); + + let got: StoredContestedName = kv.get(DetScope::Global, &key).unwrap().unwrap(); + assert_eq!(got.normalized_contested_name, "dash"); + assert_eq!(got.locked_votes, Some(4)); + assert_eq!(got.abstain_votes, Some(2)); + assert_eq!(got.end_time, Some(1_700)); + assert_eq!(got.contestants.len(), 1); + assert_eq!(got.contestants[0].created_at, Some(10)); + } + + #[test] + fn missing_contest_record_reads_as_none() { + let kv = empty_kv(); + let got: Option<StoredContestedName> = kv + .get(DetScope::Global, &contested_name_key("never-stored")) + .unwrap(); + assert!(got.is_none()); + } + + #[test] + fn contest_key_is_prefixed_with_normalized_name() { + assert_eq!(contested_name_key("dash"), "det:contested_name:dash"); + } +} diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 27b4fafaf..56974b686 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -1,117 +1,396 @@ use super::AppContext; use crate::backend_task::error::TaskError; -use crate::model::qualified_contract::QualifiedContract; -use crate::model::wallet::WalletSeedHash; -use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; +use crate::model::qualified_contract::{InsertTokensToo, QualifiedContract}; +use crate::ui::tokens::tokens_screen::{ + IdentityTokenBalance, IdentityTokenIdentifier, TokenInfo, TokenInfoWithDataContract, +}; +use crate::wallet_backend::{DetKv, DetScope, KvAdapterError, UpstreamTokenBalances}; use bincode::config; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructure, + PlatformSerializableWithPlatformVersion, +}; use dash_sdk::platform::{DataContract, Identifier}; use dash_sdk::query_types::IndexMap; -use rusqlite::Result; -use std::sync::Arc; -use std::sync::atomic::Ordering; +use serde::{Deserialize, Serialize}; -impl AppContext { - /// Retrieves all contracts from the database plus the system contracts from app context. - pub fn get_contracts( - &self, - limit: Option<u32>, - offset: Option<u32>, - ) -> Result<Vec<QualifiedContract>> { - // Get contracts from the database - let mut contracts = self.db.get_contracts(self, limit, offset)?; - - // Add the DPNS contract to the list - let dpns_contract = QualifiedContract { - contract: Arc::clone(&self.dpns_contract).as_ref().clone(), - alias: Some("dpns".to_string()), - }; +/// Key prefix for user-registered contract entries in the per-network +/// wallet k/v store. The full key is `det:contract:<contract_id_base58>` +/// and lives in the global (`None`) scope — contracts are a +/// network-scoped concept inside the per-network k/v store. +const CONTRACT_KEY_PREFIX: &str = "det:contract:"; - // Insert the DPNS contract at 0 - contracts.insert(0, dpns_contract); +/// Key prefix for the local token registry. One entry per known token at +/// `det:token:<base58_token_id>`. Lives in the global (`None`) scope — +/// tokens are network-scoped via the surrounding per-network k/v store. +const TOKEN_KEY_PREFIX: &str = "det:token:"; - // Add the token history contract to the list - let token_history_contract = QualifiedContract { - contract: Arc::clone(&self.token_history_contract).as_ref().clone(), - alias: Some("token_history".to_string()), - }; +/// Versioned key for the user-chosen ordering of `(token_id, identity_id)` +/// pairs in the My Tokens screen. Per-network (each network has its own +/// k/v store, so each holds its own ordering). +const TOKEN_ORDER_KEY: &str = "det:token_order:v1"; - // Insert the token history contract at 1 - contracts.insert(1, token_history_contract); +fn contract_key(contract_id: &Identifier) -> String { + format!( + "{}{}", + CONTRACT_KEY_PREFIX, + contract_id.to_string(Encoding::Base58) + ) +} - // Add the withdrawal contract to the list - let withdraws_contract = QualifiedContract { - contract: Arc::clone(&self.withdraws_contract).as_ref().clone(), - alias: Some("withdrawals".to_string()), - }; +fn token_key(token_id: &Identifier) -> String { + format!( + "{}{}", + TOKEN_KEY_PREFIX, + token_id.to_string(Encoding::Base58) + ) +} - // Insert the withdrawal contract at 2 - contracts.insert(2, withdraws_contract); +/// Persisted shape of a token registry entry. Token configuration is held +/// in its bincode form to keep the registry compact — decoded on demand +/// with `bincode::config::standard()` (matches the pre-C7 SQLite blob). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredToken { + /// Bincode-encoded `TokenConfiguration` (same shape as the pre-C7 + /// `token.token_config` BLOB column). + config_bytes: Vec<u8>, + /// Human-readable alias (e.g. the token's singular form in English). + alias: String, + /// Owning data-contract ID (base58 encoded in the key, raw bytes here). + data_contract_id: [u8; 32], + /// Token position within the contract's `tokens` map. + position: u16, +} - // Add the keyword search contract to the list - let keyword_search_contract = QualifiedContract { - contract: Arc::clone(&self.keyword_search_contract).as_ref().clone(), - alias: Some("keyword_search".to_string()), - }; +/// Persisted shape of a DET-local contract entry. The contract itself is +/// stored as platform-serialized bytes (the canonical wire format), with +/// a user-chosen alias alongside. Decoded back into a [`DataContract`] +/// using the active SDK's [`platform_version`](AppContext::platform_version). +#[derive(Debug, Serialize, Deserialize)] +struct StoredContract { + contract_bytes: Vec<u8>, + alias: Option<String>, +} - // Insert the keyword search contract at 3 - contracts.insert(3, keyword_search_contract); +/// Map a k/v adapter failure to the saved-contract storage error. +fn contract_err(source: KvAdapterError) -> TaskError { + TaskError::ContractStorage { source } +} - // Add the DashPay contract to the list - let dashpay_contract = QualifiedContract { - contract: Arc::clone(&self.dashpay_contract).as_ref().clone(), - alias: Some("dashpay".to_string()), - }; +/// Map a k/v adapter failure to the saved-token storage error. +fn token_err(source: KvAdapterError) -> TaskError { + TaskError::TokenStorage { source } +} + +impl AppContext { + /// Retrieves all user-registered contracts from the per-network k/v + /// store, prepended with the system contracts (DPNS, token history, + /// withdrawals, keyword search, DashPay). + pub fn get_contracts(&self) -> std::result::Result<Vec<QualifiedContract>, TaskError> { + let mut contracts = self.load_user_contracts()?; - // Insert the DashPay contract at 4 - contracts.insert(4, dashpay_contract); + // Pin the system contracts at the head of the list, in display order. + let system_contracts = [ + (&self.dpns_contract, "dpns"), + (&self.token_history_contract, "token_history"), + (&self.withdraws_contract, "withdrawals"), + (&self.keyword_search_contract, "keyword_search"), + (&self.dashpay_contract, "dashpay"), + ]; + for (index, (contract, alias)) in system_contracts.into_iter().enumerate() { + contracts.insert( + index, + QualifiedContract { + contract: contract.as_ref().clone(), + alias: Some(alias.to_string()), + }, + ); + } Ok(contracts) } + /// Read every user-registered contract from the per-network k/v + /// store. Entries that fail to decode are skipped with a warning + /// rather than aborting the whole listing. + fn load_user_contracts(&self) -> std::result::Result<Vec<QualifiedContract>, TaskError> { + let backend = self.wallet_backend()?; + let kv = backend.kv(); + let keys = kv + .list(DetScope::Global, Some(CONTRACT_KEY_PREFIX)) + .map_err(contract_err)?; + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + match kv.get::<StoredContract>(DetScope::Global, &key) { + Ok(Some(stored)) => match self.decode_stored_contract(stored) { + Ok(qc) => out.push(qc), + Err(e) => tracing::warn!( + key = %key, + error = ?e, + "Skipping unreadable contract entry", + ), + }, + Ok(None) => {} + Err(e) => tracing::warn!( + key = %key, + error = ?e, + "Skipping unreadable contract entry", + ), + } + } + Ok(out) + } + + fn decode_stored_contract( + &self, + stored: StoredContract, + ) -> std::result::Result<QualifiedContract, TaskError> { + let contract = DataContract::versioned_deserialize( + &stored.contract_bytes, + false, + self.platform_version(), + ) + .map_err(|source| TaskError::ContractEncoding { + source: Box::new(source), + })?; + Ok(QualifiedContract { + contract, + alias: stored.alias, + }) + } + pub fn get_contract_by_id( &self, contract_id: &Identifier, - ) -> Result<Option<QualifiedContract>> { - // Get the contract from the database - self.db.get_contract_by_id(*contract_id, self) + ) -> std::result::Result<Option<QualifiedContract>, TaskError> { + let backend = self.wallet_backend()?; + let key = contract_key(contract_id); + let stored: Option<StoredContract> = backend + .kv() + .get(DetScope::Global, &key) + .map_err(contract_err)?; + match stored { + Some(stored) => Ok(Some(self.decode_stored_contract(stored)?)), + None => Ok(None), + } } pub fn get_unqualified_contract_by_id( &self, contract_id: &Identifier, - ) -> Result<Option<DataContract>> { - // Get the contract from the database - self.db.get_unqualified_contract_by_id(*contract_id, self) + ) -> std::result::Result<Option<DataContract>, TaskError> { + Ok(self.get_contract_by_id(contract_id)?.map(|qc| qc.contract)) + } + + /// Insert a contract entry if no record exists under its ID, mirroring + /// the pre-C6 `INSERT OR IGNORE` semantics. Also persists token + /// metadata for the requested positions in the local `token` table. + pub fn insert_contract_if_not_exists( + &self, + data_contract: &DataContract, + contract_alias: Option<&str>, + insert_tokens_too: InsertTokensToo, + ) -> std::result::Result<(), TaskError> { + let backend = self.wallet_backend()?; + let kv = backend.kv(); + let key = contract_key(&data_contract.id()); + + // Only write the contract entry if none exists yet (INSERT OR IGNORE). + let existing: Option<StoredContract> = + kv.get(DetScope::Global, &key).map_err(contract_err)?; + if existing.is_none() { + let contract_bytes = data_contract + .serialize_to_bytes_with_platform_version(self.platform_version()) + .map_err(|source| TaskError::ContractEncoding { + source: Box::new(source), + })?; + let stored = StoredContract { + contract_bytes, + alias: contract_alias.map(str::to_string), + }; + kv.put(DetScope::Global, &key, &stored) + .map_err(contract_err)?; + } + + // Token metadata now also lives in the per-network k/v store (C7). + if !data_contract.tokens().is_empty() { + let positions: Vec<_> = match insert_tokens_too { + InsertTokensToo::AllTokensShouldBeAdded => { + data_contract.tokens().keys().cloned().collect() + } + InsertTokensToo::NoTokensShouldBeAdded => return Ok(()), + InsertTokensToo::SomeTokensShouldBeAdded(positions) => positions, + }; + for token_contract_position in positions { + if let Some(token_id) = data_contract.token_id(token_contract_position) + && let Ok(token_configuration) = + data_contract.expected_token_configuration(token_contract_position) + { + let token_name = token_configuration + .conventions() + .singular_form_by_language_code_or_default("en") + .to_string(); + self.insert_token( + &token_id, + &token_name, + token_configuration.clone(), + &data_contract.id(), + token_contract_position, + )?; + } + } + } + + Ok(()) } - // Remove contract from the database by ID - pub fn remove_contract(&self, contract_id: &Identifier) -> Result<()> { - self.db.remove_contract(contract_id.as_bytes(), self) + // Remove contract from the per-network k/v store by ID. + pub fn remove_contract(&self, contract_id: &Identifier) -> std::result::Result<(), TaskError> { + let backend = self.wallet_backend()?; + backend + .kv() + .delete(DetScope::Global, &contract_key(contract_id)) + .map_err(contract_err) } + /// Replace a contract entry while preserving the existing alias, if any. pub fn replace_contract( &self, contract_id: Identifier, new_contract: &DataContract, - ) -> Result<()> { - self.db.replace_contract(contract_id, new_contract, self) + ) -> std::result::Result<(), TaskError> { + let backend = self.wallet_backend()?; + let kv = backend.kv(); + let key = contract_key(&contract_id); + + let existing_alias = kv + .get::<StoredContract>(DetScope::Global, &key) + .map_err(contract_err)? + .and_then(|s| s.alias); + + let contract_bytes = new_contract + .serialize_to_bytes_with_platform_version(self.platform_version()) + .map_err(|source| TaskError::ContractEncoding { + source: Box::new(source), + })?; + + let stored = StoredContract { + contract_bytes, + alias: existing_alias, + }; + kv.put(DetScope::Global, &key, &stored) + .map_err(contract_err) + } + + /// Update (or clear) the alias of an existing contract entry. Returns + /// `Ok(())` even if the contract is unknown — matching the lenient + /// "alias is metadata" UX the UI relies on. + pub fn set_contract_alias( + &self, + contract_id: &Identifier, + new_alias: Option<&str>, + ) -> std::result::Result<(), TaskError> { + let backend = self.wallet_backend()?; + let kv = backend.kv(); + let key = contract_key(contract_id); + let Some(mut stored) = kv + .get::<StoredContract>(DetScope::Global, &key) + .map_err(contract_err)? + else { + // No entry — nothing to rename. UI handles "missing" elsewhere. + return Ok(()); + }; + stored.alias = new_alias.map(str::to_string); + kv.put(DetScope::Global, &key, &stored) + .map_err(contract_err) } + /// List every synced `(identity, token)` balance pair from upstream, + /// decorated with the token alias / config / data-contract fields the My + /// Tokens screen expects. Identities that have not yet completed a sync + /// pass are omitted (the UI renders them as "balance unknown"). pub fn identity_token_balances( &self, - ) -> Result<IndexMap<IdentityTokenIdentifier, IdentityTokenBalance>> { - self.db.get_identity_token_balances(self) + ) -> std::result::Result<IndexMap<IdentityTokenIdentifier, IdentityTokenBalance>, TaskError> + { + let kv = self.det_kv()?; + let balances = self.token_balances_view()?.list(); + // Cache decoded token registry entries so repeated balances under + // the same token only decode the configuration once. + let mut token_cache: std::collections::HashMap< + Identifier, + (StoredToken, TokenConfiguration), + > = std::collections::HashMap::new(); + + let mut result = IndexMap::new(); + for (identity_id, token_id, balance) in balances { + let entry = match token_cache.get(&token_id) { + Some(cached) => cached.clone(), + None => { + let token_key_s = token_key(&token_id); + let Some(stored) = kv + .get::<StoredToken>(DetScope::Global, &token_key_s) + .map_err(token_err)? + else { + tracing::debug!( + token = %token_id, + "Skipping balance for unknown token (registry entry missing)" + ); + continue; + }; + let cfg = decode_token_config(&stored.config_bytes)?; + let entry = (stored, cfg); + token_cache.insert(token_id, entry.clone()); + entry + } + }; + let (stored, token_config) = entry; + + let data_contract_id = Identifier::from(stored.data_contract_id); + result.insert( + IdentityTokenIdentifier { + identity_id, + token_id, + }, + IdentityTokenBalance { + token_id, + token_alias: stored.alias.clone(), + token_config, + identity_id, + balance, + estimated_unclaimed_rewards: None, + data_contract_id, + token_position: stored.position, + }, + ); + } + 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. pub fn remove_token_balance( &self, token_id: Identifier, identity_id: Identifier, - ) -> Result<()> { - self.db.remove_token_balance(&token_id, &identity_id, self) + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + prune_token_order(&kv, |(t, i)| !(*t == token_id && *i == identity_id))?; + Ok(()) } + /// 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. pub fn insert_token( &self, token_id: &Identifier, @@ -119,70 +398,424 @@ impl AppContext { token_configuration: TokenConfiguration, contract_id: &Identifier, token_position: u16, - ) -> Result<()> { + ) -> std::result::Result<(), TaskError> { let config = config::standard(); - let Some(serialized_token_configuration) = - bincode::encode_to_vec(&token_configuration, config).ok() - else { - // We should always be able to serialize - return Ok(()); + let config_bytes = bincode::encode_to_vec(&token_configuration, config) + .map_err(|source| TaskError::TokenConfigSerialization { source })?; + let stored = StoredToken { + config_bytes, + alias: token_name.to_string(), + data_contract_id: contract_id.to_buffer(), + position: token_position, }; + let kv = self.det_kv()?; + kv.put(DetScope::Global, &token_key(token_id), &stored) + .map_err(token_err) + } - self.db.insert_token( - token_id, - token_name, - serialized_token_configuration.as_slice(), - contract_id, - token_position, - self, - )?; + /// Reflect a proof-derived post-transaction balance for one + /// `(identity, token)` pair in the upstream-fed snapshot immediately. + /// Token mutation tasks (mint / transfer / burn / purchase / claim) call + /// this with the authoritative balance from the state-transition result so + /// the My Tokens screen updates before the next upstream sync pass. + pub fn insert_token_identity_balance( + &self, + token_id: &Identifier, + identity_id: &Identifier, + balance: u64, + ) -> std::result::Result<(), TaskError> { + self.wallet_backend()? + .apply_known_token_balance(*identity_id, *token_id, balance); + 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. + 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)?; Ok(()) } - pub fn remove_token(&self, token_id: &Identifier) -> Result<()> { - self.db.remove_token(token_id, self) + /// Read the canonical [`TokenConfiguration`] stored at + /// `det:token:<token_id>`, decoded with the pre-C7 bincode layout. + pub fn get_token_config_for_id( + &self, + token_id: &Identifier, + ) -> std::result::Result<Option<TokenConfiguration>, TaskError> { + let kv = self.det_kv()?; + let Some(stored) = kv + .get::<StoredToken>(DetScope::Global, &token_key(token_id)) + .map_err(token_err)? + else { + return Ok(None); + }; + Ok(Some(decode_token_config(&stored.config_bytes)?)) + } + + /// Reverse-lookup: find the data-contract ID that owns a given token. + pub fn get_contract_id_by_token_id( + &self, + token_id: &Identifier, + ) -> std::result::Result<Option<Identifier>, TaskError> { + let kv = self.det_kv()?; + let Some(stored) = kv + .get::<StoredToken>(DetScope::Global, &token_key(token_id)) + .map_err(token_err)? + else { + return Ok(None); + }; + Ok(Some(Identifier::from(stored.data_contract_id))) + } + + /// Read every token registry entry, decode its configuration, and return + /// the entries sorted by alias. Entries whose key does not decode back to a + /// valid token id are skipped with a warning. + fn load_sorted_tokens( + &self, + ) -> std::result::Result<Vec<(Identifier, StoredToken, TokenConfiguration)>, TaskError> { + let kv = self.det_kv()?; + let keys = kv + .list(DetScope::Global, Some(TOKEN_KEY_PREFIX)) + .map_err(token_err)?; + let mut sorted: Vec<(Identifier, StoredToken, TokenConfiguration)> = Vec::new(); + for key in keys { + let Some(stored) = kv + .get::<StoredToken>(DetScope::Global, &key) + .map_err(token_err)? + else { + continue; + }; + // Key has the base58 token-id appended directly after the + // prefix — decode it back so we can use `Identifier` keys in + // the resulting `IndexMap`. + let suffix = match key.strip_prefix(TOKEN_KEY_PREFIX) { + Some(s) => s, + None => continue, + }; + let Ok(token_id) = Identifier::from_string(suffix, Encoding::Base58) else { + tracing::warn!(key = %key, "Skipping unparseable token key"); + continue; + }; + let cfg = decode_token_config(&stored.config_bytes)?; + sorted.push((token_id, stored, cfg)); + } + sorted.sort_by(|a, b| a.1.alias.cmp(&b.1.alias)); + Ok(sorted) } - pub fn remove_wallet(&self, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { - // Acquire write lock first to ensure atomicity — if the lock fails, - // no changes have been made to the database. - let mut wallets = self.wallets.write()?; - if !wallets.contains_key(seed_hash) { - return Err(TaskError::WalletNotFound); + /// List every token in the registry alongside its owning data + /// contract. Falls back to the per-network k/v contract entry — the + /// system-contract pinning lives in [`Self::get_contracts`]. + pub fn get_all_known_tokens_with_data_contract( + &self, + ) -> std::result::Result<IndexMap<Identifier, TokenInfoWithDataContract>, TaskError> { + let mut result = IndexMap::new(); + for (token_id, stored, token_config) in self.load_sorted_tokens()? { + let contract_id = Identifier::from(stored.data_contract_id); + let Some(qc) = self.get_contract_by_id(&contract_id)? else { + tracing::debug!( + token = %token_id, + contract = %contract_id, + "Skipping token: owning contract is not cached", + ); + continue; + }; + result.insert( + token_id, + TokenInfoWithDataContract { + token_id, + token_name: stored.alias, + data_contract: qc.contract, + token_position: stored.position, + token_configuration: token_config, + description: None, + }, + ); } + Ok(result) + } - self.db.remove_wallet(seed_hash, &self.network)?; + /// Lightweight variant of + /// [`Self::get_all_known_tokens_with_data_contract`] that does not + /// resolve the owning contract. + pub fn get_all_known_tokens( + &self, + ) -> std::result::Result<IndexMap<Identifier, TokenInfo>, TaskError> { + let mut result = IndexMap::new(); + for (token_id, stored, token_config) in self.load_sorted_tokens()? { + result.insert( + token_id, + TokenInfo { + token_id, + token_name: stored.alias, + data_contract_id: Identifier::from(stored.data_contract_id), + token_position: stored.position, + token_configuration: token_config, + description: None, + }, + ); + } + Ok(result) + } - wallets.remove(seed_hash); - let has_wallet = !wallets.is_empty(); - drop(wallets); + /// Persist the user-chosen `(token_id, identity_id)` ordering at + /// `det:token_order:v1`. Mirrors pre-C7 semantics: the new list + /// replaces the previous one wholesale. + pub fn save_token_order( + &self, + all_ids: Vec<(Identifier, Identifier)>, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let payload: Vec<([u8; 32], [u8; 32])> = all_ids + .iter() + .map(|(t, i)| (t.to_buffer(), i.to_buffer())) + .collect(); + kv.put(DetScope::Global, TOKEN_ORDER_KEY, &payload) + .map_err(token_err) + } - self.has_wallet.store(has_wallet, Ordering::Relaxed); + /// Load the user-chosen `(token_id, identity_id)` ordering. Returns + /// an empty `Vec` when no ordering has been saved yet. + pub fn load_token_order( + &self, + ) -> std::result::Result<Vec<(Identifier, Identifier)>, TaskError> { + let kv = self.det_kv()?; + let Some(payload): Option<Vec<([u8; 32], [u8; 32])>> = kv + .get(DetScope::Global, TOKEN_ORDER_KEY) + .map_err(token_err)? + else { + return Ok(Vec::new()); + }; + Ok(payload + .into_iter() + .map(|(t, i)| (Identifier::from(t), Identifier::from(i))) + .collect()) + } + /// Devnet-only sweep: drop the token registry, every balance entry + /// and the saved order. 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)?; + } + // Balances are owned upstream now — nothing local to wipe. + kv.delete(DetScope::Global, TOKEN_ORDER_KEY) + .map_err(token_err)?; Ok(()) } - #[allow(dead_code)] // May be used for storing token balances - pub fn insert_token_identity_balance( - &self, - token_id: &Identifier, - identity_id: &Identifier, - balance: u64, - ) -> Result<()> { - self.db - .insert_identity_token_balance(token_id, identity_id, balance, self)?; + /// Upstream-fed token-balance view (T6 seam). Reads the lock-free + /// snapshot the wallet backend refreshes from the upstream identity-sync + /// loop. + fn token_balances_view(&self) -> std::result::Result<UpstreamTokenBalances, TaskError> { + Ok(self.wallet_backend()?.token_balances()) + } + /// Drop every user-registered contract entry for this network. Only + /// applies to devnet contexts — guarded to match the pre-C6 + /// [`Database::remove_all_contracts_in_devnet`] behaviour. + pub fn clear_user_contracts(&self) -> std::result::Result<(), TaskError> { + if self.network != Network::Devnet { + return Ok(()); + } + let backend = self.wallet_backend()?; + let kv = backend.kv(); + let keys = kv + .list(DetScope::Global, Some(CONTRACT_KEY_PREFIX)) + .map_err(contract_err)?; + for key in keys { + kv.delete(DetScope::Global, &key).map_err(contract_err)?; + } Ok(()) } pub fn get_contract_by_token_id( &self, token_id: &Identifier, - ) -> Result<Option<QualifiedContract>> { - let Some(contract_id) = self.db.get_contract_id_by_token_id(token_id, self)? else { + ) -> std::result::Result<Option<QualifiedContract>, TaskError> { + let Some(contract_id) = self.get_contract_id_by_token_id(token_id)? else { return Ok(None); }; - self.db.get_contract_by_id(contract_id, self) + self.get_contract_by_id(&contract_id) + } +} + +/// Decode a stored bincode-encoded [`TokenConfiguration`] blob. Mirrors +/// the pre-C7 read path that wrapped the same bytes inside SQLite. +fn decode_token_config(bytes: &[u8]) -> std::result::Result<TokenConfiguration, TaskError> { + bincode::decode_from_slice::<TokenConfiguration, _>(bytes, config::standard()) + .map(|(cfg, _)| cfg) + .map_err(|source| TaskError::TokenConfigEncoding { source }) +} + +/// 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<F>(kv: &DetKv, keep: F) -> std::result::Result<(), TaskError> +where + F: Fn(&(Identifier, Identifier)) -> bool, +{ + let Some(payload): Option<Vec<([u8; 32], [u8; 32])>> = kv + .get(DetScope::Global, TOKEN_ORDER_KEY) + .map_err(token_err)? + else { + return Ok(()); + }; + let before = payload.len(); + let kept: Vec<_> = payload + .into_iter() + .map(|(t, i)| (Identifier::from(t), Identifier::from(i))) + .filter(|pair| keep(pair)) + .collect(); + if kept.len() == before { + return Ok(()); + } + let serialized: Vec<([u8; 32], [u8; 32])> = kept + .iter() + .map(|(t, i)| (t.to_buffer(), i.to_buffer())) + .collect(); + kv.put(DetScope::Global, TOKEN_ORDER_KEY, &serialized) + .map_err(token_err) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::Arc; + + fn empty_kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + fn ident(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn stored_token(alias: &str, contract: u8, position: u16) -> StoredToken { + StoredToken { + config_bytes: vec![0xAB, 0xCD, 0xEF], + alias: alias.to_string(), + data_contract_id: [contract; 32], + position, + } + } + + // ---------------------------------------------------------------- + // Token registry: Global-scoped k/v round-trip. + // ---------------------------------------------------------------- + + #[test] + fn token_registry_entry_round_trips_in_global_scope() { + let kv = empty_kv(); + let token = ident(1); + let key = token_key(&token); + let stored = stored_token("MyToken", 9, 3); + kv.put(DetScope::Global, &key, &stored).unwrap(); + + let got: StoredToken = kv.get(DetScope::Global, &key).unwrap().unwrap(); + assert_eq!(got.alias, "MyToken"); + assert_eq!(got.position, 3); + assert_eq!(got.data_contract_id, [9u8; 32]); + assert_eq!(got.config_bytes, vec![0xAB, 0xCD, 0xEF]); + } + + #[test] + fn missing_token_registry_entry_reads_as_none() { + let kv = empty_kv(); + let got: Option<StoredToken> = kv.get(DetScope::Global, &token_key(&ident(2))).unwrap(); + assert!(got.is_none()); + } + + #[test] + fn token_registry_put_upserts_in_place() { + let kv = empty_kv(); + let token = ident(1); + let key = token_key(&token); + kv.put(DetScope::Global, &key, &stored_token("Old", 1, 0)) + .unwrap(); + kv.put(DetScope::Global, &key, &stored_token("New", 2, 5)) + .unwrap(); + let got: StoredToken = kv.get(DetScope::Global, &key).unwrap().unwrap(); + assert_eq!(got.alias, "New"); + assert_eq!(got.position, 5); + assert_eq!(got.data_contract_id, [2u8; 32]); + } + + #[test] + fn token_keys_carry_a_base58_suffix_that_round_trips_to_the_id() { + // The listing readers strip the prefix and decode the base58 suffix + // back into an `Identifier`; assert that contract holds. + let token = ident(5); + let key = token_key(&token); + let suffix = key.strip_prefix(TOKEN_KEY_PREFIX).unwrap(); + let decoded = Identifier::from_string(suffix, Encoding::Base58).unwrap(); + assert_eq!(decoded, token); + } + + #[test] + fn contract_keys_carry_a_base58_suffix_that_round_trips_to_the_id() { + let contract = ident(6); + let key = contract_key(&contract); + let suffix = key.strip_prefix(CONTRACT_KEY_PREFIX).unwrap(); + let decoded = Identifier::from_string(suffix, Encoding::Base58).unwrap(); + assert_eq!(decoded, contract); + } + + // ---------------------------------------------------------------- + // Token order: prune helper rewrites the list only when it shrinks. + // ---------------------------------------------------------------- + + #[test] + fn prune_token_order_drops_only_filtered_pairs() { + let kv = empty_kv(); + let keep_pair = (ident(1), ident(10)); + let drop_pair = (ident(2), ident(20)); + let payload: Vec<([u8; 32], [u8; 32])> = vec![ + (keep_pair.0.to_buffer(), keep_pair.1.to_buffer()), + (drop_pair.0.to_buffer(), drop_pair.1.to_buffer()), + ]; + kv.put(DetScope::Global, TOKEN_ORDER_KEY, &payload).unwrap(); + + prune_token_order(&kv, |(t, i)| !(*t == drop_pair.0 && *i == drop_pair.1)).unwrap(); + + let got: Vec<([u8; 32], [u8; 32])> = + kv.get(DetScope::Global, TOKEN_ORDER_KEY).unwrap().unwrap(); + assert_eq!( + got, + vec![(keep_pair.0.to_buffer(), keep_pair.1.to_buffer())] + ); + } + + #[test] + fn prune_token_order_is_noop_when_no_order_list_exists() { + let kv = empty_kv(); + // No order list has ever been written — pruning must not create one. + prune_token_order(&kv, |_| true).unwrap(); + let got: Option<Vec<([u8; 32], [u8; 32])>> = + kv.get(DetScope::Global, TOKEN_ORDER_KEY).unwrap(); + assert!(got.is_none()); + } + + #[test] + fn prune_token_order_keeps_list_intact_when_filter_drops_nothing() { + let kv = empty_kv(); + let payload: Vec<([u8; 32], [u8; 32])> = + vec![(ident(1).to_buffer(), ident(10).to_buffer())]; + kv.put(DetScope::Global, TOKEN_ORDER_KEY, &payload).unwrap(); + prune_token_order(&kv, |_| true).unwrap(); + let got: Vec<([u8; 32], [u8; 32])> = + kv.get(DetScope::Global, TOKEN_ORDER_KEY).unwrap().unwrap(); + assert_eq!(got, payload); } } diff --git a/src/model/feature_gate.rs b/src/context/feature_gate.rs similarity index 56% rename from src/model/feature_gate.rs rename to src/context/feature_gate.rs index 847458398..17e6e417a 100644 --- a/src/model/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -1,32 +1,15 @@ use crate::context::AppContext; -use crate::spv::CoreBackendMode; use dash_sdk::dpp::version::PlatformVersion; -use egui::Ui; /// Named feature gate. Each variant maps to a predicate over `AppContext`. /// /// Adding a new gate: /// 1. Add variant here /// 2. Implement predicate in `is_available()` -/// 3. Use at UI callsite (three patterns available) +/// 3. Use at UI callsite /// -/// # Usage patterns +/// # Usage /// -/// **Single widget** (using egui built-in): -/// ```ignore -/// ui.add_visible(FeatureGate::Shielded.is_available(&ctx), egui::Button::new("Shield")); -/// ``` -/// -/// **Multi-widget section** (using extension trait): -/// ```ignore -/// use crate::model::feature_gate::FeatureGateUiExt; -/// ui.feature_gated(&ctx, FeatureGate::DeveloperMode, |ui| { -/// ui.label("Debug info"); -/// ui.button("Advanced"); -/// }); -/// ``` -/// -/// **Conditional data** (direct predicate): /// ```ignore /// if FeatureGate::Shielded.is_available(&ctx) { /// items.push(something); @@ -42,12 +25,6 @@ pub enum FeatureGate { DashPay, /// Expert/developer mode — unlocks advanced UI elements DeveloperMode, - /// SPV backend mode — active when the app is running in SPV (light client) mode - SpvBackend, - /// Dash Core RPC backend mode — active when the app is running against a local Dash Core node. - /// Use this gate for functionality that requires the Core RPC / ZMQ surface (e.g. the ZMQ - /// listener) instead of reaching for a raw `core_backend_mode() == Rpc` comparison. - RpcBackend, } impl FeatureGate { @@ -74,33 +51,6 @@ impl FeatureGate { } FeatureGate::DashPay => true, // Always for now; future: network/version gate FeatureGate::DeveloperMode => ctx.is_developer_mode(), - FeatureGate::SpvBackend => ctx.core_backend_mode() == CoreBackendMode::Spv, - FeatureGate::RpcBackend => ctx.core_backend_mode() == CoreBackendMode::Rpc, - } - } -} - -/// Extension trait on [`egui::Ui`] for feature-gated UI sections. -pub trait FeatureGateUiExt { - /// Render a multi-widget block only when the gate is available. - /// When unavailable, nothing is rendered and no layout space is allocated. - fn feature_gated( - &mut self, - ctx: &AppContext, - gate: FeatureGate, - add_contents: impl FnOnce(&mut Ui), - ); -} - -impl FeatureGateUiExt for Ui { - fn feature_gated( - &mut self, - ctx: &AppContext, - gate: FeatureGate, - add_contents: impl FnOnce(&mut Ui), - ) { - if gate.is_available(ctx) { - add_contents(self); } } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 27bc4c04d..d70e8c3d2 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1,188 +1,1164 @@ use super::AppContext; use crate::backend_task::contested_names::ScheduledDPNSVote; -use crate::model::contested_name::ContestedName; -use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; -use crate::model::wallet::WalletSeedHash; +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::{ + DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, +}; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; -use rusqlite::Result; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +/// Identity blob slot, scoped to [`DetScope::Identity`]. One entry per +/// identity; the identity id is carried by the scope, so the key is a +/// fixed slot inside the upstream `meta_identity` table. +const IDENTITY_KEY: &str = "det:identity:v1"; + +/// Versioned key for the user's custom identity ordering. Holds a single +/// `Vec<[u8; 32]>` of identity IDs in display order. Lives in +/// [`DetScope::Global`] because it spans every identity. Bumping the +/// version suffix is a deliberate breaking change. +const IDENTITY_ORDER_KEY: &str = "det:identity_order:v1"; + +/// Global enumeration index: the complete set of stored identity ids. +/// [`DetScope::Identity`] has no cross-identity listing, so this Global +/// slot is the authoritative roster the load paths iterate. Maintained +/// on every identity insert and delete. Distinct from +/// [`IDENTITY_ORDER_KEY`], which is a user-ordering view that may lag the +/// full set. +const IDENTITY_INDEX_KEY: &str = "det:identity_index:v1"; + +/// Scheduled-vote slot key, scoped to [`DetScope::Identity`] of the +/// voter. The full key is `det:scheduled_vote:<contested_name>` — the +/// voter id is carried by the scope. +const SCHEDULED_VOTE_KEY_PREFIX: &str = "det:scheduled_vote:"; + +/// Global enumeration index: the complete set of voter ids that have at +/// least one scheduled vote. Scheduled votes are scoped to +/// [`DetScope::Identity`] of the voter, which has no cross-voter listing, +/// so this Global slot drives the network-wide enumeration and clear +/// paths. Maintained on insert and pruned when a voter's last scheduled +/// vote is removed. +const SCHEDULED_VOTE_VOTERS_KEY: &str = "det:scheduled_vote_voters:v1"; + +/// Top-up history slot, scoped to [`DetScope::Identity`]. One entry per +/// identity; the identity id is carried by the scope. +const TOP_UPS_KEY: &str = "det:top_ups:v1"; + +fn scheduled_vote_key(contested_name: &str) -> String { + format!("{SCHEDULED_VOTE_KEY_PREFIX}{contested_name}") +} + +/// Map a k/v adapter failure to the identity-blob storage error. +fn identity_err(source: KvAdapterError) -> TaskError { + TaskError::IdentityStorage { source } +} + +/// Map a k/v adapter failure to the scheduled-vote storage error. +fn scheduled_vote_err(source: KvAdapterError) -> TaskError { + TaskError::ScheduledVoteStorage { source } +} + +/// Map a k/v adapter failure to the top-up-history storage error. +fn top_up_err(source: KvAdapterError) -> TaskError { + TaskError::TopUpHistoryStorage { source } +} + +/// 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. +fn voter_buffer(identity_id: &[u8]) -> std::result::Result<[u8; 32], TaskError> { + Identifier::from_bytes(identity_id) + .map(|id| id.to_buffer()) + .map_err(|source| TaskError::InvalidVoterIdentifier { source }) +} + +/// Decode a stored bincode'd [`QualifiedIdentity`] blob, attaching the +/// active network. The encoder skips `status` / `network` (rehydrated by +/// callers) and `associated_wallets` / `top_ups` (filled by callers from +/// the wallet map and the top-up k/v entry). +fn decode_stored_identity( + bytes: &[u8], + network: Network, +) -> std::result::Result<QualifiedIdentity, TaskError> { + let mut qi = QualifiedIdentity::from_bytes(bytes).map_err(|detail| { + // `QualifiedIdentity::from_bytes` only fails when bincode decode + // fails. Re-derive a typed `DecodeError` so the wrapper carries a + // structured cause instead of a stringified one. + TaskError::IdentityEncoding { + source: bincode::error::DecodeError::OtherString(detail), + } + })?; + qi.network = network; + Ok(qi) +} + +/// Persisted shape of a local identity entry. The [`QualifiedIdentity`] +/// itself is stored as its own bincode encoding (`to_bytes()`), with the +/// network-scoped metadata that the pre-C7 SQLite schema kept in dedicated +/// columns alongside. Fields that the encoder skips (status, network) are +/// rehydrated from the wrapper at read time. +#[derive(Serialize, Deserialize)] +struct StoredQualifiedIdentity { + /// `QualifiedIdentity::to_bytes()` — bincode of the inner struct. + /// Carries everything `Encode` writes: identity, alias, private keys, + /// DPNS names, voter/operator associations. + qi_bytes: Vec<u8>, + /// Identity status (created/active/etc.). Held outside `qi_bytes` + /// because the bincode shape deliberately omits status — matches the + /// pre-C7 column-vs-blob split. + status: u8, + /// Identity type label (`User` / `Masternode` / `Evonode`). Stored + /// alongside the blob so filter queries (voting, user-only) avoid a + /// full decode pass. + identity_type: String, + /// Wallet seed-hash this identity was loaded from, if any. Mirrors + /// the nullable `wallet` column the SQLite schema carried. + wallet_hash: Option<[u8; 32]>, + /// Account index within `wallet_hash`. `Some` iff `wallet_hash` is + /// also `Some` (mirrors the pre-C7 `CHECK` constraint). + wallet_index: Option<u32>, +} + +impl std::fmt::Debug for StoredQualifiedIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `qi_bytes` is the bincode of a `QualifiedIdentity`, which carries + // raw private keys — never print it. Emit only the length and the + // non-secret metadata. + f.debug_struct("StoredQualifiedIdentity") + .field("qi_bytes", &"[redacted]") + .field("qi_bytes_len", &self.qi_bytes.len()) + .field("status", &self.status) + .field("identity_type", &self.identity_type) + .field("wallet_hash", &self.wallet_hash) + .field("wallet_index", &self.wallet_index) + .finish() + } +} + +/// Persisted shape of a scheduled DPNS vote. Mirrors +/// [`ScheduledDPNSVote`] but with a serde-friendly representation of +/// the SDK's [`ResourceVoteChoice`] (which only derives bincode under +/// this feature set). +#[derive(Debug, Serialize, Deserialize)] +struct StoredScheduledVote { + voter_id: [u8; 32], + contested_name: String, + choice: StoredVoteChoice, + unix_timestamp: u64, + executed_successfully: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +enum StoredVoteChoice { + TowardsIdentity([u8; 32]), + Abstain, + Lock, +} + +impl From<&ScheduledDPNSVote> for StoredScheduledVote { + fn from(v: &ScheduledDPNSVote) -> Self { + Self { + voter_id: v.voter_id.to_buffer(), + contested_name: v.contested_name.clone(), + choice: match v.choice { + ResourceVoteChoice::TowardsIdentity(id) => { + StoredVoteChoice::TowardsIdentity(id.to_buffer()) + } + ResourceVoteChoice::Abstain => StoredVoteChoice::Abstain, + ResourceVoteChoice::Lock => StoredVoteChoice::Lock, + }, + unix_timestamp: v.unix_timestamp, + executed_successfully: v.executed_successfully, + } + } +} + +impl From<StoredScheduledVote> for ScheduledDPNSVote { + fn from(v: StoredScheduledVote) -> Self { + ScheduledDPNSVote { + voter_id: Identifier::from(v.voter_id), + contested_name: v.contested_name, + choice: match v.choice { + StoredVoteChoice::TowardsIdentity(id) => { + ResourceVoteChoice::TowardsIdentity(Identifier::from(id)) + } + StoredVoteChoice::Abstain => ResourceVoteChoice::Abstain, + StoredVoteChoice::Lock => ResourceVoteChoice::Lock, + }, + unix_timestamp: v.unix_timestamp, + executed_successfully: v.executed_successfully, + } + } +} + +/// Read the Global identity-id enumeration index. Returns an empty +/// vector when the index has never been written. +fn load_identity_index(kv: &DetKv) -> std::result::Result<Vec<[u8; 32]>, TaskError> { + Ok(kv + .get::<Vec<[u8; 32]>>(DetScope::Global, IDENTITY_INDEX_KEY) + .map_err(identity_err)? + .unwrap_or_default()) +} + +/// Add `identity_id` to the Global enumeration index if absent. No-op +/// when the id is already tracked, so repeated inserts stay idempotent. +fn index_add_identity(kv: &DetKv, identity_id: &[u8; 32]) -> std::result::Result<(), TaskError> { + let mut index = load_identity_index(kv)?; + if index.contains(identity_id) { + return Ok(()); + } + index.push(*identity_id); + kv.put(DetScope::Global, IDENTITY_INDEX_KEY, &index) + .map_err(identity_err) +} + +/// Remove `identity_id` from the Global enumeration index. No-op when +/// the id is not present. +fn index_remove_identity(kv: &DetKv, identity_id: &[u8; 32]) -> std::result::Result<(), TaskError> { + let mut index = load_identity_index(kv)?; + let before = index.len(); + index.retain(|id| id != identity_id); + if index.len() == before { + return Ok(()); + } + kv.put(DetScope::Global, IDENTITY_INDEX_KEY, &index) + .map_err(identity_err) +} + +/// Delete every Identity-scoped child of `id` (blob, top-up history, all +/// scheduled votes) and prune the scheduled-vote voter index. Does not +/// touch the Global identity index — callers decide whether to drop the +/// index entry (single delete) or rewrite it wholesale (devnet sweep). +/// Outcome of [`migrate_keystore_to_vault`], so callers/tests can assert what +/// happened without re-inspecting the blob. +#[derive(Debug, PartialEq, Eq)] +enum KeystoreMigration { + /// No plaintext keys to migrate — `qi` was untouched. + Nothing, + /// The vault write failed; `qi` was restored to its resident plaintext and + /// the blob was NOT persisted (next load retries — no key loss). + VaultWriteFailed, + /// `n` keys moved to the vault and `qi` rewritten to `InVault` placeholders. + Migrated(usize), + /// The identity is password-protected, so a resident plaintext key + /// was NOT migrated to a keyless vault entry. `qi` keeps its resident key (it + /// still signs this session) and nothing is persisted; the add-key path seals + /// new keys Tier-2 explicitly. + ProtectedSkipped, +} + +/// Find an existing password-protected (Tier-2) key of this identity, as a +/// [`SecretScope`](crate::wallet_backend::secret_prompt::SecretScope) suitable +/// for verifying the identity's password when sealing a newly-added key. +/// `None` when the identity has no protected key — i.e. the identity +/// is keyless and the default path applies. +fn find_protected_identity_key_scope( + secret_store: &Arc<platform_wallet_storage::secrets::SecretStore>, + id: &[u8; 32], + qi: &QualifiedIdentity, +) -> Option<crate::wallet_backend::secret_prompt::SecretScope> { + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_seam::SecretScheme; + let view = crate::wallet_backend::IdentityKeyView::new(secret_store, *id); + qi.private_keys + .keys_set() + .into_iter() + .find_map(|(target, key_id)| match view.scheme(&target, key_id) { + Ok(SecretScheme::Protected) => Some(SecretScope::IdentityKey { + identity_id: *id, + target, + key_id, + }), + _ => None, + }) +} + +/// EAGER identity-key migration core (vault-first, crash-safe). Moves any +/// plaintext `Clear`/`AlwaysClear` keys in `qi` into the vault as raw bytes, +/// then asks `persist` to rewrite the blob with `InVault` placeholders. +/// +/// Ordering is the funds-safety contract: vault `store_all` happens FIRST. On a +/// vault-write failure `qi` is restored to its pre-migration resident plaintext +/// (so this session can still sign) and `persist` is NOT called — the legacy +/// blob stays for the next retry, and no key is lost on a mid-write fault. A +/// `persist` failure after a successful vault write is recoverable: the legacy +/// blob plus the now-redundant raw vault entries are re-detected next load and +/// the migration re-runs idempotently. +/// +/// Factored out of [`AppContext`] so it is unit-testable with a bare +/// `SecretStore` and a controllable `persist` closure. +fn migrate_keystore_to_vault( + secret_store: &Arc<platform_wallet_storage::secrets::SecretStore>, + id: &[u8; 32], + qi: &mut QualifiedIdentity, + persist: impl FnOnce(&QualifiedIdentity) -> std::result::Result<(), TaskError>, +) -> KeystoreMigration { + // Probe before cloning: the steady-state (already all-`InVault`) case must + // not pay for a full `KeyStorage` clone — that clone exists only to restore + // the resident plaintext on a vault-write failure. + if !qi.private_keys.has_plaintext_for_vault() { + return KeystoreMigration::Nothing; + } + // Fail-closed: never migrate a protected identity's resident + // plaintext to a KEYLESS vault entry — that would silently strip protection + // off a new key. Leave it resident (it still signs this session) and persist + // nothing; the add-key path seals new keys Tier-2 under the identity password. + if find_protected_identity_key_scope(secret_store, id, qi).is_some() { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + "Skipped keyless migration of a resident key on a password-protected identity", + ); + return KeystoreMigration::ProtectedSkipped; + } + let mut before = qi.private_keys.clone(); + let taken = qi.private_keys.take_plaintext_for_vault(); + let view = crate::wallet_backend::IdentityKeyView::new(secret_store, *id); + if let Err(e) = view.store_all(&taken) { + qi.private_keys = before; + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key vault migration deferred (vault write failed)", + ); + return KeystoreMigration::VaultWriteFailed; + } + let migrated = taken.len(); + // The migrated plaintext now lives only in the vault; drop the `taken` copy + // (it zeroizes on drop) so its key bytes do not linger across the DB write. + drop(taken); + // The vault write succeeded — the rollback clone is no longer + // needed. Zeroize its plaintext bytes (Clear/AlwaysClear) before it drops + // so no identity private key lingers in freed heap. + let _ = before.take_plaintext_for_vault(); + if let Err(e) = persist(qi) { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Identity-key blob rewrite deferred after vault migration", + ); + } else { + tracing::info!( + target = "context::identity_db", + identity = %hex::encode(id), + migrated, + "Migrated identity keys to the secret vault", + ); + } + KeystoreMigration::Migrated(migrated) +} + +/// Encode `qi` for at-rest storage with every resident plaintext private key +/// moved into the secret vault FIRST, leaving `InVault` placeholders in the +/// returned blob. This is the write-path twin of [`migrate_keystore_to_vault`] +/// (the load-path migration): a freshly inserted or updated identity never +/// writes `Clear` / `AlwaysClear` key bytes to `det-app.sqlite`. +/// +/// Funds-safe ordering: the vault `store_all` happens BEFORE the bytes are +/// produced. On a vault-write failure the error propagates and the caller +/// persists nothing — never plaintext, never `InVault` placeholders without the +/// backing vault entries. Operates on a clone so the caller's in-memory +/// identity keeps its resident keys (signing continues this session). A blob +/// with no plaintext keys (already migrated / watch-only) encodes unchanged. +fn encode_identity_blob_vault_first( + secret_store: &Arc<platform_wallet_storage::secrets::SecretStore>, + id: &[u8; 32], + qi: &QualifiedIdentity, +) -> std::result::Result<Vec<u8>, TaskError> { + // No resident plaintext ⇒ nothing to vault and nothing to rewrite; encode + // the borrow directly without a clone (the steady-state, already-`InVault` + // identity that callers re-save unchanged). + if !qi.private_keys.has_plaintext_for_vault() { + return Ok(qi.to_bytes()); + } + // Fail-closed: a password-protected identity must NEVER acquire a + // keyless key. If any existing key is Tier-2, refuse to move new plaintext + // into the vault keyless — the add-key path seals the new key Tier-2 under + // the identity's password and marks it `InVault` first, so a correctly-sealed + // add never reaches this branch. This closes the silent-plaintext-key leak. + // + // A Mixed identity (some keys Tier-2, some still resident plaintext) hits + // this same guard on a plain re-save — e.g. an alias edit — so the re-save + // fails closed until "Finish protecting" reseals the remaining keys under + // the identity password. This is intended secure behavior, not a regression. + if find_protected_identity_key_scope(secret_store, id, qi).is_some() { + return Err(TaskError::IdentityKeyProtectionDowngrade); + } + let mut qi = qi.clone(); + let taken = qi.private_keys.take_plaintext_for_vault(); + crate::wallet_backend::IdentityKeyView::new(secret_store, *id).store_all(&taken)?; + Ok(qi.to_bytes()) +} + +fn purge_identity_scope(kv: &DetKv, id: &[u8; 32]) -> std::result::Result<(), TaskError> { + let scope = DetScope::Identity(id); + kv.delete(scope, IDENTITY_KEY).map_err(identity_err)?; + kv.delete(scope, TOP_UPS_KEY).map_err(top_up_err)?; + delete_scheduled_votes_for_voter(kv, id) +} + +/// Read the Global scheduled-vote voter index. Returns an empty vector +/// when no voter has ever queued a scheduled vote. +fn load_scheduled_vote_voters(kv: &DetKv) -> std::result::Result<Vec<[u8; 32]>, TaskError> { + Ok(kv + .get::<Vec<[u8; 32]>>(DetScope::Global, SCHEDULED_VOTE_VOTERS_KEY) + .map_err(scheduled_vote_err)? + .unwrap_or_default()) +} + +/// Add `voter` to the Global scheduled-vote voter index if absent. +fn index_add_vote_voter(kv: &DetKv, voter: &[u8; 32]) -> std::result::Result<(), TaskError> { + let mut voters = load_scheduled_vote_voters(kv)?; + if voters.contains(voter) { + return Ok(()); + } + voters.push(*voter); + kv.put(DetScope::Global, SCHEDULED_VOTE_VOTERS_KEY, &voters) + .map_err(scheduled_vote_err) +} + +/// List the scheduled-vote entry keys queued under `voter`'s Identity scope. +fn scheduled_vote_keys( + kv: &DetKv, + voter: &[u8; 32], +) -> std::result::Result<Vec<String>, TaskError> { + kv.list(DetScope::Identity(voter), Some(SCHEDULED_VOTE_KEY_PREFIX)) + .map_err(scheduled_vote_err) +} + +/// Drop `voter` from the Global scheduled-vote voter index. No-op when the +/// voter is not present, so repeated calls stay idempotent. +fn remove_vote_voter_from_index( + kv: &DetKv, + voter: &[u8; 32], +) -> std::result::Result<(), TaskError> { + let mut voters = load_scheduled_vote_voters(kv)?; + let before = voters.len(); + voters.retain(|v| v != voter); + if voters.len() == before { + return Ok(()); + } + kv.put(DetScope::Global, SCHEDULED_VOTE_VOTERS_KEY, &voters) + .map_err(scheduled_vote_err) +} + +/// Prune `voter` from the Global scheduled-vote voter index when it no +/// longer has any scheduled votes left in its Identity scope. Keeps the +/// index from accumulating dangling voter entries. +fn prune_vote_voter_if_empty(kv: &DetKv, voter: &[u8; 32]) -> std::result::Result<(), TaskError> { + if scheduled_vote_keys(kv, voter)?.is_empty() { + remove_vote_voter_from_index(kv, voter) + } else { + Ok(()) + } +} + +/// Delete every scheduled vote queued under `voter`'s Identity scope and +/// drop the voter from the index. Used by the identity-removal cleanup +/// path. +fn delete_scheduled_votes_for_voter( + kv: &DetKv, + voter: &[u8; 32], +) -> std::result::Result<(), TaskError> { + let scope = DetScope::Identity(voter); + for key in scheduled_vote_keys(kv, voter)? { + kv.delete(scope, &key).map_err(scheduled_vote_err)?; + } + remove_vote_voter_from_index(kv, voter) +} impl AppContext { - /// Inserts a local qualified identity into the database + /// Insert (or replace) a local qualified identity in the per-network + /// wallet k/v store under [`DetScope::Identity`]. Mirrors pre-C7 + /// `INSERT OR REPLACE` semantics — wallet association is overwritten + /// from the passed-in hint. Also registers the id in the Global + /// enumeration index so the load-all paths can find it. + /// + /// The underlying k/v store offers no multi-key transaction, so the + /// enumeration index is written *before* the blob. The ordering makes a + /// mid-operation failure self-healing: a dangling index entry that points + /// at a not-yet-written blob is skipped by every reader + /// ([`Self::load_identities_filtered`] / [`Self::load_identity_order`] + /// `continue` on a missing blob), and the next successful insert fills it + /// in. The reverse order would instead hide a written identity — and its + /// keys and balances — until an unrelated update happened to re-index it. pub fn insert_local_qualified_identity( &self, qualified_identity: &QualifiedIdentity, wallet_and_identity_id_info: &Option<(WalletSeedHash, u32)>, - ) -> Result<()> { - self.db.insert_local_qualified_identity( - qualified_identity, - wallet_and_identity_id_info, - self, - ) + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let (wallet_hash, wallet_index) = match wallet_and_identity_id_info { + Some((seed, idx)) => (Some(*seed), Some(*idx)), + None => { + // Masternodes and evonodes are loaded by ProTxHash and have no + // associated HD wallet by design, so a missing wallet is normal + // for them — only a wallet-less User identity is worth flagging. + if qualified_identity.identity_type == IdentityType::User { + tracing::warn!( + identity_id = %qualified_identity.identity.id(), + alias = ?qualified_identity.alias, + "saving identity without wallet; this needs investigating", + ); + } else { + tracing::debug!( + identity_id = %qualified_identity.identity.id(), + alias = ?qualified_identity.alias, + identity_type = ?qualified_identity.identity_type, + "saving masternode/evonode identity without wallet (expected)", + ); + } + (None, None) + } + }; + let id = qualified_identity.identity.id().to_buffer(); + // Vault-first: move any plaintext keys into the vault before encoding, so + // the at-rest blob carries only `InVault` placeholders. A vault-write + // failure aborts the insert (nothing is persisted). + let qi_bytes = + encode_identity_blob_vault_first(&self.secret_store, &id, qualified_identity)?; + let stored = StoredQualifiedIdentity { + qi_bytes, + status: qualified_identity.status.as_u8(), + identity_type: qualified_identity.identity_type.as_tag().to_string(), + wallet_hash, + wallet_index, + }; + index_add_identity(&kv, &id)?; + kv.put(DetScope::Identity(&id), IDENTITY_KEY, &stored) + .map_err(identity_err) } - /// Updates a local qualified identity in the database + /// Update a local qualified identity in place. Wallet association + /// (`wallet_hash` / `wallet_index`) is preserved from the existing + /// record — pre-C7 `update_local_qualified_identity` had the same + /// behaviour by virtue of omitting those columns from its `UPDATE`. pub fn update_local_qualified_identity( &self, qualified_identity: &QualifiedIdentity, - ) -> Result<()> { - self.db - .update_local_qualified_identity(qualified_identity, self) + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let id = qualified_identity.identity.id().to_buffer(); + let scope = DetScope::Identity(&id); + let existing: Option<StoredQualifiedIdentity> = + kv.get(scope, IDENTITY_KEY).map_err(identity_err)?; + let (wallet_hash, wallet_index) = existing + .as_ref() + .map(|s| (s.wallet_hash, s.wallet_index)) + .unwrap_or((None, None)); + // Vault-first: move any plaintext keys into the vault before encoding, so + // an update never lands `Clear` / `AlwaysClear` key bytes on disk. + let qi_bytes = + encode_identity_blob_vault_first(&self.secret_store, &id, qualified_identity)?; + let stored = StoredQualifiedIdentity { + qi_bytes, + status: qualified_identity.status.as_u8(), + identity_type: qualified_identity.identity_type.as_tag().to_string(), + wallet_hash, + wallet_index, + }; + kv.put(scope, IDENTITY_KEY, &stored).map_err(identity_err)?; + // Keep the enumeration index consistent even if a caller updates + // an identity the index never learned about. + index_add_identity(&kv, &id) } - /// Sets the alias for an identity + /// Update only the user-facing alias on a stored identity. Returns + /// `Ok(())` when the identity is unknown — alias is metadata, not a + /// load-bearing identifier. pub fn set_identity_alias( &self, identifier: &Identifier, new_alias: Option<&str>, - ) -> Result<()> { - self.db.set_identity_alias(identifier, new_alias) + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let id = identifier.to_buffer(); + let scope = DetScope::Identity(&id); + let Some(mut stored) = kv + .get::<StoredQualifiedIdentity>(scope, IDENTITY_KEY) + .map_err(identity_err)? + else { + return Ok(()); + }; + let mut qi = decode_stored_identity(&stored.qi_bytes, self.network)?; + qi.alias = new_alias.map(str::to_string); + // Re-encode vault-first so an alias edit on a not-yet-migrated blob does + // not rewrite resident plaintext keys back to disk. + stored.qi_bytes = encode_identity_blob_vault_first(&self.secret_store, &id, &qi)?; + kv.put(scope, IDENTITY_KEY, &stored).map_err(identity_err) } - pub fn set_contract_alias( + /// Read the user-facing alias for a stored identity, if any. + pub fn get_identity_alias( &self, - contract_id: &Identifier, - new_alias: Option<&str>, - ) -> Result<()> { - self.db.set_contract_alias(contract_id, new_alias) + identifier: &Identifier, + ) -> std::result::Result<Option<String>, TaskError> { + let kv = self.det_kv()?; + let id = identifier.to_buffer(); + let Some(stored) = kv + .get::<StoredQualifiedIdentity>(DetScope::Identity(&id), IDENTITY_KEY) + .map_err(identity_err)? + else { + return Ok(None); + }; + let qi = decode_stored_identity(&stored.qi_bytes, self.network)?; + Ok(qi.alias) } - /// Gets the alias for an identity - pub fn get_identity_alias(&self, identifier: &Identifier) -> Result<Option<String>> { - self.db.get_identity_alias(identifier) + /// Fetches all local qualified identities from the k/v store, then + /// hydrates each identity's top-up history. + /// + /// Stops on the first corrupted identity blob and returns an error. + /// This is intentional — identities hold private keys and balance data, + /// so skipping a corrupted entry could cause loss of funds. + pub fn load_local_qualified_identities( + &self, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> { + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); + let mut identities = self.load_identities_filtered(&wallets, |_| true)?; + for identity in &mut identities { + self.hydrate_top_ups(identity); + } + Ok(identities) } - /// Fetches all local qualified identities from the database - pub fn load_local_qualified_identities(&self) -> Result<Vec<QualifiedIdentity>> { + /// Load the DET-known, wallet-owned qualified identities for one wallet — + /// every sidecar entry that carries a `wallet_index` and matches + /// `seed_hash`. Drives the cold-boot/unlock reconcile that registers them + /// into the upstream `IdentityManager`. Top-up history is intentionally not + /// hydrated: the reconcile needs only the identity and its wallet index. + pub(crate) fn load_local_qualified_identities_for_wallet( + &self, + seed_hash: &WalletSeedHash, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> { let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - self.db.get_local_qualified_identities(self, &wallets) + let target = Some(*seed_hash); + self.load_identities_filtered(&wallets, |s| { + s.wallet_index.is_some() && s.wallet_hash == target + }) + } + + /// The masternode/evonode identities for the active network — the + /// Masternodes-page card list and the page-scoped masternode pill source. + /// The complement of [`Self::load_local_user_identities`] over the FR-6 type + /// boundary. Filters the hydrated full load, so each card's top-up history + /// is available (unlike the pre-decode [`Self::load_local_voting_identities`], + /// which is un-hydrated and named for the DPNS voting flows). + pub fn load_local_masternode_identities( + &self, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> { + Ok(self + .load_local_qualified_identities()? + .into_iter() + .filter(|qi| { + matches!( + qi.identity_type, + IdentityType::Masternode | IdentityType::Evonode + ) + }) + .collect()) } - /// Fetches all local qualified identities from the database - #[allow(dead_code)] // May be used for loading identities in wallets - pub fn load_local_qualified_identities_in_wallets(&self) -> Result<Vec<QualifiedIdentity>> { + /// Read one stored qualified identity by id, hydrated like the list loads + /// (status, wallet index, network, wallets, secret access). `None` when no + /// identity with `id` is stored. Backs the load-path existence check + /// (duplicate-ProTxHash rejection) and the in-place voter-key merge. + pub fn get_local_qualified_identity( + &self, + id: &Identifier, + ) -> std::result::Result<Option<QualifiedIdentity>, TaskError> { + let kv = self.det_kv()?; + let id_buf = id.to_buffer(); + let Some(stored) = kv + .get::<StoredQualifiedIdentity>(DetScope::Identity(&id_buf), IDENTITY_KEY) + .map_err(|source| TaskError::IdentityStorage { source })? + else { + return Ok(None); + }; let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - self.db - .get_local_qualified_identities_in_wallets(self, &wallets) + let mut qi = decode_stored_identity(&stored.qi_bytes, self.network)?; + qi.status = IdentityStatus::from_u8(stored.status); + qi.wallet_index = stored.wallet_index; + qi.network = self.network; + qi.associated_wallets = wallets.clone(); + qi.secret_access = self.wallet_backend().ok().map(|b| b.secret_access()); + qi.top_ups = BTreeMap::new(); + self.migrate_identity_keys_to_vault(&kv, &id_buf, &mut qi); + Ok(Some(qi)) + } + + /// 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. + /// Sorted by identity ID for deterministic output — mirrors the + /// pre-C7 `ORDER BY id`. + /// + /// Under [`DetScope::Identity`] there is no cross-identity listing, so + /// the index ([`IDENTITY_INDEX_KEY`]) is the roster: read the ids, + /// then fetch each blob from its own identity scope. + fn load_identities_filtered<F>( + &self, + wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, + keep: F, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> + where + F: Fn(&StoredQualifiedIdentity) -> bool, + { + let kv = self.det_kv()?; + let mut ids = load_identity_index(&kv)?; + ids.sort_unstable(); + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + let Some(stored) = kv + .get::<StoredQualifiedIdentity>(DetScope::Identity(&id), IDENTITY_KEY) + .map_err(identity_err)? + else { + continue; + }; + if !keep(&stored) { + continue; + } + out.push(self.hydrate_stored_identity(&kv, &id, &stored, wallets)?); + } + // Seed the JIT chokepoint's identity prompt-copy index (alias + hint) + // so the sign-time prompt for an opted-in (Tier-2) identity shows the + // right label and hint. Display-only and best-effort — the vault scheme, + // not this index, decides whether a prompt fires. + if let Ok(backend) = self.wallet_backend() { + backend.seed_identity_prompt_index(&out); + } + Ok(out) + } + + /// Decode a stored blob and rehydrate the runtime-only fields the encoder + /// skips — status, wallet index, network, wallet map, secret access — then + /// run the crash-safe vault migration. Shared by the bulk-load and + /// single-get paths so both reconstruct an identity identically. Top-up + /// history is left empty; callers hydrate it separately when needed. + fn hydrate_stored_identity( + &self, + kv: &DetKv, + id: &[u8; 32], + stored: &StoredQualifiedIdentity, + wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, + ) -> std::result::Result<QualifiedIdentity, TaskError> { + let mut qi = decode_stored_identity(&stored.qi_bytes, self.network)?; + qi.status = IdentityStatus::from_u8(stored.status); + qi.wallet_index = stored.wallet_index; + qi.network = self.network; + qi.associated_wallets = wallets.clone(); + qi.secret_access = self.wallet_backend().ok().map(|b| b.secret_access()); + qi.top_ups = BTreeMap::new(); + self.migrate_identity_keys_to_vault(kv, id, &mut qi); + Ok(qi) + } + + /// Populate `identity.top_ups` from the per-network wallet k/v + /// store. A missing or unreadable entry is logged and treated as an + /// empty map; pre-C5 SQLite data is intentionally not migrated and + /// surfaces as empty under the "empty start" policy. + fn hydrate_top_ups(&self, identity: &mut QualifiedIdentity) { + let Ok(kv) = self.det_kv() else { + return; + }; + let id = identity.identity.id().to_buffer(); + match kv.get::<std::collections::BTreeMap<u32, u64>>(DetScope::Identity(&id), TOP_UPS_KEY) { + Ok(Some(map)) => identity.top_ups = map, + Ok(None) => {} + Err(e) => { + tracing::warn!( + identity = %identity.identity.id(), + error = ?e, + "Failed to load top-up history from wallet k/v" + ); + } + } + } + + /// Persist the running top-up history for an identity into the + /// per-network wallet k/v store. + pub fn save_top_ups( + &self, + identity_id: &Identifier, + top_ups: &std::collections::BTreeMap<u32, u64>, + ) -> 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) } pub fn get_identity_by_id( &self, identity_id: &Identifier, - ) -> Result<Option<QualifiedIdentity>> { + ) -> std::result::Result<Option<QualifiedIdentity>, TaskError> { + let kv = self.det_kv()?; + let id = identity_id.to_buffer(); + let Some(stored) = kv + .get::<StoredQualifiedIdentity>(DetScope::Identity(&id), IDENTITY_KEY) + .map_err(identity_err)? + else { + return Ok(None); + }; let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - // Get the identity from the database - let result = self.db.get_identity_by_id(identity_id, self, &wallets)?; + // Shared with the bulk-load path: rehydrates the skipped fields and runs + // the crash-safe vault migration so single-get consumers (the identity + // key password tasks and others) see vault-backed schemes rather than + // re-persisting resident plaintext. + let mut qi = self.hydrate_stored_identity(&kv, &id, &stored, &wallets)?; + self.hydrate_top_ups(&mut qi); + Ok(Some(qi)) + } - Ok(result) + /// The [`SecretScope`](crate::wallet_backend::secret_prompt::SecretScope) of + /// an existing password-protected key of `qi`, used to verify the identity's + /// password when sealing a newly-added key, or `None` when the + /// identity is not password-protected (the default keyless add applies). + pub(crate) fn protected_identity_verify_scope( + &self, + qi: &QualifiedIdentity, + ) -> std::result::Result<Option<crate::wallet_backend::secret_prompt::SecretScope>, TaskError> + { + let backend = self.wallet_backend()?; + let id = qi.identity.id().to_buffer(); + Ok(find_protected_identity_key_scope( + backend.secret_store(), + &id, + qi, + )) } - /// Fetches all voting identities from the database - pub fn load_local_voting_identities(&self) -> Result<Vec<QualifiedIdentity>> { - self.db.get_local_voting_identities(self) + /// Fetches every locally-stored identity whose `identity_type` is + /// not `User` — used by the DPNS contest voting flows. + pub fn load_local_voting_identities( + &self, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> { + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); + self.load_identities_filtered(&wallets, |s| { + !matches!( + IdentityType::from_tag(&s.identity_type), + Some(IdentityType::User) + ) + }) } - /// Fetches all local user identities from the database - pub fn load_local_user_identities(&self) -> Result<Vec<QualifiedIdentity>> { - let identities = self.db.get_local_user_identities(self)?; + /// Fetches every locally-stored identity whose `identity_type` is + /// `User`. Top-up history is *not* loaded here — matching the + /// pre-C7 query shape that the consumer screens depend on. + pub fn load_local_user_identities( + &self, + ) -> std::result::Result<Vec<QualifiedIdentity>, TaskError> { + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); + self.load_identities_filtered(&wallets, |s| { + matches!( + IdentityType::from_tag(&s.identity_type), + Some(IdentityType::User) + ) + }) + } - Ok(identities + /// Return the raw ids of every locally-stored identity, read from the + /// Global enumeration index. Cheap — no blob decode. Used by callers + /// (e.g. the network-clear sweep) that need to fan an operation out + /// over each identity's [`DetScope::Identity`] scope. + pub fn local_identity_ids(&self) -> std::result::Result<Vec<Identifier>, TaskError> { + let kv = self.det_kv()?; + Ok(load_identity_index(&kv)? .into_iter() - .map(|(mut identity, wallet_hash)| { - if let Some(wallet_id) = wallet_hash { - // Load wallets for each identity - self.load_wallet_for_identity( - &mut identity, - &[wallet_id], - ) - .unwrap_or_else(|e| { - tracing::warn!( - identity = %identity.identity.id(), - error = ?e, - "cannot load wallet for identity when loading local user identities", - ) - }) - } else { - tracing::debug!( - identity = %identity.identity.id(), - "no wallet hash found for identity when loading local user identities", - ); - } - identity - }) + .map(Identifier::from) .collect()) } - fn load_wallet_for_identity( + /// Remove a locally-stored identity and all of its Identity-scoped + /// children. Returns `Ok(())` even when the identity is unknown — + /// mirrors the pre-C7 `DELETE` which silently no-ops on missing rows. + /// + /// Cleanup verdict: explicit. DET never deletes the upstream + /// `identities` row (that table is owned by the upstream sync layer; + /// DET stores the qualified-identity blob in the `meta_identity` k/v + /// scope only), so the upstream `cascade_meta_identity_on_identity_delete` + /// trigger never fires for this path. This method therefore drains the + /// Identity scope itself — the blob, the top-up history, and every + /// scheduled vote queued for this identity — and removes the Global + /// index entries that the trigger would not touch. + pub fn delete_local_qualified_identity( &self, - identity: &mut QualifiedIdentity, - wallet_hashes: &[WalletSeedHash], - ) -> Result<()> { - let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - for wallet_hash in wallet_hashes { - if let Some(wallet) = wallets.get(wallet_hash) { - identity - .associated_wallets - .insert(*wallet_hash, wallet.clone()); - } else { - tracing::warn!( - wallet = %hex::encode(wallet_hash), - identity = %identity.identity.id(), - "wallet not found for identity when loading local user identities", - ); - } + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let id = identifier.to_buffer(); + self.clear_identity_vault_keys(&kv, &id); + purge_identity_scope(&kv, &id)?; + index_remove_identity(&kv, &id) + } + + /// EAGER identity-key migration (dialog-free): move any plaintext + /// `Clear`/`AlwaysClear` identity keys into the vault as raw bytes and + /// rewrite the blob with `InVault` placeholders so the keys are never + /// resident. + /// + /// Crash-safe ordering: vault `store_all` FIRST, then blob rewrite. If the + /// vault write fails the blob is left untouched (the in-memory `qi` is + /// restored to its resident plaintext for this session) and the next load + /// retries — keys are never lost. Idempotent: a blob already all-`InVault` + /// has nothing to take and is skipped. Best-effort: a blob-rewrite failure + /// is logged; the next load re-detects the plaintext and retries. + fn migrate_identity_keys_to_vault( + &self, + kv: &DetKv, + id: &[u8; 32], + qi: &mut QualifiedIdentity, + ) { + let _ = migrate_keystore_to_vault(&self.secret_store, id, qi, |migrated| { + self.persist_identity_blob(kv, id, migrated) + }); + } + + /// Re-persist `qi`'s blob in place, preserving the stored wallet + /// association and status. Used by the eager identity-key migration. + fn persist_identity_blob( + &self, + kv: &DetKv, + id: &[u8; 32], + qi: &QualifiedIdentity, + ) -> std::result::Result<(), TaskError> { + let scope = DetScope::Identity(id); + let existing: Option<StoredQualifiedIdentity> = + kv.get(scope, IDENTITY_KEY).map_err(identity_err)?; + let (wallet_hash, wallet_index, status) = existing + .as_ref() + .map(|s| (s.wallet_hash, s.wallet_index, s.status)) + .unwrap_or((None, None, qi.status.as_u8())); + let stored = StoredQualifiedIdentity { + qi_bytes: qi.to_bytes(), + status, + identity_type: qi.identity_type.as_tag().to_string(), + wallet_hash, + wallet_index, + }; + kv.put(scope, IDENTITY_KEY, &stored).map_err(identity_err) + } + + /// Delete every identity-key raw secret for `id` from the vault. Best + /// effort: a decode/read failure is logged and skipped so identity removal + /// never wedges on an unreadable blob — leaving a stale vault entry is + /// preferable to blocking the delete, and the entry is unreachable once the + /// blob is gone. Idempotent (deleting an absent label is `Ok`). + fn clear_identity_vault_keys(&self, kv: &DetKv, id: &[u8; 32]) { + let Ok(Some(stored)) = + kv.get::<StoredQualifiedIdentity>(DetScope::Identity(id), IDENTITY_KEY) + else { + return; + }; + let Ok(qi) = QualifiedIdentity::from_bytes(&stored.qi_bytes) else { + return; + }; + let view = crate::wallet_backend::IdentityKeyView::new(&self.secret_store, *id); + if let Err(e) = view.delete_all(qi.private_keys.keys_set()) { + tracing::warn!( + target = "context::identity_db", + identity = %hex::encode(id), + error = ?e, + "Failed to clear some identity vault keys on delete; continuing", + ); } + } - Ok(()) + /// Devnet-only sweep: drop every locally-stored identity for the + /// current network. Matches the pre-C7 + /// `delete_all_local_qualified_identities_in_devnet` guard — no-op on + /// non-devnet networks. + pub fn delete_all_local_qualified_identities_in_devnet( + &self, + ) -> std::result::Result<(), TaskError> { + if self.network != Network::Devnet { + return Ok(()); + } + let kv = self.det_kv()?; + let ids = load_identity_index(&kv)?; + for id in &ids { + self.clear_identity_vault_keys(&kv, id); + purge_identity_scope(&kv, id)?; + } + kv.delete(DetScope::Global, IDENTITY_INDEX_KEY) + .map_err(identity_err) } - /// Fetches all contested names from the database including past and active ones - pub fn all_contested_names(&self) -> Result<Vec<ContestedName>> { - self.db.get_all_contested_names(self) + /// Persist the user-chosen identity ordering at `det:identity_order:v1`. + /// Overwrites the previous list — matches pre-C7 semantics. + pub fn save_identity_order( + &self, + all_ids: Vec<Identifier>, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let payload: Vec<[u8; 32]> = all_ids.iter().map(Identifier::to_buffer).collect(); + kv.put(DetScope::Global, IDENTITY_ORDER_KEY, &payload) + .map_err(identity_err) } - /// Fetches all ongoing contested names from the database - pub fn ongoing_contested_names(&self) -> Result<Vec<ContestedName>> { - self.db.get_ongoing_contested_names(self) + /// Load the user-chosen identity ordering, dropping any references + /// that no longer point at a stored identity. + pub fn load_identity_order(&self) -> std::result::Result<Vec<Identifier>, TaskError> { + let kv = self.det_kv()?; + let Some(payload): Option<Vec<[u8; 32]>> = kv + .get(DetScope::Global, IDENTITY_ORDER_KEY) + .map_err(identity_err)? + else { + return Ok(Vec::new()); + }; + let mut kept = Vec::with_capacity(payload.len()); + let mut needs_rewrite = false; + for buf in payload { + let exists = kv + .get::<StoredQualifiedIdentity>(DetScope::Identity(&buf), IDENTITY_KEY) + .map_err(identity_err)? + .is_some(); + if exists { + kept.push(Identifier::from(buf)); + } else { + needs_rewrite = true; + } + } + if needs_rewrite { + let payload: Vec<[u8; 32]> = kept.iter().map(Identifier::to_buffer).collect(); + kv.put(DetScope::Global, IDENTITY_ORDER_KEY, &payload) + .map_err(identity_err)?; + } + Ok(kept) } - /// Inserts scheduled votes into the database - pub fn insert_scheduled_votes(&self, scheduled_votes: &Vec<ScheduledDPNSVote>) -> Result<()> { - self.db.insert_scheduled_votes(self, scheduled_votes) + /// Persist a batch of scheduled votes in the per-network wallet k/v + /// store. Each vote is scoped to [`DetScope::Identity`] of its voter; + /// existing entries with the same `(voter, contested_name)` are + /// overwritten — matching the pre-C5 `INSERT OR REPLACE` semantics. + /// Voters are tracked in a Global index so the network-wide read / + /// clear paths can find them. + pub fn insert_scheduled_votes( + &self, + scheduled_votes: &[ScheduledDPNSVote], + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + for vote in scheduled_votes { + let voter = vote.voter_id.to_buffer(); + let stored = StoredScheduledVote::from(vote); + kv.put( + DetScope::Identity(&voter), + &scheduled_vote_key(&vote.contested_name), + &stored, + ) + .map_err(scheduled_vote_err)?; + index_add_vote_voter(&kv, &voter)?; + } + Ok(()) } - /// Fetches all scheduled votes from the database - pub fn get_scheduled_votes(&self) -> Result<Vec<ScheduledDPNSVote>> { - self.db.get_scheduled_votes(self) + /// Fetch every scheduled vote queued for this network from the + /// wallet k/v store, across all voters in the Global voter index. + pub fn get_scheduled_votes(&self) -> std::result::Result<Vec<ScheduledDPNSVote>, TaskError> { + let kv = self.det_kv()?; + let voters = load_scheduled_vote_voters(&kv)?; + let mut out = Vec::new(); + for voter in voters { + let scope = DetScope::Identity(&voter); + for key in scheduled_vote_keys(&kv, &voter)? { + match kv.get::<StoredScheduledVote>(scope, &key) { + Ok(Some(stored)) => out.push(stored.into()), + Ok(None) => {} + Err(e) => { + tracing::warn!( + key = %key, + error = ?e, + "Skipping unreadable scheduled vote entry" + ); + } + } + } + } + Ok(out) } - /// Clears all scheduled votes from the database - pub fn clear_all_scheduled_votes(&self) -> Result<()> { - self.db.clear_all_scheduled_votes(self) + /// Drop every scheduled vote queued for this network. + pub fn clear_all_scheduled_votes(&self) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let voters = load_scheduled_vote_voters(&kv)?; + for voter in &voters { + let scope = DetScope::Identity(voter); + for key in scheduled_vote_keys(&kv, voter)? { + kv.delete(scope, &key).map_err(scheduled_vote_err)?; + } + } + kv.delete(DetScope::Global, SCHEDULED_VOTE_VOTERS_KEY) + .map_err(scheduled_vote_err) } - /// Clears all executed scheduled votes from the database - pub fn clear_executed_scheduled_votes(&self) -> Result<()> { - self.db.clear_executed_scheduled_votes(self) + /// Drop every scheduled vote that has already been cast successfully. + pub fn clear_executed_scheduled_votes(&self) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let voters = load_scheduled_vote_voters(&kv)?; + for voter in &voters { + let scope = DetScope::Identity(voter); + for key in scheduled_vote_keys(&kv, voter)? { + match kv.get::<StoredScheduledVote>(scope, &key) { + Ok(Some(stored)) if stored.executed_successfully => { + kv.delete(scope, &key).map_err(scheduled_vote_err)?; + } + _ => {} + } + } + prune_vote_voter_if_empty(&kv, voter)?; + } + Ok(()) } - /// Deletes a scheduled vote from the database - #[allow(clippy::ptr_arg)] - pub fn delete_scheduled_vote(&self, identity_id: &[u8], contested_name: &String) -> Result<()> { - self.db - .delete_scheduled_vote(self, identity_id, contested_name) + /// Drop a single scheduled vote keyed by `(voter_id, contested_name)`. + pub fn delete_scheduled_vote( + &self, + identity_id: &[u8], + contested_name: &str, + ) -> std::result::Result<(), TaskError> { + let voter = voter_buffer(identity_id)?; + let kv = self.det_kv()?; + kv.delete( + DetScope::Identity(&voter), + &scheduled_vote_key(contested_name), + ) + .map_err(scheduled_vote_err)?; + prune_vote_voter_if_empty(&kv, &voter) } - /// Marks a scheduled vote as executed in the database - pub fn mark_vote_executed(&self, identity_id: &[u8], contested_name: String) -> Result<()> { - self.db - .mark_vote_executed(self, identity_id, contested_name) + /// Mark a single scheduled vote as executed so future cast loops skip it. + pub fn mark_vote_executed( + &self, + identity_id: &[u8], + contested_name: String, + ) -> std::result::Result<(), TaskError> { + let voter = voter_buffer(identity_id)?; + let key = scheduled_vote_key(&contested_name); + let scope = DetScope::Identity(&voter); + let kv = self.det_kv()?; + let Some(mut stored): Option<StoredScheduledVote> = + kv.get(scope, &key).map_err(scheduled_vote_err)? + else { + return Ok(()); + }; + stored.executed_successfully = true; + kv.put(scope, &key, &stored).map_err(scheduled_vote_err) } - /// Fetches the local identities from the database and then maps them to their DPNS names. - pub fn local_dpns_names(&self) -> Result<Vec<(Identifier, DPNSNameInfo)>> { + /// Fetches the local identities from the k/v store and maps them to their DPNS names. + pub fn local_dpns_names( + &self, + ) -> std::result::Result<Vec<(Identifier, DPNSNameInfo)>, TaskError> { let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - let qualified_identities = self.db.get_local_qualified_identities(self, &wallets)?; + let qualified_identities = self.load_identities_filtered(&wallets, |_| true)?; // Map each identity's DPNS names to (Identifier, DPNSNameInfo) tuples let dpns_names = qualified_identities @@ -203,3 +1179,968 @@ impl AppContext { Ok(dpns_names) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use DetKv; + use std::sync::Arc; + + fn empty_kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + fn id(b: u8) -> [u8; 32] { + [b; 32] + } + + /// Minimal stored-identity blob carrying a synthetic `qi_bytes` + /// payload and the chosen type label. Avoids constructing a full + /// `QualifiedIdentity` (which needs an SDK identity) for storage-layer + /// tests. + fn stored(identity_type: &str) -> StoredQualifiedIdentity { + StoredQualifiedIdentity { + qi_bytes: vec![0xAB; 16], + status: 0, + identity_type: identity_type.to_string(), + wallet_hash: None, + wallet_index: None, + } + } + + fn put_identity(kv: &DetKv, id: &[u8; 32], identity_type: &str) { + kv.put(DetScope::Identity(id), IDENTITY_KEY, &stored(identity_type)) + .unwrap(); + index_add_identity(kv, id).unwrap(); + } + + // --------------------------------------------------------------- + // SEC: the redacting Debug must never print the private-key blob. + // --------------------------------------------------------------- + + #[test] + fn stored_identity_debug_redacts_qi_bytes() { + let s = StoredQualifiedIdentity { + qi_bytes: vec![0x42; 48], + status: 1, + identity_type: "User".to_string(), + wallet_hash: Some([9u8; 32]), + wallet_index: Some(3), + }; + let dbg = format!("{s:?}"); + assert!(dbg.contains("[redacted]"), "expected redaction: {dbg}"); + assert!(dbg.contains("qi_bytes_len"), "expected length field: {dbg}"); + assert!(!dbg.contains("66, 66, 66"), "leaked qi bytes: {dbg}"); + } + + // --------------------------------------------------------------- + // Identity blob: Identity-scoped round-trip + index enumeration. + // --------------------------------------------------------------- + + #[test] + fn identity_blob_round_trips_in_identity_scope() { + let kv = empty_kv(); + let a = id(1); + put_identity(&kv, &a, "User"); + let got: StoredQualifiedIdentity = kv + .get(DetScope::Identity(&a), IDENTITY_KEY) + .unwrap() + .unwrap(); + assert_eq!(got.identity_type, "User"); + // The fixed slot is invisible from the Global scope. + assert!( + kv.get::<StoredQualifiedIdentity>(DetScope::Global, IDENTITY_KEY) + .unwrap() + .is_none() + ); + } + + #[test] + fn distinct_identities_do_not_alias_in_identity_scope() { + let kv = empty_kv(); + let a = id(1); + let b = id(2); + put_identity(&kv, &a, "User"); + put_identity(&kv, &b, "Masternode"); + let got_a: StoredQualifiedIdentity = kv + .get(DetScope::Identity(&a), IDENTITY_KEY) + .unwrap() + .unwrap(); + let got_b: StoredQualifiedIdentity = kv + .get(DetScope::Identity(&b), IDENTITY_KEY) + .unwrap() + .unwrap(); + assert_eq!(got_a.identity_type, "User"); + assert_eq!(got_b.identity_type, "Masternode"); + } + + #[test] + fn identity_index_enumerates_all_identities() { + let kv = empty_kv(); + put_identity(&kv, &id(1), "User"); + put_identity(&kv, &id(2), "User"); + put_identity(&kv, &id(3), "Masternode"); + let mut index = load_identity_index(&kv).unwrap(); + index.sort_unstable(); + assert_eq!(index, vec![id(1), id(2), id(3)]); + } + + #[test] + fn identity_index_add_is_idempotent() { + let kv = empty_kv(); + index_add_identity(&kv, &id(1)).unwrap(); + index_add_identity(&kv, &id(1)).unwrap(); + assert_eq!(load_identity_index(&kv).unwrap(), vec![id(1)]); + } + + #[test] + fn identity_index_remove_drops_only_the_target() { + let kv = empty_kv(); + index_add_identity(&kv, &id(1)).unwrap(); + index_add_identity(&kv, &id(2)).unwrap(); + index_remove_identity(&kv, &id(1)).unwrap(); + assert_eq!(load_identity_index(&kv).unwrap(), vec![id(2)]); + // Removing an absent id is a no-op. + index_remove_identity(&kv, &id(9)).unwrap(); + assert_eq!(load_identity_index(&kv).unwrap(), vec![id(2)]); + } + + // --------------------------------------------------------------- + // Identity-type tag: writer and filter share one stable mapping. + // --------------------------------------------------------------- + + #[test] + fn identity_type_tag_round_trips_writer_to_filter() { + // The writer stores `as_tag()`; the load filters classify via + // `from_tag()`. They must agree for every variant, independent of the + // derived `Debug` representation. + for ty in [ + IdentityType::User, + IdentityType::Masternode, + IdentityType::Evonode, + ] { + assert_eq!(IdentityType::from_tag(ty.as_tag()), Some(ty)); + } + // Tags are fixed string constants, not the `Debug` output. + assert_eq!(IdentityType::User.as_tag(), "User"); + assert_eq!(IdentityType::Masternode.as_tag(), "Masternode"); + assert_eq!(IdentityType::Evonode.as_tag(), "Evonode"); + // The user / non-user split the load filters depend on. An unknown tag + // decodes to `None`, which the filters treat as non-user (voting) — + // preserving the pre-tag string-compare behaviour. + assert_eq!(IdentityType::from_tag("Bogus"), None); + } + + // --------------------------------------------------------------- + // Top-ups: Identity-scoped round-trip. + // --------------------------------------------------------------- + + #[test] + fn top_ups_round_trip_in_identity_scope() { + let kv = empty_kv(); + let a = id(1); + let mut map = std::collections::BTreeMap::new(); + map.insert(0u32, 100u64); + map.insert(1u32, 250u64); + kv.put(DetScope::Identity(&a), TOP_UPS_KEY, &map).unwrap(); + let got: std::collections::BTreeMap<u32, u64> = kv + .get(DetScope::Identity(&a), TOP_UPS_KEY) + .unwrap() + .unwrap(); + assert_eq!(got, map); + } + + // --------------------------------------------------------------- + // Cleanup: purge drains the whole Identity scope. + // --------------------------------------------------------------- + + #[test] + fn purge_identity_scope_drains_blob_top_ups_and_votes() { + let kv = empty_kv(); + let a = id(1); + put_identity(&kv, &a, "Masternode"); + kv.put( + DetScope::Identity(&a), + TOP_UPS_KEY, + &std::collections::BTreeMap::from([(0u32, 5u64)]), + ) + .unwrap(); + kv.put( + DetScope::Identity(&a), + &scheduled_vote_key("alice"), + &StoredScheduledVote { + voter_id: a, + contested_name: "alice".to_string(), + choice: StoredVoteChoice::Lock, + unix_timestamp: 0, + executed_successfully: false, + }, + ) + .unwrap(); + index_add_vote_voter(&kv, &a).unwrap(); + + purge_identity_scope(&kv, &a).unwrap(); + + assert!( + kv.get::<StoredQualifiedIdentity>(DetScope::Identity(&a), IDENTITY_KEY) + .unwrap() + .is_none() + ); + assert!( + kv.get::<std::collections::BTreeMap<u32, u64>>(DetScope::Identity(&a), TOP_UPS_KEY) + .unwrap() + .is_none() + ); + assert!( + kv.list(DetScope::Identity(&a), Some(SCHEDULED_VOTE_KEY_PREFIX)) + .unwrap() + .is_empty() + ); + // The voter index is pruned by the cascade-free cleanup path. + assert!(load_scheduled_vote_voters(&kv).unwrap().is_empty()); + } + + // --------------------------------------------------------------- + // Scheduled votes: per-voter Identity scope + Global voter index. + // --------------------------------------------------------------- + + #[test] + fn scheduled_vote_round_trips_in_voter_scope() { + let kv = empty_kv(); + let voter = id(1); + let key = scheduled_vote_key("dash"); + kv.put( + DetScope::Identity(&voter), + &key, + &StoredScheduledVote { + voter_id: voter, + contested_name: "dash".to_string(), + choice: StoredVoteChoice::Abstain, + unix_timestamp: 42, + executed_successfully: false, + }, + ) + .unwrap(); + index_add_vote_voter(&kv, &voter).unwrap(); + + let got: StoredScheduledVote = kv.get(DetScope::Identity(&voter), &key).unwrap().unwrap(); + assert_eq!(got.contested_name, "dash"); + assert_eq!(got.unix_timestamp, 42); + // Voter index tracks the single voter. + assert_eq!(load_scheduled_vote_voters(&kv).unwrap(), vec![voter]); + } + + #[test] + fn scheduled_votes_for_two_voters_share_a_contested_name_without_aliasing() { + let kv = empty_kv(); + let v1 = id(1); + let v2 = id(2); + let key = scheduled_vote_key("contested"); + for (v, ts) in [(v1, 10u64), (v2, 20u64)] { + kv.put( + DetScope::Identity(&v), + &key, + &StoredScheduledVote { + voter_id: v, + contested_name: "contested".to_string(), + choice: StoredVoteChoice::Lock, + unix_timestamp: ts, + executed_successfully: false, + }, + ) + .unwrap(); + index_add_vote_voter(&kv, &v).unwrap(); + } + let got1: StoredScheduledVote = kv.get(DetScope::Identity(&v1), &key).unwrap().unwrap(); + let got2: StoredScheduledVote = kv.get(DetScope::Identity(&v2), &key).unwrap().unwrap(); + assert_eq!(got1.unix_timestamp, 10); + assert_eq!(got2.unix_timestamp, 20); + let mut voters = load_scheduled_vote_voters(&kv).unwrap(); + voters.sort_unstable(); + assert_eq!(voters, vec![v1, v2]); + } + + #[test] + fn delete_scheduled_votes_for_voter_drains_scope_and_prunes_index() { + let kv = empty_kv(); + let voter = id(1); + for name in ["a", "b"] { + kv.put( + DetScope::Identity(&voter), + &scheduled_vote_key(name), + &StoredScheduledVote { + voter_id: voter, + contested_name: name.to_string(), + choice: StoredVoteChoice::Lock, + unix_timestamp: 0, + executed_successfully: false, + }, + ) + .unwrap(); + } + index_add_vote_voter(&kv, &voter).unwrap(); + + delete_scheduled_votes_for_voter(&kv, &voter).unwrap(); + assert!( + kv.list(DetScope::Identity(&voter), Some(SCHEDULED_VOTE_KEY_PREFIX)) + .unwrap() + .is_empty() + ); + assert!(load_scheduled_vote_voters(&kv).unwrap().is_empty()); + } + + #[test] + fn prune_vote_voter_keeps_voter_with_remaining_votes() { + let kv = empty_kv(); + let voter = id(1); + kv.put( + DetScope::Identity(&voter), + &scheduled_vote_key("still-here"), + &StoredScheduledVote { + voter_id: voter, + contested_name: "still-here".to_string(), + choice: StoredVoteChoice::Lock, + unix_timestamp: 0, + executed_successfully: false, + }, + ) + .unwrap(); + index_add_vote_voter(&kv, &voter).unwrap(); + + prune_vote_voter_if_empty(&kv, &voter).unwrap(); + assert_eq!(load_scheduled_vote_voters(&kv).unwrap(), vec![voter]); + } + + // --------------------------------------------------------------- + // dashpay private / address_index Identity-scope contracts are + // covered in `src/wallet_backend/dashpay.rs`; here we assert the + // identity domain's own scope isolation against a foreign scope. + // --------------------------------------------------------------- + + #[test] + fn identity_blob_is_isolated_from_a_different_identity_scope() { + let kv = empty_kv(); + let a = id(1); + let b = id(2); + put_identity(&kv, &a, "User"); + assert!( + kv.get::<StoredQualifiedIdentity>(DetScope::Identity(&b), IDENTITY_KEY) + .unwrap() + .is_none(), + "identity b must not see identity a's blob" + ); + } + + // --------------------------------------------------------------- + // F43: a wrong-length voter id surfaces a typed variant carrying the + // upstream error as a `#[source]`, not a stringified detail. + // --------------------------------------------------------------- + + #[test] + fn voter_buffer_accepts_a_32_byte_id() { + let bytes = [7u8; 32]; + assert_eq!(voter_buffer(&bytes).unwrap(), bytes); + } + + #[test] + fn voter_buffer_rejects_short_id_with_typed_source() { + let err = voter_buffer(&[0u8; 5]).expect_err("a 5-byte voter id must be rejected"); + assert!( + matches!(err, TaskError::InvalidVoterIdentifier { .. }), + "expected InvalidVoterIdentifier, got {err:?}" + ); + assert!( + std::error::Error::source(&err).is_some(), + "the typed upstream error must be preserved as the source" + ); + } + + // --------------------------------------------------------------- + // F63: the index is written before the blob, so a reader tolerates a + // dangling index entry rather than hiding a written identity. + // --------------------------------------------------------------- + + #[test] + fn dangling_index_entry_without_blob_is_skipped_by_readers() { + let kv = empty_kv(); + let present = id(1); + let dangling = id(2); + put_identity(&kv, &present, "User"); + // Simulate the post-`index_add_identity`, pre-blob-write window. + index_add_identity(&kv, &dangling).unwrap(); + + // The enumeration index lists both ids... + let mut listed = load_identity_index(&kv).unwrap(); + listed.sort_unstable(); + assert_eq!(listed, vec![present, dangling]); + + // ...but a blob read for the dangling id finds nothing, which the + // load paths treat as "skip" (they `continue` on a missing blob). + assert!( + kv.get::<StoredQualifiedIdentity>(DetScope::Identity(&dangling), IDENTITY_KEY) + .unwrap() + .is_none(), + "a dangling index entry must not resolve to a blob" + ); + } + + // --------------------------------------------------------------- + // Identity-key vault migration + deletion (funds-safety). + // --------------------------------------------------------------- + + 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::{IdentityType, PrivateKeyTarget}; + use crate::wallet_backend::IdentityKeyView; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + fn fresh_vault(dir: &std::path::Path) -> Arc<platform_wallet_storage::secrets::SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(crate::wallet_backend::single_key::open_secret_store(&path).expect("open vault")) + } + + /// A `QualifiedIdentity` carrying one `Clear` (HIGH), one `AlwaysClear` + /// (MEDIUM), and one `AtWalletDerivationPath` key. Returns the QI plus the + /// `(target, key_id)` of each plaintext key for assertions. + fn qi_with_plaintext_and_derived( + secret_high: [u8; 32], + secret_medium: [u8; 32], + ) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let high = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), + ( + QualifiedIdentityPublicKey::from(high), + PrivateKeyData::Clear(secret_high), + ), + ); + let medium = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, medium.id()), + ( + QualifiedIdentityPublicKey::from(medium), + PrivateKeyData::AlwaysClear(secret_medium), + ), + ); + let derived = IdentityPublicKey::random_key(3, Some(3), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::default(), pv).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: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// Load-path migration — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, + /// stores them in the vault FIRST, then rewrites the blob to InVault. + /// Asserts: vault-first (the raw bytes are present), the wallet-derived key + /// is untouched, zero plaintext remains, and the persist closure ran AFTER + /// the vault holds the keys. + #[test] + fn qa_002_migrate_keystore_to_vault_vault_first_then_blob() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x11); + let high = [0xAA; 32]; + let medium = [0xBB; 32]; + let mut qi = qi_with_plaintext_and_derived(high, medium); + + let view = IdentityKeyView::new(&store, id); + let mut persisted = false; + let outcome = migrate_keystore_to_vault(&store, &id, &mut qi, |migrated| { + // Vault-FIRST: by the time persist runs, the raw keys are stored. + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .is_some(), + "vault must hold the keys before the blob is rewritten" + ); + // And the in-memory blob being persisted is already InVault-only. + assert!( + migrated + .private_keys + .private_keys + .values() + .all(|(_, d)| !matches!( + d, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) + )), + "persisted blob must carry no plaintext" + ); + persisted = true; + Ok(()) + }); + + assert_eq!(outcome, KeystoreMigration::Migrated(2)); + assert!(persisted, "persist closure ran"); + // Both plaintext keys are in the vault and equal the originals. + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .unwrap(), + high + ); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .unwrap(), + medium + ); + // The wallet-derived key (key_id 3) was never plaintext → not stored. + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .unwrap() + .is_none(), + "AtWalletDerivationPath key must be untouched (not vaulted)" + ); + // KeyStorage now has zero Clear/AlwaysClear; the derived key remains. + let mut derived = 0; + for (_, d) in qi.private_keys.private_keys.values() { + match d { + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) => { + panic!("plaintext survived migration") + } + PrivateKeyData::AtWalletDerivationPath(_) => derived += 1, + _ => {} + } + } + assert_eq!(derived, 1, "wallet-derived key preserved"); + + // Idempotent: a second run finds nothing to migrate. + assert_eq!( + migrate_keystore_to_vault(&store, &id, &mut qi, |_| Ok(())), + KeystoreMigration::Nothing + ); + } + + /// Regression: the single-get `get_identity_by_id` path + /// must run the SAME vault migration the bulk `load_identities_filtered` + /// path runs, so a legacy blob with resident `Clear`/`AlwaysClear` keys is + /// migrated to the vault on read instead of returning (and re-persisting) + /// resident plaintext. Before the fix this path called only `hydrate_top_ups`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_identity_by_id_migrates_legacy_resident_keys_to_vault() { + use crate::app::TaskResult; + 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; + + // Offline wired AppContext (no network I/O) so `secret_store` is a real, + // writable vault and `get_identity_by_id` can migrate into it. + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // Stage a LEGACY blob: resident Clear/AlwaysClear keys written WITHOUT + // the vault-first encode (bypassing `insert_local_qualified_identity`). + let high = [0xAA; 32]; + let medium = [0xBB; 32]; + let qi = qi_with_plaintext_and_derived(high, medium); + let identity_id = qi.identity.id(); + let id_buf = identity_id.to_buffer(); + let kv = ctx.det_kv().expect("identity kv"); + kv.put( + DetScope::Identity(&id_buf), + IDENTITY_KEY, + &StoredQualifiedIdentity { + qi_bytes: qi.to_bytes(), + status: qi.status.as_u8(), + identity_type: qi.identity_type.as_tag().to_string(), + wallet_hash: None, + wallet_index: None, + }, + ) + .expect("stage legacy blob"); + index_add_identity(&kv, &id_buf).expect("index legacy identity"); + + // Precondition: the vault holds nothing yet for this identity. + let store = ctx.secret_store(); + let view = IdentityKeyView::new(&store, id_buf); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .is_none(), + "vault must be empty before the read-path migration" + ); + + // The single-get read MUST migrate the resident plaintext. + let loaded = ctx + .get_identity_by_id(&identity_id) + .expect("load identity") + .expect("identity present"); + assert!( + !loaded.private_keys.has_plaintext_for_vault(), + "returned identity must carry no resident plaintext after migration" + ); + + // The plaintext keys now live in the vault as raw bytes. + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .expect("Clear key migrated to vault"), + high + ); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .expect("AlwaysClear key migrated to vault"), + medium + ); + + // The persisted blob was rewritten to InVault placeholders — a re-read + // no longer re-exposes resident plaintext. + let raw: StoredQualifiedIdentity = kv + .get(DetScope::Identity(&id_buf), IDENTITY_KEY) + .unwrap() + .expect("blob present"); + let redecoded = + decode_stored_identity(&raw.qi_bytes, Network::Testnet).expect("decode rewritten blob"); + assert!( + !redecoded.private_keys.has_plaintext_for_vault(), + "rewritten blob must carry only InVault placeholders" + ); + + if let Ok(backend) = ctx.wallet_backend() { + backend.shutdown().await; + } + } + + /// Helper: a QI with one `InVault` key (key_id 1, sealed + /// Tier-2 in the vault by the caller) and one freshly-added `Clear` key + /// (key_id 2) — i.e. a new key added to a password-protected identity. + fn qi_invault_plus_new_clear() -> (QualifiedIdentity, dash_sdk::dpp::identity::KeyID) { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let existing = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, existing.id()), + ( + QualifiedIdentityPublicKey::from(existing), + PrivateKeyData::InVault, + ), + ); + let added = IdentityPublicKey::random_key(2, Some(2), pv); + let added_id = added.id(); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, added_id), + ( + QualifiedIdentityPublicKey::from(added), + PrivateKeyData::Clear([0xCC; 32]), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::default(), pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + (qi, added_id) + } + + /// The at-rest encode path REFUSES to write a new keyless + /// key onto a password-protected identity (a silent-plaintext leak). The + /// encode fails closed and the new key lands NOWHERE — not keyless, not + /// Tier-2. + #[test] + fn encode_refuses_keyless_key_on_protected_identity() { + use crate::wallet_backend::secret_seam::SecretScheme; + use platform_wallet_storage::secrets::SecretString; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x71); + let (qi, added_id) = qi_invault_plus_new_clear(); + // Seal the existing key Tier-2 so the identity is password-protected. + IdentityKeyView::new(&store, id) + .store_protected( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 1, + &[0x10; 32], + &SecretString::new("identity-password-xx"), + ) + .expect("seal existing key"); + + let err = encode_identity_blob_vault_first(&store, &id, &qi) + .expect_err("must refuse to keyless-store a new key on a protected identity"); + assert!( + matches!(err, TaskError::IdentityKeyProtectionDowngrade), + "expected IdentityKeyProtectionDowngrade, got {err:?}" + ); + // The new key was NOT written keyless (or at all). + assert_eq!( + IdentityKeyView::new(&store, id) + .scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, added_id) + .unwrap(), + SecretScheme::Absent, + "no keyless key landed for the newly-added id", + ); + } + + /// The load-path migration likewise skips a protected identity's + /// resident plaintext rather than writing it keyless — fail closed, persist + /// nothing, leave it resident for the session. + #[test] + fn migrate_skips_keyless_on_protected_identity() { + use platform_wallet_storage::secrets::SecretString; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x72); + let (mut qi, added_id) = qi_invault_plus_new_clear(); + IdentityKeyView::new(&store, id) + .store_protected( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 1, + &[0x10; 32], + &SecretString::new("identity-password-xx"), + ) + .expect("seal existing key"); + + let mut persisted = false; + let outcome = migrate_keystore_to_vault(&store, &id, &mut qi, |_| { + persisted = true; + Ok(()) + }); + assert_eq!(outcome, KeystoreMigration::ProtectedSkipped); + assert!(!persisted, "a protected-skip must persist nothing"); + // No keyless key written for the resident plaintext key. + assert_eq!( + IdentityKeyView::new(&store, id) + .scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, added_id) + .unwrap(), + crate::wallet_backend::secret_seam::SecretScheme::Absent, + ); + // The resident plaintext is preserved (it still signs this session). + assert!( + qi.private_keys + .is_in_vault(&(PrivateKeyTarget::PrivateKeyOnMainIdentity, 1)), + ); + } + + /// Write-path twin of the load-path migration: the insert/update encoder + /// (`encode_identity_blob_vault_first`) moves plaintext keys into the vault + /// FIRST and returns an `InVault`-only blob, so a freshly inserted or + /// updated identity never lands `Clear` / `AlwaysClear` key bytes in + /// `det-app.sqlite`. Regression for the gap where the migration only ran on + /// bulk load while the write paths still serialized plaintext. + #[test] + fn write_path_encodes_invault_only_and_vaults_plaintext() { + use crate::wallet_backend::leak_test_support::assert_no_leak_bytes; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x55); + let high = [0xA1; 32]; + let medium = [0xB2; 32]; + let qi = qi_with_plaintext_and_derived(high, medium); + + let blob = encode_identity_blob_vault_first(&store, &id, &qi).expect("encode"); + + // The persisted blob carries neither plaintext key in any rendered form. + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &high, "identity write-path blob (HIGH)"); + assert_no_leak_bytes(&rendered, &medium, "identity write-path blob (MEDIUM)"); + + // Decoding the stored blob yields no plaintext key variant at all. + let decoded = QualifiedIdentity::from_bytes(&blob).expect("decode"); + for (_, d) in decoded.private_keys.private_keys.values() { + assert!( + !matches!(d, PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_)), + "persisted write-path blob must carry no plaintext key", + ); + } + + // The plaintext bytes live in the vault, retrievable per (target, key_id). + let view = IdentityKeyView::new(&store, id); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .unwrap(), + high + ); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .unwrap(), + medium + ); + + // The caller's in-memory identity keeps its resident keys (signing still + // works this session) — the encoder operates on a clone. + let clear_in_caller = qi + .private_keys + .private_keys + .values() + .filter(|(_, d)| matches!(d, PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_))) + .count(); + assert_eq!( + clear_in_caller, 2, + "the caller's identity must keep its resident plaintext for this session", + ); + } + + /// Write-fault no-loss ordering. With the vault made unwritable so + /// `store_all` fails, the migration restores the resident plaintext, does + /// NOT call persist, and reports `VaultWriteFailed` — keys are never lost on + /// a mid-write fault (the write half CRASH-01's read half does not cover). + #[cfg(unix)] + #[test] + fn qa_005_vault_write_fault_leaves_keystore_intact_and_skips_persist() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let id = id(0x22); + let high = [0xCC; 32]; + let medium = [0xDD; 32]; + let mut qi = qi_with_plaintext_and_derived(high, medium); + let before = qi.private_keys.clone(); + + // Make the vault's parent dir read-only so the atomic rename-replace + // `set` fails. (The file backend rewrites the whole file on set.) + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)) + .expect("chmod ro"); + + let mut persisted = false; + let outcome = migrate_keystore_to_vault(&store, &id, &mut qi, |_| { + persisted = true; + Ok(()) + }); + + // Restore perms so tempdir cleanup works. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).ok(); + + assert_eq!(outcome, KeystoreMigration::VaultWriteFailed); + assert!( + !persisted, + "persist must NOT run when the vault write failed" + ); + assert_eq!( + qi.private_keys, before, + "the resident plaintext keystore must be restored on vault failure" + ); + } + + /// Scoped key deletion — `clear_identity_vault_keys` removes the deleted identity's vault + /// keys AND leaves other identities' keys untouched (isolation), via the + /// public delete entry point. Builds a real `AppContext`-free vault and + /// drives the free `IdentityKeyView` the deletion uses. + #[test] + fn qa_003_identity_key_deletion_is_scoped_and_isolated() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_vault(dir.path()); + let victim = id(0x33); + let bystander = id(0x44); + + // Both identities have a vaulted key under the same (target, key_id). + IdentityKeyView::new(&store, victim) + .store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x01; 32]) + .unwrap(); + IdentityKeyView::new(&store, bystander) + .store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x02; 32]) + .unwrap(); + + // Delete the victim's keys the way clear_identity_vault_keys does: + // enumerate the keystore's (target,key_id) set and delete_all. + let mut ks = KeyStorage::default(); + let pv = PlatformVersion::latest(); + let pk = IdentityPublicKey::random_key(0, Some(0), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 0), + ( + QualifiedIdentityPublicKey::from(pk), + PrivateKeyData::InVault, + ), + ); + IdentityKeyView::new(&store, victim) + .delete_all(ks.keys_set()) + .unwrap(); + + assert!( + IdentityKeyView::new(&store, victim) + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .is_none(), + "victim's vault key must be gone" + ); + assert_eq!( + *IdentityKeyView::new(&store, bystander) + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x02; 32], + "a different identity's vault key must be untouched (isolation)" + ); + } +} diff --git a/src/context/migration_status.rs b/src/context/migration_status.rs new file mode 100644 index 000000000..8e03bacbd --- /dev/null +++ b/src/context/migration_status.rs @@ -0,0 +1,193 @@ +//! Atomic snapshot of the legacy-data migration progress. +//! +//! [`MigrationStatus`] is the single source of truth the UI hot path +//! reads to decide whether to show a "your data is being migrated" +//! banner, an empty-state placeholder, or normal wallet content. The +//! [`MigrationTask`](crate::backend_task::migration::MigrationTask) +//! orchestrator writes state transitions as the migration walks each +//! legacy table; everything else is read-only. +//! +//! Backed by [`ArcSwap`] so each frame can `load()` the current state +//! without taking a lock — the UI calls this from `update()`. + +use std::sync::Arc; + +use arc_swap::ArcSwap; + +/// Which legacy domain the migration is currently working on. +/// +/// Drives banner text granularity ("Migrating your imported keys…" vs +/// "Migrating your shielded data…"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MigrationStep { + /// Sniffing `data.db` for legacy rows. + Detecting, + /// Copying `single_key_wallet` rows into the upstream `SecretStore`. + SingleKey, + /// Mirroring legacy shielded rows + cursor into the per-wallet sidecar. + Shielded, + /// Copying HD wallet seed envelopes from the legacy `wallet` table + /// into the upstream `SecretStore` vault. The full envelope + /// (ciphertext + salt + nonce + flags) travels verbatim — the + /// migrator never decrypts. + WalletSeeds, + /// Copying legacy `wallet` rows (alias / `is_main` / `core_wallet_name` + /// / master xpub) into the DET wallet-metadata sidecar in + /// `det-app.sqlite`. + WalletMeta, + /// Writing the completion sentinel and cleaning up. + Finalize, +} + +/// High-level state of the legacy migration. +/// +/// The `Failed` variant carries an `Arc<MigrationError>` rather than a +/// stringified copy: the UI banner formats the error via `Display` at +/// render time, which keeps the typed error chain reachable for the +/// log path and the details panel without a lossy `to_string()` +/// round-trip. `MigrationError` is not `Clone`, but `Arc` cheaply +/// satisfies the `Clone` bound `MigrationStatus` needs to publish a +/// state across the per-frame `load_full()` boundary. +#[derive(Debug, Clone)] +pub enum MigrationState { + /// No migration in progress and none required. + Idle, + /// Migration is currently executing the given step. + Running { step: MigrationStep }, + /// Migration completed successfully (or no legacy data was present). + Success, + /// 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. + Failed { + error: Arc<crate::backend_task::migration::MigrationError>, + }, +} + +impl PartialEq for MigrationState { + /// Compare states for the per-frame reconciler. Stateless variants + /// (`Idle`, `Success`, `Running { step }`) compare structurally; + /// `Failed` compares the wrapped error by `Arc::ptr_eq`. The + /// reconciler treats every newly-published `Failed` state as a + /// transition, so a retry that fails with a fresh error correctly + /// refreshes the banner even when the user-visible text is + /// identical. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (MigrationState::Idle, MigrationState::Idle) => true, + (MigrationState::Success, MigrationState::Success) => true, + (MigrationState::Running { step: a }, MigrationState::Running { step: b }) => a == b, + (MigrationState::Failed { error: a }, MigrationState::Failed { error: b }) => { + Arc::ptr_eq(a, b) + } + _ => false, + } + } +} + +impl Eq for MigrationState {} + +impl MigrationState { + /// Returns `true` while the migration task is mid-flight. + pub fn is_running(&self) -> bool { + matches!(self, MigrationState::Running { .. }) + } +} + +/// Atomic, cheaply-readable migration status. +/// +/// Cloned by `AppContext` and read once per UI frame. Writers (the +/// migration task) call [`set_state`](Self::set_state) to publish a +/// transition; readers call [`state`](Self::state) and dereference the +/// returned `Arc`. +#[derive(Debug)] +pub struct MigrationStatus { + state: ArcSwap<MigrationState>, +} + +impl MigrationStatus { + /// Construct an idle status. Used by `AppContext` and by tests. + pub fn new_idle() -> Self { + Self { + state: ArcSwap::from_pointee(MigrationState::Idle), + } + } + + /// Load the current state. Cheap — no lock, just a single atomic load. + pub fn state(&self) -> Arc<MigrationState> { + self.state.load_full() + } + + /// Publish a new state. Idempotent — repeated identical writes are + /// allowed and cheap. + pub fn set_state(&self, new_state: MigrationState) { + self.state.store(Arc::new(new_state)); + } +} + +impl Default for MigrationStatus { + fn default() -> Self { + Self::new_idle() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TC-MIG-001 (supporting) — MigrationStatus state transitions on the + /// success path. Drives Idle → Running{Detecting} → Running{SingleKey} + /// → Running{Shielded} → Running{Finalize} → Success. This is the + /// state machine the banner reads via `migration_running_text`; the + /// kittest in `tests/kittest/migration_banner.rs` verifies the banner + /// surface for each step. + #[test] + fn state_transitions_success_path() { + let status = MigrationStatus::new_idle(); + assert_eq!(*status.state(), MigrationState::Idle); + assert!(!status.state().is_running()); + + status.set_state(MigrationState::Running { + step: MigrationStep::Detecting, + }); + assert!(status.state().is_running()); + assert_eq!( + *status.state(), + MigrationState::Running { + step: MigrationStep::Detecting + } + ); + + for step in [ + MigrationStep::SingleKey, + MigrationStep::Shielded, + MigrationStep::WalletSeeds, + MigrationStep::WalletMeta, + MigrationStep::Finalize, + ] { + status.set_state(MigrationState::Running { step }); + assert_eq!(*status.state(), MigrationState::Running { step }); + assert!(status.state().is_running()); + } + + status.set_state(MigrationState::Success); + assert_eq!(*status.state(), MigrationState::Success); + assert!(!status.state().is_running()); + } + + /// Failure transitions carry a typed error and clear the running + /// flag. The wrapped `MigrationError` reaches the UI banner via + /// `Display` at render time — `MigrationState` itself never owns a + /// stringified copy. + #[test] + fn failed_state_clears_running_and_carries_typed_error() { + use crate::backend_task::migration::MigrationError; + + let status = MigrationStatus::new_idle(); + status.set_state(MigrationState::Failed { + error: Arc::new(MigrationError::WalletBackendUnavailable), + }); + assert!(!status.state().is_running()); + assert!(matches!(*status.state(), MigrationState::Failed { .. })); + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs index f3d728805..2ea70035e 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1,77 +1,80 @@ pub mod connection_status; +mod contested_names_db; mod contract_token_db; +pub mod feature_gate; mod identity_db; +pub mod migration_status; mod settings_db; -pub mod shielded; -mod transaction_processing; mod wallet_lifecycle; -pub(crate) use transaction_processing::get_transaction_info; - use crate::app_dir::core_cookie_path; -use crate::backend_task::error::{TaskError, is_rpc_connection_error}; -use crate::components::core_zmq_listener::ZMQConnectionEvent; +use crate::backend_task::error::TaskError; use crate::config::{Config, NetworkConfig}; -use crate::context_provider::Provider as RpcProvider; -use crate::context_provider_spv::SpvProvider; +use crate::context::feature_gate::FeatureGate; +use crate::context_provider::SpvProvider; use crate::database::Database; -use crate::model::feature_gate::FeatureGate; use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::password_info::PasswordInfo; -use crate::model::proof_log_item::RequestType; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::model::request_type::RequestType; use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; -use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::model::wallet::{PlatformAddressEntry, PlatformAddressUpdates, Wallet, WalletSeedHash}; use crate::sdk_wrapper::initialize_sdk; -use crate::spv::{CoreBackendMode, SpvManager}; use crate::utils::tasks::TaskManager; -use arc_swap::ArcSwap; +use crate::wallet_backend::{ + DetKv, DetWalletBalance, NullSecretPrompt, SecretPrompt, WalletBackend, +}; +use arc_swap::{ArcSwap, ArcSwapOption}; use connection_status::ConnectionStatus; -use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; use dash_sdk::dapi_client::AddressList; -use dash_sdk::dashcore_rpc::{Auth, Client, RpcApi}; -use dash_sdk::dpp::dashcore::{Address, Network, Txid}; +use dash_sdk::dashcore_rpc::{Auth, Client}; +use dash_sdk::dpp::dashcore::Network; #[cfg(any(test, feature = "testing"))] use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::version::v11::PLATFORM_V11; use dash_sdk::platform::DataContract; -#[cfg(any(test, feature = "testing"))] use dash_sdk::platform::Identifier; use egui::Context; +use migration_status::MigrationStatus; +use platform_wallet_storage::secrets::SecretStore; use std::collections::BTreeMap; use std::path::PathBuf; use std::str::FromStr as _; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; -use crate::model::settings::Settings; +use crate::model::settings::AppSettings; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); /// A guard that ensures settings cache invalidation happens atomically /// /// This guard holds a write lock on the cached settings, preventing reads -/// until the database update is complete and the cache is properly invalidated. -pub(crate) type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option<Settings>>; +/// until the k/v update is complete and the cache is properly invalidated. +pub(crate) type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option<AppSettings>>; #[derive(Debug)] pub struct AppContext { pub(crate) data_dir: PathBuf, pub(crate) network: Network, - developer_mode: AtomicBool, + /// App-global Expert Mode flag. A single `Arc<AtomicBool>` is created once by + /// `AppState` and shared into every per-network `AppContext`, so toggling it + /// on any context is observed by all of them (present and lazily created on a + /// later network switch). Never a per-context `AtomicBool` — that would let + /// the left-nav feature gate read a stale value on whichever context the app + /// renders from. + developer_mode: Arc<AtomicBool>, pub(crate) db: Arc<Database>, pub(crate) sdk: ArcSwap<Sdk>, - // Context providers for SDK, so we can switch when backend mode changes + // SDK context provider (quorum keys via DAPI). Chain sync is SPV-only, + // owned by upstream platform-wallet. spv_context_provider: RwLock<SpvProvider>, - rpc_context_provider: RwLock<RpcProvider>, pub(crate) config: Arc<RwLock<NetworkConfig>>, - pub(crate) rx_zmq_status: Receiver<ZMQConnectionEvent>, - pub(crate) sx_zmq_status: Sender<ZMQConnectionEvent>, pub(crate) dpns_contract: Arc<DataContract>, pub(crate) withdraws_contract: Arc<DataContract>, pub(crate) dashpay_contract: Arc<DataContract>, @@ -79,25 +82,42 @@ pub struct AppContext { pub(crate) keyword_search_contract: Arc<DataContract>, pub(crate) core_client: RwLock<Client>, pub(crate) has_wallet: AtomicBool, + /// One-shot-per-session latch for the automatic all-wallets identity sweep. + /// Set the first time Platform becomes reachable (masternode list `Synced`) + /// so the sweep runs once; cleared in + /// [`stop_spv`](Self::stop_spv) so a reconnect re-arms it. + identity_autodiscovery_fired: AtomicBool, pub(crate) wallets: RwLock<BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>>, pub(crate) single_key_wallets: RwLock<BTreeMap<SingleKeyHash, Arc<RwLock<SingleKeyWallet>>>>, - #[allow(dead_code)] // May be used for password validation - pub(crate) password_info: Option<PasswordInfo>, - pub(crate) transactions_waiting_for_finality: Mutex<BTreeMap<Txid, Option<AssetLockProof>>>, /// Whether to animate the UI elements. /// /// This is used to control animations in the UI, such as loading spinners or transitions. /// Disable for automated tests. animate: AtomicBool, - /// Cached settings to avoid expensive database reads - /// Use RwLock to allow multiple readers but exclusive writers for cache invalidation - cached_settings: RwLock<Option<Settings>>, + /// Cached settings to avoid repeated k/v reads + bincode decoding. + /// Use RwLock to allow multiple readers but exclusive writers for cache invalidation. + cached_settings: RwLock<Option<AppSettings>>, + /// Shared app-level k/v store at `<data_dir>/det-app.sqlite`. + /// Cross-network, global-scoped slot used for `AppSettings` and other + /// DET-owned application data that must outlive a single network's + /// wallet persister. Cheap to clone (`Arc<DetKv>` is `Arc`-backed). + app_kv: Arc<DetKv>, + /// Shared encrypted HD-seed vault at `<data_dir>/secrets/det-secrets.pwsvault`. + /// Opened once and handed to every per-network `AppContext` and to the + /// `WalletBackend`, because the file backend takes an exclusive advisory + /// lock — a second open of the same vault returns `AlreadyLocked`. Holding + /// the handle here lets `register_wallet` persist the seed-envelope sidecar + /// before the wallet backend is wired. Cross-network: a single + /// imported WIF / HD seed is keyed by its hash, not by the chain prefix. + secret_store: Arc<SecretStore>, // subtasks started by the app context, used for graceful shutdown pub(crate) subtasks: Arc<TaskManager>, - pub(crate) spv_manager: Arc<SpvManager>, - core_backend_mode: AtomicU8, /// Tracks the connection status to currently active network pub(crate) connection_status: Arc<ConnectionStatus>, + /// Tracks the legacy-data migration progress. Cheap to read each + /// frame from the UI. Always present and idle on fresh installs; + /// driven by [`MigrationTask::FinishUnwire`](crate::backend_task::migration::MigrationTask). + pub(crate) migration_status: Arc<MigrationStatus>, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex<Option<WalletSeedHash>>, @@ -105,6 +125,13 @@ pub struct AppContext { pub(crate) selected_wallet_hash: Mutex<Option<WalletSeedHash>>, /// Currently selected single key wallet (persisted across screen navigation) pub(crate) selected_single_key_hash: Mutex<Option<SingleKeyHash>>, + /// App-scoped selected identity — the "who am I operating as" choice, + /// persisted per-network. Primary; the operating wallet is reconciled to it. + pub(crate) selected_identity_id: Mutex<Option<Identifier>>, + /// Pending identity selection — set after creating/loading an identity so + /// the hub adopts it on return (forward-courier, mirrors + /// `pending_wallet_selection`). + pub(crate) pending_identity_selection: Mutex<Option<Identifier>>, /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, @@ -112,27 +139,94 @@ pub struct AppContext { /// Updated alongside fee_multiplier when epoch info is fetched. /// 0 means not yet fetched from the network. platform_protocol_version: AtomicU32, - /// Per-wallet shielded state (initialized lazily, keyed by wallet seed hash) - pub(crate) shielded_states: Mutex< - std::collections::HashMap< - WalletSeedHash, - crate::model::wallet::shielded::ShieldedWalletState, - >, - >, + /// Frame-safe shielded balance snapshot (credits, summed across all Orchard accounts). + /// + /// Written by the Phase-E `on_shielded_sync_completed` event handler from the + /// upstream `ShieldedSyncManager` 60-second loop; read synchronously in the frame + /// loop via [`Self::shielded_balance_duffs`]. Starts empty — returns 0 until the + /// 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<Mutex<std::collections::HashMap<WalletSeedHash, u64>>>, + /// Frame-safe platform-address balance snapshot (duffs, summed across owned addresses). + /// + /// Written by `on_platform_address_sync_completed` in [`EventBridge`] after each + /// coordinator pass; read synchronously in the frame loop via + /// [`Self::platform_balance_duffs`]. + /// + /// **Cold-start behaviour:** seeded at boot by [`Self::warm_start_platform_addresses`] + /// from the persisted upstream `platform_addresses` rows (clean per-wallet owned + /// state — no orphan-inflation risk), so the total renders immediately and + /// network-independently; the first coordinator pass then overwrites it. + /// + /// **Wallet-removal behaviour (accepted):** removing a wallet does NOT + /// evict its entry from this map (consistent with `shielded_balances` — the + /// same known gap). The stale value is never displayed because + /// removed wallets are not iterated in the UI; this is a memory leak, not a display + /// bug. A future cleanup pass should mirror `SnapshotStore::forget_wallet`. + /// + /// Must never be written from the frame loop (Nagatha ruling). + pub(crate) platform_balances: Arc<Mutex<std::collections::HashMap<WalletSeedHash, u64>>>, + /// Frame-safe `(last_sync_timestamp, sync_height)` snapshot keyed by + /// `WalletSeedHash`, written by the coordinator's platform-address sync pass + /// (the same pass that fills `platform_balances`) and read each frame via + /// [`AppContext::platform_sync_info`] to drive the "Addresses synced" label. + /// Shares `platform_balances`' wallet-removal leak (accepted, QA-B-004) and + /// must never be written from the frame loop. + pub(crate) platform_sync_cursors: + Arc<Mutex<std::collections::HashMap<WalletSeedHash, (u64, u64)>>>, /// The egui context, stored for use in non-UI code paths (e.g. display_task_result). /// Clone is O(1) — egui::Context is Arc-backed and the same instance for the app lifetime. egui_ctx: egui::Context, + /// The wallet seam. Lazily built once `AppState` has wired the + /// `TaskResult` sender (it lives on `AppState`, not here) — see + /// [`Self::ensure_wallet_backend`]. `None` until that first call; + /// wallet/identity task arms degrade to `WalletBackendNotYetWired` + /// while unset. + wallet_backend: ArcSwapOption<WalletBackend>, + /// Serializes the lazy wallet-backend construction so two concurrent + /// first-tasks cannot both run the expensive `WalletBackend::new` (which + /// takes an exclusive advisory lock on the persistor file). The + /// double-checked `ArcSwapOption` read still serves the steady state + /// lock-free; this `Mutex` is held only across the one-time build. + wallet_backend_build: tokio::sync::Mutex<()>, + /// The just-in-time secret prompt host (UI seam). Defaults to + /// [`NullSecretPrompt`] (headless: no interactive unlock); the GUI installs + /// an `EguiSecretPromptHost` before the wallet backend is built via + /// [`Self::install_secret_prompt`]. Read by [`Self::ensure_wallet_backend`] + /// to construct the backend's `SecretAccess` chokepoint. `Mutex` (not + /// `ArcSwap`, which needs a `Sized` payload) so the host can be installed + /// after `AppContext::new` but before the backend reads it; contention is + /// nil (touched only at install and backend construction). + secret_prompt: SecretPromptSlot, +} + +/// Mutex-guarded slot for the installable secret-prompt host, with an opaque +/// `Debug` (the host is `dyn` and not `Debug`) so [`AppContext`] keeps its +/// derived `Debug`. +struct SecretPromptSlot(Mutex<Arc<dyn SecretPrompt>>); + +impl std::fmt::Debug for SecretPromptSlot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretPromptSlot") + } } impl AppContext { + // The constructor takes the app's foundational dependencies — the shared + // db, k/v store, and seed vault all have to be opened once and threaded in + // so every per-network context reuses the same handle (the vault's + // exclusive advisory lock forbids a second open). + #[allow(clippy::too_many_arguments)] pub fn new( data_dir: PathBuf, network: Network, db: Arc<Database>, - password_info: Option<PasswordInfo>, subtasks: Arc<TaskManager>, connection_status: Arc<ConnectionStatus>, egui_ctx: egui::Context, + app_kv: Arc<DetKv>, + secret_store: Arc<SecretStore>, + developer_mode: Arc<AtomicBool>, ) -> Option<Arc<Self>> { let config = match Config::load_from(&data_dir) { Ok(config) => config, @@ -144,23 +238,10 @@ impl AppContext { let network_config = config.config_for_network(network).clone()?; let config_lock = Arc::new(RwLock::new(network_config.clone())); - let (sx_zmq_status, rx_zmq_status) = crossbeam_channel::unbounded(); - // Create both providers; bind to app context later (post construction) due to circularity - let spv_provider = match SpvProvider::new(db.clone(), network) { - Ok(p) => p, - Err(e) => { - tracing::error!(?network, "Failed to initialize SPV provider: {e}"); - return None; - } - }; - let rpc_provider = match RpcProvider::new(db.clone(), network, &network_config) { - Ok(p) => p, - Err(e) => { - tracing::error!(?network, "Failed to initialize RPC provider: {e}"); - return None; - } - }; + // Create the SDK context provider; bind to app context later + // (post construction) due to circularity. + let spv_provider = SpvProvider::new(network); // Parse configured DAPI addresses directly (no auto-discovery at startup) let address_list = match &network_config.dapi_addresses { @@ -194,50 +275,19 @@ impl AppContext { }; let platform_version = sdk.version(); - let dpns_contract = - match load_system_data_contract(SystemDataContract::DPNS, platform_version) { - Ok(c) => c, - Err(e) => { - tracing::error!(?network, "Failed to load DPNS contract: {e}"); - return None; - } - }; - - let withdrawal_contract = - match load_system_data_contract(SystemDataContract::Withdrawals, platform_version) { - Ok(c) => c, - Err(e) => { - tracing::error!(?network, "Failed to load Withdrawals contract: {e}"); - return None; - } - }; + let load_contract = |contract: SystemDataContract, label: &str| { + load_system_data_contract(contract, platform_version) + .inspect_err(|e| tracing::error!(?network, "Failed to load {label} contract: {e}")) + .ok() + }; + let dpns_contract = load_contract(SystemDataContract::DPNS, "DPNS")?; + let withdrawal_contract = load_contract(SystemDataContract::Withdrawals, "Withdrawals")?; let token_history_contract = - match load_system_data_contract(SystemDataContract::TokenHistory, platform_version) { - Ok(c) => c, - Err(e) => { - tracing::error!(?network, "Failed to load TokenHistory contract: {e}"); - return None; - } - }; - + load_contract(SystemDataContract::TokenHistory, "TokenHistory")?; let keyword_search_contract = - match load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) { - Ok(c) => c, - Err(e) => { - tracing::error!(?network, "Failed to load KeywordSearch contract: {e}"); - return None; - } - }; - - let dashpay_contract = - match load_system_data_contract(SystemDataContract::Dashpay, platform_version) { - Ok(c) => c, - Err(e) => { - tracing::error!(?network, "Failed to load Dashpay contract: {e}"); - return None; - } - }; + load_contract(SystemDataContract::KeywordSearch, "KeywordSearch")?; + let dashpay_contract = load_contract(SystemDataContract::Dashpay, "Dashpay")?; let addr = format!( "http://{}:{}", @@ -257,32 +307,17 @@ impl AppContext { } }; - let wallets: BTreeMap<_, _> = match db.get_wallets(&network) { - Ok(w) => w, - Err(e) => { - tracing::error!(?network, "Failed to load wallets from database: {e}"); - return None; - } - } - .into_iter() - .map(|w| (w.seed_hash(), Arc::new(RwLock::new(w)))) - .collect(); - - let single_key_wallets: BTreeMap<_, _> = match db.get_single_key_wallets(network) { - Ok(w) => w, - Err(e) => { - tracing::error!( - ?network, - "Failed to load single key wallets from database: {e}" - ); - return None; - } - } - .into_iter() - .map(|w| (w.key_hash(), Arc::new(RwLock::new(w)))) - .collect(); + // T-W-01 / T-W-01b: both HD and single-key wallets are now + // rehydrated from the upstream `SecretStore` + DET k/v sidecars + // by `WalletBackend::new`, not from the legacy `wallet` / + // `single_key_wallet` SQLite tables. The maps start empty here + // and are filled inside `ensure_wallet_backend` (see + // `WalletBackend::hydrate_context_wallets`). + let wallets: BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>> = BTreeMap::new(); + let single_key_wallets: BTreeMap<SingleKeyHash, Arc<RwLock<SingleKeyWallet>>> = + BTreeMap::new(); - let developer_mode_enabled = config.developer_mode.unwrap_or(false); + let developer_mode_enabled = developer_mode.load(Ordering::Relaxed); let animate = match developer_mode_enabled { true => { @@ -292,57 +327,22 @@ impl AppContext { false => AtomicBool::new(true), // Animations are enabled by default }; - let spv_manager = match SpvManager::new( - &data_dir, - network, - Arc::clone(&config_lock), - subtasks.clone(), - ) { - Ok(manager) => manager, - Err(err) => { - tracing::error!(?err, ?network, "Failed to initialize SPV manager"); - return None; - } - }; - - // Load the use_local_spv_node setting and apply to SPV manager - let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); - spv_manager.set_use_local_node(use_local_spv_node); - - // Wire up push-based SPV status updates to ConnectionStatus - spv_manager.set_connection_status(Arc::clone(&connection_status)); - - // Load the core backend mode from settings. The DB column default is - // SPV (=1), and the v34 migration pins every existing user who does - // not have a configured local Dash Core node onto SPV. Developer mode - // is a visibility toggle — it does not override the persisted choice. - let saved_core_backend_mode = db - .get_settings() - .ok() - .flatten() - .map(|s| s.7) // core_backend_mode is the 8th element (index 7) - .unwrap_or(CoreBackendMode::Spv.as_u8()); - - // Load saved wallet selection, validating that the wallets still exist - let (saved_wallet_hash, saved_single_key_hash) = - db.get_selected_wallet_hashes().unwrap_or((None, None)); - - // Only use the saved hash if the wallet still exists - let selected_wallet_hash = saved_wallet_hash.filter(|h| wallets.contains_key(h)); - let selected_single_key_hash = - saved_single_key_hash.filter(|h| single_key_wallets.contains_key(h)); + // Wallet selection is restored from the per-network wallet k/v + // store inside `ensure_wallet_backend` once the backend is + // wired. At `AppContext::new` time the backend does not exist + // yet, so both selections start as `None` and are populated + // lazily by the eager backend init in `AppState`. + let selected_wallet_hash: Option<WalletSeedHash> = None; + let selected_single_key_hash: Option<SingleKeyHash> = None; let app_context = AppContext { data_dir, network, - developer_mode: AtomicBool::new(developer_mode_enabled), + developer_mode, db, sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), - rpc_context_provider: rpc_provider.into(), config: config_lock, - sx_zmq_status, - rx_zmq_status, dpns_contract: Arc::new(dpns_contract), withdraws_contract: Arc::new(withdrawal_contract), dashpay_contract: Arc::new(dashpay_contract), @@ -350,56 +350,48 @@ impl AppContext { keyword_search_contract: Arc::new(keyword_search_contract), core_client: core_client.into(), has_wallet: (!wallets.is_empty() || !single_key_wallets.is_empty()).into(), + identity_autodiscovery_fired: AtomicBool::new(false), wallets: RwLock::new(wallets), single_key_wallets: RwLock::new(single_key_wallets), - password_info, - transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), animate, cached_settings: RwLock::new(None), + app_kv, + secret_store, subtasks, - spv_manager, - core_backend_mode: AtomicU8::new(saved_core_backend_mode), connection_status, + migration_status: Arc::new(MigrationStatus::new_idle()), pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), + selected_identity_id: Mutex::new(None), + pending_identity_selection: Mutex::new(None), fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), platform_protocol_version: AtomicU32::new(0), - shielded_states: Mutex::new(std::collections::HashMap::new()), + shielded_balances: 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, + wallet_backend: ArcSwapOption::const_empty(), + wallet_backend_build: tokio::sync::Mutex::new(()), + secret_prompt: SecretPromptSlot(Mutex::new( + Arc::new(NullSecretPrompt) as Arc<dyn SecretPrompt> + )), }; let app_context = Arc::new(app_context); - // Bind providers to the newly created app_context. The saved mode in - // `settings.core_backend_mode` is the single source of truth: we bind - // SPV first to ensure a provider is always registered, then rebind to - // RPC only if that is the saved choice. - if let Err(e) = app_context - .spv_context_provider - .read() - .map_err(|e| e.to_string()) - .and_then(|provider| provider.bind_app_context(app_context.clone())) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return None; - } - - // If the saved mode is RPC, rebind the RPC provider (overrides SPV registration above). - if app_context.core_backend_mode() == CoreBackendMode::Rpc - && let Err(e) = app_context - .rpc_context_provider - .read() - .map_err(|e| e.to_string()) - .and_then(|provider| provider.bind_app_context(app_context.clone())) - { - tracing::error!("Failed to bind RPC provider: {}", e); - return None; + // Bind the SDK context provider. Chain sync is SPV-only (owned by + // upstream platform-wallet); the SPV provider is the sole SDK + // quorum/context provider. + match app_context.spv_context_provider.read() { + Ok(provider) => provider.bind_app_context(app_context.clone()), + Err(e) => { + tracing::error!("Failed to bind SPV provider: {}", e); + return None; + } } - app_context.bootstrap_loaded_wallets(); - Some(app_context) } @@ -420,89 +412,103 @@ impl AppContext { &self.data_dir } - pub fn network(&self) -> Network { - self.network + /// Shared app-level k/v store. Cheap clone — `Arc<DetKv>` is `Arc`-backed. + pub fn app_kv(&self) -> Arc<DetKv> { + Arc::clone(&self.app_kv) } - pub fn core_backend_mode(&self) -> CoreBackendMode { - self.core_backend_mode.load(Ordering::Relaxed).into() + /// The per-network DET key/value store, or a typed error when the wallet + /// backend is not yet initialized. Single accessor shared by every + /// `context/*_db.rs` module. + pub(crate) fn det_kv(&self) -> Result<DetKv, TaskError> { + Ok(self.wallet_backend()?.kv()) } - pub fn connection_status(&self) -> &ConnectionStatus { - &self.connection_status + /// Shared encrypted HD-seed vault. Cheap clone — `Arc<SecretStore>` is + /// `Arc`-backed. The wallet backend reuses this same handle rather than + /// opening its own, because the file vault takes an exclusive advisory + /// lock that a second open would trip. + pub fn secret_store(&self) -> Arc<SecretStore> { + Arc::clone(&self.secret_store) } - pub fn egui_ctx(&self) -> &egui::Context { - &self.egui_ctx + /// Shared migration-status handle. Cheap to clone — backed by `Arc`. + /// Readers (the UI hot path) call `.state()` each frame; writers + /// (the [`MigrationTask`](crate::backend_task::migration::MigrationTask) + /// orchestrator) call `.set_state()`. + pub fn migration_status(&self) -> Arc<MigrationStatus> { + Arc::clone(&self.migration_status) } - pub fn set_core_backend_mode(self: &Arc<Self>, mode: CoreBackendMode) { - self.set_core_backend_mode_inner(mode, true); - } - - /// Switch the backend mode in-memory only, without persisting to the DB. - /// Used by headless (MCP/CLI) mode to force SPV without overwriting the - /// GUI's saved preference. - pub fn set_core_backend_mode_volatile(self: &Arc<Self>, mode: CoreBackendMode) { - self.set_core_backend_mode_inner(mode, false); - } - - fn set_core_backend_mode_inner(self: &Arc<Self>, mode: CoreBackendMode, persist: bool) { - // TODO: mid-session mode switches here do not manage ZMQ listener - // lifecycle. - // - RPC → SPV: any listener spawned at startup or on the last - // network switch keeps running, burning a socket + retry loop - // against a Core node that is no longer in use (pre-existing - // bug, predates the SPV-default flip). - // - SPV → RPC: no listener is spawned here, so Expert users - // toggling to Local Dash Core node mid-session lose ZMQ - // real-time events until restart or network switch (the - // FeatureGate::RpcBackend gate in spawn_zmq_listener blocks - // the startup spawn while in SPV mode). - // Fixing this requires an AppContext → AppState signal so the - // listener map (owned by AppState) can be torn down or spawned - // in response to mode changes. Cross-module refactor, deliberately - // scoped out of PR #836. - // Ref: CR-1 on dashpay/dash-evo-tool#836. - - // Switch SDK context provider to match the selected backend. - // Only store/persist the mode after binding succeeds — otherwise the app - // would report the new mode while still wired to the old provider. - #[allow(clippy::needless_return)] - match mode { - CoreBackendMode::Spv => { - if let Err(e) = self - .spv_context_provider - .read() - .map_err(|e| e.to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return; - } - } - CoreBackendMode::Rpc => { - if let Err(e) = self - .rpc_context_provider - .read() - .map_err(|e| e.to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind RPC provider: {}", e); - return; - } - } - } + /// Open (or create) the shared app k/v store at + /// `<data_dir>/det-app.sqlite`. Used by every `AppContext::new` + /// callsite — pass a single `Arc<DetKv>` to all per-network + /// contexts so they share the same blob. + pub fn open_app_kv( + data_dir: &std::path::Path, + ) -> Result<Arc<DetKv>, platform_wallet_storage::WalletStorageError> { + use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + let path = data_dir.join("det-app.sqlite"); + let config = SqlitePersisterConfig::new(path); + let persister = Arc::new(SqlitePersister::open(config)?); + Ok(Arc::new(DetKv::new(persister))) + } - self.core_backend_mode - .store(mode.as_u8(), Ordering::Relaxed); + /// Open (or create) the shared encrypted seed vault at + /// `<data_dir>/secrets/det-secrets.pwsvault`. Called once per process and + /// the resulting `Arc<SecretStore>` is handed to every per-network + /// `AppContext` and reused by the `WalletBackend` — the file backend holds + /// an exclusive advisory lock for the handle's lifetime, so a second open + /// of the same vault would fail with `AlreadyLocked`. The vault is + /// cross-network: seeds and imported keys are scoped by hash, not chain. + pub fn open_secret_store(data_dir: &std::path::Path) -> Result<Arc<SecretStore>, TaskError> { + crate::wallet_backend::single_key::open_secret_store(&Self::secret_store_path(data_dir)) + .map(Arc::new) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + }) + } - if persist { - let _guard = self.invalidate_settings_cache(); - if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { - tracing::error!("Failed to persist core backend mode: {}", e); - } - } + /// Open the shared seed vault with an explicit `passphrase` — the + /// funds-safe, non-destructive recovery door for a **legacy vault** an + /// older build sealed with a passphrase. The keyless [`Self::open_secret_store`] + /// fails such a vault with + /// [`SecretStoreError::WrongPassphrase`](platform_wallet_storage::secrets::SecretStoreError::WrongPassphrase); + /// the GUI boot seam re-opens it in place with this. Same vault file, same + /// `Arc<SecretStore>` contract — it never deletes, recreates, or rekeys + /// the vault, so wallet seeds are never at risk. + pub fn open_secret_store_with_passphrase( + data_dir: &std::path::Path, + passphrase: platform_wallet_storage::secrets::SecretString, + ) -> Result<Arc<SecretStore>, TaskError> { + crate::wallet_backend::single_key::open_secret_store_with_passphrase( + &Self::secret_store_path(data_dir), + passphrase, + ) + .map(Arc::new) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + }) + } + + /// Absolute path of the shared seed vault: `<data_dir>/secrets/det-secrets.pwsvault`. + fn secret_store_path(data_dir: &std::path::Path) -> std::path::PathBuf { + let mut path = data_dir.to_path_buf(); + path.push("secrets"); + path.push("det-secrets.pwsvault"); + path + } + + pub fn network(&self) -> Network { + self.network + } + + pub fn connection_status(&self) -> &ConnectionStatus { + &self.connection_status + } + + pub fn egui_ctx(&self) -> &egui::Context { + &self.egui_ctx } /// Get the cached fee multiplier permille (1000 = 1x, 2000 = 2x) @@ -538,6 +544,197 @@ impl AppContext { } } + /// Synchronous read of the frame-safe shielded balance for `seed_hash`. + /// + /// Returns the total shielded balance in duffs, summed across all Orchard accounts. + /// Returns `0` if no sync has completed yet (field is empty on cold boot). + /// + /// This is the **read side** of the push snapshot; the write side is + /// `on_shielded_sync_completed` in Phase E. Safe to call from the egui frame + /// loop — no blocking I/O, no async (Nagatha ruling). + pub fn shielded_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + self.shielded_balances + .lock() + .ok() + .and_then(|map| map.get(seed_hash).copied()) + .unwrap_or(0) + / CREDITS_PER_DUFF + } + + /// Synchronous, frame-safe reader for the per-wallet shielded balance in + /// Platform **credits** (the native unit for Platform/Orchard operations). + /// Returns 0 when no snapshot has been written yet. + /// + /// Use this for screens that display or operate on credits (shielded send, + /// unshield, coin-selection). For screens that sum Core + Platform + + /// Shielded in duffs, use [`Self::shielded_balance_duffs`] instead. + pub fn shielded_balance_credits(&self, seed_hash: &WalletSeedHash) -> u64 { + self.shielded_balances + .lock() + .ok() + .and_then(|map| map.get(seed_hash).copied()) + .unwrap_or(0) + } + + /// Synchronous read of the frame-safe platform-address balance for `seed_hash`. + /// + /// Returns the total platform balance in **duffs**, summed across all OWNED platform + /// payment addresses for the wallet. Returns `0` if no sync has completed yet. + /// + /// This is the **read side** of the coordinator-push snapshot; the write side is + /// `on_platform_address_sync_completed` in [`EventBridge`]. Safe to call from + /// the egui frame loop — no blocking I/O, no async (Nagatha ruling). + pub fn platform_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + self.platform_balances + .lock() + .ok() + .and_then(|map| map.get(seed_hash).copied()) + .unwrap_or(0) + } + + /// Synchronous read of the latest platform-address sync cursor + /// `(last_sync_timestamp, sync_height)` for `seed_hash`, or `None` until at + /// least one platform-address sync pass has completed for the wallet. The + /// cursor advances on every successful pass regardless of whether it found + /// any funded addresses, so `Some` means "a sync pass has completed," not + /// "funds were found." + /// + /// Read side of the same coordinator-push snapshot as + /// [`platform_balance_duffs`](Self::platform_balance_duffs); the write side + /// is `on_platform_address_sync_completed` in [`EventBridge`]. Drives the + /// "Addresses synced" label. Safe to call from the egui frame loop. + pub fn platform_sync_info(&self, seed_hash: &WalletSeedHash) -> Option<(u64, u64)> { + self.platform_sync_cursors + .lock() + .ok() + .and_then(|map| map.get(seed_hash).copied()) + } + + /// Drop the cached sync cursor for `seed_hash` so the "Addresses synced" + /// label reverts to "never synced" — used by the developer "Clear Platform + /// Addresses" tool after it wipes the in-memory address pools. + pub fn clear_platform_sync_info(&self, seed_hash: &WalletSeedHash) { + if let Ok(mut map) = self.platform_sync_cursors.lock() { + map.remove(seed_hash); + } + } + + /// Shared per-address seeding loop for the platform-address maps. + /// + /// For each `(seed_hash, entries)` batch, converts every raw 20-byte P2PKH + /// hash to a canonical address, hands it to `write_info` (the caller picks + /// overwrite vs seed-if-absent semantics), and registers the address for + /// signing — the exact DIP-17 index when known, the bounded + /// reverse-derivation scan otherwise. Without the signer registration a + /// balance is visible yet unwithdrawable. + /// + /// Each wallet write lock is held briefly for a pure `BTreeMap` update — no + /// I/O, no network, no await — matching the codebase's frame-loop write + /// pattern. + fn seed_platform_address_entries<'a, I, W>(&self, batches: I, write_info: W) + where + I: IntoIterator<Item = (&'a WalletSeedHash, &'a [PlatformAddressEntry])>, + W: Fn(&mut Wallet, dash_sdk::dpp::dashcore::Address, u64, u32), + { + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + let network = self.network; + let Ok(wallets) = self.wallets.read() else { + return; + }; + for (seed_hash, entries) in batches { + if let Some(wallet_arc) = wallets.get(seed_hash) + && let Ok(mut wallet) = wallet_arc.write() + { + for entry in entries { + let addr = PlatformP2PKHAddress::new(entry.hash).to_address(network); + let canonical = Wallet::canonical_address(&addr, network); + write_info(&mut wallet, canonical.clone(), entry.balance, entry.nonce); + match entry.index { + Some(index) => wallet.register_platform_payment_address( + &canonical, + entry.account, + index, + network, + ), + None => { + wallet.reconcile_platform_address(&canonical, network); + } + } + } + } + } + } + + /// Populate each wallet's `platform_address_info` from a coordinator-push batch. + /// + /// Called by `AppState` when a [`BackendTaskSuccessResult::PlatformAddressSyncPushed`] + /// result arrives. Overwrites each address's cached info via + /// [`Wallet::set_platform_address_info`], keeping the per-address tab + /// consistent with the coordinator-push total balance without requiring a + /// manual Refresh on cold start. + pub fn apply_platform_address_push(&self, updates: PlatformAddressUpdates) { + self.seed_platform_address_entries( + updates + .iter() + .map(|(seed_hash, entries)| (seed_hash, entries.as_slice())), + |wallet, address, balance, nonce| { + wallet.set_platform_address_info(address, balance, nonce) + }, + ); + } + + /// Warm-start the platform-address UI from persisted upstream state. + /// + /// Runs once at boot, after the wallet backend is wired and before the + /// coordinator's first (network) pass. Seeds the per-address tab + /// (`platform_address_info`), the frame-safe total balance + /// (`platform_balances`), and the "Addresses synced" cursor + /// (`platform_sync_cursors`) so the whole platform section renders the + /// last-synced snapshot immediately — network-independent, with no + /// "never synced" gap on an offline cold boot. Each map is seeded with + /// `or_insert`, so a live coordinator push that already landed wins. + pub(crate) fn warm_start_platform_addresses(&self) { + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + let Ok(backend) = self.wallet_backend() else { + return; + }; + let seeds = backend.persisted_platform_address_warm_start(); + if seeds.is_empty() { + return; + } + + // Seed the per-address tab with an insert-if-absent write so a live + // push that somehow landed first is never clobbered by staler persisted + // data (the balance and cursor maps below use the same `or_insert` guard). + self.seed_platform_address_entries( + seeds + .iter() + .filter(|(_, e, _)| !e.is_empty()) + .map(|(seed_hash, entries, _)| (seed_hash, entries.as_slice())), + |wallet, address, balance, nonce| { + wallet.seed_platform_address_info(address, balance, nonce) + }, + ); + + if let Ok(mut balances) = self.platform_balances.lock() { + for (seed_hash, entries, _) in seeds.iter().filter(|(_, e, _)| !e.is_empty()) { + let total_credits: u64 = entries.iter().map(|e| e.balance).sum(); + balances + .entry(*seed_hash) + .or_insert(total_credits / CREDITS_PER_DUFF); + } + } + + if let Ok(mut cursors) = self.platform_sync_cursors.lock() { + for (seed_hash, _, cursor) in &seeds { + if let Some(cursor) = cursor { + cursors.entry(*seed_hash).or_insert(*cursor); + } + } + } + } + /// Get a fee estimator configured with the cached fee multiplier. /// Use this instead of `PlatformFeeEstimator::new()` to get accurate fee estimates /// that reflect the current network fee multiplier. @@ -549,6 +746,12 @@ impl AppContext { self.developer_mode.load(Ordering::Relaxed) } + /// A clone of the shared app-global Expert Mode flag, for wiring a + /// newly-created per-network context to the same flag (see the field docs). + pub fn developer_mode_handle(&self) -> Arc<AtomicBool> { + Arc::clone(&self.developer_mode) + } + /// Repaints the UI if animations are enabled. /// /// Called by UI elements that need to trigger a repaint, such as loading spinners or animated icons. @@ -612,23 +815,9 @@ impl AppContext { } }; - let new_sdk = match self.core_backend_mode() { - CoreBackendMode::Spv => { - let provider = self.spv_context_provider.read()?.clone(); - initialize_sdk(address_list, self.network, provider) - .map_err(|e| TaskError::SdkInitializationFailed { detail: e })? - } - CoreBackendMode::Rpc => { - let rpc_provider = RpcProvider::new(self.db.clone(), self.network, &cfg) - .map_err(|e| TaskError::RpcProviderCreationFailed { detail: e })?; - { - let mut guard = self.rpc_context_provider.write()?; - *guard = rpc_provider.clone(); - } - initialize_sdk(address_list, self.network, rpc_provider) - .map_err(|e| TaskError::SdkInitializationFailed { detail: e })? - } - }; + let provider = self.spv_context_provider.read()?.clone(); + let new_sdk = initialize_sdk(address_list, self.network, provider) + .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; // 4. Swap in the new SDK and client { @@ -637,19 +826,11 @@ impl AppContext { } self.sdk.store(Arc::new(new_sdk)); - // Rebind providers to ensure they hold the new AppContext reference. - // bind_app_context also registers the provider with the SDK, so the - // active provider (last bound) wins. + // Rebind the provider to hold the new AppContext reference. + // bind_app_context also registers the provider with the SDK. self.spv_context_provider .read()? - .bind_app_context(self.clone()) - .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; - if self.core_backend_mode() == CoreBackendMode::Rpc { - self.rpc_context_provider - .read()? - .bind_app_context(self.clone()) - .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; - } + .bind_app_context(self.clone()); Ok(()) } @@ -681,180 +862,455 @@ impl AppContext { .map_err(|e| TaskError::CoreRpc { source: e }) } - /// Build an RPC client targeting a specific Core wallet by name. - /// Returns the base (no-wallet) client if `wallet_name` is `None`. - pub fn core_client_for_wallet(&self, wallet_name: Option<&str>) -> Result<Client, TaskError> { - let cfg = self.config.read().map_err(|_| TaskError::LockPoisoned { - resource: "NetworkConfig", - })?; - let base = format!("http://{}:{}", cfg.rpc_host(), cfg.rpc_port(self.network)); - let url = match wallet_name { - Some(name) if !name.is_empty() => { - if name.contains("..") { - return Err(TaskError::InvalidCoreWalletName { - name: name.to_string(), - }); + /// Convert an SDK error to a [`TaskError`], with special handling for + /// [`dash_sdk::Error::DriveProofError`]: emits a structured tracing event + /// at target `proof_log` and returns [`TaskError::ProofError`] with the + /// SDK error preserved as the source. + /// + /// All other SDK errors are converted via [`TaskError::from`]. + pub(crate) fn log_drive_proof_error( + &self, + e: dash_sdk::Error, + request_type: RequestType, + ) -> TaskError { + match e { + dash_sdk::Error::DriveProofError(proof_error, proof_bytes, block_info) => { + tracing::error!( + target: "proof_log", + request_type = ?request_type, + height = block_info.height, + time_ms = block_info.time_ms, + proof_bytes_len = proof_bytes.len(), + %proof_error, + "drive proof verification failed", + ); + TaskError::ProofError { + source_error: Box::new(dash_sdk::Error::DriveProofError( + proof_error, + proof_bytes, + block_info, + )), } - let encoded = urlencoding::encode(name); - format!("{}/wallet/{}", base, encoded) } - _ => base, - }; - Self::create_core_rpc_client(&url, self.network, &cfg.devnet_name, &cfg) + e => TaskError::from(e), + } } - /// Import an address into the correct Core wallet if it's not already known. - /// Uses `core_wallet_name` to target the right wallet on multi-wallet nodes. - /// No-op if the address is already watched/mine. + /// Recovers from a Drive proof error on a contract put/update. /// - /// In SPV mode this is a no-op: there is no Dash Core node to import into. - /// HD-derived addresses are tracked by the SPV wallet manager watching the - /// BIP44 account derived from the same xprv — `Wallet::register_address` - /// records them in wallet state (`known_addresses`) but does not update the - /// SPV bloom filter directly. Incoming UTXOs for these addresses are - /// populated via the SPV reconciliation path (`reconcile_spv_wallets()`), - /// which is what downstream checks such as - /// `capture_qr_funding_utxo_if_available` observe. - pub fn ensure_address_imported( + /// Logs the proof failure via [`Self::log_drive_proof_error`], notifies the + /// UI, then re-fetches the contract by id — the transition may have + /// committed despite the proof error — and persists it through `persist`. + /// Returns [`BackendTaskSuccessResult::ContractSavedAfterProofError`] when + /// the contract is found, otherwise the original proof error. + pub(crate) async fn recover_contract_after_proof_error( &self, - address: &Address, - core_wallet_name: Option<&str>, - label: Option<&str>, + sdk: &Sdk, + contract_id: Identifier, + proof_error: dash_sdk::Error, + sender: &crate::utils::egui_mpsc::SenderAsync<crate::app::TaskResult>, + persist: impl FnOnce(&Self, &DataContract), + ) -> Result<crate::backend_task::BackendTaskSuccessResult, TaskError> { + use crate::app::TaskResult; + use crate::backend_task::BackendTaskSuccessResult; + use dash_sdk::platform::Fetch; + + // Give the network time to propagate the block before re-fetching. + const REFETCH_DELAY_REGTEST: std::time::Duration = std::time::Duration::from_secs(3); + const REFETCH_DELAY_DEFAULT: std::time::Duration = std::time::Duration::from_secs(10); + + let task_error = + self.log_drive_proof_error(proof_error, RequestType::BroadcastStateTransition); + + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::ProofErrorLogged, + ))) + .await + .map_err(|_| TaskError::InternalSendError)?; + + let delay = match self.network { + Network::Regtest => REFETCH_DELAY_REGTEST, + _ => REFETCH_DELAY_DEFAULT, + }; + tokio::time::sleep(delay).await; + + if let Ok(Some(contract)) = DataContract::fetch(sdk, contract_id).await { + persist(self, &contract); + return Ok(BackendTaskSuccessResult::ContractSavedAfterProofError); + } + + Err(task_error) + } + + /// Lazily build the wallet seam, idempotently. + /// + /// `WalletBackend::new` is async and needs the `TaskResult` sender, which + /// lives on `AppState` — so construction cannot happen in `Self::new`. + /// `AppState` calls this once it has both the context and the sender. + /// Subsequent calls are no-ops (first writer wins). + pub async fn ensure_wallet_backend( + self: &Arc<Self>, + task_result_sender: crate::utils::egui_mpsc::SenderAsync<crate::app::TaskResult>, ) -> Result<(), TaskError> { - if self.core_backend_mode() != CoreBackendMode::Rpc { + // Fast path: already wired, no lock needed. + if self.wallet_backend.load().is_some() { return Ok(()); } - let client = self.core_client_for_wallet(core_wallet_name)?; - let info = client - .get_address_info(address) - .map_err(|e| self.rpc_error_with_url(e))?; - if !(info.is_watchonly || info.is_mine) { - client - .import_address(address, label, Some(false)) - .map_err(|e| self.rpc_error_with_url(e))?; + // Serialize construction so concurrent first-tasks do not both run the + // expensive build (which takes an exclusive advisory lock on the + // persistor file). Re-check under the guard — a racer may have wired it + // while we waited. + let _build_guard = self.wallet_backend_build.lock().await; + if self.wallet_backend.load().is_some() { + return Ok(()); } + let sdk = std::sync::Arc::new(self.sdk.load().as_ref().clone()); + let backend = WalletBackend::new( + self, + sdk, + Arc::clone(&self.connection_status), + task_result_sender, + self.secret_prompt(), + ) + .await?; + self.wallet_backend.store(Some(Arc::new(backend))); + drop(_build_guard); + self.restore_selected_wallet_from_kv(); + self.restore_selected_identity_from_kv(); + // Render the platform section (per-address tab, total, "Addresses synced" + // label) from persisted upstream state immediately — network-independent, + // before the coordinator's first pass, which only fires once a network + // sync succeeds. + self.warm_start_platform_addresses(); + + // Bootstrap addresses and promote any verified-open seeds into the + // JIT chokepoint's session cache for the cold-boot path. Signing no + // longer depends on this — the chokepoint pulls the seed just-in-time + // from the encrypted vault, and a no-password wallet signs via the + // unprotected fast-path with no prompt regardless. This runs after the + // backend is wired and `ctx.wallets` is populated so address bootstrap + // has the reconstructed wallets to work from. Idempotent. + self.bootstrap_loaded_wallets().await; Ok(()) } - /// Import address into Core, ignoring errors. For best-effort registration. + /// Populate the in-memory selected-wallet pointers from the wallet + /// backend's k/v store. Called from [`Self::ensure_wallet_backend`] + /// once the backend exists; safe to call again later (idempotent + /// — re-reads kv and re-validates against currently loaded wallets). + /// Each pointer is kept only if the referenced wallet is still + /// loaded for this network, matching the pre-C4 validation step. + fn restore_selected_wallet_from_kv(&self) { + let Ok(backend) = self.wallet_backend() else { + return; + }; + let selected = backend.get_selected_wallet(); + + if let Ok(mut guard) = self.selected_wallet_hash.lock() { + let candidate = selected + .hd_wallet_hash + .filter(|h| self.wallets.read().is_ok_and(|w| w.contains_key(h))); + *guard = candidate; + } + if let Ok(mut guard) = self.selected_single_key_hash.lock() { + let candidate = selected.single_key_hash.filter(|h| { + self.single_key_wallets + .read() + .is_ok_and(|w| w.contains_key(h)) + }); + *guard = candidate; + } + } + + /// The wallet seam, or `WalletBackendNotYetWired` if not yet built. + pub fn wallet_backend(&self) -> Result<Arc<WalletBackend>, TaskError> { + self.wallet_backend + .load_full() + .ok_or(TaskError::WalletBackendNotYetWired) + } + + /// Install the interactive secret-prompt host (the egui host in the GUI). /// - /// No-ops in SPV mode — mirroring [`Self::ensure_address_imported`] — because there is no - /// RPC client to talk to and every call would fail silently, wasting resources. - pub fn try_import_address( + /// Must be called **before** [`Self::ensure_wallet_backend`] builds the + /// backend, since that is where the prompt is read into the `SecretAccess` + /// chokepoint. Headless callers (MCP / CLI) skip this and keep the default + /// [`NullSecretPrompt`], which surfaces `SecretPromptUnavailable` for any + /// passphrase-protected scope. + pub fn install_secret_prompt(&self, prompt: Arc<dyn SecretPrompt>) { + if let Ok(mut guard) = self.secret_prompt.0.lock() { + *guard = prompt; + } + } + + /// The currently-installed secret-prompt host. Falls back to the headless + /// [`NullSecretPrompt`] if the lock is poisoned (a panicked installer can + /// never strand the backend without a prompt). + pub fn secret_prompt(&self) -> Arc<dyn SecretPrompt> { + self.secret_prompt + .0 + .lock() + .map(|g| Arc::clone(&g)) + .unwrap_or_else(|_| Arc::new(NullSecretPrompt) as Arc<dyn SecretPrompt>) + } + + /// Persist the per-network selected-wallet pointer to the wallet + /// backend's k/v store. Logs and swallows the write if the backend + /// is not yet wired or the kv layer errors — wallet selection is + /// best-effort persistence (the in-memory mutex in `AppContext` + /// stays authoritative for the running process). + pub fn persist_selected_wallet_kv( &self, - address: &Address, - core_wallet_name: Option<&str>, - label: Option<&str>, + hd_wallet_hash: Option<WalletSeedHash>, + single_key_hash: Option<SingleKeyHash>, ) { - if self.core_backend_mode() != CoreBackendMode::Rpc { + let Ok(backend) = self.wallet_backend() else { + tracing::debug!("Skipping selected-wallet persist; wallet backend not yet wired"); return; - } - if let Ok(client) = self.core_client_for_wallet(core_wallet_name) { - let _ = client.import_address(address, label, Some(false)); + }; + let blob = crate::model::selected_wallet::SelectedWallet { + hd_wallet_hash, + single_key_hash, + }; + if let Err(e) = backend.set_selected_wallet(&blob) { + tracing::warn!( + network = ?self.network, + error = ?e, + "Failed to persist selected wallet to wallet k/v" + ); } } - /// Convert an RPC error to `TaskError`, enriching connection failures with - /// the configured host:port so the user knows which address was unreachable. - pub(crate) fn rpc_error_with_url(&self, e: dash_sdk::dashcore_rpc::Error) -> TaskError { - if is_rpc_connection_error(&e) { - let url = self - .config - .read() - .ok() - .map(|c| format!("{}:{}", c.rpc_host(), c.rpc_port(self.network))) - .unwrap_or_else(|| "unknown".to_string()); - TaskError::CoreRpcConnectionFailed { - url, - source: Some(Box::new(e)), - } - } else { - TaskError::from(e) - } + /// The currently selected HD wallet for this network, if any. + pub fn selected_wallet_hash(&self) -> Option<WalletSeedHash> { + self.selected_wallet_hash.lock().ok().and_then(|g| *g) } - /// List wallets currently loaded in Dash Core. - pub fn list_core_wallets(&self) -> Result<Vec<String>, TaskError> { - let client = self.core_client_for_wallet(None)?; - client - .list_wallets() - .map_err(|e| self.rpc_error_with_url(e)) + /// The app-scoped selected identity for this network, if any. + pub fn selected_identity_id(&self) -> Option<Identifier> { + self.selected_identity_id.lock().ok().and_then(|g| *g) } - /// Try to detect which loaded Core wallet owns the given address. + /// Resolve the active identity every operate-as read uses: the selected + /// identity when still loaded, else the first loaded identity, else `None`. /// - /// Returns `Ok(Some(name))` if exactly one wallet recognizes it, - /// `Ok(None)` if ambiguous (0 or >1 matches). - pub fn try_detect_core_wallet_for_address( - &self, - address: &Address, - ) -> Result<Option<String>, TaskError> { - let core_wallets = self.list_core_wallets()?; - if core_wallets.is_empty() { - return Err(TaskError::NoCoreWalletsLoaded); + /// FR-6 boundary (resolution layer): the app-global operate-as identity is + /// **always** a User identity. The candidate set is filtered to + /// [`IdentityType::User`] before resolving, so neither the keep-if-loaded + /// check nor the first-loaded fallback can ever resolve a masternode/evonode + /// — even when a masternode is the only or first loaded identity, or was + /// persisted as the selection in a prior session. Masternode/evonode + /// identities are page-scoped (the Masternodes page), never the app-global + /// identity. + pub fn resolve_selected_identity(&self) -> Option<QualifiedIdentity> { + let identities = self.load_local_qualified_identities().ok()?; + let user_ids: Vec<Identifier> = identities + .iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect(); + let chosen = crate::model::selected_identity::resolve_selected( + self.selected_identity_id(), + &user_ids, + )?; + identities.into_iter().find(|qi| qi.identity.id() == chosen) + } + + /// In-memory single-key selection (read directly for the persist blob). + fn current_single_key_hash(&self) -> Option<SingleKeyHash> { + self.selected_single_key_hash.lock().ok().and_then(|g| *g) + } + + /// Owning HD wallet of an identity, derived from its signing-key + /// derivation path (the reliable owner — never + /// `associated_wallets.keys().next()`). Best-effort: `None` when the + /// identity is unknown or has no wallet-derived signing key. + fn owning_wallet_hash(&self, id: Identifier) -> Option<WalletSeedHash> { + let identities = self.load_local_qualified_identities().ok()?; + let qi = identities.into_iter().find(|qi| qi.identity.id() == id)?; + let wallet = crate::ui::identities::get_selected_wallet(&qi, Some(self), None).ok()??; + let hash = wallet.read().ok()?.seed_hash(); + Some(hash) + } + + /// Set the app-scoped selected identity and reconcile the operating wallet + /// to its owner. Writes both mutexes directly and persists both blobs once; + /// never calls a sibling setter (no reconciliation recursion — R5). The + /// wallet write is cosmetic — signing always re-derives from the identity. + pub fn set_selected_identity(&self, id: Option<Identifier>) { + if let Ok(mut g) = self.selected_identity_id.lock() { + *g = id; } - if core_wallets.len() == 1 { - return Ok(Some(core_wallets.into_iter().next().unwrap())); + // Reconcile the derived wallet to the identity's owner. A wallet-less + // (imported-by-id) identity has NO owning wallet, so the pointer must + // be cleared to `None` — otherwise the breadcrumb wallet pill keeps + // showing the previous identity's wallet while Home/operate-as use the + // wallet-less one (the pill and the active identity disagree). Selecting + // an identity always writes the owner (`None` included); only clearing + // the identity (`id == None`) leaves the wallet pointer untouched. + if let Some(id) = id { + let owner = self.owning_wallet_hash(id); + if let Ok(mut g) = self.selected_wallet_hash.lock() { + *g = owner; + } } - // Multiple wallets — check which one recognizes the address - let mut matches = Vec::new(); - for wallet_name in &core_wallets { - let client = self.core_client_for_wallet(Some(wallet_name))?; - match client.get_address_info(address) { - Ok(info) if info.is_mine || info.is_watchonly => { - matches.push(wallet_name.clone()); - } - Ok(_) => {} - Err(e) => { - tracing::debug!(?e, wallet_name, "get_address_info failed"); - } + self.persist_selected_identity_kv(id); + self.persist_selected_wallet_kv( + self.selected_wallet_hash(), + self.current_single_key_hash(), + ); + } + + /// Set the selected HD wallet and reconcile the active identity to that + /// wallet's identities (keep-if-owned, else its first identity, else + /// `None` → picker). Writes both mutexes directly and persists both blobs + /// once; never calls a sibling setter (R5). + pub fn set_selected_hd_wallet(&self, hash: Option<WalletSeedHash>) { + if let Ok(mut g) = self.selected_wallet_hash.lock() { + *g = hash; + } + let reconciled = match hash { + Some(h) => { + // FR-6 boundary: reconcile only over the wallet's User identities + // so a masternode/evonode is never resolved as the app-global + // identity via this cross-axis wallet-switch side effect. + let ids: Vec<Identifier> = self + .load_local_qualified_identities_for_wallet(&h) + .map(|v| { + v.iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect() + }) + .unwrap_or_default(); + crate::model::selected_identity::resolve_selected(self.selected_identity_id(), &ids) } + None => None, + }; + if let Ok(mut g) = self.selected_identity_id.lock() { + *g = reconciled; } - match matches.len() { - 1 => Ok(Some(matches.into_iter().next().unwrap())), - _ => Ok(None), // 0 or >1 matches — ambiguous + self.persist_selected_wallet_kv(hash, self.current_single_key_hash()); + self.persist_selected_identity_kv(reconciled); + } + + /// Set the selected single-key wallet and persist. Single-key wallets own + /// no identities, so there is no identity to reconcile; this also closes + /// the pre-existing single-key persist gap (R10). + pub fn set_selected_single_key_wallet(&self, hash: Option<SingleKeyHash>) { + if let Ok(mut g) = self.selected_single_key_hash.lock() { + *g = hash; } + self.persist_selected_wallet_kv(self.selected_wallet_hash(), hash); } - /// Convert an SDK error to a [`TaskError`], with special handling for - /// [`dash_sdk::Error::DriveProofError`]: logs the proof data to the database - /// and returns [`TaskError::ProofError`] with the SDK error preserved as the source. + /// Stage an identity to become active when the hub is next shown — set + /// after creating/loading an identity. Forward-courier; mirrors + /// `pending_wallet_selection`. + pub fn set_pending_identity_selection(&self, id: Identifier) { + if let Ok(mut g) = self.pending_identity_selection.lock() { + *g = Some(id); + } + } + + /// Take any staged pending identity selection, clearing it. + pub fn take_pending_identity_selection(&self) -> Option<Identifier> { + self.pending_identity_selection + .lock() + .ok() + .and_then(|mut g| g.take()) + } + + /// Persist the per-network selected-identity pointer. Best-effort, mirrors + /// [`Self::persist_selected_wallet_kv`]; the in-memory mutex stays + /// authoritative for the running process. + pub fn persist_selected_identity_kv(&self, id: Option<Identifier>) { + let Ok(backend) = self.wallet_backend() else { + tracing::debug!("Skipping selected-identity persist; wallet backend not yet wired"); + return; + }; + let blob = crate::model::selected_identity::SelectedIdentity { identity_id: id }; + if let Err(e) = backend.set_selected_identity(&blob) { + tracing::warn!( + network = ?self.network, + error = ?e, + "Failed to persist selected identity to wallet k/v" + ); + } + } + + /// Populate the in-memory selected-identity pointer from the wallet + /// backend's k/v store, keeping it only if the identity is still loaded for + /// this network. Called from [`Self::ensure_wallet_backend`] after the + /// wallet pointers are restored; idempotent. + fn restore_selected_identity_from_kv(&self) { + let Ok(backend) = self.wallet_backend() else { + return; + }; + let stored = backend.get_selected_identity().identity_id; + // FR-6 one-time sanitization: masternodes were Hub-pickable in prior + // sessions, so a persisted `selected_identity_id` may point at a + // masternode/evonode. The app-global identity must always be a User + // identity, so keep the persisted selection only if it is a still-loaded + // User identity — otherwise clear it (the count-based hub default takes + // over). In-memory only, matching the non-destructive restore contract: + // a transient empty load leaves the KV blob untouched for the next pass. + let user_ids: Vec<Identifier> = self + .load_local_qualified_identities() + .map(|v| { + v.iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect() + }) + .unwrap_or_default(); + let kept = crate::model::selected_identity::keep_if_loaded(stored, &user_ids); + if let Ok(mut g) = self.selected_identity_id.lock() { + *g = kept; + } + } + + /// Confirmed / unconfirmed / total chain balance for an HD wallet, read + /// from the display-only `WalletBackend` snapshot (P4a). Pre-first-sync + /// (or backend not yet wired) yields a zeroed balance, which callers + /// render as the existing "syncing" state. /// - /// All other SDK errors are converted via [`TaskError::from`]. - pub(crate) fn log_drive_proof_error( + /// DISPLAY-ONLY: this never participates in coin selection — spending + /// goes through `WalletBackend::send_payment` / + /// `create_asset_lock_proof` (A04 fund-safety gate). + pub fn snapshot_balance(&self, seed_hash: &WalletSeedHash) -> DetWalletBalance { + self.wallet_backend() + .map(|wb| wb.wallet_balance(seed_hash)) + .unwrap_or_default() + } + + /// Number of UTXOs in the wallet's display snapshot. Used to estimate the + /// Core (L1) transaction fee for a "Max" send, which spends every UTXO. + /// + /// DISPLAY-ONLY: like the other snapshot reads, this never drives coin + /// selection — it only sizes the fee reserved off the displayed balance. + pub fn snapshot_utxo_count(&self, seed_hash: &WalletSeedHash) -> usize { + self.wallet_backend() + .map(|wb| wb.utxos(seed_hash).len()) + .unwrap_or(0) + } + + /// Whether the wallet's snapshot shows any confirmed or unconfirmed + /// funds. Replaces the legacy `Wallet::has_balance` predicate. + pub fn snapshot_has_balance(&self, seed_hash: &WalletSeedHash) -> bool { + let b = self.snapshot_balance(seed_hash); + b.confirmed > 0 || b.unconfirmed > 0 + } + + /// UTXO-derived per-address balances from the snapshot (P4a). Replaces + /// reads of the legacy `Wallet::address_balances` map. + pub fn snapshot_address_balances( &self, - e: dash_sdk::Error, - request_type: RequestType, - ) -> TaskError { - use crate::model::proof_log_item::ProofLogItem; - match e { - dash_sdk::Error::DriveProofError(proof_error, proof_bytes, block_info) => { - if let Err(db_err) = self.db.insert_proof_log_item(ProofLogItem { - request_type, - request_bytes: vec![], - verification_path_query_bytes: vec![], - height: block_info.height, - time_ms: block_info.time_ms, - proof_bytes: proof_bytes.clone(), - error: Some(proof_error.to_string()), - }) { - tracing::warn!( - height = block_info.height, - proof_error = %proof_error, - "Failed to persist proof log entry for {request_type:?}: {}", - db_err - ); - } - TaskError::ProofError { - source_error: Box::new(dash_sdk::Error::DriveProofError( - proof_error, - proof_bytes, - block_info, - )), - } - } - e => TaskError::from(e), - } + seed_hash: &WalletSeedHash, + ) -> std::collections::BTreeMap<dash_sdk::dpp::dashcore::Address, u64> { + self.wallet_backend() + .map(|wb| wb.address_balances(seed_hash)) + .unwrap_or_default() } } @@ -883,14 +1339,10 @@ impl AppContext { } /// Returns the default platform version for the given network. -pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { - // TODO: Ideally use sdk.load().version() but this is a free function with no sdk access - match network { - Network::Mainnet => &PLATFORM_V11, - Network::Testnet => &PLATFORM_V11, - Network::Devnet => &PLATFORM_V11, - Network::Regtest => &PLATFORM_V11, - } +// TODO: Ideally use sdk.load().version() but this is a free function with no sdk access. +// Every network currently pins the same version. +pub(crate) const fn default_platform_version(_network: &Network) -> &'static PlatformVersion { + &PLATFORM_V11 } #[cfg(test)] @@ -907,38 +1359,250 @@ mod tests { assert!(!url.contains(' ')); } - /// A fresh data directory (no pre-existing settings) must resolve to the - /// SPV backend — both because the `settings.core_backend_mode` column - /// defaults to 1 (SPV) and because `AppContext::new()`'s `unwrap_or` - /// fallback now uses SPV. This is the non-network-dependent replacement - /// for a full `AppContext::new()` integration test: it exercises the same - /// DB-column/unwrap pathway that drives the stored mode at startup. + // ── FR-6 resolution-layer boundary (B1) ────────────────────────────────── + + /// Build an offline, wired `AppContext` (no network I/O) so the identity + /// store is a real, writable DB the accessors read from. Returns the temp + /// dir (kept alive by the caller) alongside the context. + async fn offline_ctx() -> (tempfile::TempDir, std::sync::Arc<AppContext>) { + use crate::app::TaskResult; + 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; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = + std::sync::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, + dash_sdk::dpp::dashcore::Network::Testnet, + db, + std::sync::Arc::new(TaskManager::new()), + std::sync::Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + (temp_dir, ctx) + } + + /// Regression (mn-live-qa Bug 1): `developer_mode` is a single app-global + /// flag shared by every per-network `AppContext`. Toggling it on the context + /// for one network must be observable from the context for another — + /// otherwise the left-nav feature gate (`FeatureGate::DeveloperMode`) reads a + /// stale value on whichever per-network context the app renders from, and the + /// Expert-Mode-gated Masternodes tab never appears until an app restart + /// re-reads the persisted flag from config. #[test] - fn fresh_db_resolves_to_spv_backend_mode() { - let tmp = tempfile::tempdir().unwrap(); - let db_file_path = tmp.path().join("test_data.db"); - let db = crate::database::Database::new(&db_file_path).unwrap(); - db.initialize(&db_file_path).unwrap(); - - // Column default path: a freshly initialized DB reports SPV (=1). - let settings = db - .get_settings() - .expect("settings read") - .expect("settings row present"); - // Settings tuple: index 7 is core_backend_mode (see AppContext::new). + fn developer_mode_is_shared_across_network_contexts() { + 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 = + std::sync::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 subtasks = std::sync::Arc::new(TaskManager::new()); + let connection_status = std::sync::Arc::new(ConnectionStatus::new()); + let egui_ctx = egui::Context::default(); + // A single app-global developer-mode flag, owned by `AppState` and shared + // into every per-network context (mirrors the real construction path). + let developer_mode = std::sync::Arc::new(AtomicBool::new(false)); + + // The startup context (Mainnet) and a second context (Testnet) built the + // way `AppState` builds one on a live network switch — reusing the shared + // db / kv / secret store / developer-mode flag. + let mainnet = AppContext::new( + data_dir.clone(), + Network::Mainnet, + db.clone(), + subtasks.clone(), + connection_status.clone(), + egui_ctx.clone(), + app_kv.clone(), + secret_store.clone(), + developer_mode.clone(), + ) + .expect("mainnet AppContext::new"); + let testnet = AppContext::new( + data_dir, + Network::Testnet, + db, + subtasks, + connection_status, + egui_ctx, + app_kv, + secret_store, + developer_mode, + ) + .expect("testnet AppContext::new"); + + assert!(!mainnet.is_developer_mode()); + assert!(!testnet.is_developer_mode()); + + // Toggle Expert Mode on ONE context, exactly as the Settings checkbox does. + mainnet.enable_developer_mode(true); + + assert!( + testnet.is_developer_mode(), + "developer mode toggled on one network's context must be visible on \ + another network's context" + ); + } + + /// Seed one wallet-less identity of `identity_type` into the live identity + /// DB and return its id. + fn seed_typed(ctx: &AppContext, byte: u8, identity_type: IdentityType) -> Identifier { + use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + + let pv = PlatformVersion::latest(); + let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: Some(format!("seed-{byte:02x}")), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: std::collections::BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: ctx.network(), + }; + ctx.insert_local_qualified_identity(&qi, &None) + .expect("seed identity insert"); + id + } + + /// TC-FR6-01…06 + TC-NAV-12b — the User-only / masternode accessors split by + /// type, and `resolve_selected_identity()` never resolves a masternode: not + /// via keep-if-loaded, and not via the first-loaded fallback even when a + /// masternode is the only/first loaded identity. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fr6_accessors_and_resolution_filter_exclude_masternodes() { + let (_dir, ctx) = offline_ctx().await; + + // TC-NAV-12b: only a masternode loaded, nothing selected → resolve is + // None, never the masternode via the first-loaded fallback. + let mn = seed_typed(&ctx, 0x91, IdentityType::Masternode); + assert_eq!(ctx.selected_identity_id(), None, "nothing selected"); + assert!( + ctx.resolve_selected_identity().is_none(), + "a lone masternode must never resolve as the app-global identity" + ); + assert_eq!( + ctx.load_local_masternode_identities().unwrap().len(), + 1, + "the masternode accessor lists the masternode" + ); + assert!( + ctx.load_local_user_identities().unwrap().is_empty(), + "the User accessor excludes the masternode" + ); + + // Add an Evonode and a User. + let evo = seed_typed(&ctx, 0xE2, IdentityType::Evonode); + let user = seed_typed(&ctx, 0x71, IdentityType::User); + + let user_ids: Vec<Identifier> = ctx + .load_local_user_identities() + .unwrap() + .iter() + .map(|qi| qi.identity.id()) + .collect(); + assert_eq!(user_ids, vec![user], "User accessor lists only the User id"); + + let mn_ids: std::collections::BTreeSet<Identifier> = ctx + .load_local_masternode_identities() + .unwrap() + .iter() + .map(|qi| qi.identity.id()) + .collect(); + assert_eq!( + mn_ids, + [mn, evo].into_iter().collect(), + "masternode accessor lists MN + Evonode, never the User" + ); + + // The control: the unfiltered accessor still lists all three (the legacy + // Identities table reads this — masternodes stay visible there). + assert_eq!( + ctx.load_local_qualified_identities().unwrap().len(), + 3, + "the unfiltered accessor keeps MN/Evonode (legacy table control)" + ); + + // With a User present, resolve falls back to the User, never the MN/Evo. + assert_eq!( + ctx.resolve_selected_identity().map(|qi| qi.identity.id()), + Some(user), + "resolve falls back to the first User, never a masternode", + ); + } + + /// TC-NAV-12c — a masternode persisted as `selected_identity_id` in a prior + /// session is sanitized to `None` on context load; a User selection is kept. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fr6_stale_masternode_selection_sanitized_on_restore() { + let (_dir, ctx) = offline_ctx().await; + let mn = seed_typed(&ctx, 0xA1, IdentityType::Masternode); + let user = seed_typed(&ctx, 0x72, IdentityType::User); + + // Persist a masternode as the selection (the pre-FR-6 Hub allowed this). + ctx.set_selected_identity(Some(mn)); + assert_eq!(ctx.selected_identity_id(), Some(mn), "MN persisted"); + + // Even before restore, the resolution filter refuses to resolve the MN. + assert_eq!( + ctx.resolve_selected_identity().map(|qi| qi.identity.id()), + Some(user), + "resolution filter alone never resolves the persisted masternode", + ); + + // Context-load sanitization clears the stale MN selection in memory. + ctx.restore_selected_identity_from_kv(); assert_eq!( - settings.7, - CoreBackendMode::Spv.as_u8(), - "fresh DB should default to SPV" + ctx.selected_identity_id(), + None, + "a stale masternode selection is cleared on load", ); - // Default-fallback path: if settings were missing/unreadable, the - // fallback used by `AppContext::new()` must also resolve to SPV. Guard - // against silent drift of both the enum default and the as_u8 mapping. - assert_eq!(CoreBackendMode::default(), CoreBackendMode::Spv); + // A User selection survives the same sanitization. + ctx.set_selected_identity(Some(user)); + ctx.restore_selected_identity_from_kv(); assert_eq!( - CoreBackendMode::from(CoreBackendMode::Spv.as_u8()), - CoreBackendMode::Spv + ctx.selected_identity_id(), + Some(user), + "a User selection is kept by the sanitizer", ); } } diff --git a/src/context/settings_db.rs b/src/context/settings_db.rs index 88f3c7542..a25cea90d 100644 --- a/src/context/settings_db.rs +++ b/src/context/settings_db.rs @@ -1,84 +1,332 @@ +//! `AppSettings` accessors layered on the shared app k/v store. +//! +//! Reads use a small in-memory cache so the egui frame loop can probe +//! settings every frame without paying for a bincode decode each time. +//! Writes go straight to the k/v store; the cache is invalidated under +//! a `SettingsCacheGuard` so concurrent readers cannot observe a +//! stale value. + use super::{AppContext, SettingsCacheGuard}; -use crate::model::settings::Settings; +use crate::model::settings::{AppSettings, detect_dash_qt_path}; use crate::ui::RootScreenType; -use rusqlite::Result; +use crate::ui::theme::ThemeMode; +use crate::wallet_backend::poison::RwLockRecover; +use crate::wallet_backend::{DetScope, KvAdapterError}; impl AppContext { - /// Updates the `start_root_screen` in the settings table - pub fn update_settings(&self, root_screen_type: RootScreenType) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - - self.db - .insert_or_update_settings(self.network, root_screen_type) + /// Persist the chosen root screen and pin the active-network field + /// to this context's network. + pub fn update_settings(&self, root_screen_type: RootScreenType) -> Result<(), KvAdapterError> { + self.update_app_settings(|settings| { + settings.root_screen_type = root_screen_type; + settings.network = self.network; + }) } - /// Updates the main password settings - pub fn update_main_password( - &self, - salt: &[u8], - nonce: &[u8], - password_check: &[u8], - ) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - - self.db.update_main_password(salt, nonce, password_check) + /// Persist the theme preference. + pub fn update_theme_preference(&self, theme_mode: ThemeMode) -> Result<(), KvAdapterError> { + self.update_app_settings(|settings| settings.theme_mode = theme_mode) } - /// Updates the Dash Core execution settings - pub fn update_dash_core_execution_settings( - &self, - custom_dash_qt_path: Option<std::path::PathBuf>, - overwrite_dash_conf: bool, - ) -> Result<()> { - let _guard = self.invalidate_settings_cache(); + /// Persist the `auto_start_spv` flag. + pub fn update_auto_start_spv(&self, auto_start: bool) -> Result<(), KvAdapterError> { + self.update_app_settings(|settings| settings.auto_start_spv = auto_start) + } - self.db - .update_dash_core_execution_settings(custom_dash_qt_path, overwrite_dash_conf) + /// Persist the `onboarding_completed` flag. + pub fn update_onboarding_completed(&self, completed: bool) -> Result<(), KvAdapterError> { + self.update_app_settings(|settings| settings.onboarding_completed = completed) } - /// Updates the disable_zmq flag in settings - pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - self.db.update_disable_zmq(disable) + /// Atomically read-modify-write the persisted [`AppSettings`]. + /// + /// `mutate` receives the current settings by mutable reference; the new + /// value is then persisted and mirrored into the cache. The whole + /// read → mutate → persist cycle runs under one held `cached_settings` + /// write lock (the same guard [`Self::set_app_settings`] uses), so + /// concurrent updaters cannot lose each other's writes — every call + /// observes the prior committed state. + pub fn update_app_settings( + &self, + mutate: impl FnOnce(&mut AppSettings), + ) -> Result<(), KvAdapterError> { + // Holding this guard across the full cycle serialises updaters and + // blocks readers from observing a half-applied value. + let mut guard = self.invalidate_settings_cache(); + let mut settings = self.load_app_settings_uncached(); + mutate(&mut settings); + // On a put failure the cache stays cleared, so the next read reloads + // from the store instead of serving a value that never persisted. + self.app_kv + .put(DetScope::Global, AppSettings::KV_KEY, &settings)?; + *guard = Some(settings); + Ok(()) } - /// Invalidates the settings cache and returns a guard + /// Invalidates the settings cache and returns a guard. /// /// The cache is invalidated immediately and the guard prevents concurrent access - /// until the database operation is complete. This ensures atomicity and prevents - /// race conditions regardless of whether the database operation succeeds or fails. + /// until the underlying k/v operation completes. This ensures atomicity and + /// prevents race conditions regardless of whether the write succeeds. pub fn invalidate_settings_cache(&'_ self) -> SettingsCacheGuard<'_> { - let mut guard = self.cached_settings.write().unwrap(); + let mut guard = self.cached_settings.write_recover(); *guard = None; guard } - /// Retrieves the current settings - /// - /// ## Cached + /// Read the persisted [`AppSettings`]. /// - /// This function uses a cache to avoid expensive database operations. - /// The cache is invalidated when settings are updated. - /// - /// Use [`AppContext::invalidate_settings_cache`] to invalidate the cache. - pub fn get_settings(&self) -> Result<Option<Settings>> { - // First, try to read from cache + /// Returns defaults when the blob is absent (first run) or when the + /// stored value fails to decode (e.g. a future schema). Cached + /// in-memory between updates. + pub fn get_app_settings(&self) -> AppSettings { + // Fast path: cache hit under a read lock. + if let Some(cached) = self.cached_settings.read_recover().clone() { + return cached; + } + + // Cache miss: hold the write lock across the load+populate so a + // concurrent `set_app_settings` (which also holds this write lock for + // its whole duration) cannot slip a fresh value in between our read and + // write and then be clobbered by our stale load. + let mut guard = self.cached_settings.write_recover(); + // Double-check: a racer may have populated the cache while we waited. + if let Some(cached) = guard.clone() { + return cached; + } + + let loaded = self.load_app_settings_uncached(); + *guard = Some(loaded.clone()); + loaded + } + + /// Load and decode [`AppSettings`] straight from the k/v store, applying the + /// dash-qt autodetect fallback. Bypasses the cache — the caller must hold + /// the `cached_settings` write lock. A missing or undecodable blob falls + /// back to defaults. + fn load_app_settings_uncached(&self) -> AppSettings { + match self + .app_kv + .get::<AppSettings>(DetScope::Global, AppSettings::KV_KEY) { - let cache = self.cached_settings.read().unwrap(); - if let Some(ref settings) = *cache { - return Ok(Some(settings.clone())); + Ok(Some(s)) => with_dash_qt_path_fallback(s), + Ok(None) => AppSettings::default(), + Err(e) => { + tracing::warn!( + error = ?e, + "Failed to load AppSettings from app k/v; using defaults" + ); + AppSettings::default() } } + } - // Cache miss, read from database - let settings = self.db.get_settings()?.map(Settings::from); + /// Write the [`AppSettings`] blob to the shared app k/v store. + pub fn set_app_settings(&self, settings: &AppSettings) -> Result<(), KvAdapterError> { + let mut guard = self.invalidate_settings_cache(); + self.app_kv + .put(DetScope::Global, AppSettings::KV_KEY, settings)?; + *guard = Some(settings.clone()); + Ok(()) + } +} - // Update cache with the fresh data - { - let mut cache = self.cached_settings.write().unwrap(); - *cache = settings.clone(); +/// Fills in an autodetected `dash_qt_path` when a decoded settings blob has +/// none. `None` means "autodetect" (see `AppSettings::dash_qt_path` docs); +/// decoding itself stays pure (no filesystem IO), so this fallback runs once +/// here, at the settings-load call site. +fn with_dash_qt_path_fallback(mut settings: AppSettings) -> AppSettings { + if settings.dash_qt_path.is_none() { + settings.dash_qt_path = detect_dash_qt_path(); + } + settings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use dash_sdk::dpp::dashcore::Network; + use std::sync::Arc; + + fn empty_kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + /// F69 — the `AppSettings` blob survives a put/get round-trip through the + /// k/v store with every field intact, and an absent blob reads as `None` + /// (the path `get_app_settings` maps to its `Default`). + #[test] + fn app_settings_round_trips_through_kv() { + let kv = empty_kv(); + + // Absent blob reads as None. + assert!( + kv.get::<AppSettings>(DetScope::Global, AppSettings::KV_KEY) + .unwrap() + .is_none(), + "a fresh k/v store has no settings blob" + ); + + // A non-default value must round-trip field-for-field. + let mut settings = AppSettings::default(); + settings.network = Network::Testnet; + settings.theme_mode = ThemeMode::Dark; + settings.user_mode = crate::model::settings::UserMode::Beginner; + settings.auto_start_spv = true; + settings.disable_zmq = true; + settings.onboarding_completed = true; + + kv.put(DetScope::Global, AppSettings::KV_KEY, &settings) + .unwrap(); + let got: AppSettings = kv + .get(DetScope::Global, AppSettings::KV_KEY) + .unwrap() + .expect("settings blob present after put"); + + assert_eq!(got.network, Network::Testnet); + assert_eq!(got.theme_mode, ThemeMode::Dark); + assert_eq!(got.user_mode, crate::model::settings::UserMode::Beginner); + assert!(got.auto_start_spv); + assert!(got.disable_zmq); + assert!(got.onboarding_completed); + } + + /// A decoded blob with no stored Dash-Qt path gets one autodetect pass at + /// load time, so installing Dash-Qt after first launch is picked up + /// without a manual edit. + #[test] + fn dash_qt_path_fallback_autodetects_when_unset() { + let settings = AppSettings { + dash_qt_path: None, + ..AppSettings::default() + }; + let filled = with_dash_qt_path_fallback(settings); + assert_eq!(filled.dash_qt_path, detect_dash_qt_path()); + } + + /// A stored Dash-Qt path is preserved verbatim — autodetect never + /// overrides an explicit user choice. + #[test] + fn dash_qt_path_fallback_preserves_explicit_value() { + let stored = std::path::PathBuf::from("/custom/path/to/dash-qt"); + let settings = AppSettings { + dash_qt_path: Some(stored.clone()), + ..AppSettings::default() + }; + let filled = with_dash_qt_path_fallback(settings); + assert_eq!(filled.dash_qt_path, Some(stored)); + } + + /// Build a network-free [`AppContext`] backed by throwaway temp storage — + /// enough to exercise the settings read-modify-write path. + fn test_app_context(dir: &std::path::Path) -> Arc<AppContext> { + crate::app_dir::ensure_env_file(dir); + let db_file = dir.join("data.db"); + let db = Arc::new(crate::database::Database::new(&db_file).expect("db")); + db.create_tables(true).expect("create tables"); + db.set_default_version().expect("set version"); + let app_kv = AppContext::open_app_kv(dir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(dir).expect("open secret store"); + AppContext::new( + dir.to_path_buf(), + Network::Testnet, + db, + Default::default(), + Default::default(), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("AppContext") + } + + /// Each `update_app_settings` call observes the state committed by the + /// previous call. A closure that reads a field written by an earlier update + /// must see it, and independent updates must all accumulate rather than the + /// last writer overwriting a stale full-blob snapshot. + #[test] + fn update_app_settings_reads_prior_committed_state() { + use crate::model::settings::UserMode; + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + + ctx.update_app_settings(|s| s.user_mode = UserMode::Beginner) + .unwrap(); + ctx.update_app_settings(|s| { + // The RMW must hand us the value the previous update committed. + assert_eq!( + s.user_mode, + UserMode::Beginner, + "read-modify-write must observe the prior committed write" + ); + s.onboarding_completed = true; + }) + .unwrap(); + + let got = ctx.get_app_settings(); + assert_eq!(got.user_mode, UserMode::Beginner, "first update survived"); + assert!(got.onboarding_completed, "second update landed"); + } + + /// Concurrent updates to distinct fields must not lose writes. + /// Each thread flips its own boolean; under a non-atomic read-modify-write + /// a thread's stale snapshot would clobber a sibling field written by + /// another thread. Holding the cache lock across the whole cycle guarantees + /// every flip survives. + #[test] + fn concurrent_field_updates_do_not_lose_writes() { + use std::sync::Barrier; + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + + // Baseline: every boolean under test starts false. + let base = AppSettings { + overwrite_dash_conf: false, + disable_zmq: false, + onboarding_completed: false, + show_evonode_tools: false, + close_dash_qt_on_exit: false, + auto_start_spv: false, + ..AppSettings::default() + }; + ctx.set_app_settings(&base).unwrap(); + + type Flip = fn(&mut AppSettings); + let flips: [Flip; 6] = [ + |s| s.overwrite_dash_conf = true, + |s| s.disable_zmq = true, + |s| s.onboarding_completed = true, + |s| s.show_evonode_tools = true, + |s| s.close_dash_qt_on_exit = true, + |s| s.auto_start_spv = true, + ]; + let barrier = Arc::new(Barrier::new(flips.len())); + let handles: Vec<_> = flips + .into_iter() + .map(|flip| { + let ctx = Arc::clone(&ctx); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + for _ in 0..64 { + ctx.update_app_settings(flip).unwrap(); + } + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); } - Ok(settings) + let got = ctx.get_app_settings(); + assert!(got.overwrite_dash_conf); + assert!(got.disable_zmq); + assert!(got.onboarding_completed); + assert!(got.show_evonode_tools); + assert!(got.close_dash_qt_on_exit); + assert!(got.auto_start_spv, "no concurrent field update may be lost"); } } diff --git a/src/context/shielded.rs b/src/context/shielded.rs deleted file mode 100644 index f61c05d7a..000000000 --- a/src/context/shielded.rs +++ /dev/null @@ -1,698 +0,0 @@ -use std::sync::OnceLock; - -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::{TaskError, shielded_build_error}; -use crate::backend_task::shielded::ShieldedTask; -use crate::context::AppContext; -use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_orchard_keys}; -use dash_sdk::grovedb_commitment_tree::{ - ClientPersistentCommitmentTree, Nullifier, Position, ProvingKey, -}; -use std::sync::Arc; - -static PROVING_KEY: OnceLock<ProvingKey> = OnceLock::new(); - -/// Get or build the Halo 2 ProvingKey (cached for app lifetime). -/// -/// The first call takes ~30 seconds to build the key. Subsequent calls return -/// immediately from the cache. Use `warm_up_proving_key()` on a background -/// thread at app startup to avoid blocking the user's first shielded operation. -pub fn get_proving_key() -> &'static ProvingKey { - PROVING_KEY.get_or_init(ProvingKey::build) -} - -/// Whether the proving key has already been built and cached. -pub fn is_proving_key_ready() -> bool { - PROVING_KEY.get().is_some() -} - -impl AppContext { - /// Run a shielded pool task. - pub async fn run_shielded_task( - self: &Arc<Self>, - task: ShieldedTask, - ) -> Result<BackendTaskSuccessResult, TaskError> { - match task { - ShieldedTask::WarmUpProvingKey => { - let _ = get_proving_key(); - Ok(BackendTaskSuccessResult::ProvingKeyReady) - } - - ShieldedTask::InitializeShieldedWallet { seed_hash } => { - self.initialize_shielded_wallet(seed_hash) - } - - ShieldedTask::SyncNotes { seed_hash } => self.sync_shielded_notes(seed_hash).await, - - ShieldedTask::ShieldCredits { - seed_hash, - amount, - from_address, - nonce_override, - } => { - self.shield_credits_task(seed_hash, amount, from_address, nonce_override) - .await - } - - ShieldedTask::ShieldedTransfer { - seed_hash, - amount, - recipient_address_bytes, - } => { - self.shielded_transfer_task(seed_hash, amount, recipient_address_bytes) - .await - } - - ShieldedTask::UnshieldCredits { - seed_hash, - amount, - to_platform_address, - } => { - self.unshield_credits_task(seed_hash, amount, to_platform_address) - .await - } - - ShieldedTask::CheckNullifiers { seed_hash } => { - self.check_nullifiers_task(seed_hash).await - } - - ShieldedTask::ShieldFromAssetLock { - seed_hash, - amount_duffs, - source_address, - } => { - self.shield_from_asset_lock_task(seed_hash, amount_duffs, source_address) - .await - } - - ShieldedTask::ShieldedWithdrawal { - seed_hash, - amount, - to_core_address, - } => { - self.shielded_withdrawal_task(seed_hash, amount, to_core_address) - .await - } - } - } - - /// Increment the stored nonce for a platform address after a successful state transition. - /// - /// Updates both the in-memory wallet and the persisted DB record so that - /// subsequent operations (single-shot or batch) read the correct next nonce - /// without needing a full platform-address sync. - pub fn bump_platform_address_nonce( - &self, - seed_hash: &WalletSeedHash, - from_address: &dash_sdk::dpp::address_funds::PlatformAddress, - ) { - let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - let wallet_arc = match wallets.get(seed_hash) { - Some(w) => w.clone(), - None => return, - }; - drop(wallets); - - let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); - // Find the matching entry (platform_address_info is keyed by core Address) - let mut found: Option<(dash_sdk::dpp::dashcore::Address, u64, u32)> = None; - for (core_addr, info) in wallet.platform_address_info.iter_mut() { - if let Ok(pa) = - dash_sdk::dpp::address_funds::PlatformAddress::try_from(core_addr.clone()) - && &pa == from_address - { - info.nonce += 1; - found = Some((core_addr.clone(), info.balance, info.nonce)); - break; - } - } - drop(wallet); - - // Persist updated nonce to DB - if let Some((core_addr, balance, new_nonce)) = found { - let _ = self.db.set_platform_address_info( - seed_hash, - &core_addr, - balance, - new_nonce, - &self.network, - ); - } - } - - /// Set the cached nonce for a platform address to a specific value. - /// Used during nonce mismatch retry to align the cache with Platform's - /// expected nonce. - fn set_platform_address_nonce( - &self, - seed_hash: &WalletSeedHash, - from_address: &dash_sdk::dpp::address_funds::PlatformAddress, - nonce: u32, - ) { - let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); - let wallet_arc = match wallets.get(seed_hash) { - Some(w) => w.clone(), - None => return, - }; - drop(wallets); - - let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); - for (core_addr, info) in wallet.platform_address_info.iter_mut() { - if let Ok(pa) = - dash_sdk::dpp::address_funds::PlatformAddress::try_from(core_addr.clone()) - && &pa == from_address - { - info.nonce = nonce; - let _ = self.db.set_platform_address_info( - seed_hash, - core_addr, - info.balance, - nonce, - &self.network, - ); - break; - } - } - } - - /// Extract the expected nonce from an `AddressInvalidNonceError` inside - /// a boxed SDK error. Returns `None` if the error chain doesn't contain - /// the expected nonce. - fn extract_expected_nonce(sdk_error: &dash_sdk::Error) -> Option<u32> { - use dash_sdk::dpp::consensus::ConsensusError; - use dash_sdk::dpp::consensus::state::state_error::StateError; - let consensus = match sdk_error { - dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.as_ref()?, - dash_sdk::Error::Protocol(dash_sdk::dpp::ProtocolError::ConsensusError(ce)) => { - ce.as_ref() - } - _ => return None, - }; - if let ConsensusError::StateError(StateError::AddressInvalidNonceError(e)) = consensus { - Some(e.expected_nonce()) - } else { - None - } - } - - /// Get the default shielded payment address for a wallet. - pub fn shielded_default_address( - &self, - seed_hash: &WalletSeedHash, - ) -> Option<dash_sdk::grovedb_commitment_tree::PaymentAddress> { - let states = self - .shielded_states - .lock() - .unwrap_or_else(|e| e.into_inner()); - states.get(seed_hash).map(|s| s.keys.default_address) - } - - /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. - pub(crate) fn initialize_shielded_wallet( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - ) -> Result<BackendTaskSuccessResult, TaskError> { - // Check if already initialized - { - let states = self.shielded_states.lock()?; - if states.contains_key(&seed_hash) { - let balance = states - .get(&seed_hash) - .map(|s| s.shielded_balance) - .unwrap_or(0); - return Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }); - } - } - - // Get the wallet seed - let seed_bytes = { - let wallets = self.wallets.read()?; - let wallet_arc = wallets.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; - let wallet = wallet_arc.read()?; - match &wallet.wallet_seed { - crate::model::wallet::WalletSeed::Open(open) => open.seed, - crate::model::wallet::WalletSeed::Closed(_) => { - return Err(TaskError::WalletLocked); - } - } - }; - - let keys = - derive_orchard_keys(&seed_bytes, self.network, 0).map_err(shielded_build_error)?; - - let network_str = self.network.to_string(); - - let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - - let mut last_synced_index = 0u64; - - // Resume from persisted tree state if available - if let Ok(Some(pos)) = commitment_tree.max_leaf_position() { - last_synced_index = u64::from(pos) + 1; - } - - let (last_nullifier_sync_height, last_nullifier_sync_timestamp) = self - .db - .get_nullifier_sync_info(&seed_hash, &network_str) - .unwrap_or((0, 0)); - - let mut state = ShieldedWalletState { - keys, - notes: Vec::new(), - commitment_tree: std::sync::Mutex::new(commitment_tree), - last_synced_index, - last_nullifier_sync_height, - last_nullifier_sync_timestamp, - shielded_balance: 0, - last_notes_synced_at: None, - last_nullifiers_synced_at: None, - }; - - // Load persisted notes from DB and reconstruct Note objects - let note_rows = self - .db - .get_unspent_shielded_notes(&seed_hash, &network_str)?; - for row in note_rows { - if let Some(note) = crate::model::wallet::shielded::deserialize_note(&row.note_data) - && let Some(nullifier) = Nullifier::from_bytes(&row.nullifier).into_option() - { - state.notes.push(ShieldedNote { - note, - position: Position::from(row.position), - cmx: row.cmx, - nullifier, - block_height: row.block_height, - is_spent: false, - value: row.value, - }); - } - } - state.recalculate_balance(); - - // Safety net: if the tree has been synced but no notes exist at all - // (spent or unspent), force a full resync from index 0. This handles - // the case where change notes from prior operations were only in memory - // and the app restarted before the next sync persisted them. - // We check ALL notes (including spent) to avoid a false positive when - // the user legitimately spent everything. - if state.last_synced_index > 0 && state.notes.is_empty() { - let all_notes = self.db.get_all_shielded_notes(&seed_hash, &network_str)?; - if all_notes.is_empty() { - tracing::warn!( - "Shielded init: wallet {} tree synced to index {} but no notes in DB — forcing full resync", - hex::encode(seed_hash.as_slice()), - state.last_synced_index, - ); - self.db.clear_commitment_tree_tables().map_err(|e| { - TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - } - })?; - let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; - state.commitment_tree = std::sync::Mutex::new(fresh_tree); - state.last_synced_index = 0; - } - } - - let balance = state.shielded_balance; - - let mut states = self.shielded_states.lock()?; - states.insert(seed_hash, state); - - Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }) - } - - /// Sync shielded notes from platform. - pub(crate) async fn sync_shielded_notes( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - ) -> Result<BackendTaskSuccessResult, TaskError> { - // Take the state temporarily for the async operation - let mut state = { - let mut states = self.shielded_states.lock()?; - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = crate::backend_task::shielded::sync::sync_notes( - self, - &seed_hash, - &mut state, - self.network, - ) - .await; - - if result.is_ok() { - state.last_notes_synced_at = Some(std::time::Instant::now()); - } - - // Put state back - { - let mut states = self.shielded_states.lock()?; - states.insert(seed_hash, state); - } - - let (new_notes, balance) = result?; - Ok(BackendTaskSuccessResult::ShieldedNotesSynced { - seed_hash, - new_notes, - balance, - }) - } - - /// Shield credits from a platform address into the shielded pool. - /// - /// Unlike other shielded operations, shield_credits only needs the - /// payment address from the shielded state (no tree or notes access). - /// We read it without removing the state so parallel operations can share it. - async fn shield_credits_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - amount: u64, - from_address: dash_sdk::dpp::address_funds::PlatformAddress, - nonce_override: Option<u32>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let default_address = { - let states = self.shielded_states.lock()?; - let state = states.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; - state.keys.default_address - }; - - let result = crate::backend_task::shielded::bundle::shield_credits( - self, - &seed_hash, - &default_address, - amount, - from_address, - nonce_override, - None, - ) - .await; - - match result { - Ok(()) => { - self.bump_platform_address_nonce(&seed_hash, &from_address); - Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) - } - Err(TaskError::ShieldedNonceMismatch { ref source_error }) => { - // Extract the expected nonce from the error and retry once. - // The proof is already built — only the nonce in the state - // transition needs updating. Unfortunately the current API - // rebuilds the entire transition, but this avoids the user - // having to manually retry. - let expected_nonce = Self::extract_expected_nonce(source_error); - if let Some(nonce) = expected_nonce { - tracing::info!( - "Shield credits: nonce mismatch, retrying with expected nonce {nonce}" - ); - // Update cached nonce to expected - 1 so the retry reads expected - self.set_platform_address_nonce( - &seed_hash, - &from_address, - nonce.saturating_sub(1), - ); - - crate::backend_task::shielded::bundle::shield_credits( - self, - &seed_hash, - &default_address, - amount, - from_address, - Some(nonce), - None, - ) - .await?; - - self.bump_platform_address_nonce(&seed_hash, &from_address); - Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount }) - } else { - // Could not extract nonce — surface the original error - Err(result.unwrap_err()) - } - } - Err(e) => Err(e), - } - } - - /// Transfer credits within the shielded pool. - async fn shielded_transfer_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - amount: u64, - recipient_address_bytes: Vec<u8>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - self.with_anchor_retry( - &seed_hash, - "transfer", - async |state: &ShieldedWalletState| { - crate::backend_task::shielded::bundle::shielded_transfer( - self, - &seed_hash, - state, - amount, - &recipient_address_bytes, - ) - .await - }, - ) - .await?; - Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount }) - } - - /// Unshield credits from the shielded pool to a platform address. - async fn unshield_credits_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - amount: u64, - to_platform_address: dash_sdk::dpp::address_funds::PlatformAddress, - ) -> Result<BackendTaskSuccessResult, TaskError> { - self.with_anchor_retry( - &seed_hash, - "unshield", - async |state: &ShieldedWalletState| { - crate::backend_task::shielded::bundle::unshield_credits( - self, - &seed_hash, - state, - amount, - to_platform_address, - ) - .await - }, - ) - .await?; - Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount }) - } - - /// Withdraw credits from the shielded pool to a core L1 address. - async fn shielded_withdrawal_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - amount: u64, - to_core_address: dash_sdk::dpp::dashcore::Address, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let to_core_address_clone = to_core_address.clone(); - self.with_anchor_retry( - &seed_hash, - "withdrawal", - async |state: &ShieldedWalletState| { - crate::backend_task::shielded::bundle::shielded_withdrawal( - self, - &seed_hash, - state, - amount, - to_core_address_clone.clone(), - ) - .await - }, - ) - .await?; - Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount }) - } - - /// Run a shielded operation with automatic retry on anchor mismatch. - /// - /// Takes the shielded state from the map, calls `operation`, and if it - /// fails with `ShieldedAnchorMismatch`, syncs notes once and retries. - /// On success, marks spent notes and puts the state back. - async fn with_anchor_retry( - self: &Arc<Self>, - seed_hash: &WalletSeedHash, - operation_name: &str, - operation: impl AsyncFn(&ShieldedWalletState) -> Result<Vec<Nullifier>, TaskError>, - ) -> Result<Vec<Nullifier>, TaskError> { - let mut state = { - let mut states = self.shielded_states.lock()?; - states.remove(seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = operation(&state).await; - - // INTENTIONAL(SEC-001): Shielded state removed from map during async operation. - // Panic during shielded operation loses state for the session — restart recovers. - - let result = if matches!(result, Err(TaskError::ShieldedAnchorMismatch { .. })) { - tracing::debug!( - "Shielded anchor mismatch during {operation_name} — syncing notes and retrying" - ); - let sync_result = crate::backend_task::shielded::sync::sync_notes( - self, - seed_hash, - &mut state, - self.network, - ) - .await; - match sync_result { - Ok(_) => { - state.last_notes_synced_at = Some(std::time::Instant::now()); - // Fix SEC-002: verify sufficient balance after sync before retrying - state.recalculate_balance(); - if state.shielded_balance == 0 { - tracing::warn!( - "Shielded {operation_name}: no unspent balance after anchor retry sync" - ); - Err(TaskError::ShieldedInsufficientBalance { - available: 0, - required: 1, - }) - } else { - operation(&state).await - } - } - Err(e) => { - tracing::warn!("Note sync after anchor mismatch failed: {e}"); - Err(e) - } - } - } else { - result - }; - - if let Ok(ref spent_nullifiers) = result { - let notes_before = state.unspent_notes().len(); - self.mark_notes_spent(seed_hash, &mut state, spent_nullifiers); - let notes_after = state.unspent_notes().len(); - tracing::debug!( - "Shielded {operation_name}: marked {} note(s) spent (unspent notes: {} -> {}), new balance: {} credits", - spent_nullifiers.len(), - notes_before, - notes_after, - state.shielded_balance, - ); - } - - { - let mut states = self.shielded_states.lock()?; - states.insert(*seed_hash, state); - } - - result - } - - /// Shield core DASH directly into the shielded pool via asset lock. - /// - /// Unlike operations that spend shielded notes (transfer, unshield, withdrawal), - /// shield-from-asset-lock only reads the payment address — it doesn't use the - /// commitment tree for witnesses, so anchor retry is not applicable. - async fn shield_from_asset_lock_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - amount_duffs: u64, - source_address: Option<dash_sdk::dashcore_rpc::dashcore::Address>, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let state_ref = { - let mut states = self.shielded_states.lock()?; - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = crate::backend_task::shielded::bundle::shield_from_asset_lock( - self, - &seed_hash, - &state_ref, - amount_duffs, - source_address.as_ref(), - ) - .await; - - // Always put state back - { - let mut states = self.shielded_states.lock()?; - states.insert(seed_hash, state_ref); - } - - let credits = result?; - Ok(BackendTaskSuccessResult::ShieldedFromAssetLock { - seed_hash, - amount: credits, - }) - } - - /// Check nullifiers to detect spent notes. - pub(crate) async fn check_nullifiers_task( - self: &Arc<Self>, - seed_hash: WalletSeedHash, - ) -> Result<BackendTaskSuccessResult, TaskError> { - let mut state = { - let mut states = self.shielded_states.lock()?; - states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? - }; - - let result = crate::backend_task::shielded::nullifiers::check_nullifiers( - self, - &seed_hash, - &mut state, - self.network, - ) - .await; - - if result.is_ok() { - state.last_nullifiers_synced_at = Some(std::time::Instant::now()); - } - - // Put state back - { - let mut states = self.shielded_states.lock()?; - states.insert(seed_hash, state); - } - - let spent_count = result?; - Ok(BackendTaskSuccessResult::ShieldedNullifiersChecked { - seed_hash, - spent_count, - }) - } - - /// Mark notes as spent in both memory and DB after a successful broadcast. - fn mark_notes_spent( - &self, - seed_hash: &WalletSeedHash, - state: &mut ShieldedWalletState, - spent_nullifiers: &[Nullifier], - ) { - let network_str = self.network.to_string(); - for nf in spent_nullifiers { - let nf_bytes = nf.to_bytes(); - for note in &mut state.notes { - if !note.is_spent && note.nullifier.to_bytes() == nf_bytes { - note.is_spent = true; - let _ = self - .db - .mark_shielded_note_spent(seed_hash, &nf_bytes, &network_str); - } - } - } - state.recalculate_balance(); - } -} diff --git a/src/context/transaction_processing.rs b/src/context/transaction_processing.rs deleted file mode 100644 index 0c315e4ae..000000000 --- a/src/context/transaction_processing.rs +++ /dev/null @@ -1,378 +0,0 @@ -use super::AppContext; -use crate::backend_task::error::TaskError; -use crate::model::wallet::{Wallet, WalletSeedHash}; -use crate::spv::CoreBackendMode; -use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType; -use dash_sdk::dpp::dashcore::{Address, InstantLock, OutPoint, Transaction, TxOut, Txid}; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; -use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, RwLock}; - -impl AppContext { - /// Broadcast a raw transaction via Core RPC or SPV depending on backend mode. - pub(crate) async fn broadcast_raw_transaction( - &self, - tx: &Transaction, - ) -> Result<Txid, TaskError> { - match self.core_backend_mode() { - CoreBackendMode::Rpc => self - .core_client - .read()? - .send_raw_transaction(tx) - .map_err(TaskError::from), - CoreBackendMode::Spv => { - self.spv_manager - .broadcast_transaction(tx) - .await - .map_err(|e| TaskError::SpvBroadcastFailed { detail: e })?; - Ok(tx.txid()) - } - } - } - - /// Wait for an asset lock proof (InstantLock or ChainLock) for the given transaction. - /// - /// Polls `transactions_waiting_for_finality` until a proof appears, with a - /// backend-mode-dependent timeout (SPV: 5 min, RPC: 2 min). Cleans up the - /// tracking entry on both success and timeout. - pub(crate) async fn wait_for_asset_lock_proof( - &self, - tx_id: Txid, - ) -> Result<AssetLockProof, TaskError> { - use tokio::time::Duration; - - let timeout_duration = match self.core_backend_mode() { - CoreBackendMode::Spv => Duration::from_secs(300), - CoreBackendMode::Rpc => Duration::from_secs(120), - }; - - match tokio::time::timeout(timeout_duration, async { - loop { - { - let proofs = self.transactions_waiting_for_finality.lock()?; - if let Some(Some(proof)) = proofs.get(&tx_id) { - return Ok::<_, TaskError>(proof.clone()); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(Ok(proof)) => { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } - Ok(proof) - } - Ok(Err(e)) => { - // Lock poisoned — return immediately instead of spinning - Err(e) - } - Err(_) => { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } - Err(TaskError::ConfirmationTimeout) - } - } - } - - /// Broadcast an asset lock transaction with the store-before-broadcast - /// safety pattern, removing spent UTXOs only after a successful broadcast. - /// - /// Performs the following steps in order: - /// 1. Register the transaction for finality tracking. - /// 2. Store the asset lock in the DB **before** broadcast — prevents an SPV - /// InstantSend race where the finality proof arrives before the DB row. - /// 3. Broadcast the transaction. On failure: clean up the finality tracker - /// and pre-stored DB row; UTXOs are **not** removed. - /// 4. On success: remove spent UTXOs from the wallet and DB. - /// - /// # UTXO selection race window - /// - /// Callers select UTXOs while holding the wallet write lock, then drop it - /// before calling this method (which re-acquires the lock in step 4). During - /// the gap — covering steps 1–3 and the network broadcast — another task - /// could theoretically select the same UTXOs via `select_unspent_utxos_for`, - /// leading to a double-spend attempt on-chain (which Core would reject). - /// - /// We cannot hold the `std::sync::RwLock` write guard across the async - /// broadcast because the guard is `!Send` and tokio tasks require `Send` - /// futures. Fixing this properly requires either migrating to - /// `tokio::sync::RwLock` (large refactor) or adding a UTXO reservation - /// mechanism. In practice the risk is negligible: users would have to - /// trigger two fund operations on the same wallet near-simultaneously. - /// - /// Returns the [`Txid`] of the broadcast transaction. - pub(crate) async fn broadcast_and_commit_asset_lock( - &self, - asset_lock_transaction: &Transaction, - amount: u64, - wallet_seed_hash: &WalletSeedHash, - wallet: &Arc<RwLock<Wallet>>, - used_utxos: &BTreeMap<OutPoint, (TxOut, Address)>, - ) -> Result<Txid, TaskError> { - let tx_id = asset_lock_transaction.txid(); - - // Step 1: Register for finality tracking. - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - // Step 2: Store the asset lock transaction in the database *before* broadcast. - self.db.store_asset_lock_transaction( - asset_lock_transaction, - amount, - None, - wallet_seed_hash, - self.network, - )?; - - // Step 3: Broadcast. On failure, clean up DB row and finality tracker. - if let Err(e) = self.broadcast_raw_transaction(asset_lock_transaction).await { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { - proofs.remove(&tx_id); - } - let _ = self.db.delete_asset_lock_transaction(tx_id.as_byte_array()); - return Err(e); - } - - // Step 4: Broadcast succeeded — commit UTXO removal now. - { - let mut wallet_guard = wallet.write()?; - wallet_guard - .remove_selected_utxos(used_utxos, &self.db, self.network) - .map_err(|e| TaskError::UtxoUpdateFailed { detail: e })?; - } - - Ok(tx_id) - } - - pub(crate) fn received_transaction_finality( - &self, - tx: &Transaction, - islock: Option<InstantLock>, - chain_locked_height: Option<CoreBlockHeight>, - ) -> Result<Vec<(OutPoint, TxOut, Address)>, TaskError> { - // Initialize a vector to collect wallet outpoints - let mut wallet_outpoints = Vec::new(); - - // Identify the wallets associated with the transaction - let wallets = self.wallets.read()?; - for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write()?; - for (vout, tx_out) in tx.output.iter().enumerate() { - let address = if let Ok(output_addr) = - Address::from_script(&tx_out.script_pubkey, self.network) - { - if wallet.known_addresses.contains_key(&output_addr) { - output_addr - } else { - continue; - } - } else { - continue; - }; - self.db.insert_utxo( - tx.txid().as_byte_array(), - vout as u32, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - self.db - .add_to_address_balance(&wallet.seed_hash(), &address, tx_out.value)?; - - // Create the OutPoint and insert it into the wallet.utxos entry - let out_point = OutPoint::new(tx.txid(), vout as u32); - wallet - .utxos - .entry(address.clone()) - .or_insert_with(HashMap::new) // Initialize inner HashMap if needed - .insert(out_point, tx_out.clone()); // Insert the TxOut at the OutPoint - - // Collect the outpoint - wallet_outpoints.push((out_point, tx_out.clone(), address.clone())); - - wallet - .address_balances - .entry(address.clone()) - .and_modify(|balance| *balance += tx_out.value) - .or_insert(tx_out.value); - - // Check if this is a DashPay contact payment - if let Ok(Some((owner_id, contact_id, address_index))) = - self.db.get_dashpay_address_mapping(&address) - { - // Update the highest receive index if needed - if let Ok(indices) = self.db.get_contact_address_indices(&owner_id, &contact_id) - && address_index >= indices.highest_receive_index - { - let _ = self.db.update_highest_receive_index( - &owner_id, - &contact_id, - address_index + 1, - ); - } - - // Save the payment record - let _ = self.db.save_payment( - &tx.txid().to_string(), - &contact_id, // from contact - &owner_id, // to us - tx_out.value as i64, - None, // memo not available for incoming - "received", - ); - - tracing::info!( - "DashPay payment received: {} duffs from contact {} to address {} (index {})", - tx_out.value, - contact_id.to_string( - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 - ), - address, - address_index - ); - } - } - } - if matches!( - tx.special_transaction_payload, - Some(AssetLockPayloadType(_)) - ) { - self.received_asset_lock_finality(tx, islock, chain_locked_height)?; - } - Ok(wallet_outpoints) - } - - /// Store the asset lock transaction in the database and update the wallet. - pub(crate) fn received_asset_lock_finality( - &self, - tx: &Transaction, - islock: Option<InstantLock>, - chain_locked_height: Option<CoreBlockHeight>, - ) -> Result<(), TaskError> { - // Extract the asset lock payload from the transaction - let Some(AssetLockPayloadType(payload)) = tx.special_transaction_payload.as_ref() else { - return Ok(()); - }; - - let proof = if let Some(islock) = islock.as_ref() { - // Deserialize the InstantLock - Some(AssetLockProof::Instant(InstantAssetLockProof::new( - islock.clone(), - tx.clone(), - 0, - ))) - } else { - chain_locked_height.map(|chain_locked_height| { - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: chain_locked_height, - out_point: OutPoint::new(tx.txid(), 0), - }) - }) - }; - - { - let mut transactions = self.transactions_waiting_for_finality.lock()?; - - if let Some(asset_lock_proof) = transactions.get_mut(&tx.txid()) { - *asset_lock_proof = proof.clone(); - } - } - - // Identify the wallet associated with the transaction - let wallets = self.wallets.read()?; - for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write()?; - - // Check if any of the addresses in the transaction outputs match the wallet's known addresses - let matches_wallet = payload.credit_outputs.iter().any(|tx_out| { - if let Ok(output_addr) = Address::from_script(&tx_out.script_pubkey, self.network) { - wallet.known_addresses.contains_key(&output_addr) - } else { - false - } - }); - - if matches_wallet { - // Calculate the total amount from the credit outputs - let amount: u64 = payload - .credit_outputs - .iter() - .map(|tx_out| tx_out.value) - .sum(); - - // Store the asset lock transaction in the database - self.db.store_asset_lock_transaction( - tx, - amount, - islock.as_ref(), - &wallet.seed_hash(), - self.network, - )?; - - let first = payload - .credit_outputs - .first() - .ok_or(TaskError::AssetLockNoCreditOutputs)?; - - let address = Address::from_script(&first.script_pubkey, self.network) - .map_err(|e| TaskError::AssetLockAddressDerivationFailed { source: e })?; - - // Add the asset lock to the wallet's unused_asset_locks - wallet - .unused_asset_locks - .push((tx.clone(), address, amount, islock, proof)); - - break; // Exit the loop after updating the relevant wallet - } - } - - Ok(()) - } -} - -pub(crate) struct DapiTransactionInfo { - pub is_chain_locked: bool, - pub height: u32, - pub confirmations: u32, -} - -/// Query transaction info from DAPI. Works in both SPV and RPC modes -/// since DAPI (platform gRPC) is always available via the SDK. -pub(crate) async fn get_transaction_info( - sdk: &Sdk, - tx_id: &Txid, -) -> Result<DapiTransactionInfo, TaskError> { - use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; - use dash_sdk::dapi_grpc::core::v0::GetTransactionRequest; - - let response = sdk - .execute( - GetTransactionRequest { - id: tx_id.to_string(), - }, - RequestSettings::default(), - ) - .await - .into_inner() - .map_err(|e| TaskError::PlatformFetchError { - source: Box::new(dash_sdk::Error::DapiClientError(e)), - })?; - - Ok(DapiTransactionInfo { - is_chain_locked: response.is_chain_locked, - height: response.height, - confirmations: response.confirmations, - }) -} diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs deleted file mode 100644 index a90345d93..000000000 --- a/src/context/wallet_lifecycle.rs +++ /dev/null @@ -1,1005 +0,0 @@ -use super::AppContext; -use super::get_transaction_info; -use crate::backend_task::error::TaskError; -use crate::database::is_unique_constraint_violation; -use crate::model::feature_gate::FeatureGate; -use crate::model::wallet::{ - AddressInfo as WalletAddressInfo, DerivationPathHelpers, DerivationPathReference, - DerivationPathType, TransactionStatus, Wallet, WalletSeedHash, WalletTransaction, -}; -use crate::spv::{AssetLockFinalityEvent, CoreBackendMode, SpvManager}; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{Address, Network}; -use dash_sdk::dpp::key_wallet::Network as WalletNetwork; -use dash_sdk::dpp::key_wallet::account::AccountType; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ - ManagedWalletInfo, wallet_info_interface::WalletInfoInterface, -}; -use std::sync::atomic::Ordering; -use std::sync::{Arc, RwLock}; - -impl AppContext { - pub fn spv_manager(&self) -> &Arc<SpvManager> { - &self.spv_manager - } - - pub fn clear_spv_data(&self) -> Result<(), TaskError> { - self.spv_manager - .clear_data_dir() - .map_err(|e| TaskError::SpvClearDataFailed { detail: e }) - } - - pub fn clear_network_database(&self) -> Result<(), TaskError> { - self.db.clear_network_data(self.network)?; - - if let Ok(mut wallets) = self.wallets.write() { - wallets.clear(); - } - - if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { - single_key_wallets.clear(); - } - - self.has_wallet.store(false, Ordering::Relaxed); - - Ok(()) - } - - pub fn start_spv(self: &Arc<Self>) -> Result<(), TaskError> { - // Skip if SPV is already active — avoids orphaned listener tasks from - // re-registering channels while existing handlers still hold old senders. - if self.spv_manager.status().status.is_active() { - return Ok(()); - } - - // Count wallets that will be loaded into SPV (open wallets with accessible seeds). - // This is read synchronously so the SPV thread can wait for exactly this many. - let expected_wallets = self - .wallets - .read() - .map(|guard| { - guard - .values() - .filter(|w| { - w.read() - .ok() - .is_some_and(|g| g.is_open() && g.seed_bytes().is_ok()) - }) - .count() - }) - .unwrap_or(0); - // Register reconcile channel BEFORE starting SPV so the event handlers - // (spawned inside run_spv_loop) always capture a valid sender. - self.spv_setup_reconcile_listener(); - self.spv_setup_finality_listener(); - self.spv_manager - .start(expected_wallets) - .map_err(|e| TaskError::SpvStartFailed { detail: e })?; - // Immediately reflect the new SPV status in ConnectionStatus so the - // UI sees the change on the next frame instead of waiting for the - // next throttled trigger_refresh() cycle (2-10 seconds). - self.connection_status - .set_spv_status(self.spv_manager.status().status); - self.connection_status.refresh_state(); - Ok(()) - } - - /// Persist a wallet to the database, register it in the in-memory map, - /// save its known addresses, and load it into SPV if applicable. - /// - /// This is the single entry point for adding a wallet to the system. - /// UI screens should call this after constructing a [`Wallet`] via - /// [`Wallet::new_from_seed()`]. - pub fn register_wallet( - self: &Arc<Self>, - wallet: Wallet, - ) -> Result<(WalletSeedHash, Arc<RwLock<Wallet>>), TaskError> { - // 1. Persist wallet and known addresses atomically - let addresses: Vec<_> = wallet - .known_addresses - .iter() - .map(|(address, path)| { - ( - address, - path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - ) - }) - .collect(); - - self.db - .store_wallet_with_addresses(&wallet, &self.network, &addresses) - .map_err(|e| { - if is_unique_constraint_violation(&e) { - TaskError::WalletAlreadyImported - } else { - TaskError::Database { source: e } - } - })?; - - let seed_hash = wallet.seed_hash(); - - // 2. Register in-memory - let wallet_arc = Arc::new(RwLock::new(wallet)); - let mut wallets = self.wallets.write()?; - wallets.insert(seed_hash, wallet_arc.clone()); - self.has_wallet.store(true, Ordering::Relaxed); - drop(wallets); - - // 3. Bootstrap any additional addresses and load into SPV - self.bootstrap_wallet_addresses(&wallet_arc); - if self.core_backend_mode() == CoreBackendMode::Spv { - self.handle_wallet_unlocked(&wallet_arc); - } - - Ok((seed_hash, wallet_arc)) - } - - pub fn bootstrap_wallet_addresses(&self, wallet: &Arc<RwLock<Wallet>>) { - if let Ok(mut guard) = wallet.write() { - // Bootstrap when no addresses exist (fresh wallet) or when - // platform payment addresses haven't been derived yet (wallet - // created with only a Core address via new_from_seed). - // INTENTIONAL(CODE-006): Bootstrap checks only PlatformPayment address type. - // Other platform address types may trigger redundant re-derivation, but - // bootstrap_known_addresses() is idempotent so this is safe. - let has_platform_addresses = guard.watched_addresses.values().any(|info| { - info.path_reference - == crate::model::wallet::DerivationPathReference::PlatformPayment - }); - if guard.known_addresses.is_empty() || !has_platform_addresses { - tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); - guard.bootstrap_known_addresses(self); - } - } - } - - pub fn handle_wallet_unlocked(self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>) { - if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { - self.queue_spv_wallet_load(seed_hash, seed_bytes); - // Note: Platform address sync is not done here. - // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. - - // Initialize shielded wallet state only when the network supports it - // (all shielded state transitions present). On mainnet (which doesn't - // support shielded transactions yet), skip entirely to avoid - // unnecessary sync attempts and log noise. - if FeatureGate::Shielded.is_available(self) { - match self.initialize_shielded_wallet(seed_hash) { - Ok(_) => { - tracing::trace!( - seed = %hex::encode(seed_hash), - "Shielded wallet state initialized on unlock" - ); - self.queue_shielded_sync(seed_hash); - } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded wallet init skipped on unlock" - ), - } - } - } - } - - pub fn handle_wallet_locked(self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>) { - let seed_hash = match wallet.read() { - Ok(guard) => guard.seed_hash(), - Err(err) => { - tracing::warn!(error = %err, "Unable to read wallet during lock handling"); - return; - } - }; - self.queue_spv_wallet_unload(seed_hash); - } - - /// Initialize shielded state for unlocked wallets that were skipped - /// because the protocol version wasn't known at unlock time. - /// Called when the protocol version first crosses the shielded threshold. - pub(crate) fn init_missing_shielded_wallets(self: &Arc<Self>) { - // Collect candidate seed hashes while holding locks, then release - // before calling initialize_shielded_wallet (which re-acquires both). - let candidates: Vec<WalletSeedHash> = (|| { - let wallets = self.wallets.read().ok()?; - let existing = self.shielded_states.lock().ok()?; - Some( - wallets - .iter() - .filter(|(hash, wallet_arc)| { - !existing.contains_key(*hash) - && wallet_arc.read().ok().map(|w| w.is_open()).unwrap_or(false) - }) - .map(|(hash, _)| *hash) - .collect(), - ) - })() - .unwrap_or_default(); - - for seed_hash in candidates { - match self.initialize_shielded_wallet(seed_hash) { - Ok(_) => { - tracing::info!( - seed = %hex::encode(seed_hash), - "Shielded wallet initialized after protocol version update" - ); - self.queue_shielded_sync(seed_hash); - } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded wallet init failed after protocol version update" - ), - } - } - } - - /// Queue async SyncNotes -> CheckNullifiers for an already-initialized - /// shielded wallet. Tracked via `subtasks` so it participates in graceful - /// shutdown and cancellation. - /// - /// Uses `spawn_blocking(block_on(...))` because the async methods on - /// `Arc<Self>` produce futures that borrow `self`, which the compiler - /// cannot prove are `'static` (rust-lang/rust#100013). The trampoline - /// resolves the futures synchronously on a blocking thread, satisfying - /// the `'static` bound required by `spawn_sync`. - fn queue_shielded_sync(self: &Arc<Self>, seed_hash: WalletSeedHash) { - let ctx = Arc::clone(self); - self.subtasks.spawn_sync("shielded_sync", async move { - let handle = tokio::runtime::Handle::current(); - let result = tokio::task::spawn_blocking(move || { - handle.block_on(async { - match ctx.sync_shielded_notes(seed_hash).await { - Ok(_) => { - if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { - tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded nullifier check after init failed" - ); - } - } - Err(e) => tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded note sync after init failed" - ), - } - }) - }) - .await; - if let Err(e) = result { - tracing::debug!( - seed = %hex::encode(seed_hash), - error = %e, - "Shielded sync task panicked" - ); - } - }); - } - - fn wallet_seed_snapshot(wallet: &Arc<RwLock<Wallet>>) -> Option<(WalletSeedHash, [u8; 64])> { - let guard = wallet.read().ok()?; - if !guard.is_open() { - return None; - } - let seed_bytes = match guard.seed_bytes() { - Ok(bytes) => *bytes, - Err(err) => { - tracing::warn!(error = %err, wallet = %hex::encode(guard.seed_hash()), "Unable to snapshot wallet seed for SPV load"); - return None; - } - }; - Some((guard.seed_hash(), seed_bytes)) - } - - fn queue_spv_wallet_load(self: &Arc<Self>, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync("spv_wallet_load", async move { - if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed"); - } - }); - } - - fn queue_spv_wallet_unload(self: &Arc<Self>, seed_hash: WalletSeedHash) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync("spv_wallet_unload", async move { - if let Err(error) = spv.unload_wallet(seed_hash).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to unload SPV wallet"); - } - }); - } - - /// Queue automatic discovery of identities derived from a wallet. - /// Checks identity indices 0 through max_identity_index for existing identities on the network. - pub fn queue_wallet_identity_discovery( - self: &Arc<Self>, - wallet: &Arc<RwLock<Wallet>>, - max_identity_index: u32, - ) { - let ctx = Arc::clone(self); - let wallet_clone = Arc::clone(wallet); - self.subtasks - .spawn_sync("wallet_identity_discovery", async move { - if let Err(error) = ctx - .discover_identities_from_wallet(&wallet_clone, max_identity_index) - .await - { - tracing::warn!( - %error, - "Failed to discover identities from wallet" - ); - } - }); - } - - pub fn bootstrap_loaded_wallets(self: &Arc<Self>) { - let wallets: Vec<_> = { - let guard = self.wallets.read().unwrap(); - guard.values().cloned().collect() - }; - - for wallet in wallets.iter() { - self.bootstrap_wallet_addresses(wallet); - self.handle_wallet_unlocked(wallet); - } - - // Auto-refresh UTXOs from Core on startup so balances are current - // without requiring the user to manually click Refresh (fixes GH#522). - // Only in RPC mode — SPV mode handles UTXO loading via reconciliation. - if self.core_backend_mode() == CoreBackendMode::Rpc { - for wallet in wallets { - let ctx = Arc::clone(self); - self.subtasks - .spawn_sync("refresh_wallet_utxos", async move { - let result = - tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) - .await; - match result { - Err(e) => tracing::warn!( - "Failed to auto-refresh wallet UTXOs on startup: {}", - e - ), - Ok(Err(e)) => tracing::warn!( - "Failed to auto-refresh wallet UTXOs on startup: {}", - e - ), - Ok(Ok(_)) => {} - } - }); - } - - let single_key_wallets: Vec<_> = { - let guard = self.single_key_wallets.read().unwrap(); - guard.values().cloned().collect() - }; - for wallet in single_key_wallets { - let ctx = Arc::clone(self); - self.subtasks - .spawn_sync("refresh_single_key_wallet_utxos", async move { - let result = tokio::task::spawn_blocking(move || { - ctx.refresh_single_key_wallet_info(wallet) - }) - .await; - match result { - Err(e) => tracing::warn!( - "Failed to auto-refresh single key wallet UTXOs on startup: {}", - e - ), - Ok(Err(e)) => tracing::warn!( - "Failed to auto-refresh single key wallet UTXOs on startup: {}", - e - ), - Ok(Ok(())) => {} - } - }); - } - } - } - - /// Update wallet platform address info from SDK-returned AddressInfos. - /// This uses the proof-verified data from SDK operations rather than fetching. - pub(crate) fn update_wallet_platform_address_info_from_sdk( - &self, - seed_hash: WalletSeedHash, - address_infos: &dash_sdk::query_types::AddressInfos, - ) -> Result<(), TaskError> { - let wallet_arc = { - let wallets = self.wallets.read()?; - wallets - .get(&seed_hash) - .cloned() - .ok_or(TaskError::WalletNotFound)? - }; - - let mut wallet = wallet_arc.write()?; - - for (platform_addr, maybe_info) in address_infos.iter() { - if let Some(info) = maybe_info { - // Convert PlatformAddress to core Address using the network - let core_addr = platform_addr.to_address_with_network(self.network); - - // Update in-memory wallet state - wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - - // Update database - if let Err(e) = self.db.set_platform_address_info( - &seed_hash, - &core_addr, - info.balance, - info.nonce, - &self.network, - ) { - tracing::warn!("Failed to store Platform address info in database: {}", e); - } - - tracing::debug!( - "Updated platform address {} balance={} nonce={} from SDK response", - core_addr, - info.balance, - info.nonce - ); - } - } - - Ok(()) - } - - pub(crate) fn register_spv_address( - &self, - wallet: &Arc<RwLock<Wallet>>, - address: Address, - derivation_path: DerivationPath, - path_type: DerivationPathType, - path_reference: DerivationPathReference, - ) -> Result<bool, TaskError> { - let mut guard = wallet.write()?; - if guard.known_addresses.contains_key(&address) { - return Ok(false); - } - - let (path_reference, path_type) = - self.classify_derivation_metadata(&derivation_path, path_reference, path_type); - - let seed_hash = guard.seed_hash(); - - self.db.add_address_if_not_exists( - &seed_hash, - &address, - &self.network, - &derivation_path, - path_reference, - path_type, - None, - )?; - - guard - .known_addresses - .insert(address.clone(), derivation_path.clone()); - guard.watched_addresses.insert( - derivation_path, - WalletAddressInfo { - address, - path_type, - path_reference, - }, - ); - - Ok(true) - } - - pub(crate) fn wallet_network_key(&self) -> WalletNetwork { - match self.network { - Network::Mainnet => WalletNetwork::Mainnet, - Network::Testnet => WalletNetwork::Testnet, - Network::Devnet => WalletNetwork::Devnet, - Network::Regtest => WalletNetwork::Regtest, - } - } - - fn sync_spv_account_addresses( - &self, - wallet_info: &ManagedWalletInfo, - wallet_arc: &Arc<RwLock<Wallet>>, - ) { - let collection = wallet_info.accounts(); - - let mut inserted = 0u32; - for account in collection.all_accounts() { - let account_type = account.account_type.to_account_type(); - let Some((path_reference, path_type)) = Self::spv_account_metadata(&account_type) - else { - continue; - }; - - for address in account.account_type.all_addresses() { - if let Some(info) = account.get_address_info(&address) - && let Ok(true) = self.register_spv_address( - wallet_arc, - address.clone(), - info.path.clone(), - path_type, - path_reference, - ) - { - inserted += 1; - } - } - } - - if inserted > 0 { - tracing::debug!(added = inserted, "Registered SPV-managed addresses"); - } - } - - fn spv_account_metadata( - account_type: &AccountType, - ) -> Option<(DerivationPathReference, DerivationPathType)> { - match account_type { - AccountType::IdentityRegistration => Some(( - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, - DerivationPathType::CREDIT_FUNDING, - )), - AccountType::IdentityInvitation => Some(( - DerivationPathReference::BlockchainIdentityCreditInvitationFunding, - DerivationPathType::CREDIT_FUNDING, - )), - AccountType::IdentityTopUp { .. } | AccountType::IdentityTopUpNotBoundToIdentity => { - Some(( - DerivationPathReference::BlockchainIdentityCreditTopupFunding, - DerivationPathType::CREDIT_FUNDING, - )) - } - AccountType::Standard { .. } => Some(( - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - )), - AccountType::ProviderVotingKeys => Some(( - DerivationPathReference::ProviderVotingKeys, - DerivationPathType::CLEAR_FUNDS, - )), - AccountType::ProviderOwnerKeys => Some(( - DerivationPathReference::ProviderOwnerKeys, - DerivationPathType::CLEAR_FUNDS, - )), - AccountType::ProviderOperatorKeys => Some(( - DerivationPathReference::ProviderOperatorKeys, - DerivationPathType::CLEAR_FUNDS, - )), - AccountType::ProviderPlatformKeys => Some(( - DerivationPathReference::ProviderPlatformNodeKeys, - DerivationPathType::CLEAR_FUNDS, - )), - // BlockchainIdentities addresses are bootstrapped by DET directly - // (not via SDK WalletManager accounts) and registered with SPV - // through register_spv_address() during wallet bootstrap. Other - // account types (CoinJoin, DashPay, PlatformPayment, AssetLock*) - // are either not yet supported or operate off-chain. - _ => None, - } - } - - fn classify_derivation_metadata( - &self, - derivation_path: &DerivationPath, - default_ref: DerivationPathReference, - default_type: DerivationPathType, - ) -> (DerivationPathReference, DerivationPathType) { - let components = derivation_path.as_ref(); - if components.len() >= 5 - && matches!(components[0], ChildNumber::Hardened { index: 9 }) - && matches!(components[2], ChildNumber::Hardened { index: 5 }) - && matches!(components[3], ChildNumber::Hardened { .. }) - { - let hardened_leaf = matches!(components.last(), Some(ChildNumber::Hardened { .. })); - if !hardened_leaf { - return ( - DerivationPathReference::BlockchainIdentities, - DerivationPathType::SINGLE_USER_AUTHENTICATION, - ); - } - } - - if derivation_path.is_bip32() { - return (DerivationPathReference::BIP32, default_type); - } - - if derivation_path.is_bip44(self.network) { - return (DerivationPathReference::BIP44, default_type); - } - - (default_ref, default_type) - } - - /// Listen for SPV instant lock / chain lock events and populate - /// transactions_waiting_for_finality so identity registration can proceed. - pub fn spv_setup_finality_listener(self: &Arc<Self>) { - let rx = self.spv_manager.register_finality_channel(); - let ctx = Arc::clone(self); - let cancel = self.subtasks.cancellation_token.clone(); - self.subtasks - .spawn_sync("spv_finality_listener", async move { - tokio::pin!(rx); - loop { - tokio::select! { - _ = cancel.cancelled() => break, - maybe = rx.recv() => { - let Some(event) = maybe else { break; }; - // Wrap handler in select so cancellation can interrupt - // even when blocked on locks held by the SPV sync thread. - tokio::select! { - _ = cancel.cancelled() => break, - result = ctx.handle_spv_finality_event(event) => { - if let Err(e) = result { - tracing::debug!("SPV finality event error: {}", e); - } - } - } - } - } - } - }); - } - - async fn handle_spv_finality_event( - &self, - event: AssetLockFinalityEvent, - ) -> Result<(), TaskError> { - match event { - AssetLockFinalityEvent::InstantLock { txid, instant_lock } => { - // Check if this txid is pending in transactions_waiting_for_finality - let is_pending = { - let transactions = self.transactions_waiting_for_finality.lock()?; - matches!(transactions.get(&txid), Some(None)) - }; - if !is_pending { - return Ok(()); - } - - // Retrieve the full transaction from the database - let (tx, ..) = self - .db - .get_asset_lock_transaction(txid.as_byte_array())? - .ok_or(TaskError::AssetLockTransactionNotFoundInDatabase)?; - - self.received_asset_lock_finality(&tx, Some(*instant_lock), None)?; - } - AssetLockFinalityEvent::ChainLock { - height: _height, .. - } => { - // Get all pending txids (where proof is None) - let pending_txids: Vec<dash_sdk::dpp::dashcore::Txid> = { - let transactions = self.transactions_waiting_for_finality.lock()?; - transactions - .iter() - .filter_map( - |(txid, proof)| if proof.is_none() { Some(*txid) } else { None }, - ) - .collect() - }; - if pending_txids.is_empty() { - return Ok(()); - } - - let sdk = self.sdk.load().as_ref().clone(); - - for txid in pending_txids { - match get_transaction_info(&sdk, &txid).await { - Ok(tx_info) if tx_info.is_chain_locked && tx_info.height > 0 => { - if let Ok(Some((tx, ..))) = - self.db.get_asset_lock_transaction(txid.as_byte_array()) - { - let _ = self.received_asset_lock_finality( - &tx, - None, - Some(tx_info.height), - ); - } - } - _ => { - // Transaction not yet chain-locked at this height, or DAPI - // lookup failed — will retry on next chain lock event. - } - } - } - } - } - Ok(()) - } - - /// Subscribe to SPV reconcile signals and debounce updates. - pub fn spv_setup_reconcile_listener(self: &Arc<Self>) { - use tokio::time::{Duration, Instant, sleep}; - let rx = self.spv_manager.register_reconcile_channel(); - let ctx = Arc::clone(self); - let cancel = self.subtasks.cancellation_token.clone(); - self.subtasks.spawn_sync("spv_reconcile_listener", async move { - tokio::pin!(rx); - let mut last = Instant::now(); - loop { - tokio::select! { - _ = cancel.cancelled() => break, - maybe = rx.recv() => { - if maybe.is_none() { break; } - // simple debounce window - if last.elapsed() > Duration::from_millis(300) { - // Wrap in select so cancellation can interrupt when - // blocked on locks held by the SPV sync thread. - tokio::select! { - _ = cancel.cancelled() => break, - result = ctx.reconcile_spv_wallets() => { - if let Err(e) = result { tracing::debug!("SPV reconcile error: {}", e); } - } - } - last = Instant::now(); - } else { - tokio::select! { - _ = cancel.cancelled() => break, - _ = sleep(Duration::from_millis(300)) => {} - } - tokio::select! { - _ = cancel.cancelled() => break, - result = ctx.reconcile_spv_wallets() => { - if let Err(e) = result { tracing::debug!("SPV reconcile error: {}", e); } - } - } - last = Instant::now(); - } - } - } - } - }); - } - - /// Reconcile SPV wallet state into DET. - pub async fn reconcile_spv_wallets(&self) -> Result<(), TaskError> { - let wm_arc = self.spv_manager.wallet(); - let wm: tokio::sync::RwLockReadGuard<'_, dash_sdk::dpp::key_wallet_manager::WalletManager> = - wm_arc.read().await; - let mapping = self.spv_manager.det_wallets_snapshot(); - - // Take a snapshot of known addresses per wallet so we can scope DB updates - let wallets_guard = self.wallets.read()?; - - for (seed_hash, wallet_id) in mapping.iter() { - // Log total balance for visibility - let balance = wm - .get_wallet_balance(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); - - let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { - continue; - }; - - let Some(wallet_arc) = wallets_guard.get(seed_hash).cloned() else { - continue; - }; - - self.sync_spv_account_addresses(wallet_info, &wallet_arc); - - if let Ok(mut wallet) = wallet_arc.write() { - wallet.update_spv_balances( - balance.spendable(), - balance.unconfirmed(), - balance.total(), - ); - // Persist balances to database - if let Err(e) = self.db.update_wallet_balances( - seed_hash, - balance.spendable(), - balance.unconfirmed(), - balance.total(), - ) { - tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); - } - } - - // Get the wallet's known addresses (only update those to avoid cross-wallet churn) - let mut known_addresses: std::collections::BTreeSet<Address> = { - let w = wallet_arc.read()?; - w.known_addresses.keys().cloned().collect() - }; - - // Clear existing UTXOs for these addresses in this network - for addr in &known_addresses { - let _ = self.db.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - rusqlite::params![addr.to_string(), self.network.to_string()], - ); - } - - // Read current UTXOs from SPV and re-insert, registering unknown addresses if derivation metadata is available - let utxos = wm - .wallet_utxos(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - - let mut per_address_sum: std::collections::BTreeMap<Address, u64> = Default::default(); - // Build in-memory UTXO map to update wallet model - let mut new_utxos: std::collections::HashMap< - Address, - std::collections::HashMap< - dash_sdk::dpp::dashcore::OutPoint, - dash_sdk::dpp::dashcore::TxOut, - >, - > = Default::default(); - - for u in utxos { - let outpoint = u.outpoint; - let tx_out = u.txout.clone(); - - // Derive address from script - let address = match Address::from_script(&tx_out.script_pubkey, self.network) { - Ok(a) => a, - Err(_) => continue, - }; - - // Always track the UTXO in the in-memory map for correct balance calculation - new_utxos - .entry(address.clone()) - .or_default() - .insert(outpoint, tx_out.clone()); - - // Always count the UTXO value in per-address sum - *per_address_sum.entry(address.clone()).or_default() += tx_out.value; - - // If address unknown to DET, try to register using SPV metadata - if !known_addresses.contains(&address) { - let collection = wallet_info.accounts(); - let mut registered = false; - for acc in collection.all_accounts() { - if let Some(ai) = acc.get_address_info(&address) { - let account_type = acc.account_type.to_account_type(); - let (path_reference, path_type) = - Self::spv_account_metadata(&account_type).unwrap_or_else(|| { - let default_ref = if ai.path.is_bip44(self.network) { - DerivationPathReference::BIP44 - } else if ai.path.is_bip32() { - DerivationPathReference::BIP32 - } else { - tracing::warn!( - path = %ai.path, - "SPV address has unrecognized derivation path structure" - ); - DerivationPathReference::Unknown - }; - (default_ref, DerivationPathType::CLEAR_FUNDS) - }); - - if let Ok(inserted) = self.register_spv_address( - &wallet_arc, - address.clone(), - ai.path.clone(), - path_type, - path_reference, - ) { - if inserted { - known_addresses.insert(address.clone()); - } - registered = true; - } - break; - } - } - if !registered { - tracing::debug!( - wallet = %hex::encode(seed_hash), - address = %address, - value = tx_out.value, - "SPV UTXO address not registered in DET (counted in balance but not in address table)" - ); - // Still persist the UTXO to DB and delete stale entry first - let _ = self.db.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - rusqlite::params![address.to_string(), self.network.to_string()], - ); - } - } - - // Insert UTXO row into DB - self.db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - } - - // Write per-address balances and UTXOs into wallet model - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut w) = wref.write() - { - // Update in-memory UTXOs map - w.utxos = new_utxos; - - // Zero out balances for known addresses that no longer have any UTXOs. - // Without this, spent addresses retain stale non-zero balances because - // per_address_sum only contains addresses with current UTXOs. - for addr in &known_addresses { - if !w.utxos.contains_key(addr) - && let Err(e) = w.update_address_balance(addr, 0, self) - { - tracing::debug!(address = %addr, error = %e, "Failed to zero spent address balance"); - } - } - - for (addr, sum) in per_address_sum.into_iter() { - if let Err(e) = w.update_address_balance(&addr, sum, self) { - tracing::debug!(address = %addr, error = %e, "Failed to update address balance"); - } - } - } - - let history = wm - .wallet_transaction_history(wallet_id) - .map_err(|e| crate::spv::SpvError::WalletError(e.to_string()))?; - let wallet_transactions: Vec<WalletTransaction> = history - .into_iter() - .map(|record| { - let height = record.height(); - let block_info = record.block_info(); - let status = TransactionStatus::from_height(height); - WalletTransaction { - txid: record.txid, - transaction: record.transaction.clone(), - timestamp: block_info.map(|bi| bi.timestamp() as u64).unwrap_or(0), - height, - block_hash: block_info.map(|bi| bi.block_hash()), - net_amount: record.net_amount, - fee: record.fee, - label: Some(record.label.clone()).filter(|s| !s.is_empty()), - // SPV transaction history is per-wallet — all entries - // involve our addresses, so is_ours is always true. - is_ours: true, - status, - } - }) - .collect(); - - tracing::info!( - wallet = %hex::encode(seed_hash), - spv_transactions = wallet_transactions.len(), - spv_spendable = balance.spendable(), - spv_total = balance.total(), - "SPV reconcile summary" - ); - - // Only replace transactions if SPV returned some, to avoid wiping - // previously persisted history when SPV hasn't populated history yet. - if !wallet_transactions.is_empty() { - self.db.replace_wallet_transactions( - seed_hash, - &self.network, - &wallet_transactions, - )?; - } - - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut wallet) = wref.write() - && !wallet_transactions.is_empty() - { - wallet.set_transactions(wallet_transactions); - } - } - - Ok(()) - } - - pub fn stop_spv(&self) { - self.spv_manager.stop(); - // Immediately reflect the new SPV status in ConnectionStatus so the - // UI sees the change on the next frame instead of waiting for the - // next throttled trigger_refresh() cycle (2-10 seconds). - self.connection_status - .set_spv_status(self.spv_manager.status().status); - self.connection_status.refresh_state(); - // Reset the throttle timer so trigger_refresh() starts polling - // at 200ms intervals and picks up the Stopped transition quickly. - self.connection_status.reset_timer(); - } -} diff --git a/src/context/wallet_lifecycle/bootstrap.rs b/src/context/wallet_lifecycle/bootstrap.rs new file mode 100644 index 000000000..4c5300747 --- /dev/null +++ b/src/context/wallet_lifecycle/bootstrap.rs @@ -0,0 +1,519 @@ +//! Address bootstrap and post-unlock warmup: deriving addresses, reconciling +//! managed identities, warming auth-key caches, and queuing identity discovery. + +use super::*; + +impl AppContext { + /// Whether `wallet` still needs its bootstrap address set derived. + /// + /// `true` for a fresh wallet (no known addresses) or one created with only + /// a Core address (no Platform-payment addresses yet). Idempotent: a + /// fully-bootstrapped wallet returns `false`. + fn wallet_needs_bootstrap(guard: &Wallet) -> bool { + // INTENTIONAL: Bootstrap checks only PlatformPayment address + // type. Other platform address types may trigger redundant + // re-derivation, but `bootstrap_known_addresses` is idempotent so this + // is safe. + let has_platform_addresses = guard.watched_addresses.values().any(|info| { + info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment + }); + guard.known_addresses.is_empty() || !has_platform_addresses + } + + /// Bootstrap a wallet's address set from a borrowed HD seed. + /// + /// The sync bridge used by the **fresh-register** path only + /// ([`Self::register_wallet`]): a just-created or just-imported wallet's + /// seed is in the caller's hand from construction, so it is passed in by + /// borrow rather than read from any parked field — an open `Wallet` parks + /// no seed (R3). The borrow is fanned down into the seed-as-parameter + /// [`Wallet::bootstrap_known_addresses`]; no `bootstrap_*` child reaches + /// back into the wallet for a seed. A locked wallet is skipped and + /// bootstraps later via [`Self::bootstrap_wallet_addresses_jit`] once its + /// seed is resolvable through the chokepoint. + pub fn bootstrap_wallet_addresses(&self, wallet: &Arc<RwLock<Wallet>>, seed: &[u8; 64]) { + if let Ok(mut guard) = wallet.write() { + if !guard.is_open() { + tracing::debug!("Skipping address bootstrap for locked wallet"); + return; + } + if Self::wallet_needs_bootstrap(&guard) { + tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); + guard.bootstrap_known_addresses(seed, self); + } + } + } + + /// Bootstrap a wallet's address set by resolving its HD seed just-in-time + /// through the [`SecretAccess`](crate::wallet_backend::SecretAccess) + /// chokepoint, holding one `with_secret_session` for the whole bootstrap + /// run. + /// + /// The async sibling of [`Self::bootstrap_wallet_addresses`] for the + /// cold-boot path. To preserve the prompt-free startup contract it operates + /// only on wallets whose seed already resolves without asking the user — an + /// unprotected wallet (resolved via the chokepoint's no-passphrase + /// fast-path) or a protected one whose seed the user already promoted to the + /// session cache on unlock. A still-locked protected wallet is left for its + /// unlock gesture to bootstrap, exactly as before; this method never forces + /// a passphrase prompt at startup. + /// + /// This is also the W2 cold-boot reconciliation point: inside + /// the same prompt-free seed scope it registers any wallet present in DET + /// sidecars but absent from the upstream SPV persistor (migrated installs, + /// wallets created before the fix, post-reset), so received funds become + /// visible without a launch-time password prompt. Registration is + /// independent of address bootstrap: an already-bootstrapped wallet that + /// was never registered upstream still gets registered here. + pub async fn bootstrap_wallet_addresses_jit(&self, wallet: &Arc<RwLock<Wallet>>) { + let Ok(backend) = self.wallet_backend() else { + return; + }; + let seed_hash = { + let Ok(guard) = wallet.read() else { + return; + }; + // Gate on the open seed being resolvable prompt-free: an open + // wallet at cold boot is either unprotected (no-prompt fast-path) or + // already session-cached via the unlock gesture. A locked protected + // wallet is skipped to avoid a surprise startup prompt. + if !guard.is_open() { + return; + } + guard.seed_hash() + }; + + // An open wallet always enters the seed scope: shielded key binding runs + // on every cold boot and `ensure_shielded_bound` is idempotent (upstream + // does an in-memory check and returns immediately when already bound), so + // there is no cheap pre-check that would let us skip the scope. Address + // bootstrap and upstream registration are re-checked inside the scope, so + // entering with nothing to do is harmless. The upstream 60 s + // ShieldedSyncManager loop picks up any newly bound wallets automatically. + let wallet = Arc::clone(wallet); + let result = backend + .secret_access() + .with_secret_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + async |session| { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletLocked)?; + if let Ok(mut guard) = wallet.write() { + // Re-check under the write lock: a concurrent bootstrap + // may have run between the read above and here. + if Self::wallet_needs_bootstrap(&guard) { + tracing::info!(wallet = %hex::encode(seed_hash), "Bootstrapping wallet addresses (JIT seed)"); + guard.bootstrap_known_addresses(seed, self); + } + } + // W2 cold-boot reconciliation: register with the upstream + // SPV backend if this wallet is not yet known to it, using + // the seed already open in this scope. Idempotent and + // genesis-floored so pre-existing deposits are found. + // Best-effort — a failure is retried on the + // next boot. + if let Err(error) = backend.ensure_upstream_registered(&seed_hash, seed).await { + tracing::warn!( + wallet = %hex::encode(seed_hash), + %error, + "W2 upstream registration failed; will retry at next cold boot" + ); + } + // Identity reconcile: register every DET-known wallet-owned + // identity into the upstream manager so identity ops (top-up) + // can find them. Seed-free, idempotent; runs after the wallet + // is upstream-registered. Best-effort — retried next boot/unlock. + self.reconcile_managed_identities(&backend, &seed_hash).await; + // Lazily bind Orchard ZIP-32 keys for this wallet. + // Best-effort — a failure only defers the first shielded op prompt. + // The upstream ShieldedSyncManager 60s loop picks up any newly + // bound wallets automatically; no manual sync trigger needed. + if let Err(error) = + backend.ensure_shielded_bound(&seed_hash, seed).await + { + tracing::debug!( + wallet = %hex::encode(seed_hash), + %error, + "Shielded bind deferred; will retry on next unlock" + ); + } + // Register every established contact's DIP-15 receiving + // account so SPV watches the addresses each contact pays us + // at. Seed-bearing (the receiving path is hardened) and + // reachable only here where the seed is already open, so a + // locked wallet is skipped, never prompted. Best-effort; + // re-runs every boot/unlock because upstream keeps contact + // accounts in runtime state only. + self.register_established_contact_accounts(&backend, &seed_hash, seed) + .await; + // D4b lazy warm: populate the identity-auth public-key + // cache for the identities this wallet already knows, in + // the same prompt-free seed scope, so the steady-state + // identity-auth reads are seed-free. Best-effort — a warm + // failure only forgoes the optimisation. + if let Ok(guard) = wallet.read() { + self.warm_auth_pubkey_cache(&backend, &guard, seed, seed_hash); + } + Ok(()) + }, + ) + .await; + if let Err(e) = result { + tracing::debug!( + wallet = %hex::encode(seed_hash), + error = %e, + "JIT address bootstrap skipped" + ); + } + } + + /// 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`. + /// + /// Best-effort, idempotent, and **seed-free** — a per-identity failure is + /// logged and never aborts the rest. Called inside + /// [`Self::bootstrap_wallet_addresses_jit`]'s seed scope after upstream + /// registration (the seam reached from both cold boot and unlock); the seed + /// is not used, so it is also safe while the wallet is locked. + pub(super) async fn reconcile_managed_identities( + &self, + backend: &WalletBackend, + seed_hash: &WalletSeedHash, + ) { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identities = match self.load_local_qualified_identities_for_wallet(seed_hash) { + Ok(identities) => identities, + Err(error) => { + tracing::warn!( + wallet = %hex::encode(seed_hash), + %error, + "Identity reconcile: sidecar read failed; will retry next boot/unlock" + ); + return; + } + }; + let mut added = 0usize; + for qi in &identities { + let Some(index) = qi.wallet_index else { + continue; + }; + match backend + .ensure_identity_managed(seed_hash, &qi.identity, index) + .await + { + Ok(true) => added += 1, + Ok(false) => {} + Err(error) => tracing::debug!( + identity = %qi.identity.id(), + %error, + "Identity reconcile deferred; will retry next boot/unlock" + ), + } + } + if added > 0 { + tracing::info!( + wallet = %hex::encode(seed_hash), + added, + "Reconciled DET identities into the upstream manager" + ); + } + } + + /// Register the DIP-15 receiving accounts of every established contact of + /// every identity on `seed_hash`, so SPV watches the addresses each contact + /// pays us at. Best-effort — a failure is logged and retried next unlock. + /// + /// Called inside [`Self::bootstrap_wallet_addresses_jit`]'s seed scope, so + /// the seed is already open and a locked wallet is never reached. + async fn register_established_contact_accounts( + &self, + backend: &WalletBackend, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + ) { + let pairs = self.established_contact_pairs(backend, seed_hash).await; + match backend + .register_contact_receiving_accounts(seed_hash, seed, &pairs) + .await + { + Ok(0) => {} + Ok(count) => tracing::info!( + wallet = %hex::encode(seed_hash), + count, + "Registered contact receiving accounts for watching" + ), + Err(error) => tracing::debug!( + wallet = %hex::encode(seed_hash), + %error, + "Contact receiving-account registration deferred; will retry next unlock" + ), + } + } + + /// Collect `(owner, contact)` identity-id pairs for every accepted contact + /// of each local identity whose DashPay wallet is `seed_hash`. + async fn established_contact_pairs( + &self, + backend: &WalletBackend, + seed_hash: &WalletSeedHash, + ) -> Vec<( + dash_sdk::platform::Identifier, + dash_sdk::platform::Identifier, + )> { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::platform::Identifier; + + let Ok(identities) = self.load_local_qualified_identities() else { + return Vec::new(); + }; + let view = backend.dashpay_view(); + let mut pairs = Vec::new(); + for identity in &identities { + if identity.dashpay_wallet_seed_hash().as_ref() != Some(seed_hash) { + continue; + } + let owner = identity.identity.id(); + for contact in view.contacts(&owner).await { + if contact.contact_status != ContactStatus::Accepted { + continue; + } + if let Ok(contact_id) = Identifier::from_bytes(&contact.contact_identity_id) { + pairs.push((owner, contact_id)); + } + } + } + pairs + } + + /// Warm the identity-authentication public-key cache (D4b) for the + /// identities this wallet already knows. + /// + /// Called from inside the JIT bootstrap's `with_secret_session` scope, + /// so the borrowed seed is already in hand and no extra prompt is + /// raised. Derives the first [`AUTH_PUBKEY_WARM_KEY_COUNT`] auth keys + /// per known identity index and persists them in one whole-blob write. + /// Identities discovered later warm lazily on the read path's cold-fill. + /// Best-effort: a derivation or persist failure is logged and skipped, + /// because the read path self-heals regardless. + fn warm_auth_pubkey_cache( + &self, + backend: &WalletBackend, + wallet: &Wallet, + seed: &[u8; 64], + seed_hash: WalletSeedHash, + ) { + let network = self.network; + let view = backend.auth_pubkey_cache(); + let mut cache = view.get(network, &seed_hash); + let mut changed = false; + + for &identity_index in wallet.identities.keys() { + for key_index in 0..AUTH_PUBKEY_WARM_KEY_COUNT { + if cache.get(network, identity_index, key_index).is_some() { + continue; + } + match wallet.identity_authentication_ecdsa_public_key_from_seed( + seed, + network, + identity_index, + key_index, + ) { + Ok(public_key) => { + changed |= cache.insert(network, identity_index, key_index, &public_key); + } + Err(error) => { + tracing::debug!( + wallet = %hex::encode(seed_hash), + identity_index, + key_index, + %error, + "Skipping auth-pubkey warm for one key" + ); + } + } + } + } + + if changed && let Err(e) = view.put(network, &seed_hash, &cache) { + tracing::debug!( + wallet = %hex::encode(seed_hash), + error = %e, + "Failed to persist warmed auth-pubkey cache" + ); + } + } + + pub(crate) fn init_missing_shielded_wallets(self: &Arc<Self>) { + for wallet in self.open_wallets() { + let ctx = Arc::clone(self); + self.subtasks + .spawn_sync("shielded_bind_after_protocol_update", async move { + ctx.bootstrap_wallet_addresses_jit(&wallet).await; + }); + } + } + + /// Queue automatic, gap-limited identity discovery for every open wallet, + /// once per SPV session. + /// + /// Fired when Platform becomes reachable (masternode list `Synced`). A + /// single [`AtomicBool`](std::sync::atomic::AtomicBool) latch makes it run at + /// most once per session — a re-entrant nudge (e.g. a repeated readiness + /// event) is a no-op until [`stop_spv`](Self::stop_spv) clears the latch on + /// the next reconnect. + /// + /// Locked, password-protected wallets are skipped here: the sweep runs with + /// `allow_prompt = false`, so it never pops a passphrase modal for a wallet + /// the user has not unlocked. Such a wallet is picked up later, with the + /// user's consent, when it is unlocked + /// (see [`Self::queue_wallet_identity_discovery`]). + pub fn queue_all_wallets_identity_discovery(self: &Arc<Self>) { + use std::sync::atomic::Ordering; + + // One-shot per session: skip if already fired. + if self + .identity_autodiscovery_fired + .swap(true, Ordering::SeqCst) + { + tracing::debug!("All-wallets identity discovery already ran this session; skipping"); + return; + } + + // Snapshot only open wallets — a locked protected wallet hydrates closed + // (`is_open() == false`) and is skipped so the background sweep cannot + // trigger a passphrase prompt. + let open_wallets = self.open_wallets(); + + if open_wallets.is_empty() { + tracing::debug!("No open wallets to run automatic identity discovery for"); + return; + } + + tracing::info!( + wallet_count = open_wallets.len(), + "Starting automatic identity discovery for all open wallets" + ); + + for wallet in open_wallets { + let ctx = Arc::clone(self); + self.subtasks + .spawn_sync("all_wallets_identity_discovery", async move { + if let Err(error) = ctx + .discover_identities_gap_limited(&wallet, 0, false, None) + .await + { + tracing::warn!( + %error, + "Automatic identity discovery failed for a wallet" + ); + } + }); + } + } + + /// Queue gap-limited identity discovery for a single wallet the user just + /// unlocked, so a wallet that was locked during the all-wallets sweep still + /// gets discovered this session. + /// + /// Independent of the once-per-session `identity_autodiscovery_fired` latch + /// (that guards the all-wallets sweep, not per-wallet unlock). Gated on + /// Platform readiness: if the masternode list is not yet `Synced`, this is a + /// no-op — the wallet is now open, so the upcoming all-wallets sweep covers + /// it. The user is present for the unlock, so `allow_prompt = true`; no + /// prompt occurs anyway because the unlock just promoted the seed to the + /// session cache. Idempotent with the sweep: discovery is + /// update-preserving-alias, so a double-run is harmless. + pub fn queue_unlocked_wallet_identity_discovery( + self: &Arc<Self>, + wallet: &Arc<RwLock<Wallet>>, + ) { + if !self.connection_status.masternodes_ready() { + tracing::debug!( + "Platform not ready yet; deferring unlocked-wallet identity discovery to the all-wallets sweep" + ); + return; + } + + let ctx = Arc::clone(self); + let wallet = Arc::clone(wallet); + self.subtasks + .spawn_sync("unlocked_wallet_identity_discovery", async move { + if let Err(error) = ctx + .discover_identities_gap_limited(&wallet, 0, true, None) + .await + { + tracing::warn!( + %error, + "Identity discovery failed for the just-unlocked wallet" + ); + } + }); + } + + /// Queue automatic discovery of identities derived from a wallet. + /// Checks identity indices 0 through max_identity_index for existing identities on the network. + pub fn queue_wallet_identity_discovery( + self: &Arc<Self>, + wallet: &Arc<RwLock<Wallet>>, + max_identity_index: u32, + ) { + let ctx = Arc::clone(self); + let wallet_clone = Arc::clone(wallet); + self.subtasks + .spawn_sync("wallet_identity_discovery", async move { + if let Err(error) = ctx + .discover_identities_from_wallet(&wallet_clone, max_identity_index) + .await + { + tracing::warn!( + %error, + "Failed to discover identities from wallet" + ); + } + }); + } + + pub async fn bootstrap_loaded_wallets(self: &Arc<Self>) { + let wallets: Vec<_> = { + let guard = self.wallets.read_recover(); + guard.values().cloned().collect() + }; + + for wallet in wallets.iter() { + self.bootstrap_wallet_addresses_jit(wallet).await; + } + } + + /// Update wallet platform address info from SDK-returned AddressInfos. + /// This uses the proof-verified data from SDK operations rather than fetching. + pub(crate) fn update_wallet_platform_address_info_from_sdk( + &self, + seed_hash: WalletSeedHash, + address_infos: &dash_sdk::query_types::AddressInfos, + ) -> Result<(), TaskError> { + let wallet_arc = self.wallet_arc(&seed_hash)?; + + let mut wallet = wallet_arc.write()?; + + for (platform_addr, maybe_info) in address_infos.iter() { + if let Some(info) = maybe_info { + // Convert PlatformAddress to core Address using the network + let core_addr = platform_addr.to_address_with_network(self.network); + + wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); + + tracing::debug!( + "Updated platform address {} balance={} nonce={} from SDK response", + core_addr, + info.balance, + info.nonce + ); + } + } + + Ok(()) + } +} diff --git a/src/context/wallet_lifecycle/mod.rs b/src/context/wallet_lifecycle/mod.rs new file mode 100644 index 000000000..60b9e1d79 --- /dev/null +++ b/src/context/wallet_lifecycle/mod.rs @@ -0,0 +1,139 @@ +//! Wallet lifecycle orchestration: the thin [`AppContext`] delegation layer. +//! +//! Each method here coordinates DET-side state (`wallets`, databases, subtasks, +//! connection status) around the wallet seam. Pure upstream-crate orchestration +//! lives in [`wallet_backend`](crate::wallet_backend) — the size here is +//! coordination surface. +//! +//! The `impl AppContext` methods are grouped by responsibility across +//! submodules, mirroring the multi-impl-of-one-struct layout `wallet_backend` +//! uses: [`spv`] (backend wiring / chain-storage), [`registration`], +//! [`removal`], [`bootstrap`] (address derivation + post-unlock warmup), and +//! [`unlock`] (lock/unlock handling). Shared imports, constants, the free +//! helpers, and the [`AppContext::wallet_arc`] lookup live here in `mod.rs`. + +use super::AppContext; +use crate::backend_task::error::TaskError; +use crate::model::dashpay::ContactStatus; +use crate::model::spv_status::SpvStatus; +use crate::model::wallet::birth_height::{WalletOrigin, registration_birth_height}; +use crate::model::wallet::meta::WalletMeta; +use crate::model::wallet::seed_envelope::StoredSeedEnvelope; +use crate::model::wallet::single_key::SingleKeyWallet; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::wallet_backend::poison::RwLockRecover; +use crate::wallet_backend::{ + DetScope, WalletBackend, WalletMetaView, WalletSeedView, network_prefix, +}; +use dash_sdk::dpp::dashcore::Network; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, RwLock}; + +/// Number of identity-authentication keys warmed per known identity index +/// during the JIT bootstrap (D4b). Matches the readers' auth-key lookup +/// window so the common identity-load path serves entirely from cache. +const AUTH_PUBKEY_WARM_KEY_COUNT: u32 = 12; + +/// The upstream `dash-spv` `DiskStorageManager` chain-cache entries under the +/// per-network SPV directory. Each is a subfolder except `peers.dat`. The +/// wallet/shielded SQLite sidecars in the same directory are deliberately +/// excluded — clearing the chain cache must not touch funds or secrets. +const SPV_CHAIN_STORAGE_ENTRIES: [&str; 7] = [ + "block_headers", + "filter_headers", + "filters", + "blocks", + "metadata", + "masternodestate", + "peers.dat", +]; + +/// Per-network SPV storage directory: `<data_dir>/spv/<network>/`. Mirrors +/// `WalletBackend::resolve_spv_storage_dir` so the path resolves identically +/// whether or not the wallet backend is wired yet. +fn spv_storage_dir(data_dir: &Path, network: Network) -> PathBuf { + data_dir.join("spv").join(network_prefix(network)) +} + +/// Remove the upstream chain-sync cache files under `spv_dir`, leaving the +/// wallet (`platform-wallet.sqlite`) and shielded sidecars untouched. The +/// `DiskStorageManager` lock lives at `<spv_dir>.lock` (a sibling of the +/// directory); it is removed too so a stale lock cannot block the next sync. +/// A missing entry is the expected fresh/never-synced state and is tolerated. +fn clear_spv_chain_storage(spv_dir: &Path) -> Result<(), TaskError> { + for entry in SPV_CHAIN_STORAGE_ENTRIES { + let path = spv_dir.join(entry); + let result = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + if let Err(e) = result + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(TaskError::FileSystem { source: e }); + } + } + + let lock_path = spv_dir.with_extension("lock"); + if let Err(e) = std::fs::remove_file(&lock_path) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(TaskError::FileSystem { source: e }); + } + + Ok(()) +} + +mod bootstrap; +mod registration; +mod removal; +mod spv; +mod unlock; + +impl AppContext { + /// Resolve a loaded HD wallet by its seed hash, cloning the shared handle. + /// + /// The single source of truth for the "look up a wallet arc or + /// [`TaskError::WalletNotFound`]" pattern every backend task needs. The + /// in-memory wallet map is rebuildable (hydrated from the DB and vault), so + /// a poisoned lock is recovered rather than surfaced as an error — matching + /// the poison-recovery discipline used elsewhere for rebuildable state. + pub(crate) fn wallet_arc( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Arc<RwLock<Wallet>>, TaskError> { + crate::wallet_backend::poison::read_recover(&self.wallets) + .get(seed_hash) + .cloned() + .ok_or(TaskError::WalletNotFound) + } +} + +/// Unlink DET's two retired legacy shielded files from `spv_dir` (the active +/// network's spv directory), tolerating a missing file. +/// +/// These are the files DET's deleted shielded subsystem owned: +/// `det-shielded.sqlite` (the plaintext note sidecar) and +/// `shielded-commitment-tree.sqlite` (the grovedb commitment tree). The +/// upstream coordinator's store (`platform-wallet-shielded.sqlite`) is a +/// DIFFERENT file and is deliberately NOT touched here — it is reset via the +/// coordinator's own `clear_shielded`. Scoped strictly to `spv_dir` so a clear +/// of one network can never reach another network's files. +fn cleanup_legacy_shielded_files(spv_dir: &Path) -> Result<(), TaskError> { + const LEGACY_SHIELDED_FILES: [&str; 2] = + ["det-shielded.sqlite", "shielded-commitment-tree.sqlite"]; + for file in LEGACY_SHIELDED_FILES { + let path = spv_dir.join(file); + if let Err(e) = std::fs::remove_file(&path) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(TaskError::FileSystem { source: e }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/context/wallet_lifecycle/registration.rs b/src/context/wallet_lifecycle/registration.rs new file mode 100644 index 000000000..3e1d2b660 --- /dev/null +++ b/src/context/wallet_lifecycle/registration.rs @@ -0,0 +1,298 @@ +//! Wallet registration and persistence: importing single-key wallets, +//! registering HD wallets, and writing the seed envelope / wallet meta. + +use super::*; + +impl AppContext { + /// Single import path for an imported private key (#192). Parses the + /// WIF, writes the encrypted vault entry + enumerable sidecar through + /// [`SingleKeyView::import_wif_with_passphrase`], then mirrors the + /// result into the in-memory `single_key_wallets` map the wallet + /// screens render from (without which the key stays invisible until + /// the next cold-boot hydration). + /// + /// The in-memory mirror is rebuilt through + /// [`SingleKeyView::rebuild_display_wallet`] — the same vault-backed path + /// cold boot uses — so a passphrase-protected key is mirrored **closed** + /// (no plaintext private key retained in the long-lived map; signing + /// decrypts just-in-time through the secret chokepoint), while an + /// unprotected key is mirrored open as before. Rebuilding from the WIF + /// with `from_wif(.., None, ..)` would have parked the decrypted key in + /// the session map for the whole session, defeating the per-key + /// passphrase. + /// + /// Every UI entry point — the import dialog, the import-wallet screen, + /// and the test seam — routes through here so vault write and + /// in-memory mirror can never diverge. Returns the rebuilt display + /// wallet so the caller can select it. + pub fn import_single_key_wif( + &self, + wif: &str, + alias: Option<String>, + passphrase: crate::wallet_backend::single_key::ImportPassphrase, + ) -> Result< + ( + crate::model::single_key::ImportedKey, + Arc<RwLock<SingleKeyWallet>>, + ), + TaskError, + > { + let backend = self.wallet_backend()?; + let single_key = backend.single_key(); + let imported = single_key.import_wif_with_passphrase(wif, alias, passphrase)?; + + // Rebuild the in-memory display wallet from the just-written vault + // entry so the map matches the shape `hydrate_context_wallets` + // produces on the next cold boot. For a passphrase-protected entry + // this yields a closed wallet with no plaintext; for an unprotected + // entry it yields the open wallet the legacy path produced. + let wallet = single_key + .rebuild_display_wallet(&imported)? + .ok_or(TaskError::ImportedKeyNotFound)?; + let key_hash = wallet.key_hash(); + let wallet_arc = Arc::new(RwLock::new(wallet)); + + if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { + single_key_wallets.insert(key_hash, wallet_arc.clone()); + self.has_wallet.store(true, Ordering::Relaxed); + } + Ok((imported, wallet_arc)) + } + + /// Confirm that `passphrase` unlocks the protected imported key at + /// `address` against the encrypted vault, without leaving any plaintext in + /// the long-lived `single_key_wallets` map. Used by the wallets-screen + /// "Unlock" gesture: signing already decrypts just-in-time through the + /// secret chokepoint, so the map entry can stay closed while the user gets + /// confirmation that their passphrase is correct. Returns + /// [`TaskError::SingleKeyPassphraseIncorrect`] on a wrong passphrase. + pub fn verify_single_key_passphrase( + self: &Arc<Self>, + address: &str, + passphrase: &str, + ) -> Result<(), TaskError> { + // The unlock gesture also lazy re-wraps a protected entry to Tier-2 + // (verify_passphrase re-seals it under the same password). Protection is + // KEPT, so there is no downgrade to disclose — no notice. + let backend = self.wallet_backend()?; + backend + .single_key() + .verify_passphrase(address, passphrase)?; + Ok(()) + } + + /// Persist a wallet to the database and register it in the in-memory map. + /// + /// This is the single entry point for adding a wallet to the system. + /// UI screens should call this after constructing a [`Wallet`] via + /// [`Wallet::new_from_seed()`]. + /// + /// `seed` is the freshly-created/imported HD seed the caller already holds + /// from wallet construction. It is borrowed for the fresh-register + /// bootstrap (and, for a password wallet, to promote into the JIT session + /// cache) so registration never reads a parked seed — an open `Wallet` + /// parks none (R3). The borrow does not outlive this call. + /// + /// `origin` records whether the recovery phrase is brand-new + /// ([`WalletOrigin::Fresh`]) or pre-existing ([`WalletOrigin::Imported`]). + /// It sets the upstream SPV scan-window floor: a fresh wallet scans from + /// the current tip, an imported one from genesis so deposits made before + /// registration are still found. + pub fn register_wallet( + self: &Arc<Self>, + wallet: Wallet, + seed: &[u8; 64], + origin: WalletOrigin, + ) -> Result<(WalletSeedHash, Arc<RwLock<Wallet>>), TaskError> { + let seed_hash = wallet.seed_hash(); + let uses_password = wallet.uses_password; + + // 1. Reject a duplicate import. The upstream `platform-wallet.sqlite` + // persistor is the system of record now; DET no longer writes the + // legacy `data.db.wallet` row (the fresh-install schema gates that + // table out entirely). Uniqueness is enforced against the wallet-meta + // sidecar and the in-memory map — the same key (`seed_hash`) the + // legacy unique constraint used. + if self.wallets.read()?.contains_key(&seed_hash) + || WalletMetaView::new(&self.app_kv) + .get(self.network, &seed_hash) + .is_some() + { + return Err(TaskError::WalletAlreadyImported); + } + + // 2. Persist the seed-envelope vault entry — FAIL-CLOSED (F62). This is + // the encrypted seed the W2 cold-boot bridge re-registers from; without + // it the wallet works in-session but VANISHES with its funds on the next + // launch. If it cannot be saved, the registration is aborted here (the + // wallet is NOT inserted in-memory) so the UI tells the user the wallet + // was not saved and to retry — never a silent loss. The vault is + // AppContext-owned, so this succeeds even before the backend is wired. + self.write_seed_envelope(&wallet)?; + + // Persist the wallet-meta sidecar — FAIL-CLOSED. Cold-boot hydration + // enumerates ONLY this sidecar (`hydrate_wallets_for_network` rebuilds + // `ctx.wallets` from `WalletMetaView::list`); there is no + // upstream→meta reconstruction path. A wallet with a seed envelope but + // no meta row is never hydrated, so its funds become unreachable on the + // next launch with no self-heal. Both sidecars are required, so a meta + // write failure aborts the registration here just like the envelope + // write above. The sidecar is AppContext-owned (app_kv), so this + // succeeds even before the backend is wired. + self.write_wallet_meta(&wallet)?; + + // 3. Register in-memory + let wallet_arc = Arc::new(RwLock::new(wallet)); + let mut wallets = self.wallets.write()?; + wallets.insert(seed_hash, wallet_arc.clone()); + self.has_wallet.store(true, Ordering::Relaxed); + drop(wallets); + + // 4. Bootstrap addresses from the seed the caller holds (fresh + // register), then — for a password wallet — promote that seed into the + // JIT session cache so the rest of the session does not re-prompt. + // A no-password wallet needs no promotion: the chokepoint's + // unprotected fast-path decrypts it without a prompt regardless. + self.bootstrap_wallet_addresses(&wallet_arc, seed); + if uses_password { + self.promote_seed_to_session(seed_hash, seed); + } + + // 5. Register the wallet with the upstream SPV backend so its addresses + // are watched and received funds become visible (W1). The + // upstream `create_wallet_from_seed_bytes` is the only writer to the + // persistor, so without this the wallet is never watched. Done on a + // tracked subtask because registration is async and this entry point is + // synchronous; the seed is moved in zeroized and dropped when the task + // ends. If the backend is not wired yet, the W2 cold-boot bridge covers + // it at the next launch. + self.register_wallet_upstream(seed_hash, seed, origin); + + Ok((seed_hash, wallet_arc)) + } + + /// Spawn the W1 upstream-registration subtask for a just-registered wallet. + /// + /// Moves a zeroized copy of `seed` into the subtask; the borrow in + /// [`Self::register_wallet`] is not extended. The birth height follows the + /// wallet's [`WalletOrigin`]. Best-effort: a registration failure is logged + /// and the wallet is retried by the W2 cold-boot bridge at next launch. + fn register_wallet_upstream( + self: &Arc<Self>, + seed_hash: WalletSeedHash, + seed: &[u8; 64], + origin: WalletOrigin, + ) { + let Ok(backend) = self.wallet_backend() else { + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Wallet backend not wired yet; deferring upstream registration to next cold boot" + ); + return; + }; + let seed = zeroize::Zeroizing::new(*seed); + let birth_height = registration_birth_height(origin); + self.subtasks + .spawn_sync("wallet_upstream_registration", async move { + if let Err(error) = backend + .register_wallet_from_seed(&seed_hash, &seed, birth_height) + .await + { + tracing::warn!( + wallet = %hex::encode(seed_hash), + %error, + "Upstream wallet registration failed; will retry at next cold boot" + ); + } + }); + } + + /// Persist a newly-registered wallet's encrypted seed envelope to the + /// vault. **Fail-closed** (F62): this is the must-succeed write — the + /// envelope is the encrypted seed the W2 cold-boot bridge re-registers the + /// wallet from, so a failure here means the wallet would silently disappear + /// with its funds at the next launch. The caller propagates the error so + /// the wallet is not kept. + /// + /// Writes through the shared `secret_store` vault that `AppContext` opens at + /// boot, so it succeeds even before the wallet backend is wired: + /// the backend, once built, reuses the very same vault handle. + fn write_seed_envelope(&self, wallet: &Wallet) -> Result<(), TaskError> { + let seed_hash = wallet.seed_hash(); + let view = WalletSeedView::new(&self.secret_store); + // No-password wallets store the raw 64-byte seed directly through the + // seam: `encrypted_seed_slice()` is the verbatim seed (no DET AES-GCM). + // The non-secret metadata rides in `WalletMeta` (write_wallet_meta). + if !wallet.uses_password { + let seed: [u8; 64] = wallet.encrypted_seed_slice().try_into().map_err(|_| { + TaskError::WalletSeedStorage { + source: Box::new( + platform_wallet_storage::secrets::SecretStoreError::MalformedVault, + ), + } + })?; + return view.set_raw(&seed_hash, &seed); + } + // Password wallets keep the legacy AES-GCM envelope at creation; they + // migrate to the raw seam lazily at the next unlock (one prompt the + // user already does). + let envelope = StoredSeedEnvelope { + encrypted_seed: wallet.encrypted_seed_slice().to_vec(), + salt: wallet.salt().to_vec(), + nonce: wallet.nonce().to_vec(), + password_hint: wallet.password_hint().clone(), + uses_password: wallet.uses_password, + xpub_encoded: wallet + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(), + }; + view.set(&seed_hash, &envelope) + } + + /// Persist a newly-registered wallet's metadata (alias / is_main / + /// core_wallet_name + master xpub) to the wallet-meta sidecar. + /// **Fail-closed**: cold-boot hydration enumerates ONLY this + /// sidecar (`hydrate_wallets_for_network` lists `WalletMetaView`), and + /// nothing reconstructs the meta from the upstream persistor — so a wallet + /// with no meta row never rehydrates and its funds become unreachable. The + /// caller propagates the error so the wallet is not kept. + fn write_wallet_meta(&self, wallet: &Wallet) -> Result<(), TaskError> { + let seed_hash = wallet.seed_hash(); + let meta = WalletMeta { + alias: wallet.alias.clone().unwrap_or_default(), + is_main: wallet.is_main, + core_wallet_name: wallet.core_wallet_name.clone(), + xpub_encoded: wallet + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(), + uses_password: wallet.uses_password, + password_hint: wallet.password_hint().clone(), + }; + WalletMetaView::new(&self.app_kv).set(self.network, &seed_hash, &meta) + } + + /// Promote a known HD seed into the JIT chokepoint's session cache + /// (`UntilAppClose`), so the rest of the session does not re-prompt for + /// this wallet. + /// + /// Used by the fresh-register path, which holds the seed from wallet + /// construction. Best-effort: if the backend is not wired yet the promotion + /// is skipped — signing still resolves the seed just-in-time from the vault. + fn promote_seed_to_session(self: &Arc<Self>, seed_hash: WalletSeedHash, seed: &[u8; 64]) { + let Ok(backend) = self.wallet_backend() else { + return; + }; + let seed = zeroize::Zeroizing::new(*seed); + backend.secret_access().remember_session( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + crate::wallet_backend::SecretPlaintext::HdSeed(&seed), + crate::wallet_backend::RememberPolicy::UntilAppClose, + ); + tracing::trace!( + wallet = %hex::encode(seed_hash), + "Freshly-registered seed promoted to the session cache" + ); + } +} diff --git a/src/context/wallet_lifecycle/removal.rs b/src/context/wallet_lifecycle/removal.rs new file mode 100644 index 000000000..d3e91372a --- /dev/null +++ b/src/context/wallet_lifecycle/removal.rs @@ -0,0 +1,62 @@ +//! Wallet removal: evicting a wallet and wiping its at-rest secrets. + +use super::*; + +impl AppContext { + pub fn remove_wallet(self: &Arc<Self>, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + // Acquire write lock first to ensure atomicity — if the lock fails, + // no changes have been made to the database. + let mut wallets = self.wallets.write()?; + if !wallets.contains_key(seed_hash) { + return Err(TaskError::WalletNotFound); + } + + self.db.remove_wallet(seed_hash, &self.network)?; + + wallets.remove(seed_hash); + let has_wallet = !wallets.is_empty(); + drop(wallets); + + self.has_wallet.store(has_wallet, Ordering::Relaxed); + + // Evict the wallet's shielded balance snapshot. The seed hash is + // deterministic from the seed, so re-importing the same recovery phrase + // re-binds this exact key — without eviction the freshly-imported wallet + // would surface the removed wallet's stale shielded balance until the + // next completed sync overwrites it. + if let Ok(mut balances) = self.shielded_balances.lock() { + balances.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 + // plus the nullifier cursor (F17/F20). Synchronous so the secrets are + // gone before the UI reports success. Best-effort when the backend is + // not wired yet — a pre-wire context has none of that state. + if let Ok(backend) = self.wallet_backend() { + let upstream_id = backend.registered_wallet_id(seed_hash); + if let Err(e) = backend.forget_wallet_local_state(seed_hash, upstream_id) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to wipe local wallet secret state on removal" + ); + } + + // The upstream (watch-only, seedless) persistor row removal is the + // sole async step; it carries no secret, so drive it off-thread. + if let Some(wallet_id) = upstream_id { + let backend = Arc::clone(&backend); + self.subtasks + .spawn_sync("wallet_upstream_removal", async move { + if let Err(error) = backend.remove_upstream_wallet(&wallet_id).await { + tracing::warn!(%error, "Upstream wallet removal failed"); + } + }); + } + } + + Ok(()) + } +} diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs new file mode 100644 index 000000000..a1ae38f0e --- /dev/null +++ b/src/context/wallet_lifecycle/spv.rs @@ -0,0 +1,253 @@ +//! SPV / chain-storage lifecycle: wiring the wallet backend, starting and +//! stopping chain sync, and clearing per-network SPV and database state. + +use super::*; + +impl AppContext { + /// Delete the cached chain-sync data (headers, filters, blocks, masternode + /// state, peers) for this network so the next connection re-syncs from + /// scratch. + /// + /// Only the upstream `dash-spv` `DiskStorageManager` files under the + /// per-network SPV directory are removed; the wallet state + /// (`platform-wallet.sqlite`) and the shielded commitment tree are left + /// intact — clearing the chain cache must never touch funds or secrets. The + /// "Clear SPV Data" control is enabled only while sync is stopped, so the + /// `DiskStorageManager` has released its file lock and the deletes do not + /// race a live writer. A missing directory (never synced) is success. + pub fn clear_spv_data(&self) -> Result<(), TaskError> { + let spv_dir = spv_storage_dir(&self.data_dir, self.network); + clear_spv_chain_storage(&spv_dir) + } + + pub fn clear_network_database(self: &Arc<Self>) -> Result<(), TaskError> { + self.db.clear_network_data(self.network)?; + + // F60: permanently delete every wallet's secret-bearing state so the + // "delete all local data" promise holds — wallets must NOT rehydrate + // on next launch and encrypted seeds must NOT persist. Clear the + // persisted state (seed-envelope vault, wallet-meta + single-key + // sidecars, shielded notes, session cache) BEFORE the in-memory maps + // below, so a mid-failure crash cannot strand a recoverable seed. The + // upstream (watch-only) persistor rows have no seed and are removed + // asynchronously off the main thread. Best-effort when the backend is + // not wired yet — there is no such state in that case. + if let Ok(backend) = self.wallet_backend() { + let upstream_ids = backend.forget_all_wallets_local(); + for wallet_id in upstream_ids { + let backend = Arc::clone(&backend); + self.subtasks + .spawn_sync("wallet_upstream_removal", async move { + if let Err(error) = backend.remove_upstream_wallet(&wallet_id).await { + tracing::warn!(%error, "Upstream wallet removal failed during clear"); + } + }); + } + } + + // D4d: drain the DashPay k/v sidecar. The Global-scoped overlays + // (blocked / rejected markers, timestamps, reverse address map) + // share the `det:dashpay:` prefix and come out in one sweep. The + // per-contact private memos and address-index cursors now live in + // each owner's `DetScope::Identity` scope (Wave 2 promotion), which + // the Global sweep cannot reach — so fan the per-owner clear out + // over the identity index. Best-effort when the wallet backend has + // not been wired yet (clear at first run before any wallet exists) + // — there is nothing to drain in that case. + if let Ok(backend) = self.wallet_backend() { + let kv = backend.kv(); + match kv.list(DetScope::Global, Some("det:dashpay:")) { + Ok(keys) => { + for k in keys { + if let Err(e) = kv.delete(DetScope::Global, &k) { + tracing::warn!(key = %k, "DashPay sidecar delete failed: {e:?}"); + } + } + } + Err(e) => { + tracing::warn!("DashPay sidecar listing failed: {e:?}"); + } + } + match self.local_identity_ids() { + Ok(owners) => { + for owner in owners { + if let Err(e) = backend.dashpay_clear_owner_overlays(&owner) { + tracing::warn!( + owner = %owner, + "DashPay per-owner overlay clear failed: {e:?}" + ); + } + } + } + Err(e) => { + tracing::warn!("Identity index listing for DashPay clear failed: {e:?}"); + } + } + } + + // Reset the upstream shielded coordinator (quiesces its sync loop and + // empties the per-network store) and unlink DET's two retired legacy + // shielded files. The coordinator reset is async, so it runs off-thread + // as a best-effort subtask; the legacy-file unlinks are synchronous and + // scoped strictly to THIS network's spv directory. + if let Ok(backend) = self.wallet_backend() { + cleanup_legacy_shielded_files(backend.spv_storage_dir())?; + + let ctx = Arc::clone(self); + self.subtasks + .spawn_sync("shielded_coordinator_clear", async move { + if let Ok(backend) = ctx.wallet_backend() + && let Err(error) = backend.clear_shielded().await + { + tracing::warn!(%error, "Shielded coordinator reset failed during clear"); + } + }); + } + + if let Ok(mut wallets) = self.wallets.write() { + wallets.clear(); + } + + if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { + single_key_wallets.clear(); + } + + self.has_wallet.store(false, Ordering::Relaxed); + + Ok(()) + } + + /// Wire the wallet backend (idempotent) and then start chain sync. + /// + /// This is the single chokepoint for "start SPV" across every entry path: + /// GUI boot auto-start, the manual Connect button, MCP/CLI standalone boot, + /// and the post-network-switch restart. Wiring happens first, so the + /// historical `WalletBackendNotYetWired` fast-fail race — callers invoking + /// [`Self::start_spv`] before [`Self::ensure_wallet_backend`] had a chance + /// to complete — cannot occur. + /// + /// Both steps are idempotent: the backend is wired at most once (first + /// writer wins) and the upstream run loop is spawned at most once (guarded + /// by the backend's start latch). Chain sync runs asynchronously — progress + /// and success arrive via the `EventBridge`. + /// + /// On failure the SPV connection indicator is flipped to + /// [`SpvStatus::Error`] before the error is returned, so every caller — GUI + /// boot auto-start, the manual Connect button, the network-switch restart, + /// and the MCP/headless path — gets a consistent error state on the + /// indicator without each having to remember to set it. GUI callers may + /// additionally show a banner; headless callers need no egui context for + /// the indicator flip, which is what this method owns. + pub async fn ensure_wallet_backend_and_start_spv( + self: &Arc<Self>, + sender: crate::utils::egui_mpsc::SenderAsync<crate::app::TaskResult>, + ) -> Result<(), TaskError> { + if let Err(e) = self.ensure_wallet_backend(sender).await { + self.mark_spv_error(&e); + return Err(e); + } + let backend = self.wallet_backend()?; + // Forward-compat: `start()`'s signature is fallible though the current + // impl is infallible. The reachable start-time failure today is the + // wiring step above, surfaced via `mark_spv_error`; this branch keeps + // the start step covered should `start()` begin to fail. + if let Err(e) = backend.start().await { + self.mark_spv_error(&e); + } + Ok(()) + } + + /// Flip the SPV connection indicator to [`SpvStatus::Error`] and record the + /// failure detail. Safe in every context (GUI and headless) — it touches + /// only `ConnectionStatus` atomics, never an egui context. + fn mark_spv_error(&self, error: &TaskError) { + tracing::error!(error = %error, "Failed to start chain sync"); + self.connection_status + .set_spv_last_error(Some(format!("{error}"))); + self.connection_status.set_spv_status(SpvStatus::Error); + self.connection_status.refresh_state(); + } + + /// Stop chain sync IN PLACE, keeping the wired wallet backend so the next + /// Connect restarts the SAME instance. + /// + /// This is the disconnect counterpart to + /// [`Self::ensure_wallet_backend_and_start_spv`] and the single chokepoint + /// for "stop SPV". The sequence is: + /// + /// 1. Flip the SPV indicator to [`SpvStatus::Stopping`] so the UI shows + /// "Disconnecting…" immediately, before the async teardown runs. + /// 2. Stop the backend IN PLACE ([`WalletBackend::stop_in_place`]): stop the + /// upstream chain-sync run loop and quiesce the three coordinators, but + /// KEEP the `WalletBackend` (and its `Arc<SqlitePersister>`) wired in the + /// AppContext slot, re-arming the one-shot start latch and coordinator + /// gate so the same instance can restart. The backend is NOT shut down or + /// unwired here. + /// 3. Flip the indicator to [`SpvStatus::Stopped`] and clear the live peer + /// count, sync progress, and last error; re-arm the quorum gate and the + /// one-shot identity-sweep flag; then recompute the overall state — which + /// lands on `Disconnected` now that SPV is inactive. + /// + /// Restart-in-place is deliberate: because the persister DB is never closed + /// and reopened, the next same-network Connect fast-paths on the populated + /// slot and restarts on the re-armed latch, so a reconnect cannot hit + /// `WalletStorageError::AlreadyOpen` — impossible by construction, no release + /// barrier needed. Full teardown ([`WalletBackend::shutdown`], which quiesces + /// the coordinators so the persister can drop) never runs on a GUI path: a + /// GUI network switch keeps the outgoing per-network context cached (only its + /// secrets are forgotten), and GUI app-close aborts the subtasks and exits. + /// `shutdown` runs only on the MCP network-switch tool (draining the outgoing + /// context before the swap) and the headless / MCP-server close — all on a + /// different persister path than any live one, so none can race a reopen. + /// + /// Idempotent: a call with no wired backend still settles the indicator on + /// `Stopped`/`Disconnected`. The teardown is async (upstream `stop_in_place` + /// is async), so GUI callers dispatch this via `AppAction::StopSpv` rather + /// than blocking the frame loop. That dispatch claims the stop synchronously + /// with + /// [`ConnectionStatus::begin_spv_stop`](crate::context::connection_status::ConnectionStatus::begin_spv_stop) + /// (button disables on the click frame, second click deduped); the redundant + /// `Stopping` flip here keeps direct callers self-contained. + pub async fn stop_spv(self: &Arc<Self>) { + self.connection_status.set_spv_status(SpvStatus::Stopping); + self.connection_status.refresh_state(); + + // Restart-in-place disconnect: keep the `WalletBackend` (and its + // `Arc<SqlitePersister>`) wired in the AppContext slot — do NOT unwire + // or drop it. `stop_in_place` stops the SPV run loop and + // quiesces the three coordinators while leaving the backend + persister + // alive, and re-arms the start latch + coordinator gate so the next + // same-network Connect restarts on the SAME instance (the reconnect + // reuses it via `ensure_wallet_backend`'s populated-slot fast path). + // See this method's doc comment for why the reconnect cannot hit + // `WalletStorageError::AlreadyOpen`. + // + // Restart-in-place runtime safety: all three upstream coordinators clear + // their cancel slot under a `background_generation` guard, so a rapid + // reconnect cannot leak an uncancellable / duplicate sync loop. + // + // TODO(dash-spv#824): restart-in-place fully recreates the upstream DashSpvClient + // in SpvRuntime::run(), opening a reinit window. A block arriving at tip during + // that window can freeze dash-spv's filter committed_height one block below + // permanently → is_synced() stuck false → UI stuck on "Syncing…". Upstream bug: + // dashpay/rust-dashcore#824; DET's reconnect is the trigger. DET-side mitigations: + // quiesce header/block intake until filter init completes, or add a stall watchdog. + if let Ok(backend) = self.wallet_backend() { + backend.stop_in_place().await; + } + + self.connection_status.set_spv_status(SpvStatus::Stopped); + self.connection_status.set_spv_connected_peers(0); + self.connection_status.set_spv_sync_progress(None); + self.connection_status.set_spv_last_error(None); + // Re-arm the quorum gate so the next reconnect re-syncs the masternode + // list on the same backend instance (`stop_in_place` keeps the backend + // wired). Leaving the flag set would let early proof calls through + // before quorums exist again, re-triggering the DAPI self-ban storm. + self.connection_status.set_masternodes_ready(false); + // Re-arm the automatic identity sweep so it runs once per session. + self.identity_autodiscovery_fired + .store(false, std::sync::atomic::Ordering::SeqCst); + self.connection_status.refresh_state(); + } +} diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs new file mode 100644 index 000000000..81b74ba86 --- /dev/null +++ b/src/context/wallet_lifecycle/tests.rs @@ -0,0 +1,2861 @@ +use super::*; +use crate::app::TaskResult; +use crate::app_dir::ensure_env_file; +use crate::context::AppContext; +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; + +/// Build an offline `AppContext` for testnet in an isolated temp dir. No +/// network I/O happens at construction: the SDK and Core client are built +/// from bundled `.env` addresses but connect lazily. The `TempDir` must +/// outlive the context — its drop deletes the data dir. +fn offline_testnet_context() -> (Arc<AppContext>, SenderAsync<TaskResult>, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let (ctx, sender) = offline_testnet_context_at(temp_dir.path()); + (ctx, sender, temp_dir) +} + +/// Build an offline testnet `AppContext` rooted at an existing `data_dir`. +/// Splitting this out lets a test build a second, independent context over +/// the *same* on-disk sidecars to simulate a process restart (cold boot). +fn offline_testnet_context_at( + data_dir: &std::path::Path, +) -> (Arc<AppContext>, SenderAsync<TaskResult>) { + let db = + Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("create test database")); + offline_testnet_context_with_db(data_dir, db) +} + +/// Build an offline testnet `AppContext` whose `data.db` went through the +/// **real** `Database::initialize` fresh-install path (the path production +/// uses at `app.rs:322`), which gates the legacy `wallet`/`wallet_addresses` +/// tables OUT. Use this for fresh-install regression tests; the default +/// helper force-creates those tables via `create_tables(true)`. +fn offline_testnet_context_fresh_init( + data_dir: &std::path::Path, +) -> (Arc<AppContext>, SenderAsync<TaskResult>) { + let db_file = data_dir.join("data.db"); + let db = crate::database::Database::new(&db_file).expect("create fresh test database"); + db.initialize(&db_file) + .expect("fresh Database::initialize should succeed"); + offline_testnet_context_with_db(data_dir, Arc::new(db)) +} + +fn offline_testnet_context_with_db( + data_dir: &std::path::Path, + db: Arc<crate::database::Database>, +) -> (Arc<AppContext>, SenderAsync<TaskResult>) { + let data_dir = data_dir.to_path_buf(); + ensure_env_file(&data_dir); + + let subtasks = Arc::new(TaskManager::new()); + let connection_status = Arc::new(ConnectionStatus::new()); + let egui_ctx = egui::Context::default(); + let app_kv = AppContext::open_app_kv(&data_dir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("open secret store"); + + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + subtasks, + connection_status, + egui_ctx, + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("AppContext::new should succeed offline with bundled testnet config"); + + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + (ctx, sender) +} + +/// Recursively copy a directory tree. Cold-boot tests reopen wallet state +/// over a fresh path (identical on-disk bytes) to sidestep the persister's +/// single-open advisory lock a lingering subtask may still hold. +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) { + std::fs::create_dir_all(dst).expect("mkdir dst"); + for entry in std::fs::read_dir(src).expect("read_dir") { + let entry = entry.expect("dir entry"); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_dir_recursive(&from, &to); + } else { + std::fs::copy(&from, &to).expect("copy file"); + } + } +} + +/// Process-global serialization lock for tests that tear a wallet backend +/// down and immediately rebuild it over the *same* on-disk path. The +/// upstream persister enforces a single open per `platform-wallet.sqlite` +/// (`WalletStorageError::AlreadyOpen`); a bootstrap subtask spawned by +/// `ensure_wallet_backend` may keep its `Arc<WalletBackend>` — and that +/// open's advisory lock — alive a beat past `stop_spv`, so under parallel +/// scheduling the reopen can lose the race. Serializing these reopen tests +/// removes the scheduler pressure so the lingering subtask drops the old +/// handle before the reopen. Mirrors `support::data_dir_lock` in the +/// kittest suite. Held across awaits, hence a `tokio::sync::Mutex`. +async fn backend_reopen_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await +} + +/// Before the wallet seam is wired, the `wallet_backend()` gate must fail +/// fast with the typed `WalletBackendNotYetWired` rather than handing back a +/// half-built backend. This is the gate the speculative pre-wire callers +/// were tripping. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wallet_backend_gate_errors_when_not_wired() { + let (ctx, _sender, _tmp) = offline_testnet_context(); + + let err = ctx + .wallet_backend() + .expect_err("wallet_backend() must fail before the backend is wired"); + assert!( + matches!(err, TaskError::WalletBackendNotYetWired), + "expected WalletBackendNotYetWired, got: {err:?}" + ); +} + +/// Wiring the backend must not start chain sync: `ensure_wallet_backend` +/// builds the seam but leaves the upstream run loop unstarted, so the start +/// latch stays low until the chokepoint (or a manual Connect) starts it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wiring_does_not_start_chain_sync() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx + .wallet_backend() + .expect("backend must be wired after ensure_wallet_backend"); + assert!( + !backend.is_started(), + "wiring alone must not start chain sync" + ); + + backend.shutdown().await; +} + +/// The async chokepoint wires the backend and starts chain sync in one call, +/// so a caller need not have wired the backend beforehand. Pins the +/// "ensure-then-start" sequencing the GUI/MCP/network-switch paths share. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ensure_wallet_backend_and_start_spv_wires_then_starts() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + assert!( + ctx.wallet_backend().is_err(), + "precondition: backend unwired before the chokepoint" + ); + + ctx.ensure_wallet_backend_and_start_spv(sender) + .await + .expect("chokepoint should wire then start offline"); + + let backend = ctx + .wallet_backend() + .expect("backend must be wired after the chokepoint"); + assert!( + backend.is_started(), + "chokepoint must have started chain sync" + ); + + backend.shutdown().await; +} + +/// The Disconnect chokepoint must produce a *visible* state change: after a +/// successful start, `stop_spv` stops chain sync IN PLACE — keeping the +/// backend wired for a restart — and settles the indicator on `Stopped` / +/// `Disconnected`. Regression guard ensuring the Disconnect button drives +/// the overall state out of its active value while preserving the backend. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop_spv_in_place_keeps_backend_and_disconnects_indicator() { + use crate::context::connection_status::OverallConnectionState; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + ctx.ensure_wallet_backend_and_start_spv(sender) + .await + .expect("chokepoint should wire then start offline"); + assert!( + ctx.wallet_backend().is_ok(), + "precondition: backend wired after start" + ); + // Simulate a session that reached quorum readiness, so the disconnect + // has a flag to re-arm. + ctx.connection_status().set_masternodes_ready(true); + + ctx.stop_spv().await; + + let backend = ctx + .wallet_backend() + .expect("stop_spv must KEEP the backend wired for restart-in-place (NOT unwire it)"); + assert!( + !backend.is_started(), + "stop_spv must re-arm the start latch so the next Connect can restart" + ); + assert!( + !ctx.connection_status().masternodes_ready(), + "stop_spv must re-arm the quorum gate so the next reconnect waits for masternode re-sync" + ); + assert_eq!( + ctx.connection_status().spv_status(), + SpvStatus::Stopped, + "stop_spv must leave the SPV indicator Stopped" + ); + assert_eq!( + ctx.connection_status().overall_state(), + OverallConnectionState::Disconnected, + "stop_spv must leave the overall state Disconnected" + ); + assert_eq!( + ctx.connection_status().spv_connected_peers(), + 0, + "stop_spv must clear the live peer count" + ); +} + +/// `stop_spv` is idempotent: calling it with no wired backend must not panic +/// and must still settle the indicator on `Stopped` / `Disconnected`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop_spv_is_idempotent_without_a_wired_backend() { + use crate::context::connection_status::OverallConnectionState; + + let (ctx, _sender, _tmp) = offline_testnet_context(); + assert!( + ctx.wallet_backend().is_err(), + "precondition: backend unwired" + ); + + ctx.stop_spv().await; + + assert_eq!(ctx.connection_status().spv_status(), SpvStatus::Stopped); + assert_eq!( + ctx.connection_status().overall_state(), + OverallConnectionState::Disconnected + ); +} + +/// Restart-in-place reconnect: a same-network Disconnect → Connect keeps the +/// SAME `WalletBackend` (and its `Arc<SqlitePersister>`) wired, so the +/// persister DB is never closed/reopened and `AlreadyOpen` is impossible by +/// construction — no release barrier needed. Drives the real production +/// path: `stop_spv()` (in-place) then `ensure_wallet_backend_and_start_spv()`. +/// +/// Validated offline (passes now): the backend pointer is identical across +/// disconnect→connect (reuse, not rebuild); `is_started()` is cleared by +/// `stop_spv` and re-set by the reconnect (latch + gate re-armed); the +/// reconnect returns `Ok` with no `AlreadyOpen`. +/// +/// Upstream Q3 race protection now lives in the pinned platform rev: all +/// three coordinators (incl. `platform_address_sync` since b4506492) gate +/// their cancel-slot clear on `background_generation`, so a rapid restart of +/// the SAME instance cannot leak an uncancellable / duplicate loop. This +/// offline test asserts the DET-level reuse/restart contract; it does not +/// itself force the timing race — full live behavior is covered by the +/// network-gated (`#[ignore]`d) backend-e2e B-reconnect test. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reconnect_restart_in_place_reuses_backend() { + use crate::context::connection_status::OverallConnectionState; + + let _reopen_guard = backend_reopen_lock().await; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + ctx.ensure_wallet_backend_and_start_spv(sender.clone()) + .await + .expect("initial start should wire then start offline"); + let first = ctx.wallet_backend().expect("backend wired after start"); + assert!(first.is_started(), "initial start must latch the backend"); + let first_ptr = Arc::as_ptr(&first); + drop(first); + + // Disconnect IN PLACE via the production chokepoint: the backend stays + // wired (slot not taken), the start latch is re-armed, the indicator + // settles on Disconnected. + ctx.stop_spv().await; + let after_stop = ctx + .wallet_backend() + .expect("stop_spv must KEEP the backend wired for restart-in-place"); + assert!( + !after_stop.is_started(), + "stop_spv must re-arm the start latch (is_started == false)" + ); + assert_eq!( + ctx.connection_status().overall_state(), + OverallConnectionState::Disconnected, + "stop_spv must settle the indicator on Disconnected" + ); + assert!( + !ctx.connection_status().masternodes_ready(), + "stop_spv must re-arm the quorum gate (masternodes_ready == false)" + ); + drop(after_stop); + + // Reconnect: `ensure_wallet_backend` fast-paths on the populated slot + // (no `WalletBackend::new`, no `SqlitePersister::open`), so the SAME + // instance restarts — structurally immune to `AlreadyOpen`. + ctx.ensure_wallet_backend_and_start_spv(sender) + .await + .expect("reconnect should restart the SAME backend in place"); + let second = ctx + .wallet_backend() + .expect("backend still wired after reconnect"); + assert_eq!( + first_ptr, + Arc::as_ptr(&second), + "restart-in-place must REUSE the same backend, not rebuild it" + ); + assert!( + second.is_started(), + "reconnect must restart chain sync on the reused backend's re-armed latch" + ); + + second.shutdown().await; +} + +/// Two genuinely-parallel first-open attempts on the SAME never-wired context +/// must NOT race into a double `WalletBackend::new` / `SqlitePersister::open`. +/// The upstream persister is single-open-per-path, so a concurrent double-open +/// errors — `WalletStorageError::AlreadyOpen` against a live persister (the +/// reported production symptom) or a DB-init race on a fresh file. +/// +/// This guards the GUI's `finalize_network_switch` fast path, which spawns a +/// `wallet-backend-eager-init` subtask on every switch with no re-entrancy +/// guard: a rapid switch-away-and-back to the same (already-cached) network +/// fires a second eager-init for the same context before the first finishes +/// wiring. `ensure_wallet_backend` serializes them behind the per-context +/// `wallet_backend_build` mutex with a double-checked slot — the first builds +/// and stores, the second re-checks under the guard, sees the populated slot, +/// and no-ops. One open, one shared backend, no error. The eager-init entry +/// `ensure_wallet_backend_and_start_spv` delegates its open to exactly this +/// function, so guarding the open here covers that path too. +/// +/// Deleting the guard (fast-path recheck + build mutex + post-guard recheck) +/// makes both racers reach `WalletBackend::new` and the second's open fails — +/// verified: the test then panics on the `must succeed` expectation. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_ensure_wallet_backend_does_not_double_open() { + let (ctx, sender, _tmp) = offline_testnet_context(); + assert!( + ctx.wallet_backend().is_err(), + "precondition: backend must be unwired before the concurrent race" + ); + + let ctx_a = Arc::clone(&ctx); + let ctx_b = Arc::clone(&ctx); + let sender_a = sender.clone(); + let sender_b = sender.clone(); + let a = tokio::spawn(async move { ctx_a.ensure_wallet_backend(sender_a).await }); + let b = tokio::spawn(async move { ctx_b.ensure_wallet_backend(sender_b).await }); + let (ra, rb) = tokio::join!(a, b); + + ra.expect("first-open task A must not panic") + .expect("concurrent first-open A must succeed — a double-open would error"); + rb.expect("first-open task B must not panic") + .expect("concurrent first-open B must succeed — a double-open would error"); + + // Exactly one backend was built and both racers converged on it (first + // writer wins; the second no-ops on the populated slot). + let backend = ctx + .wallet_backend() + .expect("backend must be wired after the concurrent open"); + + backend.shutdown().await; +} + +/// A failure at the (fallible) wiring step must surface — the +/// chokepoint returns `Err` AND flips the SPV indicator to `Error`, so the +/// user does not silently fall back to `Disconnected` with no feedback. +/// +/// Induces the wiring failure offline by planting a regular file where the +/// per-network SPV storage directory would be created: `WalletBackend::new` +/// calls `create_dir_all(data_dir/spv/testnet)`, which cannot succeed when a +/// path component (`spv`) is a file rather than a directory. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chokepoint_wiring_failure_flips_indicator_to_error() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + // Block the SPV storage dir creation: a file at `data_dir/spv` makes + // `create_dir_all(.../spv/testnet)` fail deterministically (no reliance + // on filesystem permissions, which root can bypass in CI). + std::fs::write(ctx.data_dir().join("spv"), b"not a directory") + .expect("plant blocking file at the spv path"); + + assert_ne!( + ctx.connection_status.spv_status(), + SpvStatus::Error, + "precondition: indicator must not already be in the Error state" + ); + + let err = ctx + .ensure_wallet_backend_and_start_spv(sender) + .await + .expect_err("wiring must fail when the spv path is blocked by a file"); + assert!( + matches!(err, TaskError::FileSystem { .. }), + "expected a FileSystem wiring error, got: {err:?}" + ); + + assert_eq!( + ctx.connection_status.spv_status(), + SpvStatus::Error, + "wiring failure must flip the SPV indicator to Error" + ); +} + +/// Cold-boot signability regression, adapted to the JIT secret model: a +/// no-password wallet must remain signable after a cold-boot hydration +/// without any seed ever being parked in a long-lived cache. +/// +/// Under the JIT chokepoint there is no `inner.seeds` cache to fill or +/// clear; signing decrypts the seed just-in-time from the encrypted vault +/// envelope. For a no-password wallet (`uses_password = false`) the +/// chokepoint's unprotected fast-path decrypts with **no passphrase and no +/// prompt** — so the wallet signs whether or not the session cache holds +/// it. This test proves that: +/// 1. a freshly-registered no-password wallet signs in-process; and +/// 2. after `forget_all_secrets()` wipes the session cache (the exact +/// state a real cold-boot leaves: watch-only, nothing remembered) the +/// wallet STILL signs — the seed is pulled from the vault on demand. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_password_wallet_resignable_via_unlock_chokepoint() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0x24u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("cold-boot".to_string()), + None, // no password + ) + .expect("build no-password wallet"); + assert!(wallet.is_open(), "a no-password wallet is open on creation"); + + let (seed_hash, wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + let backend = ctx.wallet_backend().expect("backend wired"); + + // Live (same-process) state: registration wrote the seed envelope to + // the vault, so the chokepoint can decrypt the no-password seed. + backend + .assert_can_sign(&seed_hash) + .await + .expect("freshly-registered no-password wallet must sign in-process"); + + // Simulate the seedless cold-boot state: wipe the session cache so + // nothing is remembered (what hydration leaves behind). The wallet is + // still `Open` for display, but no plaintext seed is cached anywhere. + backend.forget_all_secrets(); + assert!( + wallet_arc.read_recover().is_open(), + "the wallet is still Open after the session cache is dropped" + ); + + // The JIT guarantee: a no-password wallet signs from the vault with no + // prompt and no cache — the unprotected fast-path covers it. + backend + .assert_can_sign(&seed_hash) + .await + .expect("no-password wallet must sign after cold-boot via the JIT fast-path"); + + backend.shutdown().await; +} + +/// Leaving a network must not strand session-cached secrets on the +/// outgoing context. `finalize_network_switch` funnels through +/// [`WalletBackend::forget_all_secrets`]; this exercises that exact call +/// against a populated session cache and asserts it is emptied — the JIT +/// design's eager "no secrets linger across a network change" guarantee. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn network_switch_path_clears_outgoing_session_cache() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0x31u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("switching".to_string()), + None, + ) + .expect("build wallet"); + let (seed_hash, _wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + let scope = crate::wallet_backend::SecretScope::HdSeed { seed_hash }; + + // Promote the seed into the session cache (what the unlock gesture or a + // remembered op leaves behind). + let held = zeroize::Zeroizing::new(seed); + backend.secret_access().remember_session( + &scope, + crate::wallet_backend::SecretPlaintext::HdSeed(&held), + crate::wallet_backend::RememberPolicy::UntilAppClose, + ); + assert!( + backend.secret_access().is_session_cached(&scope), + "precondition: the seed is session-cached before the switch" + ); + + // The exact call `finalize_network_switch` makes on the outgoing + // context before leaving it. + backend.forget_all_secrets(); + + assert!( + !backend.secret_access().is_session_cached(&scope), + "the outgoing context's session cache must be empty after the switch path runs" + ); + + backend.shutdown().await; +} + +/// W1 idempotency: registering the same wallet twice with the +/// upstream backend is a no-op the second time — the wallet is watched once, +/// never double-watched. The pre-fix bug was the *opposite* (a never-watched +/// wallet); this pins that the new writer is also safe to call repeatedly, +/// as both W1 (create/import) and W2 (cold-boot) may fire for one wallet in +/// a single session. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_wallet_from_seed_is_idempotent() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let seed = [0x5Au8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + + assert!( + !backend.is_wallet_registered(&seed_hash), + "precondition: wallet must not be registered before the first call" + ); + + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("first registration must succeed"); + assert!( + backend.is_wallet_registered(&seed_hash), + "the wallet must be registered after the first call" + ); + assert_eq!( + backend.wallet_count().await, + 1, + "exactly one wallet is watched after the first registration" + ); + + // Second call: idempotent no-op, no double-watch. + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("second registration must be a no-op, not an error"); + assert_eq!( + backend.wallet_count().await, + 1, + "a repeat registration must not double-watch the wallet" + ); + + backend.shutdown().await; +} + +/// Regression guard for issue #7 (now PASSES — was the bug reproducer). +/// +/// Before the upstream fix (platform PR #3828), `WalletAccountCreationOptions::Default` +/// created BOTH a BIP32 account-0 (`m/0'`, depth-1) and a BIP44 account-0 +/// (`m/44'/coin'/0'`, depth-3), but the persistor collapsed both +/// `StandardAccountType` variants to the single `account_type` label +/// `"standard"`. They shared the `account_registrations` primary key +/// `(wallet_id, account_type, account_index)`, so the BIP32 row overwrote the +/// BIP44 row via `ON CONFLICT DO UPDATE`. The seedless cold-boot reload then +/// read back the depth-1 xpub, it matched no DET sidecar bridge entry, and the +/// fund-routing gate rejected every wallet -> systematic WalletNotLoaded. +/// +/// The fix distinguishes the two standard accounts in the persistor key: +/// the label is now `"standard_bip44"` vs `"standard_bip32"`, so both rows +/// coexist and the BIP44 depth-3 xpub survives alongside the BIP32 one. +/// This guard asserts the post-fix invariant: a current-binary wallet +/// survives create -> persist -> real `load_from_persistor_seedless` -> gate, +/// BOTH standard rows persist, and the stored BIP44 xpub matches the bridge. +/// +/// It inspects the persistor `account_registrations` directly (a read-only +/// rusqlite connection) rather than reopening an AppContext, because the +/// offline harness can't release the shared `app_kv` advisory lock to reopen. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn issue7_fresh_persistor_bip44_xpub_matches_det_bridge() { + let _serialize = backend_reopen_lock().await; + let temp_dir = tempfile::tempdir().expect("tempdir"); + + let seed = [0x71u8; 64]; + let (seed_hash, meta_xpub) = { + // ---- First boot: create + register through the full W1 path ---- + let (ctx, sender) = offline_testnet_context_at(temp_dir.path()); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend (first boot)"); + let backend = ctx.wallet_backend().expect("backend wired (first boot)"); + + let wallet = + crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + let det_master_bip44 = wallet.master_bip44_ecdsa_extended_public_key; + + // Write the wallet-meta sidecar (the seedless bridge key) DIRECTLY — + // avoid `register_wallet`, which spawns an upstream-registration + // subtask that keeps an `Arc<WalletBackend>` (and the shared app_kv + // handle) alive and blocks the cold-boot reopen below. + backend + .wallet_meta() + .set( + Network::Testnet, + &seed_hash, + &crate::model::wallet::meta::WalletMeta { + alias: String::new(), + is_main: false, + core_wallet_name: None, + xpub_encoded: det_master_bip44.encode().to_vec(), + uses_password: false, + password_hint: None, + }, + ) + .expect("write wallet-meta sidecar"); + + // W1 upstream registration via the REAL create_wallet_from_seed_bytes + // writer (awaited, no spawn). Confirms the FRESH in-memory create + // resolves through the gate. + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("W1 upstream registration must succeed on first boot"); + assert!( + backend.is_wallet_registered(&seed_hash), + "precondition: a fresh in-memory create must resolve through the gate" + ); + let meta_xpub = det_master_bip44.encode().to_vec(); + + backend.shutdown().await; + // Drain ctx1's subtasks + drop everything so the persistor + app_kv + // advisory locks release before the cold-boot reopen. + let _ = ctx.subtasks.shutdown_async().await; + drop(backend); + drop(ctx); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + (seed_hash, meta_xpub) + }; + + // Cold boot over a COPY of the on-disk state: the first context's + // app_kv/persistor advisory locks can linger in-process, so cold-booting + // over an identical-bytes copy drives the genuine + // `load_from_persistor_seedless` inside `WalletBackend::new` without a + // lock conflict. + let cold_dir = tempfile::tempdir().expect("cold tempdir"); + copy_dir_recursive(temp_dir.path(), cold_dir.path()); + + let cold_boot_registered = { + let data_dir = cold_dir.path().to_path_buf(); + let app_kv = AppContext::open_app_kv(&data_dir).expect("cold-boot open app k/v"); + let secret_store = + AppContext::open_secret_store(&data_dir).expect("cold-boot open secret store"); + let db = Arc::new( + create_database_at_path(&data_dir.join("data.db")).expect("reopen test database"), + ); + let ctx2 = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("cold-boot AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender2 = SenderAsync::new(tx, ctx2.egui_ctx().clone()); + // ensure_wallet_backend -> WalletBackend::new runs the real + // load_from_persistor_seedless pass (builds the bridge from the + // sidecar, loads the persistor, resolves via the fund-routing gate). + ctx2.ensure_wallet_backend(sender2) + .await + .expect("ensure_wallet_backend (cold boot)"); + let backend2 = ctx2.wallet_backend().expect("backend wired (cold boot)"); + let registered = backend2.is_wallet_registered(&seed_hash); + backend2.shutdown().await; + let _ = ctx2.subtasks.shutdown_async().await; + registered + }; + let _ = seed_hash; + + // Inspect the persistor on disk directly (a fresh read-only rusqlite + // connection; SQLite allows concurrent readers, so the lingering app_kv + // handle on the *other* file does not block this). This shows exactly + // what the seedless reload would read back for the BIP44 account-0 row — + // the gate's "loaded" side — without needing a second AppContext. + let persistor_path = temp_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let conn = rusqlite::Connection::open_with_flags( + &persistor_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open persistor read-only"); + let rows: Vec<(String, i64, Vec<u8>)> = conn + .prepare( + "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations", + ) + .expect("prepare") + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .expect("query") + .map(|r| r.expect("row")) + .collect(); + + // The seedless reload needs a BIP44 account-0 ("standard_bip44", 0) row + // to rebuild the watch-only account the gate reads. If it's absent or + // under a different key, the gate rejects every wallet on a fresh DB. + // The label is "standard_bip44" (not the pre-fix "standard"): the fix + // distinguishes the two StandardAccountType variants so the BIP44 row no + // longer shares a primary key with — and is no longer overwritten by — + // the BIP32 account-0 row. + let bip44_0_blob = rows + .iter() + .find(|(at, idx, _)| at == "standard_bip44" && *idx == 0) + .map(|(_, _, blob)| blob.clone()); + assert!( + bip44_0_blob.is_some(), + "persistor has no BIP44 account-0 (standard_bip44,0) row after W1. rows={rows:?}" + ); + + // Coexistence guarantee (the heart of the fix): the BIP32 account-0 row + // must ALSO survive — the collision used to drop one of the two. People + // hold funds on the BIP32 m/0' account, so it must never be clobbered. + let bip32_0_present = rows + .iter() + .any(|(at, idx, _)| at == "standard_bip32" && *idx == 0); + assert!( + bip32_0_present, + "persistor lost the BIP32 account-0 (standard_bip32,0) row — the collision fix must keep BOTH standard accounts. rows={rows:?}" + ); + + // The gate invariant: the persisted BIP44 account-0 xpub, decoded exactly + // as the seedless reload does, must equal DET's sidecar bridge xpub — + // that equality is what the fund-routing gate checks on a cold boot. + // Before the fix the stored row was the depth-1 BIP32 xpub, which + // differed and rejected every wallet. + { + use platform_wallet::changeset::AccountRegistrationEntry; + let blob = bip44_0_blob.unwrap(); + let cfg = bincode::config::standard(); + let (entry, _): (AccountRegistrationEntry, usize) = + bincode::serde::decode_from_slice(&blob, cfg).expect("decode stored entry"); + let stored_xpub_encoded = entry.account_xpub.encode().to_vec(); + assert_eq!( + stored_xpub_encoded, meta_xpub, + "stored BIP44 account-0 xpub must match the DET bridge xpub — the fund-routing gate rejects the wallet otherwise" + ); + } + + // Primary invariant: a current-binary wallet must survive + // create -> persist -> real load_from_persistor_seedless -> gate. A + // failure here means the persistor regressed to storing the depth-1 + // BIP32 row, which would resurrect the systematic WalletNotLoaded. + assert!( + cold_boot_registered, + "a current-binary wallet must resolve after cold-boot seedless reload; \ + a failure here resurrects the systematic WalletNotLoaded on a fresh DB" + ); +} + +/// `WalletTask::ListTrackedAssetLocks` reads tracked locks off the UI thread +/// through the App Task System. This drives the production dispatch path +/// (`run_backend_task`) for a registered wallet and asserts it returns the +/// typed `TrackedAssetLocks` result — the route the egui frame loop now uses +/// instead of the deleted in-runtime blocking read. A freshly-registered +/// wallet has no locks, so an empty list is the expected, panic-free result. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_tracked_asset_locks_task_returns_typed_result() { + use crate::backend_task::BackendTask; + use crate::backend_task::BackendTaskSuccessResult; + use crate::backend_task::wallet::WalletTask; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + let seed = [0x9Eu8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + // `run_backend_task` wires the backend on first wallet task and + // registers the wallet with the upstream manager. + let result = ctx + .run_backend_task( + BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { seed_hash }), + sender, + ) + .await + .expect("listing tracked asset locks must succeed"); + + match result { + BackendTaskSuccessResult::TrackedAssetLocks { + seed_hash: got_hash, + locks, + } => { + assert_eq!( + got_hash, seed_hash, + "result must carry the requested wallet" + ); + assert!( + locks.is_empty(), + "a freshly-registered wallet has no tracked asset locks" + ); + } + other => panic!("expected TrackedAssetLocks, got: {other:?}"), + } + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + +/// W2 reconciliation (idempotency across the two writers): once a +/// wallet is registered, the W2 `ensure_upstream_registered` path is a +/// no-op — it never re-registers or double-watches. This is the cold-boot +/// bridge's safety property: an already-watched wallet is left untouched +/// while a missing one is filled exactly once. +/// +/// The full cross-process cold-boot reload (a fresh `AppContext` over the +/// same persistor re-watching the wallet) and the live below-tip funding +/// repro both require process isolation — DET's `SpvProvider` holds a +/// strong `Arc<AppContext>`, so a second in-process context cannot open the +/// same secret-store vault. Those assertions live in the `#[ignore]` +/// backend-e2e lane (`tests/backend-e2e/wallet_reregistration.rs`), which +/// runs each context in its own workdir slot. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ensure_upstream_registered_is_noop_when_already_registered() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let seed = [0x6Bu8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + + // W1 registers it once. + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("initial registration must succeed"); + assert_eq!(backend.wallet_count().await, 1); + + // W2 over the same, already-registered wallet is a no-op. + backend + .ensure_upstream_registered(&seed_hash, &seed) + .await + .expect("W2 must be a no-op, not an error, for a registered wallet"); + assert_eq!( + backend.wallet_count().await, + 1, + "W2 must not double-watch an already-registered wallet" + ); + + backend.shutdown().await; +} + +/// Root-cause regression: `register_wallet` persists the +/// seed-envelope sidecar **before** the wallet backend is wired. +/// +/// This is the exact ordering the backend-e2e harness uses — register the +/// framework wallet first, wire the backend second. The pre-fix bug was that +/// `write_wallet_sidecars` required `self.wallet_backend()`, so the envelope +/// was never written and the W2 cold-boot bridge could not find a seed to +/// register from. With the vault handle owned by `AppContext`, the write +/// succeeds regardless of wiring order. Reading the envelope back through the +/// shared handle is the assertion that would have failed before the fix. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_wallet_persists_seed_envelope_before_backend_wired() { + let (ctx, _sender, _tmp) = offline_testnet_context(); + + assert!( + ctx.wallet_backend().is_err(), + "precondition: the backend must be unwired so we exercise the pre-wire path" + ); + + let seed = [0x7Cu8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("pre-wire".to_string()), + None, + ) + .expect("build no-password wallet"); + let (seed_hash, _wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Imported) + .expect("register wallet before the backend is wired"); + + // A no-password wallet persists the RAW seed via the seam (no legacy + // envelope), and the xpub rides in the WalletMeta sidecar. + let raw = WalletSeedView::new(&ctx.secret_store()) + .get_raw(&seed_hash) + .expect("vault read must not error") + .expect("the raw seed must be persisted at register time, even unwired"); + assert_eq!( + &*raw, &seed, + "persisted raw seed must equal the wallet seed" + ); + assert!( + WalletSeedView::new(&ctx.secret_store()) + .legacy_envelope_get(&seed_hash) + .unwrap() + .is_none(), + "no legacy envelope is written for a no-password wallet" + ); + let meta = WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .expect("wallet-meta sidecar persisted at register time"); + assert!(!meta.uses_password, "no-password wallet meta flag"); + assert_eq!( + meta.xpub_encoded, + ctx.wallets + .read() + .unwrap() + .get(&seed_hash) + .unwrap() + .read() + .unwrap() + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(), + "the persisted xpub must match the registered wallet's BIP44 account xpub" + ); +} + +/// End-to-end on the harness ordering: a wallet registered +/// **before** the backend is wired is registered with the upstream SPV +/// manager once the backend comes up — the W2 cold-boot bridge fires from +/// the seed envelope persisted at register time. +/// +/// This is the in-process half of the live repro: it proves the chain from +/// the persisted envelope through `bootstrap_loaded_wallets` → +/// `bootstrap_wallet_addresses_jit` → `ensure_upstream_registered` without a +/// launch-time prompt (the wallet is unprotected, so the chokepoint's +/// no-passphrase fast-path resolves the seed). The funded below-tip balance +/// assertion needs a live testnet and lives in the `#[ignore]` backend-e2e +/// lane. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wallet_registered_before_wiring_is_upstream_registered_on_cold_boot() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + let seed = [0x8Du8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("cold-boot-bridge".to_string()), + None, + ) + .expect("build no-password wallet"); + let (seed_hash, _wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Imported) + .expect("register wallet before wiring"); + + // Wiring runs hydration + the cold-boot bootstrap, which drives the W2 + // bridge from the now-persisted seed envelope. + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + assert!( + backend.is_wallet_registered(&seed_hash), + "the wallet must be upstream-registered by the W2 bridge after wiring" + ); + assert_eq!( + backend.wallet_count().await, + 1, + "exactly one wallet must be watched after the cold-boot bridge runs" + ); + + backend.shutdown().await; +} + +/// `unregistered_open_wallet_count` must count a wallet whose +/// `RwLock` is poisoned, so a prior panic can never let a premature +/// "completed" sentinel through. The previous implementation counted over +/// the `open_wallets()` snapshot, which drops a poisoned-lock wallet +/// (`read().ok()...unwrap_or(false)`) before the fail-safe could see it — +/// that version returns 0 here and fails this test. +#[tokio::test] +async fn unregistered_count_fails_safe_on_poisoned_wallet_lock() { + let (ctx, _sender, _tmp) = offline_testnet_context(); + + // One wallet, inserted straight into the map (no backend wired). + let wallet = + crate::model::wallet::Wallet::new_from_seed([0x42u8; 64], Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + let arc = Arc::new(std::sync::RwLock::new(wallet)); + ctx.wallets + .write() + .expect("wallets map lock") + .insert(seed_hash, Arc::clone(&arc)); + + // Poison the wallet's lock by panicking while holding its write guard. + let poisoner = Arc::clone(&arc); + let _ = std::thread::spawn(move || { + let _guard = poisoner.write().expect("acquire write lock"); + panic!("intentional poison for the fail-safe test"); + }) + .join(); + assert!( + arc.read().is_err(), + "precondition: the wallet lock must be poisoned", + ); + + assert_eq!( + ctx.unregistered_open_wallet_count(), + 1, + "a poisoned wallet lock must fail safe (counted), not be silently dropped", + ); +} + +/// Fresh-install regression: on a truly-fresh install the real +/// `Database::initialize` path gates the legacy `wallet`/`wallet_addresses` +/// tables OUT, so `register_wallet` must not depend on them. The pre-fix +/// `store_wallet_with_addresses` ran an unguarded `INSERT INTO wallet` that +/// failed with `no such table: wallet`, so `register_wallet` returned `Err` +/// before any in-memory registration — fresh installs could never create or +/// import a wallet. This drives the exact production path and asserts success +/// plus in-memory registration. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_wallet_succeeds_on_fresh_install_without_legacy_tables() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let (ctx, _sender) = offline_testnet_context_fresh_init(temp_dir.path()); + + // Precondition: the fresh-install schema must NOT carry the legacy + // wallet table — this is the state that exposed the bug. Querying it + // surfaces sqlite's "no such table: wallet" error. + let probe = ctx.db.get_wallets(&Network::Testnet); + assert!( + probe.is_err(), + "precondition: fresh install must not create the legacy `wallet` table" + ); + + let seed = [0x9Eu8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("fresh-install".to_string()), + None, + ) + .expect("build no-password wallet"); + let seed_hash = wallet.seed_hash(); + + let (returned_hash, _wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register_wallet must succeed on a fresh install"); + assert_eq!(returned_hash, seed_hash); + + assert!( + ctx.wallets.read_recover().contains_key(&seed_hash), + "the wallet must be registered in-memory after register_wallet" + ); + assert!( + ctx.has_wallet.load(Ordering::Relaxed), + "the has_wallet flag must flip true after a successful registration" + ); +} + +/// Removing a wallet wipes its secret-bearing state: the encrypted +/// seed-envelope vault entry. Orchard state lives in the upstream +/// coordinator and is detached on removal. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_wipes_seed_envelope() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xA1u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + + // Precondition: the raw seed is present (no-password wallet stores raw). + assert!( + WalletSeedView::new(&ctx.secret_store()) + .get_raw(&seed_hash) + .expect("vault read") + .is_some(), + "precondition: the raw seed must exist before removal" + ); + + ctx.remove_wallet(&seed_hash).expect("remove wallet"); + + // The seed (the JIT decrypt source) is gone in BOTH forms. + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); + assert!( + view.get_raw(&seed_hash) + .expect("raw read after removal") + .is_none(), + "the raw seed must be deleted from the vault on removal" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after removal") + .is_none(), + "any legacy envelope must also be gone on 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 +/// surface the removed wallet's stale shielded balance until the next sync +/// overwrote it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_evicts_shielded_balance_snapshot() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xB2u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + + // Seed a snapshot entry as the sync-completed push writer would. + ctx.shielded_balances + .lock() + .expect("lock shielded_balances") + .insert(seed_hash, 123_456); + assert_eq!( + ctx.shielded_balance_credits(&seed_hash), + 123_456, + "precondition: the snapshot entry must exist before removal" + ); + + ctx.remove_wallet(&seed_hash).expect("remove wallet"); + + assert!( + ctx.shielded_balances + .lock() + .expect("lock shielded_balances") + .get(&seed_hash) + .is_none(), + "the shielded balance snapshot must be evicted on removal" + ); + + backend.shutdown().await; +} + +/// F17/F20 (fresh-install regression): removing a wallet must still wipe +/// its secret-bearing state on a truly-fresh install where the legacy +/// `wallet`/`wallet_addresses`/`utxos` tables are gated OUT of the schema. +/// +/// The sibling `remove_wallet_wipes_seed_envelope` +/// builds its context with `create_tables(true)`, which force-creates +/// those legacy tables and therefore masks this path. Here the real +/// `Database::initialize` fresh path runs, so the unguarded +/// `SELECT address FROM wallet_addresses` in `Database::remove_wallet` +/// errored with `no such table` and propagated through +/// `AppContext::remove_wallet` BEFORE the secret wipe — leaving the seed +/// envelope on disk. The existence-guarded +/// statements now no-op cleanly so the caller reaches the wipe. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_wipes_secrets_on_fresh_install_without_legacy_tables() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let (ctx, sender) = offline_testnet_context_fresh_init(temp_dir.path()); + + // Precondition: the fresh-install schema must NOT carry the legacy + // `wallet_addresses` table — querying it surfaces sqlite's + // "no such table: wallet" error from `get_wallets`. This is the state + // under which the unguarded `remove_wallet` aborted before the wipe. + assert!( + ctx.db.get_wallets(&Network::Testnet).is_err(), + "precondition: fresh install must not create the legacy wallet tables" + ); + + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xF6u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + + // Precondition: the raw seed exists. + assert!( + WalletSeedView::new(&ctx.secret_store()) + .get_raw(&seed_hash) + .expect("vault read") + .is_some(), + "precondition: the raw seed must exist before removal" + ); + + // Pre-fix this returned `Err(no such table: wallet_addresses)` and the + // wipe below never ran. + ctx.remove_wallet(&seed_hash) + .expect("remove_wallet must succeed on a fresh install"); + + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); + assert!( + view.get_raw(&seed_hash) + .expect("raw read after removal") + .is_none(), + "the raw seed must be deleted from the vault on a fresh install" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after removal") + .is_none(), + "no legacy envelope must survive removal on a fresh install" + ); + + backend.shutdown().await; +} + +/// F60 — "delete all local data" must leave no wallet recoverable: the +/// wallet-meta sidecar (which the cold-boot picker reads) and the +/// seed-envelope vault (which holds the encrypted seed) must both be +/// empty. Before the fix, `clear_network_database` cleared only legacy +/// data.db + the in-memory maps, so wallets rehydrated on next launch and +/// encrypted seeds persisted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_network_database_wipes_wallet_meta_and_seed_envelope() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xB2u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + // Preconditions: both the meta sidecar and the seed envelope exist. + assert!( + WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .is_some(), + "precondition: wallet-meta sidecar must exist before clear" + ); + assert!( + WalletSeedView::new(&ctx.secret_store()) + .get_raw(&seed_hash) + .expect("vault read") + .is_some(), + "precondition: raw seed must exist before clear" + ); + + ctx.clear_network_database() + .expect("clear_network_database should succeed"); + + // The wallet must not rehydrate: its meta and seed (both forms) are gone. + assert!( + WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .is_none(), + "wallet-meta sidecar must be empty after clear (no rehydration)" + ); + let store = ctx.secret_store(); + let view = WalletSeedView::new(&store); + assert!( + view.get_raw(&seed_hash) + .expect("raw read after clear") + .is_none(), + "raw seed must be deleted from the vault after clear" + ); + assert!( + view.legacy_envelope_get(&seed_hash) + .expect("legacy read after clear") + .is_none(), + "no legacy envelope must survive clear" + ); + assert!( + ctx.wallets.read_recover().is_empty(), + "the in-memory wallet map must be empty after clear" + ); + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + +/// F131 — locking a wallet must wipe the session-cached seed. Before the +/// fix `handle_wallet_locked` was an empty no-op, so after an +/// `UntilAppClose` unlock the plaintext seed stayed resident and the wallet +/// kept signing with no prompt despite being "locked". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn lock_wipes_session_cached_seed() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xC3u8; 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 (_seed_hash, wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + let scope = crate::wallet_backend::SecretScope::HdSeed { seed_hash }; + + // Promote the seed into the session cache (what an UntilAppClose unlock + // leaves behind). + let held = zeroize::Zeroizing::new(seed); + backend.secret_access().remember_session( + &scope, + crate::wallet_backend::SecretPlaintext::HdSeed(&held), + crate::wallet_backend::RememberPolicy::UntilAppClose, + ); + assert!( + backend.secret_access().is_session_cached(&scope), + "precondition: the seed is session-cached before the lock" + ); + + ctx.handle_wallet_locked(&wallet_arc); + + assert!( + !backend.secret_access().is_session_cached(&scope), + "locking must wipe the session-cached seed" + ); + + backend.shutdown().await; +} + +/// F62 — when the seed-envelope vault write fails, `register_wallet` must +/// FAIL CLOSED: return `Err` and NOT keep the wallet. The envelope is the +/// encrypted seed the W2 cold-boot bridge re-registers from, so silently +/// keeping an in-session wallet whose seed was never saved would lose the +/// wallet and its funds at the next launch. Before the fix the envelope +/// write was best-effort (warn + Ok), so the wallet was kept regardless. +/// +/// Induces the write failure permission-free by replacing the vault file +/// with a directory: the store's atomic `persist` rename onto a directory +/// path fails deterministically (root cannot bypass this). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_wallet_fails_closed_when_seed_envelope_write_fails() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let (ctx, _sender) = offline_testnet_context_at(temp_dir.path()); + + // Replace the resident vault file with a directory so the next vault + // write (the atomic persist rename) fails. + let vault_path = temp_dir.path().join("secrets").join("det-secrets.pwsvault"); + std::fs::remove_file(&vault_path).expect("remove vault file"); + std::fs::create_dir(&vault_path).expect("plant directory at vault path"); + + let seed = [0xD4u8; 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 result = ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh); + assert!( + result.is_err(), + "register_wallet must fail closed when the seed envelope cannot be saved" + ); + assert!( + !ctx.wallets.read_recover().contains_key(&seed_hash), + "a wallet whose seed was not saved must not be kept in memory" + ); + assert!( + !ctx.has_wallet.load(Ordering::Relaxed), + "has_wallet must not flip true when registration fails closed" + ); +} + +/// When the wallet-meta sidecar write fails, `register_wallet` +/// must FAIL CLOSED: return `Err` and NOT keep the wallet. Cold-boot +/// hydration (`hydrate_wallets_for_network`) enumerates ONLY the meta +/// sidecar — `ctx.wallets` is rebuilt solely from `WalletMetaView::list`. +/// A wallet whose seed envelope was saved but whose meta row is missing is +/// never hydrated, so its funds become unreachable with no self-heal (there +/// is no upstream→meta reconstruction path). Both sidecars are required, so +/// the meta write must be fail-closed just like the seed-envelope write. +/// +/// Induces the meta-write failure permission-free by dropping the +/// `meta_global` table from `det-app.sqlite` (which backs `app_kv`) through +/// a second connection: the next `WalletMetaView::set` upsert errors with +/// "no such table", deterministically, with no filesystem race. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn register_wallet_fails_closed_when_wallet_meta_write_fails() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let (ctx, _sender) = offline_testnet_context_at(temp_dir.path()); + + // Drop the table the wallet-meta sidecar upserts into, so the next + // `WalletMetaView::set` fails. The persister holds its own connection; + // a second connection to the same file is enough to drop the shared + // schema object. + { + let meta_db = temp_dir.path().join("det-app.sqlite"); + let conn = rusqlite::Connection::open(&meta_db).expect("open det-app.sqlite second handle"); + conn.execute("DROP TABLE meta_global", []) + .expect("drop meta_global to force the wallet-meta write to fail"); + } + + let seed = [0x17u8; 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 result = ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh); + assert!( + result.is_err(), + "register_wallet must fail closed when the wallet-meta sidecar cannot be saved" + ); + assert!( + !ctx.wallets.read_recover().contains_key(&seed_hash), + "a wallet with no meta row must not be kept in memory (it would never hydrate)" + ); + assert!( + !ctx.has_wallet.load(Ordering::Relaxed), + "has_wallet must not flip true when registration fails closed" + ); +} + +/// Build a valid BIP44 account-0 master xpub for a legacy wallet row. +fn legacy_master_epk_bytes(seed: &[u8; 64]) -> Vec<u8> { + 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() +} + +/// F140 — a wallet migrated from legacy `data.db` must be visible right +/// after the migration completes, NOT only after a second restart. The bug: +/// `WalletBackend::new` runs `hydrate_context_wallets` against the still- +/// empty sidecars at first boot; migration then populates the sidecars but +/// never re-hydrates `ctx.wallets`, so the in-memory map stays empty until +/// the next launch reads the now-populated sidecars. The fix re-hydrates at +/// the end of a successful migration. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn migrated_wallet_is_visible_without_second_restart() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + // Seed a legacy `wallet` row with a valid xpub so the migration's + // seed + meta passes produce a hydratable wallet. + use crate::database::test_helpers::seed_legacy_unprotected_hd_wallet_row; + let seed = [0xE5u8; 64]; + let seed_hash: WalletSeedHash = crate::model::wallet::ClosedKeyItem::compute_seed_hash(&seed); + let epk = legacy_master_epk_bytes(&seed); + seed_legacy_unprotected_hd_wallet_row( + &ctx.db, + &seed_hash, + &seed, + &epk, + "migrated-wallet", + Network::Testnet, + ) + .expect("insert legacy wallet row"); + + // Wire the backend: hydration runs now, against the EMPTY sidecars + // (migration has not run yet), so ctx.wallets is empty. + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + assert!( + !ctx.wallets.read_recover().contains_key(&seed_hash), + "precondition: the migrated wallet is not yet hydrated (sidecars empty at wiring)" + ); + + // Run the migration. It populates the sidecars AND now re-hydrates. + crate::backend_task::migration::finish_unwire::run(&ctx) + .await + .expect("migration should succeed"); + + // The migrated wallet must be visible WITHOUT a second backend build. + assert!( + ctx.wallets.read_recover().contains_key(&seed_hash), + "the migrated wallet must be in ctx.wallets right after migration (no second restart)" + ); + assert!( + ctx.has_wallet.load(Ordering::Relaxed), + "has_wallet must be true after a migrated wallet is hydrated" + ); + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + +/// F140 (resolve half) — a wallet migrated from legacy `data.db` at cold +/// start must be RESOLVABLE through the wallet backend right after the +/// migration completes, NOT only after a second restart. The bug: the +/// post-migration re-hydration (`hydrate_context_wallets`) refills +/// `ctx.wallets` (so the wallet shows in the picker and addresses resolve), +/// but it never re-runs the W2 cold-boot reconciliation +/// (`bootstrap_loaded_wallets` → `ensure_upstream_registered`). So the +/// upstream `id_map` stays empty and every seed-keyed operation +/// (`resolve_wallet`) returns `WalletNotLoaded` until the next launch — +/// exactly the "wallet still loading" banner that repeats forever in the +/// field report. The companion F140 test above only proves `ctx.wallets` +/// visibility; this one proves upstream registration, which is what +/// `resolve_wallet` keys off. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn migrated_wallet_is_upstream_registered_without_second_restart() { + let (ctx, sender, _tmp) = offline_testnet_context(); + + // Seed a legacy unprotected `wallet` row whose verbatim seed and + // published xpub agree, so the migration's seed + meta passes produce a + // wallet the W2 fund-routing gate will accept. + use crate::database::test_helpers::seed_legacy_unprotected_hd_wallet_row; + let seed = [0xD7u8; 64]; + let seed_hash: WalletSeedHash = crate::model::wallet::ClosedKeyItem::compute_seed_hash(&seed); + let epk = legacy_master_epk_bytes(&seed); + seed_legacy_unprotected_hd_wallet_row( + &ctx.db, + &seed_hash, + &seed, + &epk, + "migrated-wallet", + Network::Testnet, + ) + .expect("insert legacy wallet row"); + + // Wire the backend: hydration + the cold-boot bootstrap run NOW, against + // the EMPTY sidecars (the migration has not run yet), so the upstream + // persistor is empty and nothing is registered. + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + assert!( + !backend.is_wallet_registered(&seed_hash), + "precondition: the migrated wallet is not yet upstream-registered (sidecars empty at wiring)" + ); + + // Run the cold-start migration. It populates the sidecars, re-hydrates + // `ctx.wallets`, AND must re-run the W2 cold-boot reconciliation so the + // just-migrated wallet is registered upstream. + crate::backend_task::migration::finish_unwire::run(&ctx) + .await + .expect("migration should succeed"); + + // The migrated wallet must be RESOLVABLE WITHOUT a second backend build: + // `is_wallet_registered` reads the same `id_map` that `resolve_wallet` + // consults, so this is a deterministic proxy for "`resolve_wallet` + // succeeds". + assert!( + backend.is_wallet_registered(&seed_hash), + "the migrated wallet must be upstream-registered right after migration (no second restart)" + ); + + backend.shutdown().await; +} + +/// Protected cold-start hydration — a *password-protected* wallet migrated +/// from legacy `data.db` at cold start must hydrate into `ctx.wallets` but +/// must NOT be upstream-registered until the user unlocks it. The cold-start +/// migration re-runs the W2 cold-boot bridge +/// (`bootstrap_loaded_wallets` → `bootstrap_wallet_addresses_jit`), but that +/// bridge gates on `Wallet::is_open()`: a protected wallet hydrates as +/// `WalletSeed::Closed`, so `is_open()` is `false` and the bridge returns +/// early — before any `with_secret_session` (no passphrase prompt) and +/// before `ensure_upstream_registered` (no registration). The companion +/// unprotected test above proves eager registration of unprotected wallets; +/// this one locks in the deferral for protected wallets so it can't +/// silently regress into a surprise startup prompt or a `WalletLocked` +/// failure mid-migration. +/// +/// It would FAIL if someone dropped the `is_open()` gate and made the +/// bridge enter the seed scope for a locked protected wallet: the chokepoint +/// would request a passphrase prompt during migration (the recording prompt +/// double below would see a non-zero call count), which is exactly the +/// surprise startup prompt the deferral exists to prevent. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn migrated_protected_wallet_registration_is_deferred_until_unlock() { + use crate::database::test_helpers::seed_legacy_protected_hd_wallet_row; + use crate::model::wallet::encryption::encrypt_message; + use crate::wallet_backend::{ + SecretPrompt, SecretPromptCancelled, SecretPromptReply, SecretPromptRequest, + }; + use std::sync::atomic::AtomicUsize; + + /// A `SecretPrompt` double that records how many times the chokepoint + /// asked the host to unlock a wallet, then declines like a headless + /// host. A still-locked protected wallet must NOT trigger any request + /// during cold-start migration — the count must stay zero. + #[derive(Default)] + struct RecordingPrompt { + requests: AtomicUsize, + } + #[async_trait::async_trait] + impl SecretPrompt for RecordingPrompt { + async fn request( + &self, + _request: SecretPromptRequest, + ) -> Result<SecretPromptReply, SecretPromptCancelled> { + self.requests.fetch_add(1, Ordering::Relaxed); + Err(SecretPromptCancelled) + } + fn is_interactive(&self) -> bool { + // Interactive on purpose: a non-interactive host would let the + // chokepoint short-circuit before requesting. We want any + // attempt to reach `request` so a dropped gate is observable. + true + } + } + + let (ctx, sender, _tmp) = offline_testnet_context(); + + // Install the recording prompt BEFORE the backend is built — that is + // when the chokepoint reads the host (see `install_secret_prompt`). + let prompt = Arc::new(RecordingPrompt::default()); + ctx.install_secret_prompt(prompt.clone() as Arc<dyn SecretPrompt>); + + // Stage a legacy PROTECTED `wallet` row: the seed is AES-GCM-encrypted + // under a passphrase the test never feeds back in, so the wallet stays + // locked across the whole migration. The published BIP44 xpub agrees + // with the seed so the W2 fund-routing gate would accept it *if* the + // gate were reached — it must not be. + let seed = [0x42u8; 64]; + let passphrase = "correct-horse-battery-staple"; + let seed_hash: WalletSeedHash = crate::model::wallet::ClosedKeyItem::compute_seed_hash(&seed); + let epk = legacy_master_epk_bytes(&seed); + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext: encrypted_seed, + salt, + nonce, + } = encrypt_message(&seed, passphrase).expect("encrypt legacy seed"); + seed_legacy_protected_hd_wallet_row( + &ctx.db, + &seed_hash, + &encrypted_seed, + &salt, + &nonce, + &epk, + "protected-wallet", + Some("the usual passphrase"), + Network::Testnet, + ) + .expect("insert legacy protected wallet row"); + + // Wire the backend: hydration + the cold-boot bootstrap run now against + // the EMPTY sidecars (migration has not run), so nothing is registered. + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + // (a) The cold-start migration must complete with NO error and NO panic. + // A passphrase prompt is impossible here (offline, headless) — if the + // deferral broke and the bridge entered the seed scope, the locked + // envelope would surface `WalletLocked` inside `bootstrap_*`. That path + // is best-effort/logged (it does not fail the migration), so the strong + // assertion is the deferred-registration check in (b). + crate::backend_task::migration::finish_unwire::run(&ctx) + .await + .expect("migration must succeed for a protected wallet (no error, no prompt)"); + + // (b) The protected wallet is hydrated into `ctx.wallets` (visible in + // the picker, name preserved) but stays LOCKED — `is_open()` is false. + let wallet_arc = ctx + .wallets + .read() + .unwrap() + .get(&seed_hash) + .cloned() + .expect("protected wallet must be hydrated into ctx.wallets after migration"); + assert!( + !wallet_arc.read_recover().is_open(), + "a migrated protected wallet must hydrate locked (WalletSeed::Closed)" + ); + assert!( + wallet_arc.read_recover().uses_password, + "the hydrated wallet must carry the password flag" + ); + + // (b cont.) Registration is DEFERRED: the wallet is present in + // `ctx.wallets` but NOT yet in the upstream `id_map` that + // `resolve_wallet` keys off. This is the regression trap — eager + // registration would flip this `true`. + assert!( + !backend.is_wallet_registered(&seed_hash), + "a still-locked protected wallet must NOT be upstream-registered by the migration (deferred to unlock)" + ); + + // (c) The migration itself must not register any wallet at all: with a + // single locked protected wallet, the watched-wallet set stays empty. + assert_eq!( + backend.wallet_count().await, + 0, + "the migration must register no wallets while the only wallet is locked" + ); + + // (a, strong form) The deferral is prompt-free: the cold-boot bridge + // must never have asked the host to unlock the wallet. This is the + // regression trap — dropping the `is_open()` gate would make the bridge + // enter the seed scope and request a prompt, flipping this above zero. + assert_eq!( + prompt.requests.load(Ordering::Relaxed), + 0, + "the migration must never prompt for a passphrase while a protected wallet is locked" + ); + + backend.shutdown().await; +} + +/// Protected-unlock reconciliation (the delete-DB + re-import +/// acceptance flow): a password-protected wallet that hydrates LOCKED at cold +/// boot, and is therefore deferred by the W2 bridge (proven by +/// [`migrated_protected_wallet_registration_is_deferred_until_unlock`]), MUST +/// become upstream-registered on the unlock gesture — without a second app +/// restart. +/// +/// The gap this guards: before the fix, the unlock path +/// ([`AppContext::handle_wallet_unlocked`]) only promoted the just-verified +/// seed into the session cache; it never re-drove +/// [`AppContext::bootstrap_wallet_addresses_jit`], so the wallet stayed out +/// of the upstream `id_map` that `resolve_wallet` keys off and every +/// seed-keyed operation kept failing with `WalletNotLoaded` for the rest of +/// the session. The fix re-drives the JIT bootstrap from +/// `handle_wallet_unlocked` once the seed is in the session cache; this test +/// asserts the post-unlock registration that fix enables. +/// +/// Staging mirrors the deferral test: a legacy PROTECTED `wallet` row is +/// migrated so the wallet hydrates `Closed` (locked) with EMPTY persistor and +/// is NOT registered. Then the wallet is opened with the real passphrase and +/// `handle_wallet_unlocked` is invoked exactly as the unlock popup does +/// (`src/ui/components/wallet_unlock_popup.rs`), passing the passphrase so the +/// seed resolves prompt-free from the session cache. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn protected_wallet_registers_upstream_on_unlock_without_restart() { + use crate::database::test_helpers::seed_legacy_protected_hd_wallet_row; + use crate::model::wallet::encryption::encrypt_message; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + // Stage a legacy PROTECTED `wallet` row whose published BIP44 xpub agrees + // with the seed, so the W2 fund-routing gate accepts it once reached. The + // passphrase is the one the test feeds back in at unlock time. + let seed = [0x42u8; 64]; + let passphrase = "correct-horse-battery-staple"; + let seed_hash: WalletSeedHash = crate::model::wallet::ClosedKeyItem::compute_seed_hash(&seed); + let epk = legacy_master_epk_bytes(&seed); + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext: encrypted_seed, + salt, + nonce, + } = encrypt_message(&seed, passphrase).expect("encrypt legacy seed"); + seed_legacy_protected_hd_wallet_row( + &ctx.db, + &seed_hash, + &encrypted_seed, + &salt, + &nonce, + &epk, + "protected-wallet", + Some("the usual passphrase"), + Network::Testnet, + ) + .expect("insert legacy protected wallet row"); + + // Wire the backend, then run the cold-start migration. This reproduces + // the boot state of the acceptance flow: the protected wallet hydrates + // into `ctx.wallets` but stays LOCKED, and the W2 bridge defers it. + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + crate::backend_task::migration::finish_unwire::run(&ctx) + .await + .expect("migration must succeed for a protected wallet"); + + let wallet_arc = ctx + .wallets + .read() + .unwrap() + .get(&seed_hash) + .cloned() + .expect("protected wallet must be hydrated into ctx.wallets after migration"); + + // Precondition: the locked protected wallet is NOT yet registered — the + // exact `WalletNotLoaded`-producing state the unlock must clear. + assert!( + !wallet_arc.read_recover().is_open(), + "precondition: the protected wallet hydrates locked" + ); + assert!( + !backend.is_wallet_registered(&seed_hash), + "precondition: a still-locked protected wallet is not upstream-registered" + ); + + // The unlock gesture, exactly as the unlock popup performs it: open the + // in-memory wallet by verifying the passphrase, then notify the context + // with that passphrase so the seed is promoted to the session cache and + // (with the fix) the JIT bootstrap is re-driven. + wallet_arc + .write() + .unwrap() + .wallet_seed + .open(passphrase) + .expect("correct passphrase opens the wallet"); + ctx.handle_wallet_unlocked(&wallet_arc, passphrase); + + // `handle_wallet_unlocked` spawns the registration on a tracked subtask, + // so poll the `id_map` (what `resolve_wallet` consults) with a bounded + // deadline rather than racing it. The deadline is generous because the + // unlock reconciliation uses the genesis-floored `Imported` birth height + // (`ensure_upstream_registered`), and the upstream + // `create_wallet_from_seed_bytes` scan-window setup over the empty + // offline persistor takes several seconds with no chain to read. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + while !backend.is_wallet_registered(&seed_hash) { + assert!( + tokio::time::Instant::now() < deadline, + "the protected wallet must be upstream-registered after unlock (no second restart)" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + // The wallet is now watched exactly once — the unlock reconciliation does + // not double-watch. + assert_eq!( + backend.wallet_count().await, + 1, + "exactly one wallet must be watched after the unlock reconciliation" + ); + + // Tier-2 keep-protection migration post-conditions. The + // unlock decrypted the legacy AES-GCM envelope and RE-WRAPPED the seed + // as a Tier-2 object-password envelope (protection KEPT, not downgraded + // to a raw secret), then dropped the legacy envelope. + let store = ctx.secret_store(); + let seed_view = WalletSeedView::new(&store); + // Steady state is Tier-2 protected. + assert_eq!( + seed_view.scheme(&seed_hash).expect("scheme"), + crate::wallet_backend::secret_seam::SecretScheme::Protected, + "the seed must be re-wrapped to Tier-2, never downgraded to raw" + ); + // A raw (password-free) read of a protected seed must fail — never strip. + assert!( + seed_view.get_raw(&seed_hash).is_err(), + "a raw read of a Tier-2-protected seed must fail" + ); + // It reads back only WITH the object password, byte-for-byte. + let pw = platform_wallet_storage::secrets::SecretString::new(passphrase); + let protected = seed_view + .get_protected(&seed_hash, &pw) + .expect("protected read") + .expect("the seed must be re-stored as Tier-2 after the migrating unlock"); + assert_eq!( + &*protected, &seed, + "Tier-2 seed must equal the true 64-byte seed" + ); + assert!( + seed_view + .legacy_envelope_get(&seed_hash) + .expect("legacy read") + .is_none(), + "the legacy envelope must be deleted after migration" + ); + // The sidecar password flag STAYS true — protection was kept, so the + // metadata stays accurate (no downgrade flip). + let meta = WalletMetaView::new(&ctx.app_kv()) + .get(Network::Testnet, &seed_hash) + .expect("wallet meta present"); + assert!( + meta.uses_password, + "WalletMeta.uses_password must stay true — Tier-2 keeps protection" + ); + + // A SECOND secret resolve still requires the object password (Tier-2 is + // not prompt-free): a scripted prompt that supplies it resolves the seed. + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::{SecretAccess, SecretScope}; + let prompt = std::sync::Arc::new(TestPrompt::new([ScriptedAnswer::once(passphrase)])); + let sa = SecretAccess::new(ctx.secret_store(), prompt.clone(), Network::Testnet); + let resolved = sa + .with_secret(&SecretScope::HdSeed { seed_hash }, |pt| { + Ok(pt.expose_hd_seed().copied()) + }) + .await + .expect("second resolve with the password"); + assert_eq!(resolved, Some(seed), "password resolve returns the seed"); + assert_eq!( + prompt.ask_count(), + 1, + "the protected seed prompts exactly once" + ); + + backend.shutdown().await; +} + +/// F61 — clearing the SPV chain cache removes every `dash-spv` storage +/// folder/file (and the storage lock) under the per-network directory while +/// leaving the wallet (`platform-wallet.sqlite`) and shielded sidecars +/// intact. The pre-fix `clear_spv_data` was a no-op that still reported +/// success. +#[test] +fn clear_spv_chain_storage_removes_chain_cache_but_keeps_wallet_sidecars() { + let tmp = tempfile::tempdir().expect("tempdir"); + let spv_dir = spv_storage_dir(tmp.path(), Network::Testnet); + std::fs::create_dir_all(&spv_dir).expect("create spv dir"); + + // Plant one file inside each chain-storage folder, plus the loose + // peers.dat and the sibling storage lock. + for entry in [ + "block_headers", + "filter_headers", + "filters", + "blocks", + "metadata", + "masternodestate", + ] { + let folder = spv_dir.join(entry); + std::fs::create_dir_all(&folder).expect("create chain folder"); + std::fs::write(folder.join("segment.dat"), b"x").expect("write chain segment"); + } + std::fs::write(spv_dir.join("peers.dat"), b"peers").expect("write peers"); + std::fs::write(spv_dir.with_extension("lock"), b"lock").expect("write lock"); + + // Plant the wallet + shielded sidecars that must survive the clear. + let wallet_sqlite = spv_dir.join("platform-wallet.sqlite"); + let shielded_tree = spv_dir.join("shielded-commitment-tree.sqlite"); + std::fs::write(&wallet_sqlite, b"wallet").expect("write wallet sqlite"); + std::fs::write(&shielded_tree, b"tree").expect("write shielded tree"); + + clear_spv_chain_storage(&spv_dir).expect("clear must succeed"); + + for entry in SPV_CHAIN_STORAGE_ENTRIES { + assert!( + !spv_dir.join(entry).exists(), + "chain-storage entry {entry} must be deleted" + ); + } + assert!( + !spv_dir.with_extension("lock").exists(), + "the storage lock must be deleted" + ); + assert!( + wallet_sqlite.exists(), + "platform-wallet.sqlite must survive an SPV-cache clear" + ); + assert!( + shielded_tree.exists(), + "the shielded commitment tree must survive an SPV-cache clear" + ); +} + +/// F61 — a never-synced network has no SPV directory at all; clearing it is +/// a success, not an error. +#[test] +fn clear_spv_chain_storage_is_ok_when_directory_absent() { + let tmp = tempfile::tempdir().expect("tempdir"); + let spv_dir = spv_storage_dir(tmp.path(), Network::Testnet); + assert!( + !spv_dir.exists(), + "precondition: no spv dir on a fresh install" + ); + clear_spv_chain_storage(&spv_dir).expect("clearing an absent cache must succeed"); +} + +/// Seed a legacy password-protected `single_key_wallet` row into the +/// context's `data.db`, encrypted under `password`. Returns the +/// derived address. The default test DB created `single_key_wallet` +/// via `create_tables(true)`, so we only INSERT. +fn seed_legacy_protected_single_key( + ctx: &Arc<AppContext>, + raw_key: &[u8; 32], + password: &str, + alias: Option<&str>, +) -> String { + use crate::model::wallet::single_key::ClosedSingleKey; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; + + let path = ctx.db.db_file_path().expect("data.db path"); + let conn = rusqlite::Connection::open(&path).expect("open data.db"); + + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext, + salt, + nonce, + } = ClosedSingleKey::encrypt_private_key(raw_key, password).expect("encrypt"); + let priv_key = PrivateKey::from_byte_array(raw_key, Network::Testnet).expect("priv"); + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); + let key_hash = ClosedSingleKey::compute_key_hash(raw_key); + conn.execute( + "INSERT INTO single_key_wallet + (key_hash, encrypted_private_key, salt, nonce, public_key, + address, alias, uses_password, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8)", + rusqlite::params![ + key_hash.as_slice(), + ciphertext, + salt, + nonce, + pub_key.inner.serialize().to_vec(), + address, + alias, + Network::Testnet.to_string(), + ], + ) + .expect("insert legacy protected row"); + address +} + +/// T-SK-03 end-to-end — a legacy password-protected single-key row is +/// restored with the correct old password: the key lands in the modern +/// vault, becomes listable, and drops off the pending list. A wrong +/// password leaves the legacy row intact and surfaces the generic +/// failure (no oracle, no corruption). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restore_protected_single_key_round_trip_and_wrong_password() { + use crate::backend_task::migration::single_key_restore::{ + list_pending_protected_restores, restore_protected_single_key, + }; + use crate::wallet_backend::single_key::ImportPassphrase; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let mut raw = [0u8; 32]; + raw[31] = 0x2A; + let address = + seed_legacy_protected_single_key(&ctx, &raw, "old-legacy-password", Some("savings")); + + // The protected row shows up as pending (still encrypted under the + // old password; not in the modern vault yet). + let pending = list_pending_protected_restores(&ctx).expect("list pending"); + assert_eq!(pending.len(), 1, "exactly one protected row awaits restore"); + assert_eq!(pending[0].address, address); + + // Wrong password: generic failure, nothing restored, row intact. + let err = restore_protected_single_key( + &ctx, + &address, + "WRONG-password", + ImportPassphrase::default(), + ) + .expect_err("wrong password must fail"); + assert!( + matches!(err, TaskError::SingleKeyPassphraseIncorrect), + "wrong password must surface the generic incorrect error, got {err:?}" + ); + let still_pending = list_pending_protected_restores(&ctx).expect("re-list pending"); + assert_eq!( + still_pending.len(), + 1, + "a failed restore must leave the protected row pending and uncorrupted" + ); + + // Correct password: the key is restored into the modern vault under + // a fresh passphrase and becomes listable at the same address (S5). + let restored_addr = restore_protected_single_key( + &ctx, + &address, + "old-legacy-password", + ImportPassphrase { + passphrase: Some(zeroize::Zeroizing::new("a-fresh-strong-passphrase".into())), + hint: Some("the new one".into()), + }, + ) + .expect("correct password must restore the key"); + assert_eq!(restored_addr, address, "restored address must be stable"); + + // It is now in the modern single-key index and no longer pending. + let backend = ctx.wallet_backend().expect("backend wired"); + let listed = backend.single_key().list(); + assert!( + listed + .iter() + .any(|k| k.address == address && k.has_passphrase), + "restored key must be listable and passphrase-protected" + ); + let after = list_pending_protected_restores(&ctx).expect("final pending"); + assert!( + after.is_empty(), + "the restored key must drop off the pending list" + ); +} + +/// A protected key restored WITHOUT choosing a new passphrase +/// (`has_passphrase == false`) is still fully recovered, so the +/// data-loss gate must recognize it as restored and permit the future +/// T7 drop. Before the fix the gate keyed on `has_passphrase` and +/// would have blocked the drop forever. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gate_recognizes_restore_without_new_passphrase() { + use crate::backend_task::migration::finish_unwire::{ + drop_legacy_single_key_table_when_safe, ensure_legacy_single_key_table_droppable, + }; + use crate::backend_task::migration::single_key_restore::restore_protected_single_key; + use crate::wallet_backend::single_key::ImportPassphrase; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let mut raw = [0u8; 32]; + raw[31] = 0x5B; + let address = + seed_legacy_protected_single_key(&ctx, &raw, "old-legacy-password", Some("plain")); + + // While the protected row is un-restored, the gate must block. + let blocked = ensure_legacy_single_key_table_droppable(&ctx) + .expect_err("gate must block while a protected row is un-restored"); + assert!( + matches!(blocked, TaskError::MigrationFailed { .. }), + "blocked drop must wrap the migration error, got {blocked:?}" + ); + + // Restore WITHOUT a new passphrase → has_passphrase == false. + restore_protected_single_key( + &ctx, + &address, + "old-legacy-password", + ImportPassphrase::default(), + ) + .expect("restore without a new passphrase must succeed"); + let backend = ctx.wallet_backend().expect("backend wired"); + assert!( + backend + .single_key() + .list() + .iter() + .any(|k| k.address == address && !k.has_passphrase), + "the key must be restored unprotected (has_passphrase == false)" + ); + + // The gate must now recognize the address as restored and permit + // the drop — keyed on presence, not the passphrase flag. + ensure_legacy_single_key_table_droppable(&ctx) + .expect("gate must recognize an unprotected restore as restored"); + drop_legacy_single_key_table_when_safe(&ctx) + .expect("the sanctioned drop must succeed once every key is restored"); +} + +/// Build a deterministic compressed testnet WIF from `raw` so the +/// single-key import tests stay offline and reproducible. +fn testnet_wif_from_raw(raw: &[u8; 32]) -> String { + use dash_sdk::dpp::dashcore::PrivateKey; + PrivateKey::from_byte_array(raw, Network::Testnet) + .expect("valid private key bytes") + .to_wif() +} + +/// Importing a **passphrase-protected** single key must NOT retain the +/// decrypted private key in the long-lived `single_key_wallets` session +/// map. The in-memory mirror must come back closed — exactly the shape +/// cold boot reconstructs — so the per-key passphrase is not silently +/// defeated by a plaintext copy lingering for the whole session. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn protected_single_key_import_does_not_retain_plaintext_in_session_map() { + use crate::wallet_backend::single_key::ImportPassphrase; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let mut raw = [0u8; 32]; + raw[31] = 0x77; + let wif = testnet_wif_from_raw(&raw); + + let passphrase = ImportPassphrase { + passphrase: Some(zeroize::Zeroizing::new("a-strong-passphrase".into())), + hint: Some("the test one".into()), + }; + let (imported, wallet_arc) = ctx + .import_single_key_wif(&wif, Some("protected".into()), passphrase) + .expect("protected import must succeed"); + assert!( + imported.has_passphrase, + "the imported metadata must record the per-key passphrase" + ); + + // The in-memory mirror must be closed: no `is_open`, no plaintext key + // obtainable, and the underlying data must be the encrypted variant. + let guard = wallet_arc.read().expect("read mirror"); + assert!( + !guard.is_open(), + "a protected single key must be mirrored closed, not open with plaintext" + ); + assert!( + guard.private_key(Network::Testnet).is_none(), + "no plaintext private key may be retrievable from the session-map mirror" + ); + assert!( + matches!( + guard.private_key_data, + crate::model::wallet::single_key::SingleKeyData::Closed(_) + ), + "the mirrored key data must be the Closed (encrypted) variant" + ); + assert!( + guard.uses_password, + "the mirror must advertise that it needs a password" + ); + + // The same closed entry must be the one tracked in the session map. + let key_hash = guard.key_hash(); + drop(guard); + let map = ctx.single_key_wallets.read().expect("read map"); + let in_map = map.get(&key_hash).expect("imported key present in map"); + assert!( + !in_map.read().expect("read map entry").is_open(), + "the session-map entry for a protected key must stay closed" + ); +} + +/// Companion to the protected-key test: an **unprotected** single key +/// has no passphrase by definition, so plaintext in the session map is +/// inherent and the mirror is expected to be open. This guards against +/// over-correcting and breaking the no-passphrase fast path. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unprotected_single_key_import_mirrors_open() { + use crate::wallet_backend::single_key::ImportPassphrase; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let mut raw = [0u8; 32]; + raw[31] = 0x55; + let wif = testnet_wif_from_raw(&raw); + + let (imported, wallet_arc) = ctx + .import_single_key_wif(&wif, Some("plain".into()), ImportPassphrase::default()) + .expect("unprotected import must succeed"); + assert!( + !imported.has_passphrase, + "an unprotected import must record no per-key passphrase" + ); + + let guard = wallet_arc.read().expect("read mirror"); + assert!( + guard.is_open(), + "an unprotected single key is mirrored open (plaintext is inherent)" + ); + assert!( + guard.private_key(Network::Testnet).is_some(), + "an unprotected mirror exposes its private key for signing" + ); + assert!( + !guard.uses_password, + "an unprotected mirror must not advertise a password requirement" + ); +} + +/// The "Unlock" gesture for a protected single key must confirm the +/// passphrase against the vault WITHOUT re-parking the decrypted private +/// key in the long-lived `single_key_wallets` map. The map entry must stay +/// closed both before and after a successful unlock; a wrong passphrase +/// surfaces the generic incorrect error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn protected_single_key_unlock_verifies_without_reparking_plaintext() { + use crate::wallet_backend::single_key::ImportPassphrase; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let mut raw = [0u8; 32]; + raw[31] = 0x91; + let wif = testnet_wif_from_raw(&raw); + let pass = "a-strong-passphrase"; + + let passphrase = ImportPassphrase { + passphrase: Some(zeroize::Zeroizing::new(pass.into())), + hint: None, + }; + let (_imported, wallet_arc) = ctx + .import_single_key_wif(&wif, Some("protected".into()), passphrase) + .expect("protected import must succeed"); + let address = wallet_arc.read().expect("read mirror").address.to_string(); + + // Closed before the unlock gesture. + assert!( + !wallet_arc.read().expect("read mirror").is_open(), + "a protected key must be closed before unlock" + ); + + // A wrong passphrase surfaces the generic incorrect error and leaves + // the entry closed. + let wrong = ctx + .verify_single_key_passphrase(&address, "not-the-passphrase") + .expect_err("a wrong passphrase must fail"); + assert!( + matches!(wrong, TaskError::SingleKeyPassphraseIncorrect), + "wrong passphrase must surface the generic incorrect error, got {wrong:?}" + ); + assert!( + !wallet_arc.read().expect("read mirror").is_open(), + "a failed unlock must leave the key closed" + ); + + // The correct passphrase verifies successfully — and the key STILL + // stays closed: no plaintext is re-parked in the session map. + ctx.verify_single_key_passphrase(&address, pass) + .expect("the correct passphrase must verify"); + let guard = wallet_arc.read().expect("read mirror"); + assert!( + !guard.is_open(), + "a successful unlock must NOT open the map entry (no plaintext re-parked)" + ); + assert!( + guard.private_key(Network::Testnet).is_none(), + "no plaintext private key may be retrievable after unlock" + ); + assert!( + matches!( + guard.private_key_data, + crate::model::wallet::single_key::SingleKeyData::Closed(_) + ), + "the map entry must remain the Closed (encrypted) variant after unlock" + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Automatic identity-discovery trigger / latch / re-arm +// ────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn all_wallets_discovery_latch_is_one_shot_until_stop_spv() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + assert!( + !ctx.identity_autodiscovery_fired.load(Ordering::SeqCst), + "latch starts unfired" + ); + + // First fire latches; a second fire is swallowed (no second sweep). + ctx.queue_all_wallets_identity_discovery(); + assert!( + ctx.identity_autodiscovery_fired.load(Ordering::SeqCst), + "first call must set the one-shot latch" + ); + ctx.queue_all_wallets_identity_discovery(); + assert!( + ctx.identity_autodiscovery_fired.load(Ordering::SeqCst), + "latch stays set; the second call is a no-op" + ); + + // stop_spv re-arms the latch so the next reconnect runs discovery again. + ctx.stop_spv().await; + assert!( + !ctx.identity_autodiscovery_fired.load(Ordering::SeqCst), + "stop_spv must clear the latch to re-arm discovery on reconnect" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn open_wallets_snapshot_excludes_locked_wallets() { + use crate::database::test_helpers::seed_legacy_protected_hd_wallet_row; + use crate::model::wallet::encryption::encrypt_message; + + let (ctx, sender, _tmp) = offline_testnet_context(); + + // A locked, password-protected wallet staged via the legacy migration + // row: it hydrates `WalletSeed::Closed` and must be excluded. + let locked_seed = [0x77u8; 64]; + let locked_hash: WalletSeedHash = + crate::model::wallet::ClosedKeyItem::compute_seed_hash(&locked_seed); + let epk = legacy_master_epk_bytes(&locked_seed); + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext: encrypted_seed, + salt, + nonce, + } = encrypt_message(&locked_seed, "a-passphrase-never-fed-back").expect("encrypt seed"); + seed_legacy_protected_hd_wallet_row( + &ctx.db, + &locked_hash, + &encrypted_seed, + &salt, + &nonce, + &epk, + "locked-wallet", + None, + Network::Testnet, + ) + .expect("insert legacy protected wallet row"); + + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + // An open, no-password wallet registered alongside it. + let open_seed = [0x66u8; 64]; + let open_wallet = + crate::model::wallet::Wallet::new_from_seed(open_seed, Network::Testnet, None, None) + .expect("build open wallet"); + let open_hash = open_wallet.seed_hash(); + ctx.register_wallet(open_wallet, &open_seed, WalletOrigin::Fresh) + .expect("register open wallet"); + + let snapshot: Vec<WalletSeedHash> = ctx + .open_wallets() + .iter() + .map(|w| w.read_recover().seed_hash()) + .collect(); + + assert!( + snapshot.contains(&open_hash), + "the open wallet must be in the snapshot" + ); + assert!( + !snapshot.contains(&locked_hash), + "the locked protected wallet must be excluded from the snapshot" + ); + + backend.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rediscovery_update_preserves_user_alias_and_wallet_binding() { + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::platform::Identifier; + use std::collections::BTreeMap; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let identity_id = Identifier::from([7u8; 32]); + let make_qi = |alias: Option<&str>| { + let identity = Identity::create_basic_identity(identity_id, ctx.platform_version()) + .expect("basic identity"); + 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: KeyStorage { + private_keys: BTreeMap::new(), + }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + }; + + // Initial store with a user alias and a wallet binding. + let wallet_hash: WalletSeedHash = [0x09u8; 32]; + ctx.insert_local_qualified_identity(&make_qi(Some("my-id")), &Some((wallet_hash, 3))) + .expect("insert identity with alias"); + + // Simulate re-discovery: build a FRESH QI with no alias, carry the + // existing alias (the carry-over under test), then update in place. + let mut refreshed = make_qi(None); + let existing = ctx + .get_identity_by_id(&identity_id) + .expect("load existing") + .expect("identity present"); + refreshed.alias = existing.alias; + ctx.update_local_qualified_identity(&refreshed) + .expect("update preserving alias"); + + // The alias survives, and the wallet binding is preserved by the update. + let reloaded = ctx + .get_identity_by_id(&identity_id) + .expect("reload identity") + .expect("identity present after update"); + assert_eq!( + reloaded.alias.as_deref(), + Some("my-id"), + "the user alias must survive a re-discovery update (F-1 regression guard)" + ); + assert_eq!( + reloaded.wallet_index, + Some(3), + "the wallet binding index must be preserved across the update" + ); + + backend.shutdown().await; +} + +/// C.7 regression guard: `ensure_identity_funding_accounts` must succeed on +/// a cold-booted (watch-only) wallet for a fresh `IdentityTopUp{index}`. +/// +/// # Background +/// +/// DET always reloads wallets **seedless** from the upstream persister. +/// `WalletBackend::new` → `load_from_persistor_seedless` → upstream +/// `load_from_persistor()` → `Wallet::new_watch_only(…)`. The wallet has +/// the BIP44/BIP32 accounts it was persisted with, but **no root private +/// key**. +/// +/// `WalletAccountCreationOptions::Default` (used by +/// `register_wallet_from_seed`) creates `IdentityRegistration` by default +/// and persists it in the account manifest. `IdentityTopUp{n}` is NOT +/// created by default — it is added only after a register/top-up, so on +/// every cold boot the manifest lacks it. +/// +/// Before the fix, `provision_identity_funding_account` called +/// `kw.add_account(account_type, None)`. On a cold-boot wallet that path +/// reaches `root_extended_keys.rs:428` and fails: +/// +/// `WalletBackend { source: AssetLockTransaction("Invalid parameter: +/// Watch-only wallet has no private key") }` +/// +/// After the fix it builds a short-lived signable wallet from the provided +/// seed bytes, derives the account xpub, and calls +/// `kw.add_account(account_type, Some(xpub))` — succeeds regardless of +/// private-key availability. +/// +/// # Why deterministic +/// +/// The cold-booted wallet unconditionally has no root private key; the +/// failure path is hit every time regardless of timing or network state. +/// +/// # Test structure +/// +/// Two-boot scenario to match production: +/// 1. **Boot 1**: wire backend, write both sidecars (wallet-meta + upstream +/// persister) from seed. +/// 2. **Boot 2 (cold)**: `WalletBackend::new` over a copy of the same +/// data dir runs `load_from_persistor_seedless` — the upstream wallet is +/// loaded watch-only. Then `ensure_identity_funding_accounts` for a +/// fresh `IdentityTopUp{3}` must return `Ok`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet() { + // ── Boot 1: write wallet-meta sidecar + upstream persister from seed ── + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let seed = [0xC7u8; 64]; + let seed_hash = { + let wallet = crate::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + None, + None, // no password + ) + .expect("build wallet"); + let h = wallet.seed_hash(); + + let (ctx, sender) = offline_testnet_context_at(temp_dir.path()); + + // Register the wallet BEFORE wiring the backend. register_wallet + // writes the DET sidecars (seed-envelope vault + wallet-meta), but + // register_wallet_upstream checks ctx.wallet_backend() and, finding it + // not yet wired, returns early without spawning the background + // "wallet_upstream_registration" subtask. This avoids the concurrency + // hazard: if the backend were wired first the background subtask would + // race with the synchronous register_wallet_from_seed call below — + // both call create_wallet_from_seed_bytes for the same wallet. The + // upstream register_wallet inserts into wallet_manager (step A) and into + // self.wallets (step B) with async work in between; a concurrent caller + // that arrives between A and B sees WalletAlreadyExists but then + // get_wallet returns None → WalletNotFound panic. Under CI load + // (1000+ concurrent tests) this window is reliably hit. + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("boot 1: ctx.register_wallet"); + + // Wire the backend now so the explicit registration below has the + // upstream persister available. + ctx.ensure_wallet_backend(sender) + .await + .expect("boot 1: ensure_wallet_backend offline"); + + // Write the upstream persister synchronously — no background subtask + // is in flight (we didn't wire the backend when register_wallet ran), + // so this call is race-free. + let backend1 = ctx.wallet_backend().expect("boot 1 backend"); + backend1 + .register_wallet_from_seed(&h, &seed, Some(0)) + .await + .expect("boot 1: upstream register"); + backend1.shutdown().await; + + h + }; + // ctx is dropped here, releasing app_kv / secret_store file handles. + + // ── Cold-boot copy: avoid file-lock conflicts with lingering subtasks ── + // + // The background registration subtask may still hold an Arc<WalletBackend> + // (and thus an open SqlitePersister handle on temp_dir). We copy the + // on-disk state to a fresh path so Boot 2's SqlitePersister::open does + // not collide with the old one. Identical on-disk bytes — the fund- + // routing gate and the persisted manifest are preserved. + let cold_dir = tempfile::tempdir().expect("cold tempdir"); + copy_dir_recursive(temp_dir.path(), cold_dir.path()); + + // ── Boot 2 (cold): load from persister → watch-only upstream wallet ── + + let (ctx2, sender2) = offline_testnet_context_at(cold_dir.path()); + ctx2.ensure_wallet_backend(sender2) + .await + .expect("boot 2 (cold): ensure_wallet_backend offline"); + let backend2 = ctx2.wallet_backend().expect("boot 2 backend"); + + assert!( + backend2.is_wallet_registered(&seed_hash), + "cold boot must load the wallet from the persisted sidecars" + ); + + // `IdentityTopUp{3}` is absent from the account manifest (it is never + // created by WalletAccountCreationOptions::Default) — so the cold-booted + // watch-only wallet triggers the provisioning branch. + // + // Before the fix: kw.add_account(IdentityTopUp{3}, None) + // → "Watch-only wallet has no private key" → Err + // After the fix: builds a seed wallet, derives the account xpub, + // calls kw.add_account(IdentityTopUp{3}, Some(xpub)) → Ok + let registration_index = 3u32; + backend2 + .ensure_identity_funding_accounts(&seed_hash, &seed, registration_index) + .await + .expect( + "cold-booted watch-only wallet: IdentityTopUp{3} provisioning must succeed; \ + if 'Watch-only wallet has no private key' appears the fix has been reverted", + ); + + // Idempotent: both accounts now present — second call is a no-op. + backend2 + .ensure_identity_funding_accounts(&seed_hash, &seed, registration_index) + .await + .expect("second call must be idempotent (both accounts already present)"); + + backend2.shutdown().await; +} + +/// Build a minimal basic identity for manager-reconcile tests — only its +/// id() and (empty) public_keys() are read by `add_identity`. +fn basic_test_identity() -> dash_sdk::dpp::identity::Identity { + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + Identity::create_basic_identity(Identifier::random(), PlatformVersion::latest()) + .expect("basic identity") +} + +/// Wrap a basic identity in a minimal wallet-owned `QualifiedIdentity` for +/// sidecar-reconcile tests. +fn wallet_owned_qualified_identity( + wallet_index: Option<u32>, +) -> crate::model::qualified_identity::QualifiedIdentity { + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + QualifiedIdentity { + identity: basic_test_identity(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index, + top_ups: std::collections::BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } +} + +/// `ensure_identity_managed` on a wallet that is not upstream-registered +/// fails with `WalletNotLoaded` (and the reconcile driver logs-and-skips it +/// rather than aborting). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ensure_identity_managed_unregistered_wallet_is_wallet_not_loaded() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let unknown_seed: WalletSeedHash = [0x5Au8; 32]; + let identity = basic_test_identity(); + let err = backend + .ensure_identity_managed(&unknown_seed, &identity, 0) + .await + .expect_err("an unregistered wallet must not resolve"); + assert!( + matches!(err, TaskError::WalletNotLoaded), + "expected WalletNotLoaded, got: {err:?}" + ); + + backend.shutdown().await; +} + +/// `ensure_identity_managed` registers a previously-unknown identity (→ +/// `true`), then a second call is a no-op (→ `false`). Runs with no secret +/// session promoted, proving the reconcile is seed-free / locked-safe. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ensure_identity_managed_registers_then_noops_while_locked() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let seed = [0x2Cu8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet with upstream manager"); + + let identity = basic_test_identity(); + + // No secret session is open here — the wallet is effectively locked. + let first = backend + .ensure_identity_managed(&seed_hash, &identity, 0) + .await + .expect("registering a new identity must succeed while locked"); + assert!(first, "first call newly registers the identity"); + + let second = backend + .ensure_identity_managed(&seed_hash, &identity, 0) + .await + .expect("second call must be idempotent"); + assert!(!second, "second call is a no-op (already managed)"); + + backend.shutdown().await; +} + +/// `reconcile_managed_identities` registers exactly the wallet-owned +/// identities (`wallet_index.is_some()` and matching `seed_hash`) and leaves +/// index-less sidecar entries alone — proven via the idempotent +/// `ensure_identity_managed` (already-managed → `false`, never-managed → +/// `true`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reconcile_managed_identities_registers_only_wallet_owned() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let seed = [0x3Du8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + backend + .register_wallet_from_seed(&seed_hash, &seed, None) + .await + .expect("register wallet with upstream manager"); + + // Two wallet-owned identities (should be reconciled) and one index-less + // identity (should be skipped by the `wallet_index.is_some()` filter). + let owned_a = wallet_owned_qualified_identity(Some(0)); + let owned_b = wallet_owned_qualified_identity(Some(1)); + let detached = wallet_owned_qualified_identity(None); + ctx.insert_local_qualified_identity(&owned_a, &Some((seed_hash, 0))) + .expect("insert owned_a"); + ctx.insert_local_qualified_identity(&owned_b, &Some((seed_hash, 1))) + .expect("insert owned_b"); + ctx.insert_local_qualified_identity(&detached, &None) + .expect("insert detached"); + + ctx.reconcile_managed_identities(&backend, &seed_hash).await; + + // The two wallet-owned identities are now managed → ensure is a no-op. + assert!( + !backend + .ensure_identity_managed(&seed_hash, &owned_a.identity, 0) + .await + .expect("owned_a"), + "wallet-owned identity A must already be managed after reconcile" + ); + assert!( + !backend + .ensure_identity_managed(&seed_hash, &owned_b.identity, 1) + .await + .expect("owned_b"), + "wallet-owned identity B must already be managed after reconcile" + ); + // The index-less identity was skipped → ensure newly registers it. + assert!( + backend + .ensure_identity_managed(&seed_hash, &detached.identity, 0) + .await + .expect("detached"), + "index-less identity must have been skipped by the reconcile filter" + ); + + backend.shutdown().await; +} diff --git a/src/context/wallet_lifecycle/unlock.rs b/src/context/wallet_lifecycle/unlock.rs new file mode 100644 index 000000000..5c66f11e3 --- /dev/null +++ b/src/context/wallet_lifecycle/unlock.rs @@ -0,0 +1,210 @@ +//! Lock / unlock handling: reacting to a wallet becoming unlocked or locked +//! and snapshotting the set of open wallets. + +use super::*; + +impl AppContext { + /// Honor the "keep unlocked" gesture for a password-protected wallet. + /// + /// Since the JIT migration this is **not** a seed-distribution point — + /// signing pulls the seed just-in-time from the encrypted vault through + /// the [`SecretAccess`](crate::wallet_backend::SecretAccess) chokepoint. + /// Its only job is to promote the just-verified seed into the session cache + /// (`UntilAppClose`) so the rest of the session's operations on this wallet + /// do not re-prompt, then re-drive the JIT bootstrap so the wallet is + /// upstream-registered this session. + /// + /// `passphrase` is the secret the UI just validated via + /// [`WalletSeed::open`](crate::model::wallet::WalletSeed::open). Callers + /// invoke this only when the user opted to keep a password wallet unlocked; + /// a non-remember unlock simply does not call here, and a no-password wallet + /// resolves prompt-free through the chokepoint's unprotected fast-path. + /// + /// The seed is obtained ONLY by decrypting the stored envelope through the + /// chokepoint — no parked seed is read, because an open `Wallet` parks none + /// (R3). Shielded state is not warmed here: it is derived on the first + /// shielded operation via the chokepoint. + pub fn handle_wallet_unlocked( + self: &Arc<Self>, + wallet: &Arc<RwLock<Wallet>>, + passphrase: &str, + ) { + let (seed_hash, uses_password) = match wallet.read() { + Ok(guard) => (guard.seed_hash(), guard.uses_password), + Err(_) => return, + }; + + // No-password wallets need no promotion — they resolve prompt-free + // through the chokepoint's unprotected fast-path. + if !uses_password { + return; + } + + let Ok(backend) = self.wallet_backend() else { + return; + }; + let secret = platform_wallet_storage::secrets::SecretString::new(passphrase); + match backend.secret_access().promote_hd_seed_with_passphrase( + &seed_hash, + Some(&secret), + crate::wallet_backend::RememberPolicy::UntilAppClose, + ) { + // Tier-2 keep-protection: the seed re-wraps under the same password + // inside the chokepoint — no downgrade to finalize, `uses_password` + // stays accurate. The verified-open just promotes it to the cache. + Ok(()) => tracing::trace!( + wallet = %hex::encode(seed_hash), + "Verified-open seed promoted to the session cache on unlock" + ), + Err(error) => tracing::debug!( + wallet = %hex::encode(seed_hash), + %error, + "Unlock seed promotion skipped" + ), + } + + // W2 reconciliation on the unlock gesture. A + // password-protected wallet hydrates `Closed` at cold boot, so + // `bootstrap_wallet_addresses_jit` skips it (no surprise startup prompt) + // and it is never upstream-registered until the seed becomes available. + // The unlock just verified the passphrase and promoted the seed into the + // session cache above, so re-driving the JIT bootstrap now registers the + // wallet with the upstream SPV backend without a second prompt — the + // difference between the wallet being usable this session and a + // `WalletNotLoaded` until the next launch. Idempotent (an + // already-registered wallet is a no-op) and resolved prompt-free from the + // session cache. The in-memory wallet is already flipped `Open` by the + // unlock callsite before this runs, so the JIT `is_open()` gate passes. + self.drive_unlock_registration(wallet); + + // The background all-wallets sweep skips a wallet that is locked at + // Platform-ready time, so a just-unlocked wallet is searched here. This + // is the "searched after unlock" path the all-wallets sweep documents. + self.queue_unlocked_wallet_identity_discovery(wallet); + } + + /// Spawn the unlock-triggered JIT bootstrap/registration for a wallet whose + /// seed was just promoted to the session cache by [`Self::handle_wallet_unlocked`]. + /// + /// `handle_wallet_unlocked` is synchronous (called from the UI thread) while + /// [`Self::bootstrap_wallet_addresses_jit`] is async, so the reconciliation + /// runs on a tracked subtask — mirroring [`Self::register_wallet_upstream`]. + /// Best-effort: the JIT bootstrap logs and swallows its own failures, and a + /// missing-backend cold-boot path is covered by `bootstrap_loaded_wallets`. + fn drive_unlock_registration(self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>) { + let ctx = Arc::clone(self); + let wallet = Arc::clone(wallet); + self.subtasks + .spawn_sync("wallet_unlock_registration", async move { + ctx.bootstrap_wallet_addresses_jit(&wallet).await; + }); + } + + /// Wipe the session-cached seed when a wallet is locked. + /// + /// Unlock promotes the seed into the JIT session cache with + /// [`RememberPolicy::UntilAppClose`](crate::wallet_backend::RememberPolicy), + /// and signing resolves cache-first — so without this the wallet would keep + /// signing prompt-free after a "lock", leaving plaintext seed bytes + /// resident. Forgetting the seed's scope here restores the locked + /// guarantee: the next signing op re-prompts (or, for a no-password wallet, + /// resolves through the chokepoint's unprotected fast-path). Mirrors + /// [`Self::handle_wallet_unlocked`]'s promotion, in reverse. + pub fn handle_wallet_locked(self: &Arc<Self>, wallet: &Arc<RwLock<Wallet>>) { + let Ok(seed_hash) = wallet.read().map(|guard| guard.seed_hash()) else { + return; + }; + let Ok(backend) = self.wallet_backend() else { + return; + }; + backend + .secret_access() + .forget(&crate::wallet_backend::SecretScope::HdSeed { seed_hash }); + tracing::trace!( + wallet = %hex::encode(seed_hash), + "Session-cached seed wiped on wallet lock" + ); + } + + /// Bind Orchard ZIP-32 keys for all currently-open wallets that have not + /// yet been shielded-bound through the upstream coordinator. Called when + /// the network protocol version first crosses the shielded threshold — + /// at that point open wallets are typically already bootstrapped and + /// registered, so the regular JIT path in + /// [`Self::bootstrap_wallet_addresses_jit`] would have been the right + /// vehicle, but it may not have run yet for wallets opened before the + /// version was known. + /// + /// Reuses `bootstrap_wallet_addresses_jit` (which now unconditionally + /// calls `ensure_shielded_bound`) so the logic is not duplicated. + /// The upstream 60 s `ShieldedSyncManager` loop picks up any newly bound + /// wallets automatically — no manual sync trigger needed. + /// Snapshot the currently-open wallet arcs, dropping the read lock before + /// returning. A locked protected wallet hydrates `WalletSeed::Closed`, so + /// `is_open()` excludes it — the single source of truth for "which wallets a + /// background pass may touch without a passphrase prompt." + pub(super) fn open_wallets(self: &Arc<Self>) -> Vec<Arc<RwLock<Wallet>>> { + self.wallets + .read() + .ok() + .map(|wallets| { + wallets + .values() + .filter(|w| w.read().ok().map(|g| g.is_open()).unwrap_or(false)) + .cloned() + .collect() + }) + .unwrap_or_default() + } + + /// Count wallets that block the cold-start completion sentinel: an OPEN + /// wallet not yet registered with the upstream wallet backend, OR any + /// wallet whose lock cannot be read. + /// + /// The migration writes its sentinel only when this is zero. Soundness for + /// the registered set relies on the copy step rejecting exactly what + /// hydration drops (see + /// `migration::finish_unwire::hd_seed_row_is_hydratable`), so every wallet + /// that reached the vault is hydrated and seen here. + /// + /// Counted (sentinel withheld): + /// - a readable, open, not-yet-registered wallet; + /// - any wallet whose `RwLock` cannot be read — fail-safe, so a poisoned + /// lock can never green-light a premature "completed". + /// + /// Excluded (does not block): + /// - a readable, `Closed` / locked password-protected wallet — it registers + /// on its unlock gesture, so requiring it would wedge the sentinel on a + /// protected install. + /// + /// Counts over the raw `self.wallets` map, NOT the [`Self::open_wallets`] + /// snapshot — that snapshot already drops a poisoned-lock wallet before the + /// fail-safe could see it. A poisoned OUTER map lock is recovered via + /// `into_inner` so a prior panic elsewhere cannot zero the count. When the + /// backend is not yet wired nothing is registered, so every open (or + /// unreadable) wallet counts. + pub(crate) fn unregistered_open_wallet_count(self: &Arc<Self>) -> usize { + let backend = self.wallet_backend().ok(); + let guard = match self.wallets.read() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard + .values() + .filter(|w| match w.read() { + // Unreadable per-wallet lock: cannot prove it is registered, so + // fail safe and count it (withholds the sentinel). + Err(_) => true, + // Readable Closed / locked-protected: excluded — it registers on + // its unlock gesture, so requiring it would wedge the sentinel. + Ok(g) if !g.is_open() => false, + // Readable and open: unregistered unless the wired backend knows + // it. With no backend wired nothing is registered, so it counts. + Ok(g) => backend + .as_ref() + .map(|b| b.registered_wallet_id(&g.seed_hash()).is_none()) + .unwrap_or(true), + }) + .count() + } +} diff --git a/src/context_provider.rs b/src/context_provider.rs index 7cfda9dfa..7389c3a2c 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -1,19 +1,12 @@ -use crate::app_dir::core_cookie_path; -use crate::config::NetworkConfig; use crate::context::AppContext; -use crate::database::Database; -use dash_sdk::core::LowLevelDashCoreClient as CoreClient; +use arc_swap::ArcSwapOption; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::error::ContextProviderError; use dash_sdk::platform::{ContextProvider, DataContract, Identifier}; use rusqlite::Result; -use std::sync::{Arc, Mutex}; - -// --------------------------------------------------------------------------- -// Shared contract/token resolution used by both RPC and SPV providers. -// --------------------------------------------------------------------------- +use std::sync::Arc; /// Number of system contracts cached on [`AppContext`]. /// Update this when adding a new system contract field. @@ -25,12 +18,10 @@ pub(crate) const SYSTEM_CONTRACT_COUNT: usize = 5; /// Resolve a data contract by ID: check cached system contracts first, then DB. /// /// All system contracts are listed in `cached` — adding a new one is a single -/// array edit, which prevents the two providers from drifting out of sync. -/// The array size is tied to [`SYSTEM_CONTRACT_COUNT`] so the compiler enforces -/// completeness. -pub(crate) fn resolve_data_contract( +/// array edit. The array size is tied to [`SYSTEM_CONTRACT_COUNT`] so the +/// compiler enforces completeness. +fn resolve_data_contract( app_ctx: &AppContext, - db: &Database, data_contract_id: &Identifier, ) -> Result<Option<Arc<DataContract>>, ContextProviderError> { let cached: [&Arc<DataContract>; SYSTEM_CONTRACT_COUNT] = [ @@ -47,104 +38,75 @@ pub(crate) fn resolve_data_contract( } } - // DB fallback for user-added / non-system contracts - let dc = db - .get_contract_by_id(*data_contract_id, app_ctx) + // K/V fallback for user-added / non-system contracts + let dc = app_ctx + .get_contract_by_id(data_contract_id) .map_err(|e| ContextProviderError::Generic(e.to_string()))?; Ok(dc.map(|qc| Arc::new(qc.contract))) } -/// Resolve a token configuration from the database. -pub(crate) fn resolve_token_configuration( +/// Resolve a token configuration from the per-network k/v store. +fn resolve_token_configuration( app_ctx: &AppContext, - db: &Database, token_id: &Identifier, ) -> Result<Option<dash_sdk::dpp::data_contract::TokenConfiguration>, ContextProviderError> { - db.get_token_config_for_id(token_id, app_ctx) + app_ctx + .get_token_config_for_id(token_id) .map_err(|e| ContextProviderError::Generic(e.to_string())) } -pub(crate) struct Provider { - db: Arc<Database>, - app_context: Mutex<Option<Arc<AppContext>>>, - pub core: CoreClient, +/// SPV-based ContextProvider for the Dash SDK. +/// +/// - DataContract and TokenConfiguration are served from the local DB. +/// - Quorum public keys are resolved by the upstream `platform-wallet` +/// chain sync via [`WalletBackend`](crate::wallet_backend::WalletBackend). +#[derive(Debug, Clone)] +pub(crate) struct SpvProvider { + // `Arc` so a cloned provider (the one handed to the SDK) shares the same + // slot as the original held on `AppContext`, letting a later rebind be seen + // everywhere. `ArcSwapOption` keeps binding lock-free. + app_context: Arc<ArcSwapOption<AppContext>>, + network: Network, } -impl Provider { - /// Create new ContextProvider. - /// - /// Note that you have to bind it to app context using [`Provider::bind_app_context`]. - pub fn new( - db: Arc<Database>, - network: Network, - config: &NetworkConfig, - ) -> Result<Self, String> { - let cookie_path = core_cookie_path(network, &config.devnet_name) - .map_err(|e| format!("Failed to get core cookie path: {}", e))?; - - // Read the cookie from disk - let cookie = std::fs::read_to_string(cookie_path); - let (user, pass) = if let Ok(cookie) = cookie { - let cookie = cookie.trim(); - // split the cookie at ":", first part is user (__cookie__), second part is password - if let Some((user, password)) = cookie.split_once(':') { - (user.to_string(), password.to_string()) - } else { - return Err("Malformed cookie file: expected 'user:password' format".to_string()); - } - } else { - // Fall back to the pre-set user / pass if needed - ( - config.core_rpc_user.clone().unwrap_or_default(), - config.core_rpc_password.clone().unwrap_or_default(), - ) - }; - - let host = config.rpc_host(); - let port = config.rpc_port(network); - let core_client = CoreClient::new(host, port, &user, &pass).map_err(|e| e.to_string())?; - - Ok(Self { - db, - core: core_client, - app_context: Default::default(), - }) +impl SpvProvider { + pub fn new(network: Network) -> Self { + Self { + app_context: Arc::new(ArcSwapOption::empty()), + network, + } } - /// Set app context to the provider. - /// - /// Returns an error if any lock is poisoned (indicates a prior panic). - pub fn bind_app_context(&self, app_context: Arc<AppContext>) -> Result<(), String> { - // order matters - can cause deadlock - let cloned = app_context.clone(); - let mut ac = self - .app_context - .lock() - .map_err(|_| "Provider app_context lock poisoned".to_string())?; - ac.replace(cloned); - drop(ac); + /// Attach the `AppContext` and register this provider with the SDK. + /// + /// After this call the SDK uses this provider for proof verification and + /// quorum key resolution. + /// + /// # Thread safety + /// Called during init and mode-switch only — not on hot paths. + pub fn bind_app_context(&self, app_context: Arc<AppContext>) { + self.app_context.store(Some(app_context.clone())); app_context.sdk.load().set_context_provider(self.clone()); - Ok(()) + } + + /// Load the bound `AppContext`, or a `Config` "not ready" error if this + /// provider has not been bound yet (startup window before binding). + fn app_context(&self) -> Result<Arc<AppContext>, ContextProviderError> { + self.app_context + .load_full() + .ok_or_else(|| ContextProviderError::Config("no app context".to_string())) } } -impl ContextProvider for Provider { +impl ContextProvider for SpvProvider { fn get_data_contract( &self, data_contract_id: &Identifier, _platform_version: &PlatformVersion, ) -> Result<Option<Arc<DataContract>>, ContextProviderError> { - let guard = self - .app_context - .lock() - .map_err(|_| ContextProviderError::Config("RpcProvider lock poisoned".to_string()))?; - let app_ctx = guard - .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))? - .clone(); - drop(guard); - resolve_data_contract(&app_ctx, &self.db, data_contract_id) + let app_ctx = self.app_context()?; + resolve_data_contract(&app_ctx, data_contract_id) } fn get_token_configuration( @@ -152,61 +114,68 @@ impl ContextProvider for Provider { token_id: &Identifier, ) -> Result<Option<dash_sdk::dpp::data_contract::TokenConfiguration>, ContextProviderError> { - let guard = self - .app_context - .lock() - .map_err(|_| ContextProviderError::Config("RpcProvider lock poisoned".to_string()))?; - let app_ctx = guard - .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))? - .clone(); - drop(guard); - resolve_token_configuration(&app_ctx, &self.db, token_id) + let app_ctx = self.app_context()?; + resolve_token_configuration(&app_ctx, token_id) } fn get_quorum_public_key( &self, quorum_type: u32, quorum_hash: [u8; 32], - _core_chain_locked_height: u32, - ) -> std::result::Result<[u8; 48], ContextProviderError> { - self.core - .get_quorum_public_key(quorum_type, quorum_hash) - .map_err(|e| ContextProviderError::Generic(e.to_string())) + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + // Quorum keys come from upstream `platform-wallet`'s `SpvRuntime`, + // wrapped by `WalletBackend`. The trait method is sync but the + // upstream lookup is async; bridge with `block_in_place` (this runs + // inside SDK proof verification on a tokio worker, never the UI + // thread). + let app_ctx = self.app_context()?; + + // Before the SPV masternode list has synced, quorum keys are not yet + // resolvable. A proof failure here would get the queried DAPI node + // banned by the SDK (`ban_failed_address: true`), so short-circuit to a + // `Config` "not ready" diagnostic — the same non-ban-inducing class the + // pre-unlock guard below uses. Any stray early proof call thus degrades + // gracefully instead of triggering the self-ban storm. + if !app_ctx.connection_status().masternodes_ready() { + return Err(ContextProviderError::Config( + "masternode list not yet synced (quorums unavailable)".to_string(), + )); + } + + // The wallet-backend gate ("not yet wired") is a startup-window + // configuration state — `Config`, not `Generic`. Do NOT broadcast + // the typed error's user-facing Display ("temporarily unavailable") + // into the SDK retry classifier; emit a non-user-facing diagnostic. + let backend = app_ctx.wallet_backend().map_err(|_| { + ContextProviderError::Config("chain backend not initialized (pre-unlock)".to_string()) + })?; + // `try_current` instead of `current`: the trait method is sync and may + // be invoked outside a tokio runtime (e.g. a non-async test harness). + // Return a typed Config error rather than panicking. + let handle = tokio::runtime::Handle::try_current().map_err(|_| { + ContextProviderError::Config("no async runtime available for quorum lookup".to_string()) + })?; + tokio::task::block_in_place(|| { + handle.block_on(backend.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + )) + }) + .map_err(|e| ContextProviderError::Generic(e.to_string())) } fn get_platform_activation_height( &self, - ) -> std::result::Result< - dash_sdk::dpp::prelude::CoreBlockHeight, - dash_sdk::error::ContextProviderError, - > { - Ok(1) - } -} - -impl Clone for Provider { - fn clone(&self) -> Self { - // Clone trait doesn't allow returning Result, so we use a fallback - // If the lock is poisoned, clone with None app_context (will require rebinding) - let app_context_clone = self - .app_context - .lock() - .map(|guard| guard.clone()) - .unwrap_or_else(|poisoned| { - tracing::warn!("Provider lock poisoned during clone, using fallback"); - poisoned.into_inner().clone() - }); - Self { - core: self.core.clone(), - db: self.db.clone(), - app_context: Mutex::new(app_context_clone), - } - } -} - -impl std::fmt::Debug for Provider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Provider").finish() + ) -> Result<dash_sdk::dpp::prelude::CoreBlockHeight, ContextProviderError> { + // Core block height at which Platform activated (the `mn_rr` L1 + // locked height) per network. Mirrors the SDK's own trusted + // context provider; these are fixed once activation has happened. + Ok(match self.network { + Network::Mainnet => 2_132_092, + Network::Testnet => 1_090_319, + Network::Devnet | Network::Regtest => 1, + }) } } diff --git a/src/context_provider_spv.rs b/src/context_provider_spv.rs deleted file mode 100644 index 1479368f4..000000000 --- a/src/context_provider_spv.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::context::AppContext; -use crate::context_provider::{resolve_data_contract, resolve_token_configuration}; -use crate::database::Database; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::error::ContextProviderError; -use dash_sdk::platform::{ContextProvider, DataContract, Identifier}; -use std::sync::{Arc, Mutex}; - -/// SPV-based ContextProvider for the Dash SDK. -/// -/// - DataContract and TokenConfiguration are served from the local DB (same as RPC provider) -/// - Quorum public keys are resolved via dash-spv (through SpvManager) when in SPV mode -#[derive(Debug)] -pub(crate) struct SpvProvider { - db: Arc<Database>, - app_context: Mutex<Option<Arc<AppContext>>>, - _network: Network, -} - -impl SpvProvider { - pub fn new(db: Arc<Database>, network: Network) -> Result<Self, String> { - Ok(Self { - db, - app_context: Default::default(), - _network: network, - }) - } - - /// Attach the `AppContext` and register this provider with the SDK. - /// - /// Mirrors [`Provider::bind_app_context`](crate::context_provider::Provider::bind_app_context) - /// — after this call, the SDK - /// uses this provider for proof verification and quorum key resolution. - /// - /// Returns an error if the lock is poisoned (indicates a prior panic). - /// - /// # Thread safety - /// Called during init and mode-switch only — not on hot paths. - pub fn bind_app_context(&self, app_context: Arc<AppContext>) -> Result<(), String> { - let cloned = app_context.clone(); - let mut ac = self - .app_context - .lock() - .map_err(|_| "SpvProvider app_context lock poisoned".to_string())?; - ac.replace(cloned); - drop(ac); - - app_context.sdk.load().set_context_provider(self.clone()); - Ok(()) - } -} - -impl ContextProvider for SpvProvider { - fn get_data_contract( - &self, - data_contract_id: &Identifier, - _platform_version: &PlatformVersion, - ) -> Result<Option<Arc<DataContract>>, ContextProviderError> { - let guard = self - .app_context - .lock() - .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; - let app_ctx = guard - .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))? - .clone(); - drop(guard); - resolve_data_contract(&app_ctx, &self.db, data_contract_id) - } - - fn get_token_configuration( - &self, - token_id: &Identifier, - ) -> Result<Option<dash_sdk::dpp::data_contract::TokenConfiguration>, ContextProviderError> - { - let guard = self - .app_context - .lock() - .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; - let app_ctx = guard - .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))? - .clone(); - drop(guard); - resolve_token_configuration(&app_ctx, &self.db, token_id) - } - - fn get_quorum_public_key( - &self, - quorum_type: u32, - quorum_hash: [u8; 32], - core_chain_locked_height: u32, - ) -> Result<[u8; 48], ContextProviderError> { - let app_ctx_guard = self - .app_context - .lock() - .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; - let app_ctx = app_ctx_guard - .as_ref() - .ok_or(ContextProviderError::Config("no app context".to_string()))?; - - let spv_manager = app_ctx.spv_manager(); - - spv_manager - .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) - .map_err(ContextProviderError::Generic) - } - - fn get_platform_activation_height( - &self, - ) -> Result<dash_sdk::dpp::prelude::CoreBlockHeight, ContextProviderError> { - // TODO: wire actual activation height if needed - Ok(1) - } -} - -impl Clone for SpvProvider { - fn clone(&self) -> Self { - // Clone trait doesn't allow returning Result, so we use a fallback - // If the lock is poisoned, clone with None app_context (will require rebinding) - let app_context_clone = self - .app_context - .lock() - .map(|guard| guard.clone()) - .unwrap_or_else(|poisoned| { - tracing::warn!("SpvProvider lock poisoned during clone, using fallback"); - poisoned.into_inner().clone() - }); - Self { - db: self.db.clone(), - app_context: Mutex::new(app_context_clone), - _network: self._network, - } - } -} diff --git a/src/database/asset_lock_transaction.rs b/src/database/asset_lock_transaction.rs deleted file mode 100644 index 4329ab400..000000000 --- a/src/database/asset_lock_transaction.rs +++ /dev/null @@ -1,396 +0,0 @@ -use crate::context::AppContext; -use crate::database::Database; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{ - InstantLock, Network, Transaction, - consensus::{deserialize, serialize}, -}; -use rusqlite::{Connection, params}; - -impl Database { - /// Stores an asset lock transaction and optional InstantLock into the database. - pub fn store_asset_lock_transaction( - &self, - tx: &Transaction, - amount: u64, - islock: Option<&InstantLock>, - wallet_seed_hash: &[u8; 32], - network: Network, - ) -> rusqlite::Result<()> { - let tx_bytes = serialize(tx); - let txid = tx.txid().to_byte_array(); - - let islock_bytes = islock.map(serialize); - - let conn = self.conn.lock().unwrap(); - - let sql = " - INSERT INTO asset_lock_transaction (tx_id, transaction_data, amount, instant_lock_data, wallet, network) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(tx_id) DO UPDATE SET - transaction_data = excluded.transaction_data, - amount = excluded.amount, - instant_lock_data = COALESCE(excluded.instant_lock_data, asset_lock_transaction.instant_lock_data), - network = excluded.network; - "; - - conn.execute( - sql, - params![ - &txid, - &tx_bytes, - amount, - &islock_bytes, - wallet_seed_hash, - network.to_string() - ], - )?; - - Ok(()) - } - - /// Retrieves an asset lock transaction by its transaction ID. - #[allow(dead_code)] // May be used for querying asset locks - #[allow(clippy::type_complexity)] - pub fn get_asset_lock_transaction( - &self, - txid: &[u8; 32], - ) -> rusqlite::Result<Option<(Transaction, u64, Option<InstantLock>, [u8; 32], String)>> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT transaction_data, amount, instant_lock_data, wallet, network FROM asset_lock_transaction WHERE tx_id = ?1", - )?; - - let mut rows = stmt.query(params![txid])?; - - if let Some(row) = rows.next()? { - let tx_data: Vec<u8> = row.get(0)?; - let amount: u64 = row.get(1)?; - let islock_data: Option<Vec<u8>> = row.get(2)?; - let wallet_seed: Vec<u8> = row.get(3)?; - let network: String = row.get(4)?; - - let tx: Transaction = - deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; - let islock = if let Some(islock_bytes) = islock_data { - Some(deserialize(&islock_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?) - } else { - None - }; - - let wallet_seed_hash: [u8; 32] = wallet_seed - .try_into() - .map_err(|_| rusqlite::Error::InvalidQuery)?; - - Ok(Some((tx, amount, islock, wallet_seed_hash, network))) - } else { - Ok(None) - } - } - - /// Updates the chain locked height for an asset lock transaction. - #[allow(dead_code)] // May be used for tracking chain confirmation status - pub fn update_asset_lock_chain_locked_height( - &self, - txid: &[u8; 32], - chain_locked_height: Option<u32>, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "UPDATE asset_lock_transaction SET chain_locked_height = ?1 WHERE tx_id = ?2", - params![chain_locked_height, txid], - )?; - - Ok(()) - } - - /// Sets the identity ID for an asset lock transaction. - pub fn set_asset_lock_identity_id( - &self, - tx_id: &[u8; 32], - identity_id: &[u8; 32], - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - let rows_updated = conn.execute( - "UPDATE asset_lock_transaction - SET identity_id = ?1, identity_id_potentially_in_creation = NULL - WHERE tx_id = ?2", - params![identity_id, tx_id], - )?; - if rows_updated == 0 { - tracing::warn!( - "No rows updated. Check if tx_id {} exists and identity_id {} is correct.", - hex::encode(tx_id), - hex::encode(identity_id) - ); - } - - Ok(()) - } - - /// Deletes all asset lock transactions in Devnet variants and Regtest. - pub fn remove_all_asset_locks_identity_id_for_all_devnets_and_regtest( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM asset_lock_transaction - WHERE network LIKE 'devnet%' OR network = 'regtest'", - [], - )?; - - Ok(()) - } - - /// Removes the identity ID and identity_id_potentially_in_creation for all asset lock transactions in Devnet. - pub fn remove_all_asset_locks_identity_id_for_devnet( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - if app_context.network != Network::Devnet { - return Ok(()); - } - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - conn.execute( - "UPDATE asset_lock_transaction - SET identity_id = NULL, - identity_id_potentially_in_creation = NULL - WHERE network = ?", - params![network], - )?; - - Ok(()) - } - - /// Sets the identity ID for an asset lock transaction. - pub fn set_asset_lock_identity_id_before_confirmation_by_network( - &self, - txid: &[u8; 32], - identity_id: &[u8; 32], - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "UPDATE asset_lock_transaction SET identity_id_potentially_in_creation = ?1 WHERE tx_id = ?2", - params![identity_id, txid], - )?; - - Ok(()) - } - - /// Deletes an asset lock transaction by its transaction ID (as bytes). - pub fn delete_asset_lock_transaction(&self, txid: &[u8; 32]) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "DELETE FROM asset_lock_transaction WHERE tx_id = ?1", - params![txid], - )?; - - Ok(()) - } - - /// Retrieves all asset lock transactions. - #[allow(dead_code)] // May be used for debugging or administrative views - #[allow(clippy::type_complexity)] - pub fn get_all_asset_lock_transactions( - &self, - network: Network, - ) -> rusqlite::Result< - Vec<( - Transaction, - u64, - Option<InstantLock>, - Option<u32>, - Option<Vec<u8>>, - [u8; 32], - )>, - > { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT transaction_data, amount, instant_lock_data, chain_locked_height, identity_id, wallet, network FROM asset_lock_transaction where network = ?", - )?; - - let mut rows = stmt.query(params![network.to_string()])?; - - let mut results = Vec::new(); - - while let Some(row) = rows.next()? { - let tx_data: Vec<u8> = row.get(0)?; - let amount: u64 = row.get(1)?; - let islock_data: Option<Vec<u8>> = row.get(2)?; - let chain_locked_height: Option<u32> = row.get(3)?; - let identity_id: Option<Vec<u8>> = row.get(4)?; - let wallet_seed: Vec<u8> = row.get(5)?; - - let tx: Transaction = - deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; - let islock = if let Some(islock_bytes) = islock_data { - Some(deserialize(&islock_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?) - } else { - None - }; - - let wallet_seed_array: [u8; 32] = wallet_seed - .try_into() - .map_err(|_| rusqlite::Error::InvalidQuery)?; - - results.push(( - tx, - amount, - islock, - chain_locked_height, - identity_id, - wallet_seed_array, - )); - } - - Ok(results) - } - - /// Retrieves asset lock transactions by identity ID. - #[allow(dead_code)] // May be used for identity-specific transaction history - #[allow(clippy::type_complexity)] - pub fn get_asset_lock_transactions_by_identity_id( - &self, - identity_id: &[u8; 32], - ) -> rusqlite::Result< - Vec<( - Transaction, - u64, - Option<InstantLock>, - Option<u32>, - [u8; 32], - String, - )>, - > { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT transaction_data, amount, instant_lock_data, chain_locked_height, wallet, network FROM asset_lock_transaction WHERE identity_id = ?1", - )?; - - let mut rows = stmt.query(params![identity_id])?; - - let mut results = Vec::new(); - - while let Some(row) = rows.next()? { - let tx_data: Vec<u8> = row.get(0)?; - let amount: u64 = row.get(1)?; - let islock_data: Option<Vec<u8>> = row.get(2)?; - let chain_locked_height: Option<u32> = row.get(3)?; - let wallet_seed: Vec<u8> = row.get(4)?; - let network: String = row.get(5)?; - - let tx: Transaction = - deserialize(&tx_data).map_err(|_| rusqlite::Error::InvalidQuery)?; - let islock = if let Some(islock_bytes) = islock_data { - Some(deserialize(&islock_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?) - } else { - None - }; - - let wallet_seed_hash: [u8; 32] = wallet_seed - .try_into() - .map_err(|_| rusqlite::Error::InvalidQuery)?; - - results.push(( - tx, - amount, - islock, - chain_locked_height, - wallet_seed_hash, - network, - )); - } - - Ok(results) - } - - /// Migrates `asset_lock_transaction` so that both `identity_id` columns use - /// `ON DELETE SET NULL` instead of `ON DELETE CASCADE`. - /// - /// Safe to run multiple times: if the table already has the correct FKs it - /// exits early. - pub fn migrate_asset_lock_fk_to_set_null( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - { - // ── 1. Detect whether migration is needed ─────────────────────────────── - let mut pragma = conn.prepare("PRAGMA foreign_key_list('asset_lock_transaction')")?; - let fk_rows = pragma - .query_map([], |row| { - Ok(( - row.get::<_, String>(2)?, // table - row.get::<_, String>(6)?, // on_delete action - )) - })? - .collect::<Result<Vec<_>, _>>()?; - - // If both identity-related FKs are already SET NULL, nothing to do. - let needs_migration = fk_rows - .iter() - .filter(|(tbl, _)| tbl == "identity") - .any(|(_, action)| action.to_uppercase() != "SET NULL"); - - if !needs_migration { - return Ok(()); - } - } - - // ── 2. Recreate table with correct FK actions inside a transaction ───── - conn.execute("PRAGMA foreign_keys = OFF", [])?; - - conn.execute( - "ALTER TABLE asset_lock_transaction RENAME TO asset_lock_transaction_old", - [], - )?; - - conn.execute( - "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 - )", - [], - )?; - - conn.execute( - "INSERT INTO asset_lock_transaction - (tx_id, transaction_data, amount, instant_lock_data, - chain_locked_height, identity_id, identity_id_potentially_in_creation, - wallet, network) - SELECT tx_id, transaction_data, amount, instant_lock_data, - chain_locked_height, identity_id, - identity_id_potentially_in_creation, wallet, network - FROM asset_lock_transaction_old", - [], - )?; - - conn.execute("DROP TABLE asset_lock_transaction_old", [])?; - - conn.execute("PRAGMA foreign_keys = ON", [])?; - - Ok(()) - } -} diff --git a/src/database/contacts.rs b/src/database/contacts.rs deleted file mode 100644 index 95631748e..000000000 --- a/src/database/contacts.rs +++ /dev/null @@ -1,356 +0,0 @@ -use dash_sdk::platform::Identifier; -use rusqlite::{Connection, params}; - -#[derive(Debug, Clone)] -pub struct ContactPrivateInfo { - pub owner_identity_id: Vec<u8>, - pub contact_identity_id: Vec<u8>, - pub nickname: String, - pub notes: String, - pub is_hidden: bool, -} - -impl crate::database::Database { - pub fn init_contacts_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - let sql = " - CREATE TABLE IF NOT EXISTS contact_private_info ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - nickname TEXT, - notes TEXT, - is_hidden INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - PRIMARY KEY (owner_identity_id, contact_identity_id) - ); - "; - conn.execute(sql, [])?; - Ok(()) - } - - pub fn save_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - nickname: &str, - notes: &str, - is_hidden: bool, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO contact_private_info - (owner_identity_id, contact_identity_id, nickname, notes, is_hidden, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, unixepoch()) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - nickname, - notes, - is_hidden as i32, - ], - )?; - Ok(()) - } - - pub fn load_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<(String, String, bool)> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT nickname, notes, is_hidden FROM contact_private_info - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - )?; - - let result = stmt.query_row( - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - |row| { - Ok(( - row.get::<_, Option<String>>(0)?.unwrap_or_default(), - row.get::<_, Option<String>>(1)?.unwrap_or_default(), - row.get::<_, Option<i32>>(2)?.unwrap_or(0) != 0, - )) - }, - ); - - match result { - Ok(data) => Ok(data), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok((String::new(), String::new(), false)), - Err(e) => Err(e), - } - } - - pub fn load_all_contact_private_info( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result<Vec<ContactPrivateInfo>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, nickname, notes, is_hidden - FROM contact_private_info - WHERE owner_identity_id = ?1", - )?; - - let infos = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - Ok(ContactPrivateInfo { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - nickname: row.get(2)?, - notes: row.get(3)?, - is_hidden: row.get::<_, i32>(4)? != 0, - }) - })? - .collect::<Result<Vec<_>, _>>()?; - - Ok(infos) - } - - pub fn delete_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<()> { - let sql = "DELETE FROM contact_private_info WHERE owner_identity_id = ?1 AND contact_identity_id = ?2"; - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - Ok(()) - } - - /// Toggle or set the hidden status for a contact - /// Creates a new entry if one doesn't exist - pub fn set_contact_hidden( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - is_hidden: bool, - ) -> rusqlite::Result<()> { - // First try to load existing info to preserve nickname and notes - let (nickname, notes, _) = - self.load_contact_private_info(owner_identity_id, contact_identity_id)?; - - // Save with updated hidden status - self.save_contact_private_info( - owner_identity_id, - contact_identity_id, - &nickname, - &notes, - is_hidden, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::test_helpers::create_test_database; - - fn create_test_identifier() -> Identifier { - Identifier::random() - } - - #[test] - fn test_save_and_retrieve_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "My best friend", false) - .expect("Failed to save contact info"); - - // Retrieve it - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Alice"); - assert_eq!(notes, "My best friend"); - assert!(!is_hidden); - } - - #[test] - fn test_contact_private_info_not_found_returns_defaults() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Try to load non-existent contact info - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(!is_hidden); - } - - #[test] - fn test_update_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save initial info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Note 1", false) - .expect("Failed to save contact info"); - - // Update it - db.save_contact_private_info(&owner_id, &contact_id, "Bob", "Note 2", true) - .expect("Failed to update contact info"); - - // Retrieve updated info - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Bob"); - assert_eq!(notes, "Note 2"); - assert!(is_hidden); - } - - #[test] - fn test_delete_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Notes", false) - .expect("Failed to save contact info"); - - // Delete it - db.delete_contact_private_info(&owner_id, &contact_id) - .expect("Failed to delete contact info"); - - // Should return defaults now - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(!is_hidden); - } - - #[test] - fn test_load_all_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - - // Add multiple contacts - for i in 0..5 { - let contact_id = create_test_identifier(); - db.save_contact_private_info( - &owner_id, - &contact_id, - &format!("Contact {}", i), - &format!("Notes for contact {}", i), - i % 2 == 0, // Every other contact is hidden - ) - .expect("Failed to save contact info"); - } - - // Load all contacts for this owner - let contacts = db - .load_all_contact_private_info(&owner_id) - .expect("Failed to load all contacts"); - - assert_eq!(contacts.len(), 5); - - // Verify hidden status pattern - let hidden_count = contacts.iter().filter(|c| c.is_hidden).count(); - assert_eq!(hidden_count, 3); // 0, 2, 4 are hidden - } - - #[test] - fn test_set_contact_hidden_new_contact() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Set hidden on a new contact (should create entry) - db.set_contact_hidden(&owner_id, &contact_id, true) - .expect("Failed to set contact hidden"); - - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(is_hidden); - } - - #[test] - fn test_set_contact_hidden_preserves_existing_data() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info with nickname and notes - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Important notes", false) - .expect("Failed to save contact info"); - - // Change hidden status - db.set_contact_hidden(&owner_id, &contact_id, true) - .expect("Failed to set contact hidden"); - - // Verify nickname and notes are preserved - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Alice"); - assert_eq!(notes, "Important notes"); - assert!(is_hidden); - } - - #[test] - fn test_contacts_isolation_between_owners() { - let db = create_test_database().expect("Failed to create test database"); - let owner1 = create_test_identifier(); - let owner2 = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Both owners have the same contact but with different info - db.save_contact_private_info( - &owner1, - &contact_id, - "Alice (Owner1)", - "Notes from 1", - false, - ) - .expect("Failed to save contact info"); - db.save_contact_private_info(&owner2, &contact_id, "Alice (Owner2)", "Notes from 2", true) - .expect("Failed to save contact info"); - - // Verify isolation - let (nickname1, notes1, hidden1) = db - .load_contact_private_info(&owner1, &contact_id) - .expect("Failed to load contact info"); - let (nickname2, notes2, hidden2) = db - .load_contact_private_info(&owner2, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname1, "Alice (Owner1)"); - assert_eq!(notes1, "Notes from 1"); - assert!(!hidden1); - - assert_eq!(nickname2, "Alice (Owner2)"); - assert_eq!(notes2, "Notes from 2"); - assert!(hidden2); - } -} diff --git a/src/database/contested_names.rs b/src/database/contested_names.rs deleted file mode 100644 index 45bc23a1f..000000000 --- a/src/database/contested_names.rs +++ /dev/null @@ -1,886 +0,0 @@ -use crate::context::AppContext; -use crate::database::{CorruptedBlobError, Database}; -use crate::model::contested_name::{ContestState, Contestant, ContestedName}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::identifier::Identifier; -use dash_sdk::dpp::identity::TimestampMillis; -use dash_sdk::dpp::prelude::{BlockHeight, CoreBlockHeight}; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; -use dash_sdk::query_types::Contenders; -use rusqlite::{Result, params, params_from_iter}; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::time::Duration; -use tracing::{error, info}; - -impl Database { - pub fn get_all_contested_names(&self, app_context: &AppContext) -> Result<Vec<ContestedName>> { - let network = app_context.network.to_string(); - let contest_duration = if app_context.network == Network::Mainnet { - Duration::from_secs(60 * 60 * 24 * 14) - } else { - Duration::from_secs(60 * 90) - }; - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT - cn.normalized_contested_name, - cn.locked_votes, - cn.abstain_votes, - cn.awarded_to, - cn.end_time, - cn.locked, - cn.last_updated, - c.identity_id, - c.name, - c.votes, - c.created_at, - c.created_at_block_height, - c.created_at_core_block_height, - c.document_id, - i.info - FROM contested_name cn - LEFT JOIN contestant c - ON cn.normalized_contested_name = c.normalized_contested_name - AND cn.network = c.network - LEFT JOIN identity i - ON c.identity_id = i.id - AND c.network = i.network - WHERE cn.network = ?", - )?; - - // A hashmap to collect contested names, keyed by their normalized name - let mut contested_name_map: HashMap<String, ContestedName> = HashMap::new(); - - // Iterate over the joined rows - let rows = stmt.query_map(params![network], |row| { - let normalized_contested_name: String = row.get(0)?; - let locked_votes: Option<u32> = row.get(1)?; - let abstain_votes: Option<u32> = row.get(2)?; - let awarded_to: Option<Vec<u8>> = row.get(3)?; - let ending_time: Option<u64> = row.get(4)?; - let locked: bool = row.get(5)?; - let last_updated: Option<u64> = row.get(6)?; - let identity_id: Option<Vec<u8>> = row.get(7)?; - let contestant_name: Option<String> = row.get(8)?; - let votes: Option<u32> = row.get(9)?; - let created_at: Option<TimestampMillis> = row.get(10)?; - let created_at_block_height: Option<BlockHeight> = row.get(11)?; - let created_at_core_block_height: Option<CoreBlockHeight> = row.get(12)?; - let document_id: Option<Vec<u8>> = row.get(13)?; - let identity_info: Option<String> = row.get(14)?; - - // Convert `awarded_to` to `Identifier` if it exists - let awarded_to_id = awarded_to - .map(|id| { - Identifier::from_bytes(&id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Blob, - format!("Invalid awarded_to identifier ({} bytes): {}", id.len(), e) - .into(), - ) - }) - }) - .transpose()?; - - let state = if locked { - ContestState::Locked - } else if let Some(awarded_to_id) = awarded_to_id { - ContestState::WonBy(awarded_to_id) - } else if let Some(created_at) = created_at { - let elapsed_time = Duration::from_millis( - (std::time::UNIX_EPOCH - .elapsed() - .unwrap_or_default() - .as_millis() as u64) - .saturating_sub(created_at), - ); - - if elapsed_time <= contest_duration / 2 { - ContestState::Joinable - } else { - ContestState::Ongoing - } - } else { - ContestState::Unknown - }; - - // Create or get the contested name from the hashmap - let contested_name = contested_name_map - .entry(normalized_contested_name.clone()) - .or_insert(ContestedName { - normalized_contested_name: normalized_contested_name.clone(), - locked_votes, - abstain_votes, - awarded_to: awarded_to_id, - end_time: ending_time, - contestants: Some(Vec::new()), // Initialize as an empty vector - last_updated, - my_votes: BTreeMap::new(), // Assuming this is filled elsewhere - state, - }); - - // If there are contestant details in the row, add them - if let (Some(identity_id), Some(contestant_name), Some(votes), Some(document_id)) = - (identity_id, contestant_name, votes, document_id) - { - let contestant = Contestant { - id: Identifier::from_bytes(&identity_id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 7, - rusqlite::types::Type::Blob, - format!( - "Invalid identity_id identifier ({} bytes): {}", - identity_id.len(), - e - ) - .into(), - ) - })?, - name: contestant_name, - info: identity_info.unwrap_or_default(), - votes, - created_at, - created_at_block_height, - created_at_core_block_height, - document_id: Identifier::from_bytes(&document_id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 13, - rusqlite::types::Type::Blob, - format!( - "Invalid document_id identifier ({} bytes): {}", - document_id.len(), - e - ) - .into(), - ) - })?, - }; - - // Add the contestant to the contestants list - if let Some(contestants) = &mut contested_name.contestants { - contestants.push(contestant); - } - } - - Ok(()) - })?; - - // Ensure all rows are processed without error - for row in rows { - row?; - } - - // Collect the values from the hashmap and return as a vector - Ok(contested_name_map.into_values().collect()) - } - - pub fn get_ongoing_contested_names( - &self, - app_context: &AppContext, - ) -> Result<Vec<ContestedName>> { - let network = app_context.network.to_string(); - let contest_duration = if app_context.network == Network::Mainnet { - Duration::from_secs(60 * 60 * 24 * 14) - } else { - Duration::from_secs(60 * 90) - }; - let current_timestamp = std::time::UNIX_EPOCH - .elapsed() - .unwrap_or_default() - .as_millis() as u64; - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT - cn.normalized_contested_name, - cn.locked_votes, - cn.abstain_votes, - cn.awarded_to, - cn.end_time, - cn.locked, - cn.last_updated, - c.identity_id, - c.name, - c.votes, - c.created_at, - c.created_at_block_height, - c.created_at_core_block_height, - c.document_id, - i.info - FROM contested_name cn - LEFT JOIN contestant c - ON cn.normalized_contested_name = c.normalized_contested_name - AND cn.network = c.network - LEFT JOIN identity i - ON c.identity_id = i.id - AND c.network = i.network - WHERE cn.network = ? - AND (cn.end_time IS NULL OR cn.end_time > ?)", - )?; - - // A hashmap to collect contested names, keyed by their normalized name - let mut contested_name_map: HashMap<String, ContestedName> = HashMap::new(); - - // Iterate over the joined rows - let rows = stmt.query_map(params![network, current_timestamp], |row| { - let normalized_contested_name: String = row.get(0)?; - let locked_votes: Option<u32> = row.get(1)?; - let abstain_votes: Option<u32> = row.get(2)?; - let awarded_to: Option<Vec<u8>> = row.get(3)?; - let ending_time: Option<u64> = row.get(4)?; - let locked: bool = row.get(5)?; - let last_updated: Option<u64> = row.get(6)?; - let identity_id: Option<Vec<u8>> = row.get(7)?; - let contestant_name: Option<String> = row.get(8)?; - let votes: Option<u32> = row.get(9)?; - let created_at: Option<TimestampMillis> = row.get(10)?; - let created_at_block_height: Option<BlockHeight> = row.get(11)?; - let created_at_core_block_height: Option<CoreBlockHeight> = row.get(12)?; - let document_id: Option<Vec<u8>> = row.get(13)?; - let identity_info: Option<String> = row.get(14)?; - - // Convert `awarded_to` to `Identifier` if it exists - let awarded_to_id = awarded_to - .map(|id| { - Identifier::from_bytes(&id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Blob, - format!("Invalid awarded_to identifier ({} bytes): {}", id.len(), e) - .into(), - ) - }) - }) - .transpose()?; - - let state = if locked { - ContestState::Locked - } else if let Some(awarded_to_id) = awarded_to_id { - ContestState::WonBy(awarded_to_id) - } else if let Some(created_at) = created_at { - let elapsed_time = Duration::from_millis( - (std::time::UNIX_EPOCH - .elapsed() - .unwrap_or_default() - .as_millis() as u64) - .saturating_sub(created_at), - ); - - if elapsed_time <= contest_duration / 2 { - ContestState::Joinable - } else { - ContestState::Ongoing - } - } else { - ContestState::Unknown - }; - - // Create or get the contested name from the hashmap - let contested_name = contested_name_map - .entry(normalized_contested_name.clone()) - .or_insert(ContestedName { - normalized_contested_name: normalized_contested_name.clone(), - locked_votes, - abstain_votes, - awarded_to: awarded_to_id, - end_time: ending_time, - contestants: Some(Vec::new()), // Initialize as an empty vector - last_updated, - my_votes: BTreeMap::new(), // Assuming this is filled elsewhere - state, - }); - - // If there are contestant details in the row, add them - if let (Some(identity_id), Some(contestant_name), Some(votes), Some(document_id)) = - (identity_id, contestant_name, votes, document_id) - { - let contestant = Contestant { - id: Identifier::from_bytes(&identity_id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 7, - rusqlite::types::Type::Blob, - format!( - "Invalid identity_id identifier ({} bytes): {}", - identity_id.len(), - e - ) - .into(), - ) - })?, - name: contestant_name, - info: identity_info.unwrap_or_default(), - votes, - created_at, - created_at_block_height, - created_at_core_block_height, - document_id: Identifier::from_bytes(&document_id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 13, - rusqlite::types::Type::Blob, - format!( - "Invalid document_id identifier ({} bytes): {}", - document_id.len(), - e - ) - .into(), - ) - })?, - }; - - // Add the contestant to the contestants list - if let Some(contestants) = &mut contested_name.contestants { - contestants.push(contestant); - } - } - - Ok(()) - })?; - - // Ensure all rows are processed without error - for row in rows { - row?; - } - - // Collect the values from the hashmap and return as a vector - Ok(contested_name_map.into_values().collect()) - } - - #[allow(dead_code)] // May be used for direct contest updates from external sources - pub fn insert_or_update_name_contest( - &self, - contested_name: &ContestedName, - app_context: &AppContext, - ) -> Result<()> { - let network = app_context.network.to_string(); - - // Check if the contested name already exists and get the current values if it does - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT locked_votes, abstain_votes, awarded_to, ending_time - FROM contested_name - WHERE normalized_contested_name = ? AND network = ?", - )?; - let result = stmt.query_row( - params![contested_name.normalized_contested_name, network], - |row| { - Ok(( - row.get::<_, Option<u32>>(0)?, - row.get::<_, Option<u32>>(1)?, - row.get::<_, Option<Vec<u8>>>(2)?, - row.get::<_, Option<u64>>(3)?, - )) - }, - ); - - match result { - Ok((locked_votes, abstain_votes, awarded_to, ending_time)) => { - // Compare the current values with the new values - let db_awarded_to = awarded_to - .as_ref() - .map(|id| { - Identifier::from_bytes(id).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Blob, - format!( - "Invalid awarded_to identifier ({} bytes): {}", - id.len(), - e - ) - .into(), - ) - }) - }) - .transpose()?; - let should_update = locked_votes != contested_name.locked_votes - || abstain_votes != contested_name.abstain_votes - || db_awarded_to != contested_name.awarded_to - || ending_time != contested_name.end_time; - - if should_update { - // Update the entry if any field has changed - self.execute( - "UPDATE contested_name - SET locked_votes = ?, abstain_votes = ?, awarded_to = ?, end_time = ? - WHERE normalized_contested_name = ? AND network = ?", - params![ - contested_name.locked_votes, - contested_name.abstain_votes, - contested_name.awarded_to.as_ref().map(|id| id.to_vec()), - contested_name.end_time, - contested_name.normalized_contested_name, - network, - ], - )?; - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => { - // If the contested name doesn't exist, insert it - self.execute( - "INSERT INTO contested_name (normalized_contested_name, locked_votes, abstain_votes, awarded_to, end_time, network) - VALUES (?, ?, ?, ?, ?, ?)", - params![ - contested_name.normalized_contested_name, - contested_name.locked_votes, - contested_name.abstain_votes, - contested_name.awarded_to.as_ref().map(|id| id.to_vec()), - contested_name.end_time, - network, - ], - )?; - } - Err(e) => return Err(e), - } - - // If there are contestants, insert or update each contestant associated with the contested name - if let Some(contestants) = &contested_name.contestants { - for contestant in contestants { - self.insert_or_update_contestant( - &contested_name.normalized_contested_name, - contestant, - app_context, - )?; - } - } - - Ok(()) - } - - pub fn insert_or_update_contenders( - &self, - normalized_contested_name: &str, - contenders: &Contenders, - dpns_domain_document_type: DocumentTypeRef, - app_context: &AppContext, - ) -> Result<()> { - let network = app_context.network.to_string(); - let last_updated = chrono::Utc::now().timestamp(); // Get the current timestamp - if let Some((winner, block_info)) = contenders.winner { - match winner { - ContestedDocumentVotePollWinnerInfo::NoWinner => {} - ContestedDocumentVotePollWinnerInfo::WonByIdentity(won_by) => { - let mut conn = self.conn.lock().unwrap(); - // Start a transaction - let tx = conn.transaction()?; - tx.execute( - "UPDATE contested_name - SET awarded_to = ?, last_updated = ?, end_time = ? - WHERE normalized_contested_name = ? AND network = ?", - params![ - won_by.to_vec(), - last_updated, - block_info.time_ms, - normalized_contested_name, - network, - ], - )?; - tx.commit()?; - } - ContestedDocumentVotePollWinnerInfo::Locked => { - let mut conn = self.conn.lock().unwrap(); - // Start a transaction - let tx = conn.transaction()?; - tx.execute( - "UPDATE contested_name - SET locked = 1, last_updated = ?, end_time = ? - WHERE normalized_contested_name = ? AND network = ?", - params![ - last_updated, - block_info.time_ms, - normalized_contested_name, - network, - ], - )?; - tx.commit()?; - } - } - return Ok(()); - } - let mut conn = self.conn.lock().unwrap(); - let locked_votes = contenders.lock_vote_tally.unwrap_or(0) as i64; - let abstain_votes = contenders.abstain_vote_tally.unwrap_or(0) as i64; - - // Start a transaction - let tx = conn.transaction()?; - - // Update the `contested_name` table with locked votes, abstain votes, and last updated - tx.execute( - "UPDATE contested_name - SET locked_votes = ?, abstain_votes = ?, last_updated = ? - WHERE normalized_contested_name = ? AND network = ?", - params![ - locked_votes, - abstain_votes, - last_updated, - normalized_contested_name, - network - ], - )?; - - // Iterate over each contender in the Contenders struct - for (identity_id, contender) in &contenders.contenders { - // Convert the identity ID to bytes - let identity_id_bytes = identity_id.to_vec(); - - // Serialize the document if available - let deserialized_contender = contender - .try_to_contender(dpns_domain_document_type, app_context.platform_version()) - .map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(CorruptedBlobError(format!( - "Failed to deserialize contender for identity {}: {}", - identity_id, e - ))), - ) - })?; - - let document = deserialized_contender.document().as_ref().ok_or_else(|| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(CorruptedBlobError(format!( - "Missing contender document for identity {}", - identity_id - ))), - ) - })?; - - let name = document - .get("label") - .and_then(|value| value.as_str()) - .ok_or_else(|| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Text, - Box::new(CorruptedBlobError(format!( - "Missing or invalid contender label for identity {}", - identity_id - ))), - ) - })?; - - let created_at = document.created_at(); - let created_at_block_height = document.created_at_block_height(); - let created_at_core_block_height = document.created_at_core_block_height(); - let document_id = document.id(); - - // Check if the contender already exists - let mut stmt = tx.prepare( - "SELECT votes - FROM contestant - WHERE normalized_contested_name = ? AND identity_id = ? AND network = ?", - )?; - - let result = stmt.query_row( - params![ - normalized_contested_name, - identity_id_bytes.clone(), - network - ], - |row| row.get::<_, u64>(0), - ); - - match result { - Ok(current_votes) => { - // Update the existing entry if votes or serialized document are different - if current_votes != contender.vote_tally().unwrap_or(0) as u64 { - tx.execute( - "UPDATE contestant - SET votes = ? - WHERE normalized_contested_name = ? AND identity_id = ? AND network = ?", - params![ - contender.vote_tally().unwrap_or(0), - normalized_contested_name, - identity_id_bytes, - network, - ], - )?; - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => { - // If the contestant doesn't exist, insert it - tx.execute( - "INSERT INTO contestant (normalized_contested_name, identity_id, name, votes, created_at, created_at_block_height, created_at_core_block_height, document_id, network) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - normalized_contested_name, - identity_id_bytes, - name, - contender.vote_tally().unwrap_or(0), - created_at, - created_at_block_height, - created_at_core_block_height, - document_id.to_vec(), - network, - ], - )?; - } - Err(e) => return Err(e), - } - } - - // Commit the transaction - if let Err(e) = tx.commit() { - error!("Transaction failed to commit: {:?}", e); - return Err(e); - } - - Ok(()) - } - - #[allow(dead_code)] // May be used for individual contestant updates - pub fn insert_or_update_contestant( - &self, - contest_id: &str, - contestant: &Contestant, - app_context: &AppContext, - ) -> Result<()> { - let network = app_context.network.to_string(); - // Check if the contestant already exists and get the current values if it does - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT name, info, votes - FROM contestant - WHERE contest_id = ? AND identity_id = ? AND network = ?", - )?; - let result = stmt.query_row( - params![contest_id, contestant.id.to_vec(), network], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, u32>(2)?, - )) - }, - ); - - match result { - Ok((name, info, votes)) => { - // Compare the current values with the new values - let should_update = - name != contestant.name || info != contestant.info || votes != contestant.votes; - - if should_update { - // Update the entry if any field has changed - self.execute( - "UPDATE contestant - SET name = ?, info = ?, votes = ? - WHERE contest_id = ? AND identity_id = ? AND network = ?", - params![ - contestant.name, - contestant.info, - contestant.votes, - contest_id, - contestant.id.to_vec(), - network, - ], - )?; - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => { - // If the contestant doesn't exist, insert it - self.execute( - "INSERT INTO contestant (contest_id, identity_id, name, info, votes, network) - VALUES (?, ?, ?, ?, ?, ?)", - params![ - contest_id, - contestant.id.to_vec(), - contestant.name, - contestant.info, - contestant.votes, - network, - ], - )?; - } - Err(e) => return Err(e), - } - - Ok(()) - } - - pub fn insert_name_contests_as_normalized_names( - &self, - name_contests: Vec<String>, - app_context: &AppContext, - ) -> Result<Vec<String>> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - let mut names_to_be_updated: Vec<(String, Option<i64>)> = Vec::new(); - let mut new_names: Vec<String> = Vec::new(); - - // Define the time limit (one hour ago in Unix timestamp format) - let half_a_minute_ago = chrono::Utc::now().timestamp() - 30; - - // Chunk the name_contests into smaller groups due to SQL parameter limits - let chunk_size = 900; // Use a safe limit to stay below SQLite's limit - - for chunk in name_contests.chunks(chunk_size) { - // Prepare placeholders for the SQL IN clause - let placeholders: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", "); - let query = format!( - "SELECT normalized_contested_name, last_updated, awarded_to - FROM contested_name - WHERE network = ? AND normalized_contested_name IN ({})", - placeholders - ); - - let mut stmt = conn.prepare(&query)?; - - // Create params: network followed by each name in the chunk - let mut params: Vec<&dyn rusqlite::ToSql> = vec![&network]; - for name in chunk { - params.push(name); - } - - // Execute the query and collect outdated or never updated names - let rows = stmt.query_map(params_from_iter(params.iter()), |row| { - let name: String = row.get(0)?; - let last_updated: Option<i64> = row.get(1)?; - Ok((name, last_updated)) - })?; - - // Track the existing and outdated names - let mut existing_names = HashSet::new(); - #[allow(clippy::manual_flatten)] - for row in rows { - if let Ok((name, last_updated)) = row { - existing_names.insert(name.clone()); - if last_updated.is_none() || last_updated.unwrap() < half_a_minute_ago { - names_to_be_updated.push((name, last_updated)); - } - } - } - - // Identify and collect new names (those not in existing_names) - for name in chunk { - if !existing_names.contains(name) { - new_names.push(name.clone()); - } - } - } - - // Insert new names into the database - if !new_names.is_empty() { - let mut insert_stmt = conn.prepare( - "INSERT INTO contested_name (normalized_contested_name, network) - VALUES (?, ?)", - )?; - - for name in &new_names { - insert_stmt.execute(params![name, network])?; - } - } - - // Combine the new names and outdated names, sorted by last_updated (oldest first) - names_to_be_updated.extend(new_names.into_iter().map(|name| (name, None))); - names_to_be_updated.sort_by(|a, b| a.1.unwrap_or(0).cmp(&b.1.unwrap_or(0))); - - // Extract the names into a Vec<String> - let result_names = names_to_be_updated - .into_iter() - .map(|(name, _)| name) - .collect::<Vec<_>>(); - - Ok(result_names) - } - - pub fn update_ending_time<I>(&self, name_contests: I, app_context: &AppContext) -> Result<()> - where - I: IntoIterator<Item = (String, TimestampMillis)>, - { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - - // Prepare statement for selecting existing entries - let select_query = "SELECT end_time - FROM contested_name - WHERE network = ? AND normalized_contested_name = ?"; - - let mut select_stmt = conn.prepare(select_query)?; - - // Prepare statement for updating existing entries - let update_query = "UPDATE contested_name - SET end_time = ? - WHERE normalized_contested_name = ? AND network = ?"; - let mut update_stmt = conn.prepare(update_query)?; - - for (name, new_ending_time) in name_contests { - // Check if the name exists in the database and retrieve the current ending time - let existing_ending_time: Option<TimestampMillis> = - match select_stmt.query_row(params![network, name], |row| row.get(0)) { - Ok(ending_time) => ending_time, - Err(rusqlite::Error::QueryReturnedNoRows) => continue, // Handle no rows case gracefully - Err(e) => return Err(e), // Propagate other errors - }; - - if let Some(existing_ending_time) = existing_ending_time { - // Update only if the new ending time is greater than the existing one - if existing_ending_time < new_ending_time { - update_stmt.execute(params![new_ending_time, name, network])?; - } - } else { - // If `ending_time` is `NULL`, update with the new ending time - update_stmt.execute(params![new_ending_time, name, network])?; - } - } - - Ok(()) - } - #[allow(dead_code)] // May be used for manual vote count adjustments - pub fn update_vote_count( - &self, - contested_name: &str, - network: &str, - vote_strength: u64, - vote_choice: ResourceVoteChoice, - ) -> Result<()> { - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - - match vote_choice { - ResourceVoteChoice::TowardsIdentity(identity) => { - // Increment the contestant's vote count - tx.execute( - "UPDATE contestant - SET votes = votes + ? - WHERE normalized_contested_name = ? - AND identity_id = ? - AND network = ?", - params![vote_strength, contested_name, identity.to_vec(), network], - )?; - } - ResourceVoteChoice::Abstain => { - // Increment the abstain vote count in the contested_name table - tx.execute( - "UPDATE contested_name - SET abstain_votes = abstain_votes + ? - WHERE normalized_contested_name = ? AND network = ?", - params![vote_strength, contested_name, network], - )?; - } - ResourceVoteChoice::Lock => { - // Increment the locked vote count in the contested_name table - tx.execute( - "UPDATE contested_name - SET locked_votes = locked_votes + ? - WHERE normalized_contested_name = ? AND network = ?", - params![vote_strength, contested_name, network], - )?; - } - } - - // Commit the transaction - if let Err(e) = tx.commit() { - error!("Failed to commit transaction: {:?}", e); - return Err(e); - } - - info!("Vote tally updated successfully for '{}'", contested_name); - Ok(()) - } -} diff --git a/src/database/contracts.rs b/src/database/contracts.rs deleted file mode 100644 index afbc6ffcf..000000000 --- a/src/database/contracts.rs +++ /dev/null @@ -1,417 +0,0 @@ -use crate::context::AppContext; -use crate::database::Database; -use crate::model::qualified_contract::QualifiedContract; -use bincode::config; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; -use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; -use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; -use dash_sdk::dpp::data_contract::{DataContract, TokenContractPosition}; -use dash_sdk::dpp::identifier::Identifier; -use dash_sdk::dpp::serialization::{ - PlatformDeserializableWithPotentialValidationFromVersionedStructure, - PlatformSerializableWithPlatformVersion, -}; -use rusqlite::{Connection, Result, params, params_from_iter}; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(clippy::enum_variant_names)] -pub enum InsertTokensToo { - AllTokensShouldBeAdded, - NoTokensShouldBeAdded, - SomeTokensShouldBeAdded(Vec<TokenContractPosition>), -} - -impl Database { - pub fn insert_contract_if_not_exists( - &self, - data_contract: &DataContract, - contract_alias: Option<&str>, - insert_tokens_too: InsertTokensToo, - app_context: &AppContext, - ) -> Result<()> { - // Serialize the contract - let contract_bytes = data_contract - .serialize_to_bytes_with_platform_version(app_context.platform_version()) - .expect("expected to serialize contract"); - let contract_id = data_contract.id().to_vec(); - let network = app_context.network.to_string(); - - // Insert the contract if it does not exist - self.execute( - "INSERT OR IGNORE INTO contract (contract_id, contract, alias, network) VALUES (?, ?, ?, ?)", - params![contract_id, contract_bytes, contract_alias, network], - )?; - - // Next, if the contract has tokens, add the tokens - if !data_contract.tokens().is_empty() { - let positions = match insert_tokens_too { - InsertTokensToo::AllTokensShouldBeAdded => { - data_contract.tokens().keys().cloned().collect() - } - InsertTokensToo::NoTokensShouldBeAdded => { - return Ok(()); - } - InsertTokensToo::SomeTokensShouldBeAdded(positions) => positions, - }; - for token_contract_position in positions { - if let Some(token_id) = data_contract.token_id(token_contract_position) - && let Ok(token_configuration) = - data_contract.expected_token_configuration(token_contract_position) - { - let config = config::standard(); - let Some(serialized_token_configuration) = - bincode::encode_to_vec(token_configuration, config).ok() - else { - // We should always be able to serialize - return Ok(()); - }; - let token_name = token_configuration - .conventions() - .singular_form_by_language_code_or_default("en"); - self.insert_token( - &token_id, - token_name, - serialized_token_configuration.as_slice(), - &data_contract.id(), - token_contract_position, - app_context, - )?; - } - } - } - - Ok(()) - } - - pub fn get_contract_by_id( - &self, - contract_id: Identifier, - app_context: &AppContext, - ) -> Result<Option<QualifiedContract>> { - let contract_id_bytes = contract_id.to_vec(); - let network = app_context.network.to_string(); - - // Query the contract by ID - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT contract, alias FROM contract WHERE contract_id = ? AND network = ?", - )?; - - let result = stmt.query_row(params![contract_id_bytes, network], |row| { - let contract_bytes: Vec<u8> = row.get(0)?; - let alias: Option<String> = row.get(1)?; // Assuming `alias` can be NULL - Ok((contract_bytes, alias)) - }); - - match result { - Ok((bytes, alias)) => { - // Deserialize the DataContract - match DataContract::versioned_deserialize( - &bytes, - false, - app_context.platform_version(), - ) { - Ok(contract) => { - // Construct the QualifiedContract - let qualified_contract = QualifiedContract { contract, alias }; - Ok(Some(qualified_contract)) - } - Err(e) => { - // Handle deserialization errors - tracing::warn!("Deserialization error: {}", e); - Ok(None) - } - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - pub fn get_unqualified_contract_by_id( - &self, - contract_id: Identifier, - app_context: &AppContext, - ) -> Result<Option<DataContract>> { - let contract_id_bytes = contract_id.to_vec(); - let network = app_context.network.to_string(); - - // Query the contract by ID - let conn = self.conn.lock().unwrap(); - let mut stmt = - conn.prepare("SELECT contract FROM contract WHERE contract_id = ? AND network = ?")?; - - let result = stmt.query_row(params![contract_id_bytes, network], |row| { - let contract_bytes: Vec<u8> = row.get(0)?; - Ok(contract_bytes) - }); - - match result { - Ok(bytes) => { - // Deserialize the DataContract - match DataContract::versioned_deserialize( - &bytes, - false, - app_context.platform_version(), - ) { - Ok(contract) => Ok(Some(contract)), - Err(e) => { - // Handle deserialization errors - tracing::warn!("Deserialization error: {}", e); - Ok(None) - } - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - pub fn replace_contract( - &self, - contract_id: Identifier, - data_contract: &DataContract, - app_context: &AppContext, - ) -> Result<()> { - let contract_bytes = data_contract - .serialize_to_bytes_with_platform_version(app_context.platform_version()) - .expect("expected to serialize contract"); - let network = app_context.network.to_string(); - - // Get the existing contract alias (if any) - let existing_alias = { - let conn = self.conn.lock().unwrap(); - conn.query_row( - "SELECT alias FROM contract WHERE contract_id = ? AND network = ?", - params![contract_id.to_vec(), network.clone()], - |row| row.get::<_, Option<String>>(0), - ) - .or_else(|e| match e { - rusqlite::Error::QueryReturnedNoRows => Ok::<Option<String>, rusqlite::Error>(None), - other => Err(other), - })? - }; - - // Replace the contract - self.execute( - "REPLACE INTO contract (contract_id, contract, alias, network) VALUES (?, ?, ?, ?)", - params![ - contract_id.to_vec(), - contract_bytes, - existing_alias, - network - ], - )?; - - Ok(()) - } - - #[allow(dead_code)] // May be used for contract lookup by user-friendly names - pub fn get_contract_by_alias( - &self, - alias: &str, - app_context: &AppContext, - ) -> Result<Option<QualifiedContract>> { - let network = app_context.network.to_string(); - - // Query the contract by alias and network - let conn = self.conn.lock().unwrap(); - let mut stmt = - conn.prepare("SELECT contract, alias FROM contract WHERE alias = ? AND network = ?")?; - - let result = stmt.query_row(params![alias, network], |row| { - let contract_bytes: Vec<u8> = row.get(0)?; - let contract_alias: Option<String> = row.get(1)?; // Handle potential null values - Ok((contract_bytes, contract_alias)) - }); - - match result { - Ok((bytes, alias)) => { - // Deserialize the DataContract - match DataContract::versioned_deserialize( - &bytes, - false, - app_context.platform_version(), - ) { - Ok(contract) => { - // Construct the QualifiedContract - let qualified_contract = QualifiedContract { contract, alias }; - Ok(Some(qualified_contract)) - } - Err(e) => { - // Handle deserialization errors - tracing::warn!("Deserialization error: {}", e); - Ok(None) - } - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - pub fn get_contracts( - &self, - app_context: &AppContext, - limit: Option<u32>, - offset: Option<u32>, - ) -> Result<Vec<QualifiedContract>> { - let network = app_context.network.to_string(); - - // Build the SQL query with optional limit and offset - let mut query = String::from("SELECT contract, alias FROM contract WHERE network = ?"); - if limit.is_some() { - query.push_str(" LIMIT ?"); - } - if offset.is_some() { - query.push_str(" OFFSET ?"); - } - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare(&query)?; - - // Store the limit and offset in variables to extend their lifetimes - let limit_value; - let offset_value; - - // Collect parameters for query execution - let mut params: Vec<&dyn rusqlite::ToSql> = vec![&network]; - if let Some(l) = limit { - limit_value = l; - params.push(&limit_value); // Now `limit_value` lives long enough - } - if let Some(o) = offset { - offset_value = o; - params.push(&offset_value); // Now `offset_value` lives long enough - } - - let mut rows = stmt.query(params_from_iter(params))?; - - // Collect the results into a Vec<QualifiedContract> - let mut contracts = Vec::new(); - while let Some(row) = rows.next()? { - let contract_bytes: Vec<u8> = row.get(0)?; - let alias: Option<String> = row.get(1)?; - - // Deserialize the DataContract - match DataContract::versioned_deserialize( - &contract_bytes, - false, - app_context.platform_version(), - ) { - Ok(contract) => { - contracts.push(QualifiedContract { contract, alias }); - } - Err(e) => { - tracing::error!("Deserialization error: {}", e); - // Optionally skip this entry instead of returning an error - continue; - } - } - } - - Ok(contracts) - } - - pub fn remove_contract( - &self, - contract_id: &[u8], - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - - // 1) remove the contract itself - self.execute( - "DELETE FROM contract - WHERE contract_id = ? AND network = ?", - params![contract_id, network], - )?; - - Ok(()) - } - - /// Deletes all contracts in Devnet variants and Regtest. - pub fn remove_all_contracts_in_all_devnets_and_regtest( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM contract WHERE network LIKE 'devnet%' OR network = 'regtest'", - [], - )?; - - Ok(()) - } - - /// Deletes all local tokens and related entries (identity_token_balances, token_order) in Devnet. - pub fn remove_all_contracts_in_devnet(&self, app_context: &AppContext) -> rusqlite::Result<()> { - if app_context.network != Network::Devnet { - return Ok(()); - } - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Delete tokens and cascade deletions in related tables due to foreign keys - conn.execute("DELETE FROM contract WHERE network = ?", params![network])?; - - Ok(()) - } - - /// Updates the alias of a specified contract. - pub fn set_contract_alias( - &self, - identifier: &Identifier, - new_alias: Option<&str>, - ) -> rusqlite::Result<()> { - let id = identifier.to_vec(); - let conn = self.conn.lock().unwrap(); - - let rows_updated = conn.execute( - "UPDATE contract SET alias = ? WHERE contract_id = ?", - params![new_alias, id], - )?; - - if rows_updated == 0 { - return Err(rusqlite::Error::QueryReturnedNoRows); - } - - Ok(()) - } - - #[allow(dead_code)] // May be used for retrieving user-friendly contract names - pub fn get_contract_alias(&self, identifier: &Identifier) -> rusqlite::Result<Option<String>> { - let id = identifier.to_vec(); - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare("SELECT alias FROM contract WHERE contract_id = ?")?; - let alias: Option<String> = stmt.query_row(params![id], |row| row.get(0)).ok(); - - Ok(alias) - } - - /// Perform the database migration to change the field "name" to "alias" in the contract table. - pub fn change_contract_name_to_alias(&self, conn: &Connection) -> rusqlite::Result<()> { - // Check if the column "name" exists - let mut stmt = conn.prepare("PRAGMA table_info(contract)")?; - let mut columns = stmt.query([])?; - - let mut name_column_exists = false; - while let Some(row) = columns.next()? { - let column_name: String = row.get(1)?; - if column_name == "name" { - name_column_exists = true; - break; - } - } - - // If the column "name" exists, rename it to "alias" - if name_column_exists { - conn.execute("ALTER TABLE contract RENAME COLUMN name TO alias", [])?; - } - - Ok(()) - } -} diff --git a/src/database/dashpay.rs b/src/database/dashpay.rs deleted file mode 100644 index 84625e23f..000000000 --- a/src/database/dashpay.rs +++ /dev/null @@ -1,934 +0,0 @@ -use dash_sdk::platform::Identifier; -use rusqlite::params; -use serde::{Deserialize, Serialize}; - -/// DashPay profile data stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredProfile { - pub identity_id: Vec<u8>, - pub display_name: Option<String>, - pub bio: Option<String>, - pub avatar_url: Option<String>, - pub avatar_hash: Option<Vec<u8>>, - pub avatar_fingerprint: Option<Vec<u8>>, - pub avatar_bytes: Option<Vec<u8>>, - pub public_message: Option<String>, - pub created_at: i64, - pub updated_at: i64, -} - -/// DashPay contact information stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredContact { - pub owner_identity_id: Vec<u8>, - pub contact_identity_id: Vec<u8>, - pub username: Option<String>, - pub display_name: Option<String>, - pub avatar_url: Option<String>, - pub public_message: Option<String>, - pub contact_status: String, // "pending", "accepted", "blocked" - pub created_at: i64, - pub updated_at: i64, - pub last_seen: Option<i64>, -} - -/// DashPay contact request stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredContactRequest { - pub id: i64, - pub from_identity_id: Vec<u8>, - pub to_identity_id: Vec<u8>, - pub to_username: Option<String>, - pub account_label: Option<String>, - pub request_type: String, // "sent", "received" - pub status: String, // "pending", "accepted", "rejected", "expired" - pub created_at: i64, - pub responded_at: Option<i64>, - pub expires_at: Option<i64>, -} - -/// DashPay payment/transaction record -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredPayment { - pub id: i64, - pub tx_id: String, - pub from_identity_id: Vec<u8>, - pub to_identity_id: Vec<u8>, - pub amount: i64, // in credits - pub memo: Option<String>, - pub payment_type: String, // "sent", "received" - pub status: String, // "pending", "confirmed", "failed" - pub created_at: i64, - pub confirmed_at: Option<i64>, -} - -/// DashPay contact address index tracking per DIP-0015 -/// Tracks address indices used for sending/receiving payments per contact relationship -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContactAddressIndex { - pub owner_identity_id: Vec<u8>, - pub contact_identity_id: Vec<u8>, - /// Next address index to use when sending TO this contact - pub next_send_index: u32, - /// Highest address index seen when receiving FROM this contact (for bloom filter) - pub highest_receive_index: u32, - /// Number of addresses registered in bloom filter for this contact - pub bloom_registered_count: u32, -} - -impl crate::database::Database { - /// Initialize all DashPay-related database tables using a transaction - pub fn init_dashpay_tables_in_tx(&self, tx: &rusqlite::Connection) -> rusqlite::Result<()> { - // Profiles table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_profiles ( - identity_id BLOB NOT NULL, - network TEXT NOT NULL, - display_name TEXT, - bio TEXT, - avatar_url TEXT, - avatar_hash BLOB, - avatar_fingerprint BLOB, - avatar_bytes BLOB, - public_message TEXT, - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - PRIMARY KEY (identity_id, network) - )", - [], - )?; - - // Contacts table (extends the existing contact_private_info) - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contacts ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - network TEXT NOT NULL, - username TEXT, - display_name TEXT, - avatar_url TEXT, - public_message TEXT, - contact_status TEXT DEFAULT 'pending', - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - last_seen INTEGER, - PRIMARY KEY (owner_identity_id, contact_identity_id, network) - )", - [], - )?; - - // Contact requests table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contact_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_identity_id BLOB NOT NULL, - to_identity_id BLOB NOT NULL, - network TEXT NOT NULL, - to_username TEXT, - account_label TEXT, - request_type TEXT NOT NULL CHECK (request_type IN ('sent', 'received')), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired')), - created_at INTEGER DEFAULT (unixepoch()), - responded_at INTEGER, - expires_at INTEGER - )", - [], - )?; - - // Create index for faster queries - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_contact_requests_from - ON dashpay_contact_requests(from_identity_id)", - [], - )?; - - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_contact_requests_to - ON dashpay_contact_requests(to_identity_id)", - [], - )?; - - // Payments/transactions table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_payments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tx_id TEXT UNIQUE NOT NULL, - from_identity_id BLOB NOT NULL, - to_identity_id BLOB NOT NULL, - amount INTEGER NOT NULL, - memo TEXT, - payment_type TEXT NOT NULL CHECK (payment_type IN ('sent', 'received')), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'failed')), - created_at INTEGER DEFAULT (unixepoch()), - confirmed_at INTEGER - )", - [], - )?; - - // Create index for faster queries - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_from - ON dashpay_payments(from_identity_id)", - [], - )?; - - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_to - ON dashpay_payments(to_identity_id)", - [], - )?; - - // Contact address index tracking table (DIP-0015) - // Tracks address indices per contact for payment derivation - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contact_address_indices ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - next_send_index INTEGER DEFAULT 0, - highest_receive_index INTEGER DEFAULT 0, - bloom_registered_count INTEGER DEFAULT 0, - PRIMARY KEY (owner_identity_id, contact_identity_id) - )", - [], - )?; - - // DashPay address mappings for incoming payment detection - // Maps addresses to contact relationships for transaction matching - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_address_mappings ( - address TEXT PRIMARY KEY, - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - address_index INTEGER NOT NULL, - created_at INTEGER DEFAULT (unixepoch()) - )", - [], - )?; - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_owner - ON dashpay_address_mappings(owner_identity_id)", - [], - )?; - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_contact - ON dashpay_address_mappings(owner_identity_id, contact_identity_id)", - [], - )?; - - Ok(()) - } - - // Profile operations - - pub fn save_dashpay_profile( - &self, - identity_id: &Identifier, - network: &str, - display_name: Option<&str>, - bio: Option<&str>, - avatar_url: Option<&str>, - public_message: Option<&str>, - ) -> rusqlite::Result<()> { - // Use INSERT ... ON CONFLICT to preserve avatar_bytes when updating - let sql = " - INSERT INTO dashpay_profiles - (identity_id, network, display_name, bio, avatar_url, public_message, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch()) - ON CONFLICT(identity_id, network) DO UPDATE SET - display_name = excluded.display_name, - bio = excluded.bio, - avatar_url = excluded.avatar_url, - public_message = excluded.public_message, - updated_at = unixepoch() - "; - - let result = self.execute( - sql, - params![ - identity_id.to_buffer().to_vec(), - network, - display_name, - bio, - avatar_url, - public_message, - ], - ); - - result?; - Ok(()) - } - - /// Save avatar bytes for a profile (called after fetching avatar from network) - pub fn save_dashpay_profile_avatar_bytes( - &self, - identity_id: &Identifier, - network: &str, - avatar_bytes: Option<&[u8]>, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_profiles - SET avatar_bytes = ?1, updated_at = unixepoch() - WHERE identity_id = ?2 AND network = ?3 - "; - - self.execute( - sql, - params![avatar_bytes, identity_id.to_buffer().to_vec(), network,], - )?; - Ok(()) - } - - pub fn load_dashpay_profile( - &self, - identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<Option<StoredProfile>> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT identity_id, display_name, bio, avatar_url, avatar_hash, - avatar_fingerprint, avatar_bytes, public_message, created_at, updated_at - FROM dashpay_profiles - WHERE identity_id = ?1 AND network = ?2", - )?; - - let result = stmt.query_row(params![identity_id.to_buffer().to_vec(), network], |row| { - Ok(StoredProfile { - identity_id: row.get(0)?, - display_name: row.get(1)?, - bio: row.get(2)?, - avatar_url: row.get(3)?, - avatar_hash: row.get(4)?, - avatar_fingerprint: row.get(5)?, - avatar_bytes: row.get(6)?, - public_message: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, - }) - }); - - match result { - Ok(profile) => Ok(Some(profile)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - // Contact operations - - #[allow(clippy::too_many_arguments)] - pub fn save_dashpay_contact( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - network: &str, - username: Option<&str>, - display_name: Option<&str>, - avatar_url: Option<&str>, - public_message: Option<&str>, - contact_status: &str, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO dashpay_contacts - (owner_identity_id, contact_identity_id, network, username, display_name, - avatar_url, public_message, contact_status, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, unixepoch()) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - network, - username, - display_name, - avatar_url, - public_message, - contact_status, - ], - )?; - Ok(()) - } - - pub fn load_dashpay_contacts( - &self, - owner_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<Vec<StoredContact>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, username, display_name, - avatar_url, public_message, contact_status, created_at, updated_at, last_seen - FROM dashpay_contacts - WHERE owner_identity_id = ?1 AND network = ?2 - ORDER BY updated_at DESC", - )?; - - let contacts = stmt - .query_map( - params![owner_identity_id.to_buffer().to_vec(), network], - |row| { - Ok(StoredContact { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - username: row.get(2)?, - display_name: row.get(3)?, - avatar_url: row.get(4)?, - public_message: row.get(5)?, - contact_status: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - last_seen: row.get(9)?, - }) - }, - )? - .collect::<Result<Vec<_>, _>>()?; - - Ok(contacts) - } - - pub fn update_contact_last_seen( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_contacts - SET last_seen = unixepoch(), updated_at = unixepoch() - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 AND network = ?3 - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - network, - ], - )?; - Ok(()) - } - - /// Clear all contacts for a specific owner identity on a specific network - pub fn clear_dashpay_contacts( - &self, - owner_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<()> { - let sql = "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1 AND network = ?2"; - - self.execute( - sql, - params![owner_identity_id.to_buffer().to_vec(), network], - )?; - Ok(()) - } - - // Contact request operations - - pub fn save_contact_request( - &self, - from_identity_id: &Identifier, - to_identity_id: &Identifier, - network: &str, - to_username: Option<&str>, - account_label: Option<&str>, - request_type: &str, - ) -> rusqlite::Result<i64> { - let sql = " - INSERT INTO dashpay_contact_requests - (from_identity_id, to_identity_id, network, to_username, account_label, request_type) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "; - - let conn = self.conn.lock().unwrap(); - conn.execute( - sql, - params![ - from_identity_id.to_buffer().to_vec(), - to_identity_id.to_buffer().to_vec(), - network, - to_username, - account_label, - request_type, - ], - )?; - - Ok(conn.last_insert_rowid()) - } - - pub fn update_contact_request_status( - &self, - request_id: i64, - status: &str, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_contact_requests - SET status = ?1, responded_at = unixepoch() - WHERE id = ?2 - "; - - self.execute(sql, params![status, request_id])?; - Ok(()) - } - - pub fn load_pending_contact_requests( - &self, - identity_id: &Identifier, - network: &str, - request_type: &str, - ) -> rusqlite::Result<Vec<StoredContactRequest>> { - let conn = self.conn.lock().unwrap(); - let sql = if request_type == "sent" { - "SELECT id, from_identity_id, to_identity_id, to_username, account_label, - request_type, status, created_at, responded_at, expires_at - FROM dashpay_contact_requests - WHERE from_identity_id = ?1 AND network = ?2 AND request_type = 'sent' AND status = 'pending' - ORDER BY created_at DESC" - } else { - "SELECT id, from_identity_id, to_identity_id, to_username, account_label, - request_type, status, created_at, responded_at, expires_at - FROM dashpay_contact_requests - WHERE to_identity_id = ?1 AND network = ?2 AND request_type = 'received' AND status = 'pending' - ORDER BY created_at DESC" - }; - - let mut stmt = conn.prepare(sql)?; - let requests = stmt - .query_map(params![identity_id.to_buffer().to_vec(), network], |row| { - Ok(StoredContactRequest { - id: row.get(0)?, - from_identity_id: row.get(1)?, - to_identity_id: row.get(2)?, - to_username: row.get(3)?, - account_label: row.get(4)?, - request_type: row.get(5)?, - status: row.get(6)?, - created_at: row.get(7)?, - responded_at: row.get(8)?, - expires_at: row.get(9)?, - }) - })? - .collect::<Result<Vec<_>, _>>()?; - - Ok(requests) - } - - // Payment operations - - pub fn save_payment( - &self, - tx_id: &str, - from_identity_id: &Identifier, - to_identity_id: &Identifier, - amount: i64, - memo: Option<&str>, - payment_type: &str, - ) -> rusqlite::Result<i64> { - let sql = " - INSERT INTO dashpay_payments - (tx_id, from_identity_id, to_identity_id, amount, memo, payment_type) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "; - - let conn = self.conn.lock().unwrap(); - conn.execute( - sql, - params![ - tx_id, - from_identity_id.to_buffer().to_vec(), - to_identity_id.to_buffer().to_vec(), - amount, - memo, - payment_type, - ], - )?; - - Ok(conn.last_insert_rowid()) - } - - pub fn update_payment_status(&self, payment_id: i64, status: &str) -> rusqlite::Result<()> { - let sql = if status == "confirmed" { - "UPDATE dashpay_payments - SET status = ?1, confirmed_at = unixepoch() - WHERE id = ?2" - } else { - "UPDATE dashpay_payments - SET status = ?1 - WHERE id = ?2" - }; - - self.execute(sql, params![status, payment_id])?; - Ok(()) - } - - pub fn load_payment_history( - &self, - identity_id: &Identifier, - limit: u32, - ) -> rusqlite::Result<Vec<StoredPayment>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, tx_id, from_identity_id, to_identity_id, amount, memo, - payment_type, status, created_at, confirmed_at - FROM dashpay_payments - WHERE from_identity_id = ?1 OR to_identity_id = ?1 - ORDER BY created_at DESC - LIMIT ?2", - )?; - - let identity_bytes = identity_id.to_buffer().to_vec(); - let payments = stmt - .query_map(params![identity_bytes, limit], |row| { - Ok(StoredPayment { - id: row.get(0)?, - tx_id: row.get(1)?, - from_identity_id: row.get(2)?, - to_identity_id: row.get(3)?, - amount: row.get(4)?, - memo: row.get(5)?, - payment_type: row.get(6)?, - status: row.get(7)?, - created_at: row.get(8)?, - confirmed_at: row.get(9)?, - }) - })? - .collect::<Result<Vec<_>, _>>()?; - - Ok(payments) - } - - /// Delete all DashPay data for a specific identity - pub fn delete_dashpay_data_for_identity( - &self, - identity_id: &Identifier, - ) -> rusqlite::Result<()> { - let identity_bytes = identity_id.to_buffer().to_vec(); - - // Delete profile - self.execute( - "DELETE FROM dashpay_profiles WHERE identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contacts - self.execute( - "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contact requests - self.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id = ?1 OR to_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete payments - self.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id = ?1 OR to_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contact address indices - self.execute( - "DELETE FROM dashpay_contact_address_indices WHERE owner_identity_id = ?1", - params![&identity_bytes], - )?; - - Ok(()) - } - - // Contact address index operations (DIP-0015) - - /// Get or create contact address index entry - /// Returns (next_send_index, highest_receive_index, bloom_registered_count) - pub fn get_contact_address_indices( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<ContactAddressIndex> { - let conn = self.conn.lock().unwrap(); - - // Try to get existing entry - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, next_send_index, - highest_receive_index, bloom_registered_count - FROM dashpay_contact_address_indices - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - )?; - - let result = stmt.query_row( - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec() - ], - |row| { - Ok(ContactAddressIndex { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - next_send_index: row.get(2)?, - highest_receive_index: row.get(3)?, - bloom_registered_count: row.get(4)?, - }) - }, - ); - - match result { - Ok(indices) => Ok(indices), - Err(rusqlite::Error::QueryReturnedNoRows) => { - // Create new entry with defaults - Ok(ContactAddressIndex { - owner_identity_id: owner_identity_id.to_buffer().to_vec(), - contact_identity_id: contact_identity_id.to_buffer().to_vec(), - next_send_index: 0, - highest_receive_index: 0, - bloom_registered_count: 0, - }) - } - Err(e) => Err(e), - } - } - - /// Get the next send address index for a contact and increment it atomically. - /// This is used when sending a payment to ensure unique addresses. - /// Uses an atomic INSERT/UPDATE with RETURNING to prevent race conditions. - pub fn get_and_increment_send_index( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<u32> { - let conn = self.conn.lock().unwrap(); - - // First, ensure the row exists with default values if it doesn't - let init_sql = " - INSERT OR IGNORE INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, next_send_index, highest_receive_index) - VALUES (?1, ?2, 0, 0) - "; - conn.execute( - init_sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - - // Now atomically increment and return the old value - // We update next_send_index = next_send_index + 1 and return the old value - let update_sql = " - UPDATE dashpay_contact_address_indices - SET next_send_index = next_send_index + 1 - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 - RETURNING next_send_index - 1 - "; - - conn.query_row( - update_sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - |row| row.get(0), - ) - } - - /// Update the highest receive index seen for a contact - /// Called when we detect an incoming payment at a higher index - pub fn update_highest_receive_index( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - index: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, highest_receive_index) - VALUES (?1, ?2, ?3) - ON CONFLICT(owner_identity_id, contact_identity_id) - DO UPDATE SET highest_receive_index = MAX(highest_receive_index, ?3) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - index, - ], - )?; - - Ok(()) - } - - /// Update the bloom registered count for a contact - /// Called after registering addresses in bloom filter - pub fn update_bloom_registered_count( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - count: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, bloom_registered_count) - VALUES (?1, ?2, ?3) - ON CONFLICT(owner_identity_id, contact_identity_id) - DO UPDATE SET bloom_registered_count = ?3 - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - count, - ], - )?; - - Ok(()) - } - - /// Get all contact address indices for an identity - /// Useful for registering bloom filters on startup - pub fn get_all_contact_address_indices( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result<Vec<ContactAddressIndex>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, next_send_index, - highest_receive_index, bloom_registered_count - FROM dashpay_contact_address_indices - WHERE owner_identity_id = ?1", - )?; - - let indices = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - Ok(ContactAddressIndex { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - next_send_index: row.get(2)?, - highest_receive_index: row.get(3)?, - bloom_registered_count: row.get(4)?, - }) - })? - .collect::<Result<Vec<_>, _>>()?; - - Ok(indices) - } - - // DashPay address mapping operations - - /// Save a DashPay address mapping for incoming payment detection - pub fn save_dashpay_address_mapping( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - address: &dash_sdk::dpp::dashcore::Address, - address_index: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO dashpay_address_mappings - (address, owner_identity_id, contact_identity_id, address_index, created_at) - VALUES (?1, ?2, ?3, ?4, unixepoch()) - "; - - self.execute( - sql, - params![ - address.to_string(), - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - address_index, - ], - )?; - - Ok(()) - } - - /// Look up a DashPay address mapping to find which contact relationship it belongs to - /// Returns (owner_identity_id, contact_identity_id, address_index) if found - pub fn get_dashpay_address_mapping( - &self, - address: &dash_sdk::dpp::dashcore::Address, - ) -> rusqlite::Result<Option<(Identifier, Identifier, u32)>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, address_index - FROM dashpay_address_mappings - WHERE address = ?1", - )?; - - let result = stmt.query_row(params![address.to_string()], |row| { - let owner_bytes: Vec<u8> = row.get(0)?; - let contact_bytes: Vec<u8> = row.get(1)?; - let address_index: u32 = row.get(2)?; - Ok((owner_bytes, contact_bytes, address_index)) - }); - - match result { - Ok((owner_bytes, contact_bytes, address_index)) => { - let owner_id = Identifier::from_bytes(&owner_bytes) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let contact_id = Identifier::from_bytes(&contact_bytes) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - Ok(Some((owner_id, contact_id, address_index))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - /// Get all DashPay address mappings for an identity - pub fn get_all_dashpay_address_mappings( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result<Vec<(String, Identifier, u32)>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT address, contact_identity_id, address_index - FROM dashpay_address_mappings - WHERE owner_identity_id = ?1 - ORDER BY contact_identity_id, address_index", - )?; - - let mappings = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - let address: String = row.get(0)?; - let contact_bytes: Vec<u8> = row.get(1)?; - let address_index: u32 = row.get(2)?; - Ok((address, contact_bytes, address_index)) - })? - .filter_map(|r| { - r.ok().and_then(|(address, contact_bytes, address_index)| { - Identifier::from_bytes(&contact_bytes) - .ok() - .map(|contact_id| (address, contact_id, address_index)) - }) - }) - .collect(); - - Ok(mappings) - } - - /// Delete all address mappings for a contact relationship - pub fn delete_dashpay_address_mappings_for_contact( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<()> { - self.execute( - "DELETE FROM dashpay_address_mappings - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - Ok(()) - } -} diff --git a/src/database/identities.rs b/src/database/identities.rs deleted file mode 100644 index 6cb2a25fe..000000000 --- a/src/database/identities.rs +++ /dev/null @@ -1,566 +0,0 @@ -use crate::context::AppContext; -use crate::database::Database; -use crate::model::qualified_identity::{IdentityStatus, QualifiedIdentity}; -use crate::model::wallet::{Wallet, WalletSeedHash}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::platform::Identifier; -use rusqlite::{Connection, params}; -use std::collections::BTreeMap; -use std::sync::{Arc, RwLock}; - -impl Database { - /// Updates the alias of a specified identity. - pub fn set_identity_alias( - &self, - identifier: &Identifier, - new_alias: Option<&str>, - ) -> rusqlite::Result<()> { - let id = identifier.to_vec(); - let conn = self.conn.lock().unwrap(); - - let rows_updated = conn.execute( - "UPDATE identity SET alias = ? WHERE id = ?", - params![new_alias, id], - )?; - - if rows_updated == 0 { - return Err(rusqlite::Error::QueryReturnedNoRows); - } - - Ok(()) - } - - pub fn get_identity_alias(&self, identifier: &Identifier) -> rusqlite::Result<Option<String>> { - let id = identifier.to_vec(); - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare("SELECT alias FROM identity WHERE id = ?")?; - let alias: Option<String> = stmt.query_row(params![id], |row| row.get(0)).ok(); - - Ok(alias) - } - - pub fn insert_local_qualified_identity( - &self, - qualified_identity: &QualifiedIdentity, - wallet_and_identity_id_info: &Option<(WalletSeedHash, u32)>, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let id = qualified_identity.identity.id().to_vec(); - let data = qualified_identity.to_bytes(); - let alias = qualified_identity.alias.clone(); - let identity_type = format!("{:?}", qualified_identity.identity_type); - - let network = app_context.network.to_string(); - - let status = qualified_identity.status.as_u8(); - - if let Some((wallet, wallet_index)) = wallet_and_identity_id_info { - // If wallet information is provided, insert with wallet and wallet_index - self.execute( - "INSERT OR REPLACE INTO identity - (id, data, is_local, alias, identity_type, network, wallet, wallet_index, status) - VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)", - params![ - id, - data, - alias, - identity_type, - network, - wallet, - wallet_index, - status, - ], - )?; - } else { - tracing::warn!(identity_id=?id, alias, network, "saving identity without wallet; this needs investigating"); - // If wallet information is not provided, insert without wallet and wallet_index - self.execute( - "INSERT OR REPLACE INTO identity - (id, data, is_local, alias, identity_type, network, status) - VALUES (?, ?, 1, ?, ?, ?, ?)", - params![id, data, alias, identity_type, network, status], - )?; - } - - Ok(()) - } - - pub fn update_local_qualified_identity( - &self, - qualified_identity: &QualifiedIdentity, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - // Extract the fields from `qualified_identity` to use in the SQL update - let id = qualified_identity.identity.id().to_vec(); - let data = qualified_identity.to_bytes(); - let alias = qualified_identity.alias.clone(); - let identity_type = format!("{:?}", qualified_identity.identity_type); - - // Get the network string from the app context - let network = app_context.network.to_string(); - - let status = qualified_identity.status.as_u8(); - - // Execute the update statement - self.execute( - "UPDATE identity - SET data = ?, alias = ?, identity_type = ?, network = ?, is_local = 1, status = ? - WHERE id = ?", - params![data, alias, identity_type, network, status, id], - )?; - - Ok(()) - } - - /// Returns all local identities for the current network. - /// - /// Stops on the first corrupted identity blob and returns an error. - /// This is intentional — identities hold private keys and balance data, - /// so skipping a corrupted entry could cause loss of funds. - pub fn get_local_qualified_identities( - &self, - app_context: &AppContext, - wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, - ) -> rusqlite::Result<Vec<QualifiedIdentity>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Use a LEFT JOIN to load identities and their top-ups in a single query, - // avoiding the N+1 query pattern of querying top_up per identity. - let mut stmt = conn.prepare( - "SELECT i.data, i.alias, i.wallet_index, i.status, i.id, t.top_up_index, t.amount - FROM identity i - LEFT JOIN top_up t ON i.id = t.identity_id - WHERE i.is_local = 1 AND i.network = ? AND i.data IS NOT NULL - ORDER BY i.id", - )?; - - let mut rows = stmt.query(params![network])?; - - let mut identities: Vec<QualifiedIdentity> = Vec::new(); - let mut current_id: Option<Vec<u8>> = None; - - while let Some(row) = rows.next()? { - let id: Vec<u8> = row.get(4)?; - - if current_id.as_ref() != Some(&id) { - // New identity row - let data: Vec<u8> = row.get(0)?; - let alias: Option<String> = row.get(1)?; - let wallet_index: Option<u32> = row.get(2)?; - let status: Option<u8> = row.get(3)?; - - let mut identity = - QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; - identity.alias = alias; - identity.wallet_index = wallet_index; - identity.status = IdentityStatus::from_u8(status.unwrap_or(2)); - identity.network = app_context.network; - identity.associated_wallets = wallets.clone(); - identity.top_ups = BTreeMap::new(); - - identities.push(identity); - current_id = Some(id); - } - - // Add top-up entry if present (NULL when identity has no top-ups) - let top_up_index: Option<u32> = row.get(5)?; - let amount: Option<u64> = row.get(6)?; - if let (Some(idx), Some(amt)) = (top_up_index, amount) - && let Some(identity) = identities.last_mut() - { - identity.top_ups.insert(idx, amt); - } - } - - Ok(identities) - } - - /// Stops on the first corrupted identity blob and returns an error. - /// This is intentional — identities hold private keys and balance data, - /// so skipping a corrupted entry could cause loss of funds. - #[allow(dead_code)] // May be used for filtering identities that belong to specific wallets - pub fn get_local_qualified_identities_in_wallets( - &self, - app_context: &AppContext, - wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, - ) -> rusqlite::Result<Vec<QualifiedIdentity>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Use a LEFT JOIN to load identities and their top-ups in a single query. - let mut stmt = conn.prepare( - "SELECT i.data, i.alias, i.wallet_index, i.status, i.id, t.top_up_index, t.amount - FROM identity i - LEFT JOIN top_up t ON i.id = t.identity_id - WHERE i.is_local = 1 AND i.network = ? AND i.data IS NOT NULL AND i.wallet_index IS NOT NULL - ORDER BY i.id", - )?; - - let mut rows = stmt.query(params![network])?; - - let mut identities: Vec<QualifiedIdentity> = Vec::new(); - let mut current_id: Option<Vec<u8>> = None; - - while let Some(row) = rows.next()? { - let id: Vec<u8> = row.get(4)?; - - if current_id.as_ref() != Some(&id) { - // New identity row - let data: Vec<u8> = row.get(0)?; - let alias: Option<String> = row.get(1)?; - let wallet_index: Option<u32> = row.get(2)?; - let status: Option<u8> = row.get(3)?; - - let mut identity = - QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; - identity.alias = alias; - identity.wallet_index = wallet_index; - identity.status = IdentityStatus::from_u8(status.unwrap_or(2)); - identity.network = app_context.network; - identity.associated_wallets = wallets.clone(); - identity.top_ups = BTreeMap::new(); - - identities.push(identity); - current_id = Some(id); - } - - // Add top-up entry if present - let top_up_index: Option<u32> = row.get(5)?; - let amount: Option<u64> = row.get(6)?; - if let (Some(idx), Some(amt)) = (top_up_index, amount) - && let Some(identity) = identities.last_mut() - { - identity.top_ups.insert(idx, amt); - } - } - - Ok(identities) - } - - /// Returns an error if the stored identity blob is corrupted. - /// This is intentional — identities hold private keys and balance data, - /// so ignoring corruption could cause loss of funds. - pub fn get_identity_by_id( - &self, - identifier: &Identifier, - app_context: &AppContext, - wallets: &BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, - ) -> rusqlite::Result<Option<QualifiedIdentity>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Use a LEFT JOIN to load identity and its top-ups in a single query. - let mut stmt = conn.prepare( - "SELECT i.data, i.alias, i.wallet_index, i.status, t.top_up_index, t.amount - FROM identity i - LEFT JOIN top_up t ON i.id = t.identity_id - WHERE i.id = ? AND i.is_local = 1 AND i.network = ? AND i.data IS NOT NULL", - )?; - - let mut rows = stmt.query(params![identifier.to_buffer(), network])?; - - let mut identity: Option<QualifiedIdentity> = None; - - while let Some(row) = rows.next()? { - if identity.is_none() { - let data: Vec<u8> = row.get(0)?; - let alias: Option<String> = row.get(1)?; - let wallet_index: Option<u32> = row.get(2)?; - let status: Option<u8> = row.get(3)?; - - let mut qi = - QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; - qi.alias = alias; - qi.wallet_index = wallet_index; - qi.status = IdentityStatus::from_u8(status.unwrap_or(2)); - qi.network = app_context.network; - qi.associated_wallets = wallets.clone(); - qi.top_ups = BTreeMap::new(); - - identity = Some(qi); - } - - // Add top-up entry if present - let top_up_index: Option<u32> = row.get(4)?; - let amount: Option<u64> = row.get(5)?; - if let (Some(idx), Some(amt)) = (top_up_index, amount) - && let Some(ref mut qi) = identity - { - qi.top_ups.insert(idx, amt); - } - } - - Ok(identity) - } - - /// Stops on the first corrupted identity blob and returns an error. - /// This is intentional — identities hold private keys and balance data, - /// so skipping a corrupted entry could cause loss of funds. - pub fn get_local_voting_identities( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<Vec<QualifiedIdentity>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT data FROM identity WHERE is_local = 1 AND network = ? AND identity_type != 'User' AND data IS NOT NULL", - )?; - let identity_iter = stmt.query_map(params![network], |row| { - let data: Vec<u8> = row.get(0)?; - let mut identity = - QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; - identity.network = app_context.network; - - Ok(identity) - })?; - - let identities: rusqlite::Result<Vec<QualifiedIdentity>> = identity_iter.collect(); - identities - } - - /// Retrieves all local user identities along with their associated wallet IDs. - /// - /// Stops on the first corrupted identity blob and returns an error. - /// This is intentional — identities hold private keys and balance data, - /// so skipping a corrupted entry could cause loss of funds. - /// - /// Caller should insert wallet references into associated_wallets before using the identities. - #[allow(clippy::let_and_return)] - pub fn get_local_user_identities( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<Vec<(QualifiedIdentity, Option<WalletSeedHash>)>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT data,wallet FROM identity WHERE is_local = 1 AND network = ? AND identity_type = 'User' AND data IS NOT NULL", - )?; - let identities: Result<Vec<(QualifiedIdentity, Option<WalletSeedHash>)>, rusqlite::Error> = - stmt.query_map(params![network], |row| { - let data: Vec<u8> = row.get(0)?; - let wallet_id: Option<WalletSeedHash> = row.get(1)?; - let mut identity = - QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; - identity.network = app_context.network; - - Ok((identity, wallet_id)) - })? - .collect(); - - identities - } - - /// Deletes a local qualified identity with the given identifier from the database. - pub fn delete_local_qualified_identity( - &self, - identifier: &Identifier, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let id = identifier.to_vec(); - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Perform the deletion only if the identity is marked as local - conn.execute( - "DELETE FROM identity WHERE id = ? AND network = ? AND is_local = 1", - params![id, network], - )?; - - Ok(()) - } - - /// Deletes all local qualified identities in Devnet variants and Regtest. - pub fn delete_all_identities_in_all_devnets_and_regtest( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM identity WHERE (network LIKE 'devnet%' OR network = 'regtest')", - [], - )?; - - Ok(()) - } - - /// Deletes a local qualified identity with the given identifier from the database. - pub fn delete_all_local_qualified_identities_in_devnet( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - if app_context.network != Network::Devnet { - return Ok(()); - } - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Perform the deletion only if the identity is marked as local - conn.execute( - "DELETE FROM identity WHERE network = ? AND is_local = 1", - params![network], - )?; - - Ok(()) - } - - /// Creates the identity_order table if it doesn't already exist - /// with two columns: `pos` (int) and `identity_id` (blob). - /// pos is the "position" in the custom ordering. - pub fn initialize_identity_order_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS identity_order ( - pos INTEGER NOT NULL, - identity_id BLOB NOT NULL, - PRIMARY KEY(pos), - FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE - )", - [], - )?; - - Ok(()) - } - - /// Saves the user’s custom identity order (the entire list). - /// This method overwrites whatever was there before. - pub fn save_identity_order(&self, all_ids: Vec<Identifier>) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - let tx = conn.unchecked_transaction()?; - - // Clear existing rows - tx.execute("DELETE FROM identity_order", [])?; - - // Insert each ID with a numeric pos = 0..N - for (pos, id) in all_ids.iter().enumerate() { - let id_bytes = id.to_vec(); - tx.execute( - "INSERT INTO identity_order (pos, identity_id) - VALUES (?1, ?2)", - params![pos as i64, id_bytes], - )?; - } - - tx.commit()?; - Ok(()) - } - - /// Loads the user's custom identity order (the entire list). - /// If an identity in the order doesn't exist in the identity table, it is removed. - pub fn load_identity_order(&self) -> rusqlite::Result<Vec<Identifier>> { - let conn = self.conn.lock().unwrap(); - - // Use a LEFT JOIN to get all order entries and detect dangling references - // in a single query instead of per-row EXISTS checks. - let mut stmt = conn.prepare( - "SELECT io.identity_id, i.id IS NOT NULL AS exists_in_identity - FROM identity_order io - LEFT JOIN identity i ON io.identity_id = i.id - ORDER BY io.pos ASC", - )?; - - let mut rows = stmt.query([])?; - let mut final_list = Vec::new(); - let mut to_remove = Vec::new(); - - while let Some(row) = rows.next()? { - let id_bytes: Vec<u8> = row.get(0)?; - let exists: bool = row.get(1)?; - - let identifier = match Identifier::from_vec(id_bytes.clone()) { - Ok(id) => id, - Err(_) => { - // If parsing as an Identifier fails, queue for removal - to_remove.push(id_bytes); - continue; - } - }; - - if exists { - final_list.push(identifier); - } else { - // Queue for removal because it doesn't exist in the identity table - to_remove.push(identifier.to_vec()); - } - } - - // Remove any "dangling" references - for id in to_remove { - conn.execute( - "DELETE FROM identity_order WHERE identity_id = ?", - params![id], - )?; - } - - Ok(final_list) - } - - /// Fixes bug in identity table where network name for devnet was stored as `devnet:` instead of `devnet`. - pub fn fix_identity_devnet_network_name(&self, conn: &Connection) -> rusqlite::Result<()> { - const TABLES: [&str; 11] = [ - "asset_lock_transaction", - "contestant", - "contested_name", - "contract", - "identity", - "identity_token_balances", - "scheduled_votes", - "settings", - "token", - "utxos", - "wallet", - ]; - - for t in TABLES { - conn.execute( - &format!( - "UPDATE {} SET network = 'devnet' WHERE network = 'devnet:'", - t - ), - [], - )?; - - conn.execute( - &format!( - "UPDATE {} SET network = 'regtest' WHERE network = 'local'", - t - ), - [], - )?; - } - - tracing::debug!("Updated network names in database"); - - Ok(()) - } - - pub fn rename_identity_column_is_in_creation_to_status( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - // Rename the column in the identity table - conn.execute( - "ALTER TABLE identity RENAME COLUMN is_in_creation TO status", - [], - )?; - - // Update the status values to match the new enum - conn.execute( - "UPDATE identity SET status = 2 WHERE status = 0", // Active was 0, now it's 2 - [], - )?; - tracing::debug!("Renamed column 'is_in_creation' to 'status' in identity table"); - - Ok(()) - } -} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index c266c5538..f7e4b3201 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -35,7 +35,7 @@ impl<T> MigrationResultExt<T> for rusqlite::Result<T> { } } -pub const DEFAULT_DB_VERSION: u16 = 34; +pub const DEFAULT_DB_VERSION: u16 = 38; /// Minimal view of `.env` values the v34 migration needs. struct V34EnvSnapshot { @@ -69,7 +69,110 @@ fn read_env_file_for_v34_migration(data_dir: &Path) -> std::io::Result<V34EnvSna }) } -pub const DEFAULT_NETWORK: &str = "mainnet"; +/// Migration helper: replace the legacy `devnet:` / `local` network +/// labels with the modern `devnet` / `regtest` spellings across every +/// table that still carries a `network` column. +/// +/// Pre-C7 this lived as `Database::fix_identity_devnet_network_name` in +/// `database/identities.rs`. The file is gone — the helper is now a free +/// function so it can be called from the migration ladder without +/// reintroducing a domain-specific module. +fn fix_devnet_network_name_in_legacy_tables(conn: &Connection) -> rusqlite::Result<()> { + // `scheduled_votes` lingers on pre-C5 installs but is no longer + // created on fresh installs; the per-table existence check below + // skips it transparently on the new schema. + const TABLES: [&str; 11] = [ + "asset_lock_transaction", + "contestant", + "contested_name", + "contract", + "identity", + "identity_token_balances", + "scheduled_votes", + "settings", + "token", + "utxos", + "wallet", + ]; + + for t in TABLES { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [t], + |row| row.get(0), + )?; + if !exists { + continue; + } + // Pre-C5 `scheduled_votes` (v5 schema) had no `network` column; + // the v6 schema upgrade that added it was unwired in C5, so + // legacy DBs that never ran a later migration still have the + // old shape. Skip the UPDATE on those — the table is orphaned + // either way. + let has_network: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name='network'", + [t], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if !has_network { + continue; + } + conn.execute( + &format!("UPDATE {t} SET network = 'devnet' WHERE network = 'devnet:'"), + [], + )?; + conn.execute( + &format!("UPDATE {t} SET network = 'regtest' WHERE network = 'local'"), + [], + )?; + } + + tracing::debug!("Updated network names in database"); + Ok(()) +} + +/// Detect whether this on-disk DB carries any legacy DET wallet state. +/// +/// Returns true when any of the canary legacy tables (`wallet`, +/// `wallet_addresses`, `single_key_wallet`, `utxos`, `shielded_notes`) +/// exists and contains at least one row. Truly-fresh installs — empty +/// `data.db` or DB without those tables — return false, so the gated +/// CREATE TABLE statements in [`Database::create_tables`] are skipped +/// and the wallet state lives entirely in `platform-wallet.sqlite`. +/// +/// The check is best-effort: any sqlite read error is treated as +/// "no legacy detected" so a malformed/locked DB does not accidentally +/// recreate the dormant schema on a fresh install. +fn legacy_detected(conn: &Connection) -> bool { + const TARGETS: [&str; 5] = [ + "wallet", + "wallet_addresses", + "single_key_wallet", + "utxos", + "shielded_notes", + ]; + for table in TARGETS { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) + .unwrap_or(false); + if !exists { + continue; + } + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap_or(0); + if count > 0 { + return true; + } + } + false +} impl Database { pub fn initialize(&self, db_file_path: &Path) -> rusqlite::Result<()> { @@ -77,7 +180,7 @@ impl Database { // created with an older schema. This must happen before any queries that // depend on these columns (like db_schema_version which needs database_version). { - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); // Check if settings table exists before trying to ensure columns let settings_exists: bool = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='settings'", @@ -91,7 +194,18 @@ impl Database { // Check if this is the first time setup by looking for entries in the settings table. if self.is_first_time_setup()? { - self.create_tables()?; + // Detect legacy DET wallet state on the same DB file. Truly-fresh + // installs skip the wallet/utxos/single_key_wallet/wallet_transactions/ + // shielded_notes/shielded_wallet_meta CREATE TABLE statements — that + // state now lives in `platform-wallet.sqlite`. Pre-existing installs + // (settings row missing but wallet rows present, an unusual but + // possible recovery shape) still get the legacy tables so the + // migration ladder has something to upgrade. + let include_legacy = { + let conn = self.locked_conn(); + legacy_detected(&conn) + }; + self.create_tables(include_legacy)?; self.set_default_version()?; } else { self.run_consistency_checks(); @@ -125,6 +239,45 @@ impl Database { data_dir: Option<&Path>, ) -> Result<(), MigrationError> { match version { + 38 => { + // Drop the retired `core_backend_mode` settings column. The + // RPC/SPV backend selector it held was unwired in C3 (user + // prefs moved to the upstream k/v store) and chain sync is + // SPV-only now, so the column is permanent dead weight. + // Existence-guarded and idempotent; mutates ONLY the settings + // table and preserves every other column and value. + self.drop_core_backend_mode_column(tx) + .migration_err("settings", "v38: drop core_backend_mode column")?; + } + 37 => { + // Retire DET's home-grown shielded subsystem: the upstream + // `platform-wallet` coordinator owns all Orchard state now. + // No released build ever persisted shielded rows (v0.9.3 ships + // zero shielded code), so dropping the dead tables is safe and + // loses no user data. Existence-guarded via IF EXISTS. + tx.execute_batch( + "DROP TABLE IF EXISTS shielded_notes;\n\ + DROP TABLE IF EXISTS shielded_wallet_meta;", + ) + .migration_err("shielded_notes", "v37: drop dead shielded tables")?; + } + 36 => { + // Drop the orphaned `dashpay_dip14_quarantine_active` + // settings column left behind by an early P3a build and the + // withdrawn quarantine apparatus. Existence-guarded and + // idempotent; mutates ONLY the settings table. + self.drop_dead_settings_columns(tx) + .migration_err("settings", "v36: drop dead settings columns")?; + } + 35 => { + // Platform-wallet migration scaffolding (Stage A + Stage B) + // has been removed. v34 users advance through this arm with + // no schema or marker writes — the data.db file is left + // dormant and the future migration tool covers the wallet + // seeds. The bare version bump preserves the ladder so the + // v36+ arms keep running. + let _ = tx; + } 34 => { // SPV is now the default Core-level backend. Users who have a // configured local Dash Core node (Expert mode + at least one @@ -147,13 +300,31 @@ impl Database { None => true, }; - if migrate_to_spv { - tx.execute("UPDATE settings SET core_backend_mode = 1 WHERE id = 1", []) - .migration_err("settings", "v34: pin SPV as default backend")?; + // The `core_backend_mode` column itself was unwired in C3 + // (user prefs moved to the upstream k/v store) — only a DB + // that was created before that change still has it. Guard + // the legacy update so synthetic v33 DBs created from the + // post-C3 schema are not rejected. + let has_legacy_column: bool = tx + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='core_backend_mode'", + [], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap_or(false); + if has_legacy_column { + if migrate_to_spv { + tx.execute("UPDATE settings SET core_backend_mode = 1 WHERE id = 1", []) + .migration_err("settings", "v34: pin SPV as default backend")?; + } else { + tracing::info!( + "v34 migration: preserving existing core_backend_mode \ + (local Dash Core node configured)" + ); + } } else { - tracing::info!( - "v34 migration: preserving existing core_backend_mode \ - (local Dash Core node configured)" + tracing::debug!( + "v34 migration: legacy core_backend_mode column absent — no-op" ); } } @@ -169,16 +340,15 @@ impl Database { self.clean_orphaned_fk_rows(tx)?; self.add_core_wallet_name_column(tx) .migration_err("wallet", "add core_wallet_name column")?; - self.init_contacts_tables(tx) - .migration_err("contact_private_info", "create contacts tables")?; - self.create_shielded_tables(tx) - .migration_err("shielded_notes", "create shielded tables")?; - self.create_shielded_wallet_meta_table(tx) - .migration_err("shielded_wallet_meta", "create shielded_wallet_meta table")?; - self.add_nullifier_sync_timestamp_column(tx).migration_err( - "shielded_wallet_meta", - "add last_nullifier_sync_timestamp column", - )?; + // Legacy v33 also created `contact_private_info` — the + // table was retired in D4d (private memos now live in the + // per-network k/v sidecar). Pre-D4d installs keep the + // dormant row set; fresh installs never create the table. + // + // The old `shielded_notes` / `shielded_wallet_meta` tables are + // no longer created here: DET's shielded subsystem was retired + // and the v37 migration drops them. A DB stepping through v33 + // simply skips the create; v37 then drops any legacy copies. // Defer FK checks so parent->child rename order doesn't matter // (contestant and token have composite FKs that include network). tx.execute_batch("PRAGMA defer_foreign_keys = ON") @@ -255,77 +425,152 @@ impl Database { .migration_err("wallet_transactions", "create table")?; } 13 => { - self.init_dashpay_tables_in_tx(tx) - .migration_err("dashpay_profiles", "create DashPay tables")?; + // Legacy v13 created the DashPay tables (dashpay_profiles, + // dashpay_contacts, dashpay_contact_requests, + // dashpay_payments, dashpay_contact_address_indices, + // dashpay_address_mappings). All six were retired in D4d + // — upstream `ManagedIdentity` and the k/v sidecar now own + // the state. Pre-D4d installs keep the dormant rows; fresh + // installs never reach this arm because they jump to + // `DEFAULT_DB_VERSION` directly. } 12 => { self.add_disable_zmq_column(tx) .migration_err("settings", "add disable_zmq column")?; } 11 => { - self.rename_identity_column_is_in_creation_to_status(tx) - .migration_err("identity", "rename is_in_creation to status")?; + // Rename `is_in_creation` to `status` on the legacy + // identity table. Pre-C7 this method lived on `Database`; + // it is inlined here now that `database/identities.rs` + // is gone. + tx.execute( + "ALTER TABLE identity RENAME COLUMN is_in_creation TO status", + [], + ) + .migration_err("identity", "rename is_in_creation to status")?; + tx.execute("UPDATE identity SET status = 2 WHERE status = 0", []) + .migration_err("identity", "remap is_in_creation values")?; } 10 => { self.add_theme_preference_column(tx) .migration_err("settings", "add theme_preference column")?; } 9 => { - self.delete_all_identities_in_all_devnets_and_regtest(tx) - .migration_err("identity", "delete devnet/regtest identities")?; - self.delete_all_local_tokens_in_all_devnets_and_regtest(tx) + // The `identity` table is kept (empty CREATE) so legacy + // rows can still be cleaned up here. Fresh installs have + // an empty table — the DELETE is a no-op. + tx.execute( + "DELETE FROM identity WHERE (network LIKE 'devnet%' OR network = 'regtest')", + [], + ) + .migration_err("identity", "delete devnet/regtest identities")?; + // The `token` table was unwired in C7 — fresh installs + // do not create it. Legacy DBs still have it and pay + // the cost of the DELETE once. + if self + .table_exists(tx, "token") + .migration_err("token", "check table existence")? + { + tx.execute( + "DELETE FROM token WHERE network LIKE 'devnet%' OR network = 'regtest'", + [], + ) .migration_err("token", "delete devnet/regtest tokens")?; - self.remove_all_asset_locks_identity_id_for_all_devnets_and_regtest(tx) + } + // `asset_lock_transaction` was unwired — fresh installs do + // not create the table, so the devnet/regtest sweep is + // skipped when the table is absent. Legacy DBs still have + // it and pay the cost of the DELETE once. + if self + .table_exists(tx, "asset_lock_transaction") + .migration_err("asset_lock_transaction", "check table existence")? + { + tx.execute( + "DELETE FROM asset_lock_transaction \ + WHERE network LIKE 'devnet%' OR network = 'regtest'", + [], + ) .migration_err( "asset_lock_transaction", "clear devnet/regtest asset lock identity IDs", )?; - self.remove_all_contracts_in_all_devnets_and_regtest(tx) + } + // The `contract` table was unwired in C6 — on fresh + // installs it does not exist, so we skip the devnet/regtest + // sweep when the table is absent. Legacy DBs still have it + // and pay the cost of the DELETE once. + if self + .table_exists(tx, "contract") + .migration_err("contract", "check table existence")? + { + tx.execute( + "DELETE FROM contract WHERE network LIKE 'devnet%' OR network = 'regtest'", + [], + ) .migration_err("contract", "delete devnet/regtest contracts")?; - self.fix_identity_devnet_network_name(tx) + } + fix_devnet_network_name_in_legacy_tables(tx) .migration_err("identity", "fix devnet network name")?; } 8 => { - self.change_contract_name_to_alias(tx) - .migration_err("contract", "rename name to alias")?; + // The `contract` table was unwired in C6 — on fresh + // installs it does not exist, so we skip the column + // rename when the table is absent. + if self + .table_exists(tx, "contract") + .migration_err("contract", "check table existence")? + { + let name_column_exists: bool = tx + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('contract') WHERE name='name'", + [], + |row| Ok(row.get::<_, i64>(0)? > 0), + ) + .migration_err("contract", "inspect table columns")?; + if name_column_exists { + tx.execute("ALTER TABLE contract RENAME COLUMN name TO alias", []) + .migration_err("contract", "rename name to alias")?; + } + } } 7 => { - self.migrate_asset_lock_fk_to_set_null(tx) - .migration_err("asset_lock_transaction", "migrate FK to SET NULL")?; + // `asset_lock_transaction` was unwired — fresh installs do + // not create the table, so the FK migration is skipped + // when absent. Legacy DBs still rebuild the FK once. + if self + .table_exists(tx, "asset_lock_transaction") + .migration_err("asset_lock_transaction", "check table existence")? + { + Self::migrate_asset_lock_fk_to_set_null(tx) + .migration_err("asset_lock_transaction", "migrate FK to SET NULL")?; + } } 6 => { - self.update_scheduled_votes_table(tx) - .migration_err("scheduled_votes", "update table schema")?; - self.initialize_token_table(tx) - .migration_err("token", "create table")?; - self.drop_identity_token_balances_table(tx) - .migration_err("identity_token_balances", "drop table")?; - self.initialize_identity_token_balances_table(tx) - .migration_err("identity_token_balances", "create table")?; - tx.execute("DROP TABLE IF EXISTS identity_order", []) - .migration_err("identity_order", "drop table")?; - self.initialize_identity_order_table(tx) - .migration_err("identity_order", "create table")?; - tx.execute("DROP TABLE IF EXISTS token_order", []) - .migration_err("token_order", "drop table")?; - self.initialize_token_order_table(tx) - .migration_err("token_order", "create table")?; + // Pre-C5: `scheduled_votes` schema upgrade. The table is no longer + // created/managed; pre-C5 installs keep the orphaned table dormant. + // + // Pre-C7: this arm used to (re)create the `token`, + // `identity_token_balances`, `identity_order` and + // `token_order` tables. All four were unwired in C7 + // (tokens + identity registry moved to the per-network + // k/v store). Fresh installs no longer create them and + // legacy installs keep the dormant rows. + let _ = tx; } 5 => { - self.initialize_scheduled_votes_table(tx) - .migration_err("scheduled_votes", "create table")?; + // Pre-C5: `scheduled_votes` create. Removed in C5; the table + // is no longer created on fresh installs. } 4 => { - self.initialize_top_up_table(tx) - .migration_err("top_up", "create table")?; + // Pre-C5: `top_up` create. Removed in C5. } 3 => { self.add_custom_dash_qt_columns(tx) .migration_err("settings", "add custom dash_qt columns")?; } 2 => { - self.initialize_proof_log_table(tx) - .migration_err("proof_log", "create table")?; + // Pre-C5: `proof_log` create. Removed in C5; proof errors now + // surface via structured tracing only. } _ => { tracing::warn!("No database changes for version {}", version); @@ -370,10 +615,7 @@ impl Database { source: rusqlite::Error::InvalidQuery, }), std::cmp::Ordering::Less => { - let mut conn = self - .conn - .lock() - .expect("Failed to lock database connection"); + let mut conn = self.locked_conn(); for version in (original_version + 1)..=to_version { tracing::debug!("Applying migration v{version}"); @@ -415,7 +657,7 @@ impl Database { /// Checks if the `settings` table is empty or missing, indicating a first-time setup. fn is_first_time_setup(&self) -> rusqlite::Result<bool> { - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); // Check if the `settings` table exists by querying `sqlite_master` let table_exists: bool = conn.query_row( @@ -443,7 +685,7 @@ impl Database { /// This is to allow the app to detect when database version is too high and to prevent /// the app from running with an unsupported database version. fn db_schema_version(&self) -> rusqlite::Result<u16> { - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); let result: rusqlite::Result<u16> = conn.query_row( "SELECT database_version FROM settings WHERE id = 1", [], @@ -488,38 +730,37 @@ impl Database { } /// Creates all required tables with indexes if they don't already exist. - fn create_tables(&self) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - // Create the settings table + /// + /// `include_legacy` controls whether the wallet-family tables + /// (`wallet`, `wallet_addresses`, `utxos`, `single_key_wallet`, + /// `wallet_transactions`, `shielded_notes`, `shielded_wallet_meta`) + /// are created. Truly-fresh DET installs pass `false` so these dormant + /// schemas never appear in `data.db`; legacy installs and the migration + /// ladder still pass `true` so upgrade arms keep working. Always-present + /// tables (`settings`, `identity`, `platform_address_balances`) are + /// created regardless. + pub(crate) fn create_tables(&self, include_legacy: bool) -> rusqlite::Result<()> { + let conn = self.locked_conn(); + // Create the settings table. + // + // User-preference columns (network, theme, ZMQ, evonode tools, …) + // were unwired in C3 of the data.db unwire and moved to the + // upstream k/v store. The selected-wallet pointer + // (`selected_wallet_hash`, `selected_single_key_hash`) was + // unwired in C4 and moved to the per-network wallet k/v store. + // What survives here is the migration runner's version counter. conn.execute( "CREATE TABLE IF NOT EXISTS 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, - disable_zmq INTEGER DEFAULT 0, - theme_preference TEXT DEFAULT 'System', - core_backend_mode INTEGER DEFAULT 1, - database_version INTEGER NOT NULL, - onboarding_completed INTEGER DEFAULT 0, - show_evonode_tools INTEGER DEFAULT 0, - user_mode TEXT DEFAULT 'Advanced', - use_local_spv_node INTEGER DEFAULT 0, - auto_start_spv INTEGER DEFAULT 0, - close_dash_qt_on_exit INTEGER DEFAULT 1, - selected_wallet_hash BLOB, - selected_single_key_hash BLOB + database_version INTEGER NOT NULL )", [], )?; - // Create the wallet table - conn.execute( - "CREATE TABLE IF NOT EXISTS wallet ( + if include_legacy { + // Create the wallet table + conn.execute( + "CREATE TABLE IF NOT EXISTS wallet ( seed_hash BLOB NOT NULL PRIMARY KEY, encrypted_seed BLOB NOT NULL, salt BLOB NOT NULL, @@ -538,17 +779,17 @@ impl Database { last_terminal_block INTEGER DEFAULT 0, core_wallet_name TEXT DEFAULT NULL )", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_wallet_network ON wallet (network)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_wallet_network ON wallet (network)", + [], + )?; - // Create wallet addresses - conn.execute( - "CREATE TABLE IF NOT EXISTS wallet_addresses ( + // Create wallet addresses + conn.execute( + "CREATE TABLE IF NOT EXISTS wallet_addresses ( seed_hash BLOB NOT NULL, address TEXT NOT NULL, derivation_path TEXT NOT NULL, @@ -559,12 +800,13 @@ impl Database { PRIMARY KEY (seed_hash, address), FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", - [], - )?; + [], + )?; - // Create indexes for wallet addresses table - conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_reference ON wallet_addresses (path_reference)", [])?; - conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_type ON wallet_addresses (path_type)", [])?; + // Create indexes for wallet addresses table + conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_reference ON wallet_addresses (path_reference)", [])?; + conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_type ON wallet_addresses (path_type)", [])?; + } // Create Platform address balances table conn.execute( @@ -582,9 +824,10 @@ impl Database { [], )?; - // Create the utxos table - conn.execute( - "CREATE TABLE IF NOT EXISTS utxos ( + if include_legacy { + // Create the utxos table + conn.execute( + "CREATE TABLE IF NOT EXISTS utxos ( txid BLOB NOT NULL, vout INTEGER NOT NULL, address TEXT NOT NULL, @@ -593,49 +836,36 @@ impl Database { network TEXT NOT NULL, PRIMARY KEY (txid, vout, network) );", - [], - )?; - - // Create indexes for utxos table - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_utxos_address ON utxos (address)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_utxos_network ON utxos (network)", - [], - )?; - - // Create wallet transactions table for SPV history - self.initialize_wallet_transactions_table(&conn)?; + [], + )?; - // Create asset lock transaction table - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 indexes for utxos table + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utxos_address ON utxos (address)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_utxos_network ON utxos (network)", + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_asset_lock_transaction_network ON asset_lock_transaction (network)", - [], - )?; + // Create wallet transactions table for SPV history + self.initialize_wallet_transactions_table(&conn)?; + } - // Create the identities table - conn.execute( - "CREATE TABLE IF NOT EXISTS identity ( + // `asset_lock_transaction` was unwired entirely — fresh installs + // no longer create the table. Legacy installs keep the dormant + // rows; the migration tool drains them via git history. + + if include_legacy { + // The local identity registry lives in the per-network wallet + // k/v store. The legacy `identity` table is created only for + // legacy installs and tests. Its sole live reader, `get_wallets` + // in `database/wallet.rs`, is a legacy-only path that errors on + // the missing `wallet` table before reaching `identity` on a + // fresh install, so omitting the table here is safe. + conn.execute( + "CREATE TABLE IF NOT EXISTS identity ( id BLOB PRIMARY KEY, data BLOB, status INTEGER NOT NULL DEFAULT 0, @@ -649,92 +879,49 @@ impl Database { 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 the composite index for faster querying - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_identity_local_network_type - ON identity (is_local, network, identity_type)", - [], - )?; - - // Create the contested names table - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 the contestants table - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 the contracts table - conn.execute( - "CREATE TABLE IF NOT EXISTS contract ( - contract_id BLOB, - contract BLOB, - alias TEXT, - network TEXT NOT NULL, - PRIMARY KEY (contract_id, network) - )", - [], - )?; - - // Create indexes for the contracts table - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_alias_network ON contract (alias, network)", - [], - )?; - - self.initialize_proof_log_table(&conn)?; - self.initialize_top_up_table(&conn)?; - self.initialize_scheduled_votes_table(&conn)?; - self.initialize_token_table(&conn)?; - self.initialize_identity_order_table(&conn)?; - self.initialize_token_order_table(&conn)?; - self.initialize_identity_token_balances_table(&conn)?; - - // Initialize contacts and DashPay tables while holding the same connection lock - self.init_contacts_tables(&conn)?; - self.init_dashpay_tables_in_tx(&conn)?; - - // Initialize single key wallet table - self.initialize_single_key_wallet_table(&conn)?; + [], + )?; + } - // Initialize shielded pool tables - self.create_shielded_tables(&conn)?; - self.create_shielded_wallet_meta_table(&conn)?; + // contested_name / contestant tables removed in C6 — DPNS contest + // cache now lives in the per-network wallet k/v store. Legacy + // installs keep the dormant rows; fresh installs never create the + // tables. + + // The user-contract registry moved to the per-network wallet k/v + // store in C6. The `token` table was removed in C7, so nothing + // references `contract` any more — the empty placeholder is no + // longer created on fresh installs. Legacy installs keep the + // dormant rows. + + // Token registry, per-identity token balances, identity ordering + // and token ordering all moved to the per-network wallet k/v + // store in C7. Nothing else references these tables, so they + // are no longer created on fresh installs. Legacy installs keep + // the dormant rows. + + // DashPay tables and `contact_private_info` were retired in D4d. + // Upstream `ManagedIdentity` now owns contact / profile / payment + // state, and a per-network k/v sidecar owns DET-only overlays + // (private memo, blocked / rejected markers, timestamps, address + // index, address mapping). Fresh installs no longer create the + // tables; legacy installs keep the dormant rows. + + if include_legacy { + // Initialize single key wallet table + self.initialize_single_key_wallet_table(&conn)?; + + // The shielded pool tables (`shielded_notes` / + // `shielded_wallet_meta`) are intentionally NOT created: DET's + // shielded subsystem was retired and the upstream coordinator owns + // all Orchard state. The v37 migration drops any legacy copies. + } Ok(()) } /// Ensures that the default database version is set in the settings table. - fn set_default_version(&self) -> rusqlite::Result<()> { + pub(crate) fn set_default_version(&self) -> rusqlite::Result<()> { // TODO: Discuss migration approach with the team. // Suggested approach: // we don't change `create_tables`, we just add migrations @@ -743,12 +930,15 @@ impl Database { self.set_db_version(DEFAULT_DB_VERSION) } fn set_db_version(&self, version: u16) -> rusqlite::Result<()> { - // Default start_root_screen to 20 (RootScreenDashPayProfile) + // User-preference columns moved to the upstream k/v store (C3). + // Initialising the row only seeds the singleton primary key and + // the migration runner's version counter — everything else lives + // in `det:settings:v1` now. self.execute( - "INSERT INTO settings (id, network, start_root_screen, database_version) - VALUES (1, ?, 20, ?) + "INSERT INTO settings (id, database_version) + VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET database_version = excluded.database_version", - params![DEFAULT_NETWORK, version], + params![version], )?; Ok(()) } @@ -1097,22 +1287,48 @@ impl Database { "CREATE INDEX IF NOT EXISTS idx_wallet_network ON wallet (network)", [], )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_token_network ON token (network)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_identity_token_balances_network ON identity_token_balances (network)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_scheduled_votes_network ON scheduled_votes (network)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_asset_lock_transaction_network ON asset_lock_transaction (network)", - [], - )?; + // The `token` and `identity_token_balances` tables were unwired + // in C7 — fresh installs do not create them, so we skip the + // index creation when the underlying table is absent. Legacy + // installs still have the tables and pick up the index. + if self.table_exists(conn, "token")? { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_token_network ON token (network)", + [], + )?; + } + if self.table_exists(conn, "identity_token_balances")? { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_identity_token_balances_network ON identity_token_balances (network)", + [], + )?; + } + // The `scheduled_votes` table was unwired in C5; the index it carried + // is no longer maintained on fresh installs. Existing pre-C5 installs + // keep the orphaned table and its index dormant. Older pre-v6 shapes + // also lack the `network` column entirely — skip in that case. + if self.table_exists(conn, "scheduled_votes")? { + let has_network: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('scheduled_votes') WHERE name='network'", + [], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if has_network { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_scheduled_votes_network ON scheduled_votes (network)", + [], + )?; + } + } + // The `asset_lock_transaction` table was unwired — fresh installs + // do not create it, so we skip the index when the table is absent. + // Legacy installs still have the table and pick up the index. + if self.table_exists(conn, "asset_lock_transaction")? { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_asset_lock_transaction_network ON asset_lock_transaction (network)", + [], + )?; + } Ok(()) } @@ -1138,8 +1354,85 @@ impl Database { Ok(()) } - // Shielded table helpers (create_shielded_tables, create_shielded_wallet_meta_table, - // add_nullifier_sync_timestamp_column) are implemented in database/shielded.rs. + // DET's shielded subsystem was retired (Phase D); the old shielded table + // helpers and the `database::shielded` module were deleted. The v37 + // migration drops any legacy `shielded_notes` / `shielded_wallet_meta`. + + /// Rebuild legacy `asset_lock_transaction` rows so both `identity_id` + /// FKs use `ON DELETE SET NULL` instead of `ON DELETE CASCADE`. + /// + /// Inlined here when the `database/asset_lock_transaction` module was + /// deleted; only reachable from the v7 migration arm under a + /// `table_exists` guard. Safe to run multiple times: if the table + /// already has the correct FKs it exits early. + fn migrate_asset_lock_fk_to_set_null(conn: &Connection) -> rusqlite::Result<()> { + { + let mut pragma = conn.prepare("PRAGMA foreign_key_list('asset_lock_transaction')")?; + let fk_rows = pragma + .query_map([], |row| { + Ok(( + row.get::<_, String>(2)?, // table + row.get::<_, String>(6)?, // on_delete action + )) + })? + .collect::<Result<Vec<_>, _>>()?; + + let needs_migration = fk_rows + .iter() + .filter(|(tbl, _)| tbl == "identity") + .any(|(_, action)| action.to_uppercase() != "SET NULL"); + + if !needs_migration { + return Ok(()); + } + } + + conn.execute("PRAGMA foreign_keys = OFF", [])?; + + conn.execute( + "ALTER TABLE asset_lock_transaction RENAME TO asset_lock_transaction_old", + [], + )?; + + conn.execute( + "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 + )", + [], + )?; + + conn.execute( + "INSERT INTO asset_lock_transaction + (tx_id, transaction_data, amount, instant_lock_data, + chain_locked_height, identity_id, identity_id_potentially_in_creation, + wallet, network) + SELECT tx_id, transaction_data, amount, instant_lock_data, + chain_locked_height, identity_id, + identity_id_potentially_in_creation, wallet, network + FROM asset_lock_transaction_old", + [], + )?; + + conn.execute("DROP TABLE asset_lock_transaction_old", [])?; + + conn.execute("PRAGMA foreign_keys = ON", [])?; + + Ok(()) + } /// Remove orphaned child rows left behind when parent rows were deleted /// while FK enforcement was off (system SQLite before bundled build). @@ -1369,7 +1662,7 @@ impl Database { } /// Check if a table exists in the database. - fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result<bool> { + pub(crate) fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result<bool> { conn.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", [table], @@ -1383,8 +1676,11 @@ impl Database { /// changing the `Display`/`FromStr` representation. This migration updates /// every table that stores the network as a string column. fn rename_network_dash_to_mainnet(&self, conn: &Connection) -> Result<(), MigrationError> { + // The `settings` table dropped its `network` column in C3 — the + // active-network pointer now lives in `AppSettings` in the + // upstream k/v store. Every other domain table still keys rows + // by a `network` string and needs the rename. let tables = [ - "settings", "wallet", "identity_token_balances", "platform_address_balances", @@ -1406,12 +1702,49 @@ impl Database { ]; for table in tables { tracing::debug!(" rename_network: updating {table}"); + // `scheduled_votes` was unwired in C5; on fresh installs the + // table is absent (skip) and on legacy v5-shaped installs the + // column may be missing (also skip). The table is orphaned + // either way once `scheduled_votes` lives in k/v. + let exists = self + .table_exists(conn, table) + .migration_err(table, "check table existence")?; + if !exists { + continue; + } + let has_network: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name='network'", + [table], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .migration_err(table, "check for network column")?; + if !has_network { + continue; + } conn.execute( &format!("UPDATE {table} SET network = 'mainnet' WHERE network = 'dash'"), [], ) .migration_err(table, "rename network dash -> mainnet")?; } + // The legacy `settings.network` column may still exist in DBs that + // pre-date C3. Update it defensively — `UPDATE` against a missing + // column would error, so we gate on existence. + let settings_has_network: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='network'", + [], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap_or(false); + if settings_has_network { + conn.execute( + "UPDATE settings SET network = 'mainnet' WHERE network = 'dash'", + [], + ) + .migration_err("settings", "rename network dash -> mainnet")?; + } Ok(()) } @@ -1420,7 +1753,7 @@ impl Database { fn run_consistency_checks(&self) { const MAX_ISSUES_TO_LOG: usize = 20; - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); // PRAGMA quick_check can return multiple rows (one per issue). match conn.prepare("PRAGMA quick_check") { @@ -1557,38 +1890,19 @@ mod test { // wallet.core_wallet_name (v28) assert_column_exists(conn, "wallet", "core_wallet_name"); - // shielded_notes table (v29) - assert_table_exists(conn, "shielded_notes"); - for col in [ - "wallet_seed_hash", - "note_data", - "position", - "cmx", - "nullifier", - "block_height", - "is_spent", - "value", - "network", - ] { - assert_column_exists(conn, "shielded_notes", col); - } - - // shielded_wallet_meta table with last_nullifier_sync_timestamp (v30) - assert_table_exists(conn, "shielded_wallet_meta"); - assert_column_exists( - conn, - "shielded_wallet_meta", - "last_nullifier_sync_timestamp", - ); + // The shielded tables were retired in Phase D: v33 no longer creates + // them and the v37 migration drops any legacy copies, so after a full + // migration they must be absent. + assert_table_absent(conn, "shielded_notes"); + assert_table_absent(conn, "shielded_wallet_meta"); // wallet_transactions.status (v30) assert_column_exists(conn, "wallet_transactions", "status"); - // contact_private_info table (v29) - assert_table_exists(conn, "contact_private_info"); - - // dashpay_contact_requests table (pre-existing, but checked for completeness) - assert_table_exists(conn, "dashpay_contact_requests"); + // contact_private_info and dashpay_contact_requests were retired + // in D4d — fresh installs no longer create them. Pre-D4d installs + // keep the dormant rows, but the fresh-install path tested here + // intentionally skips them. } #[test] @@ -1613,53 +1927,51 @@ mod test { assert_eq!(version, super::DEFAULT_DB_VERSION); } - // Given a database with a missing `asset_lock_transaction` table, - // when I run the migration number 9, - // then it fails and reverts the database schema to the previous version, + // Given a database whose on-disk schema version is higher than the + // build supports, + // when I call `try_perform_migration`, + // then it returns an error and leaves the persisted version untouched + // (no row is mutated). + // + // Originally this lane simulated a v9 mid-flight failure by dropping + // `asset_lock_transaction`; that module is gone and every surviving + // migration arm is idempotent + `table_exists`-guarded, so we can no + // longer reliably provoke an intra-arm failure without contrived + // fixtures. The `Greater`-version refusal is the only failure path + // that is stable across the consolidated ladder, and it exercises the + // same "error returned, DB untouched" contract. #[test] fn test_migration_failure_rolls_back() { let temp_dir = tempfile::tempdir().unwrap(); let db_file_path = temp_dir.path().join("test_data.db"); let db = super::Database::new(&db_file_path).unwrap(); - // Identities from regtest are deleted during migration 9 const NETWORK: &str = "regtest"; - db.create_tables().unwrap(); + db.create_tables(true).unwrap(); db.set_default_version().unwrap(); - // drop the `asset_lock_transaction` table to simulate a migration failure + // Seed an identity so we can prove no DB mutation occurred. let conn = db.conn.lock().unwrap(); - conn.execute("DROP TABLE asset_lock_transaction", []) - .expect("Failed to drop asset_lock_transaction table"); - // check that we don't have any identities yet - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM identity", [], |row| row.get(0)) - .expect("Failed to count identities"); - assert_eq!(count, 0); - - // add some identity to ensure the database is not empty conn.execute( "INSERT INTO identity (id, is_local, alias, network) VALUES (?, ?, ?, ?)", rusqlite::params![vec![1u8; 32], 1, "test_identity", NETWORK], ) - .expect("Failed to insert test identity"); + .expect("insert test identity"); drop(conn); - // change version to 8 to force migration number 9 - const START_VERSION: u16 = 8; - db.set_db_version(START_VERSION).unwrap(); + // Pin the version one past what this build supports. + let future_version = DEFAULT_DB_VERSION + 1; + db.set_db_version(future_version).unwrap(); - // Simulate a migration failure by trying to apply an invalid change - let result = db.try_perform_migration(START_VERSION, DEFAULT_DB_VERSION, None); - assert!(result.is_err()); + // The `Greater` arm must refuse and not touch the DB. + let result = db.try_perform_migration(future_version, DEFAULT_DB_VERSION, None); + assert!(result.is_err(), "expected refusal"); println!("Migration failed as expected: {}", result.unwrap_err()); - // Check that the database version has not changed let version: u16 = db.db_schema_version().unwrap(); - assert_eq!(version, START_VERSION); + assert_eq!(version, future_version, "version must be untouched"); - // check that the identity was not deleted let conn = db.conn.lock().unwrap(); let count: i64 = conn .query_row( @@ -1667,10 +1979,10 @@ mod test { params![NETWORK], |row| row.get(0), ) - .expect("Failed to count identities"); + .expect("count identities"); assert_eq!( count, 1, - "Identity should not be deleted during migration failure" + "Identity must survive the rejected migration attempt" ); } @@ -1692,22 +2004,11 @@ mod test { .unwrap(); assert_eq!(version, DEFAULT_DB_VERSION); - // Fresh installs must land on SPV (core_backend_mode = 1). This is the - // user-visible contract of the v34 default-flip: non-Expert users never - // see an RPC config UI, so anything other than SPV here would strand them. - let core_backend_mode: u8 = conn - .query_row( - "SELECT core_backend_mode FROM settings WHERE id = 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - core_backend_mode, 1, - "fresh install must default to SPV (core_backend_mode = 1)" - ); - - assert_v33_schema(&conn); + // Post-T-DEV-01: truly-fresh installs no longer create the + // wallet-family tables — those live in `platform-wallet.sqlite` + // now. `assert_v33_schema` only applies to upgrade-replay DBs, + // so it has moved to `test_v33_migration_from_v27`. Here we + // only need to confirm the settings row is in place. } #[test] @@ -1717,7 +2018,7 @@ mod test { let db = super::Database::new(&db_file_path).unwrap(); // Build a full database then strip v28+ additions to simulate v27. - db.create_tables().unwrap(); + db.create_tables(true).unwrap(); db.set_default_version().unwrap(); { @@ -1836,7 +2137,7 @@ mod test { let db = super::Database::new(&db_file_path).unwrap(); // Build full schema at current version - db.create_tables().unwrap(); + db.create_tables(true).unwrap(); db.set_default_version().unwrap(); let valid_seed_hash = vec![0xAAu8; 32]; @@ -1866,6 +2167,32 @@ mod test { // Disable FK enforcement to simulate legacy system SQLite conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap(); + // The `asset_lock_transaction` table is no longer created on + // fresh installs, but this test exercises the legacy-shape + // orphan cleanup that v33 performs on installs that still + // carry it. Recreate the legacy schema manually so the v27 + // synthetic fixture matches reality for pre-unwire DBs. + conn.execute_batch( + "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 + );", + ) + .unwrap(); + // Insert orphaned wallet_transactions row (seed_hash not in wallet table). // Shielded table orphans are not needed: those tables get dropped to // simulate v27, then recreated empty by the migration. @@ -1976,7 +2303,7 @@ mod test { .unwrap(); // Strip v28+ additions to simulate v27 state (same as test_v33_migration_from_v27) - // Remove shielded tables — they'll be recreated by migration + // Remove shielded tables — Phase D drops them (v37); not recreated conn.execute("DROP TABLE IF EXISTS shielded_notes", []) .unwrap(); conn.execute("DROP TABLE IF EXISTS shielded_wallet_meta", []) @@ -2104,8 +2431,10 @@ mod test { // Shielded tables should exist but be empty (recreated fresh by migration; // the cleanup handles them gracefully even when just-created) - assert_table_exists(&conn, "shielded_notes"); - assert_table_exists(&conn, "shielded_wallet_meta"); + // Phase D retired the shielded tables — v33 no longer creates them and + // v37 drops any legacy copies, so they must be absent post-migration. + assert_table_absent(&conn, "shielded_notes"); + assert_table_absent(&conn, "shielded_wallet_meta"); // Valid wallet_transactions should survive with network renamed to mainnet let valid_txs: i64 = conn @@ -2493,10 +2822,23 @@ mod test { /// Set up a fresh v33 database in `dir` with `core_backend_mode = 0` (RPC), /// returning the `Database`. + /// + /// The v33 schema predates C3 — it still has the legacy + /// `core_backend_mode` column on the settings table — so the + /// fixture backfills it directly after `create_tables` to faithfully + /// reproduce the on-disk shape of a real pre-C3 install. fn fresh_v33_db(dir: &std::path::Path) -> super::super::Database { let db_file = dir.join("test_data.db"); let db = super::super::Database::new(&db_file).unwrap(); - db.create_tables().unwrap(); + db.create_tables(true).unwrap(); + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "ALTER TABLE settings ADD COLUMN core_backend_mode INTEGER DEFAULT 1", + [], + ) + .unwrap(); + } db.set_default_version().unwrap(); // Set starting state: v33 with the legacy RPC default. db.set_db_version(33).unwrap(); @@ -2622,4 +2964,278 @@ mod test { assert_eq!(db.db_schema_version().unwrap(), 34); } } + + // ── v38 migration: drop the retired core_backend_mode column ───── + mod v38 { + fn settings_column_exists(db: &super::super::Database, column: &str) -> bool { + let conn = db.conn.lock().unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name = ?1", + [column], + |row| row.get::<_, i32>(0).map(|c| c > 0), + ) + .unwrap() + } + + /// Build a v37 DB whose `settings` table still carries the legacy + /// `core_backend_mode` column plus a second legacy column + /// (`disable_zmq`) with a distinctive value, so the migration can be + /// shown to drop ONLY the target column and preserve the rest. + fn v37_db_with_legacy_columns(dir: &std::path::Path) -> super::super::Database { + let db_file = dir.join("test_data.db"); + let db = super::super::Database::new(&db_file).unwrap(); + db.create_tables(true).unwrap(); + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "ALTER TABLE settings ADD COLUMN core_backend_mode INTEGER DEFAULT 1", + [], + ) + .unwrap(); + conn.execute( + "ALTER TABLE settings ADD COLUMN disable_zmq INTEGER DEFAULT 0", + [], + ) + .unwrap(); + } + db.set_default_version().unwrap(); + db.set_db_version(37).unwrap(); + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "UPDATE settings SET core_backend_mode = 0, disable_zmq = 1 WHERE id = 1", + [], + ) + .unwrap(); + } + db + } + + /// A pre-v38 DB with the legacy column migrates cleanly: the + /// `core_backend_mode` column is dropped, the version advances to 38, + /// and every other settings value survives. + #[test] + fn v38_drops_column_and_preserves_other_settings() { + let tmp = tempfile::tempdir().unwrap(); + let db = v37_db_with_legacy_columns(tmp.path()); + assert!(settings_column_exists(&db, "core_backend_mode")); + + let result = db.try_perform_migration(37, 38, None); + assert!(result.is_ok(), "migration failed: {:?}", result.err()); + assert_eq!(db.db_schema_version().unwrap(), 38); + + // Target column gone. + assert!( + !settings_column_exists(&db, "core_backend_mode"), + "core_backend_mode must be dropped" + ); + // Sibling settings survive untouched. + assert!(settings_column_exists(&db, "disable_zmq")); + let disable_zmq: i64 = { + let conn = db.conn.lock().unwrap(); + conn.query_row("SELECT disable_zmq FROM settings WHERE id = 1", [], |row| { + row.get(0) + }) + .unwrap() + }; + assert_eq!(disable_zmq, 1, "unrelated settings must be preserved"); + } + + /// A DB that never had the column (fresh post-C3 schema) migrates to + /// v38 without error — the drop is a guarded no-op. + #[test] + fn v38_is_noop_when_column_absent() { + let tmp = tempfile::tempdir().unwrap(); + let db_file = tmp.path().join("fresh.db"); + let db = super::super::Database::new(&db_file).unwrap(); + db.create_tables(false).unwrap(); + db.set_default_version().unwrap(); + db.set_db_version(37).unwrap(); + assert!(!settings_column_exists(&db, "core_backend_mode")); + + let result = db.try_perform_migration(37, 38, None); + assert!(result.is_ok(), "migration failed: {:?}", result.err()); + assert_eq!(db.db_schema_version().unwrap(), 38); + assert!(!settings_column_exists(&db, "core_backend_mode")); + } + + /// Re-running the migration on an already-migrated DB is a no-op. + #[test] + fn v38_rerun_is_noop() { + let tmp = tempfile::tempdir().unwrap(); + let db = v37_db_with_legacy_columns(tmp.path()); + + db.try_perform_migration(37, 38, None).unwrap(); + assert_eq!(db.db_schema_version().unwrap(), 38); + + let result = db.try_perform_migration(38, 38, None); + assert!(result.is_ok(), "re-run should be no-op: {:?}", result.err()); + assert!( + !result.unwrap(), + "try_perform_migration should report no migration needed" + ); + assert_eq!(db.db_schema_version().unwrap(), 38); + } + } + + // ---------- T-DEV-01: legacy CREATE TABLE gating ---------- + + /// Helper: assert that a table does NOT exist in the database. + fn assert_table_absent(conn: &Connection, table: &str) { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + params![table], + |row| row.get(0), + ) + .unwrap(); + assert!(!exists, "table `{table}` must NOT exist on a fresh install"); + } + + /// TC-DEV-006 — Truly-fresh install creates no wallet-family tables. + /// + /// The gated targets (`wallet`, `wallet_addresses`, `utxos`, + /// `single_key_wallet`, `wallet_transactions`, `shielded_notes`, + /// `shielded_wallet_meta`, `identity`) are legacy schema that lives in + /// `platform-wallet.sqlite` or the per-network k/v store now. Only + /// `settings` (the migration version counter) is always created. + #[test] + fn tc_dev_006_fresh_install_omits_legacy_tables() { + let tmp = tempfile::tempdir().unwrap(); + let db_file = tmp.path().join("fresh.db"); + let db = super::Database::new(&db_file).unwrap(); + db.initialize(&db_file).unwrap(); + + let conn = db.conn.lock().unwrap(); + + // Always-present + assert_table_exists(&conn, "settings"); + + // Gated targets must be absent + for t in [ + "wallet", + "wallet_addresses", + "utxos", + "single_key_wallet", + "wallet_transactions", + "shielded_notes", + "shielded_wallet_meta", + "identity", + ] { + assert_table_absent(&conn, t); + } + } + + /// TC-MIG-006 — Existing install with legacy rows triggers full schema + /// creation so the migration ladder has tables to upgrade. + /// + /// Simulates an unusual recovery shape: a DB where the `settings` row + /// was wiped (so `is_first_time_setup` reports true) but the + /// `wallet` table still carries rows. `legacy_detected` returns true, + /// so `initialize` re-creates the wallet-family schema. + #[test] + fn tc_mig_006_legacy_rows_trigger_full_schema() { + let tmp = tempfile::tempdir().unwrap(); + let db_file = tmp.path().join("legacy.db"); + let db = super::Database::new(&db_file).unwrap(); + + // Pre-seed a legacy `wallet` row before `initialize` runs. + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "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, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL + )", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO wallet ( + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, uses_password, network + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + vec![1u8; 32], + vec![2u8; 16], + vec![3u8; 16], + vec![4u8; 12], + vec![5u8; 33], + 0i32, + "mainnet", + ], + ) + .unwrap(); + assert!(super::legacy_detected(&conn)); + } + + db.initialize(&db_file).unwrap(); + + let conn = db.conn.lock().unwrap(); + // Upgrade-replay path: wallet-family tables present. + assert_table_exists(&conn, "wallet"); + assert_table_exists(&conn, "wallet_addresses"); + assert_table_exists(&conn, "utxos"); + assert_table_exists(&conn, "single_key_wallet"); + assert_table_exists(&conn, "wallet_transactions"); + // Phase D retired the shielded tables — they are dropped by v37. + assert_table_absent(&conn, "shielded_notes"); + assert_table_absent(&conn, "shielded_wallet_meta"); + } + + /// TC-MIG-008 (partial) — Fresh install and the `data.db` file. + /// + /// Truly-fresh installs land at version `DEFAULT_DB_VERSION` with no + /// wallet-family tables. The file itself still appears on disk + /// because `Database::new` opens the SQLite connection eagerly; fully + /// suppressing the file is T-DEV-02 territory. + // TODO(T-DEV-02): suppress `data.db` file creation when no DET state + // needs to be persisted to it. + #[test] + fn tc_mig_008_fresh_install_file_state() { + let tmp = tempfile::tempdir().unwrap(); + let db_file = tmp.path().join("fresh.db"); + assert!(!db_file.exists(), "precondition: file absent"); + + let db = super::Database::new(&db_file).unwrap(); + db.initialize(&db_file).unwrap(); + + // Partial pass: file exists (Database::new opens an empty SQLite + // connection) but carries no wallet-family schema. + assert!(db_file.exists(), "Database::new opens the file eagerly"); + let conn = db.conn.lock().unwrap(); + assert_table_exists(&conn, "settings"); + for t in [ + "wallet", + "wallet_addresses", + "utxos", + "single_key_wallet", + "wallet_transactions", + "shielded_notes", + "shielded_wallet_meta", + ] { + assert_table_absent(&conn, t); + } + } + + /// `legacy_detected` returns false on an empty DB. + #[test] + fn legacy_detected_false_on_empty_db() { + let conn = Connection::open_in_memory().unwrap(); + assert!(!super::legacy_detected(&conn)); + } + + /// `legacy_detected` returns false when a target table exists but is empty. + #[test] + fn legacy_detected_false_on_empty_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE wallet (seed_hash BLOB PRIMARY KEY)", []) + .unwrap(); + assert!(!super::legacy_detected(&conn)); + } } diff --git a/src/database/mod.rs b/src/database/mod.rs index a2af9431c..ca9961d22 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,19 +1,8 @@ -mod asset_lock_transaction; -pub(crate) mod contacts; -mod contested_names; -pub(crate) mod contracts; -mod dashpay; -mod identities; mod initialization; -mod proof_log; -mod scheduled_votes; mod settings; -pub mod shielded; mod single_key_wallet; #[cfg(any(test, feature = "testing"))] pub mod test_helpers; -mod tokens; -mod top_ups; mod utxo; mod wallet; pub use wallet::WalletError; @@ -22,21 +11,6 @@ use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Params}; use std::sync::{Arc, Mutex}; -/// Returns `true` when the error is a UNIQUE or PRIMARY KEY constraint violation. -pub(crate) fn is_unique_constraint_violation(e: &rusqlite::Error) -> bool { - matches!( - e, - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ffi::ErrorCode::ConstraintViolation, - extended_code: 1555 | 2067, // SQLITE_CONSTRAINT_PRIMARYKEY | SQLITE_CONSTRAINT_UNIQUE - .. - }, - _, - ) - ) -} - /// Error indicating a corrupted data blob in the database. /// /// Converts into `rusqlite::Error::FromSqlConversionFailure` so it can @@ -59,22 +33,42 @@ impl From<CorruptedBlobError> for rusqlite::Error { #[derive(Debug)] pub struct Database { conn: Arc<Mutex<Connection>>, + /// The on-disk DB file path (`None` for in-memory test DBs). Read back by + /// the migration tasks that re-open the same file. + path: Option<std::path::PathBuf>, } impl Database { pub fn new<P: AsRef<std::path::Path>>(path: P) -> rusqlite::Result<Self> { - let conn = Connection::open(path)?; + let path_ref = path.as_ref(); + let conn = Connection::open(path_ref)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), + path: Some(path_ref.to_path_buf()), }) } - pub(crate) fn shared_connection(&self) -> Arc<Mutex<Connection>> { - self.conn.clone() + /// On-disk DB file path, if this is a file-backed database. + pub(crate) fn db_file_path(&self) -> Option<std::path::PathBuf> { + self.path.clone() + } + + /// Lock the connection mutex, recovering a poisoned guard instead of + /// panicking. + /// + /// The mutex is poisoned only when a thread panics while holding the DB + /// lock. A `rusqlite::Connection` is a plain handle with no cross-call + /// invariant that a panic can break — SQLite manages statement/transaction + /// state internally — so the guard is safe to recover. Panicking here would + /// turn one unrelated panic into a permanent, cascading failure of every + /// subsequent database call, so recovery (matching the wallet-backend + /// poison discipline for rebuildable state) is the correct behavior. + pub(crate) fn locked_conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|e| e.into_inner()) } pub fn execute<P: Params>(&self, sql: &str, params: P) -> rusqlite::Result<usize> { - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); conn.execute(sql, params) } @@ -82,122 +76,100 @@ impl Database { pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { let network_str = network.to_string(); - // Scope the connection lock so it's released before - // clear_commitment_tree_tables acquires it again. { - let mut conn = self.conn.lock().unwrap(); + let mut conn = self.locked_conn(); let tx = conn.transaction()?; - // Remove DashPay/contact data referencing identities from this network. - tx.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contacts - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contact_private_info - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_profiles - WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity_token_balances WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM token WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contract WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM scheduled_votes WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet_transactions WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM utxos WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM asset_lock_transaction WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contestant WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contested_name WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM single_key_wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM shielded_notes WHERE network = ?1", - rusqlite::params![&network_str], - )?; + // DashPay tables (dashpay_profiles, dashpay_contacts, + // dashpay_contact_requests, dashpay_payments, + // dashpay_contact_address_indices, dashpay_address_mappings) + // and contact_private_info were retired in D4d — the upstream + // ManagedIdentity owns contact/profile/payment state and a + // per-network k/v sidecar owns DET-only overlays (private + // memo, blocked/rejected markers, timestamps, address index, + // address mapping). The sidecar sweep lives in + // `AppContext::clear_network_database` because the k/v + // adapter is not reachable from `Database`. + // + // token / identity_token_balances / identity tables are no + // longer managed (C7) — token registry, per-identity balances + // and identity records all live in the per-network k/v store. + // Fresh installs do not create the tables; legacy installs + // keep the rows dormant. + // + // contract / contestant / contested_name tables are no longer + // managed (C6). Fresh installs do not have them; legacy + // installs keep the rows dormant. + + // The legacy wallet-family tables are gated out of the fresh + // schema (they live in the upstream persistor now), so each + // DELETE is existence-guarded — a fresh install has none of them + // and an unguarded DELETE would error on the first missing table, + // aborting the whole clear. `asset_lock_transaction` is omitted + // entirely: its module was deleted and the migration tool drains + // it via git history. + for table in [ + "wallet_transactions", + "utxos", + "wallet", + "single_key_wallet", + "shielded_notes", + ] { + if self.table_exists(&tx, table)? { + tx.execute( + &format!("DELETE FROM {table} WHERE network = ?1"), + rusqlite::params![&network_str], + )?; + } + } tx.commit()?; } // conn lock released here - // Commitment tree tables are optional (created lazily by grovedb). - // Log and continue if clearing them fails — the main network data - // has already been committed above. - if let Err(e) = self.clear_commitment_tree_tables() { - tracing::warn!("Failed to clear commitment tree tables: {e}"); - } + // Shielded commitment-tree data now lives in a per-network sidecar + // SQLite file under `<spv_dir>/<network>/`, not in `data.db`. The + // `AppContext::clear_network_database` caller unlinks that file + // after this method returns successfully. Ok(()) } } + +#[cfg(test)] +mod tests { + /// "Clear database" must succeed on a truly-fresh install. The legacy + /// wallet-family tables (`wallet_transactions`, `utxos`, `wallet`, + /// `single_key_wallet`, `shielded_notes`) are gated out of the fresh + /// schema, so `clear_network_data` previously errored on the first + /// `DELETE FROM` of a missing table and committed nothing. Each DELETE is + /// now guarded by an existence check, so a fresh install clears cleanly. + #[test] + fn clear_network_data_succeeds_on_fresh_install() { + use dash_sdk::dpp::dashcore::Network; + + let temp_dir = tempfile::tempdir().unwrap(); + let db_file = temp_dir.path().join("test_data.db"); + let db = super::Database::new(&db_file).unwrap(); + db.initialize(&db_file).unwrap(); + + // Precondition: the fresh schema must not carry the legacy wallet + // table, which is what made the unguarded DELETE fail. + { + let conn = db.conn.lock().unwrap(); + let wallet_exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='wallet')", + [], + |row| row.get(0), + ) + .unwrap(); + assert!( + !wallet_exists, + "precondition: fresh install must not create the legacy `wallet` table" + ); + } + + db.clear_network_data(Network::Testnet) + .expect("clear_network_data must succeed on a fresh install"); + } +} diff --git a/src/database/proof_log.rs b/src/database/proof_log.rs deleted file mode 100644 index 672523aa3..000000000 --- a/src/database/proof_log.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::database::Database; -use crate::model::proof_log_item::{ProofLogItem, RequestType}; -use rusqlite::params; -use std::ops::Range; - -impl Database { - #[allow(dead_code)] // May be used for database migrations or testing cleanup - pub fn drop_proof_log_table(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> { - // Execute the SQL command to drop the proof_log table - conn.execute("DROP TABLE IF EXISTS proof_log", [])?; - Ok(()) - } - - pub fn initialize_proof_log_table(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> { - // Create the proof log tree - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 an index on request_type and time for combined queries - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_proof_log_request_type_time ON proof_log (request_type, time_ms)", - [], - )?; - - // Create an index on time for queries ordered by time - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_proof_log_time ON proof_log (time_ms)", - [], - )?; - - // Index for error, request_type, and time - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_proof_log_error_request_type_time ON proof_log (error, request_type, time_ms)", - [], - )?; - - // Index for error and time - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_proof_log_error_time ON proof_log (error, time_ms)", - [], - )?; - Ok(()) - } - - /// Inserts a new ProofLogItem into the proof_log table - pub fn insert_proof_log_item(&self, item: ProofLogItem) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - // Convert RequestType to u8 - let request_type_int: u8 = item.request_type.into(); - - conn.execute( - "INSERT INTO proof_log (request_type, request_bytes, path_query_bytes, height, time_ms, proof_bytes, error) - VALUES (?, ?, ?, ?, ?, ?, ?)", - params![ - request_type_int, - item.request_bytes, - item.verification_path_query_bytes, - item.height, - item.time_ms, - item.proof_bytes, - item.error, - ], - )?; - - Ok(()) - } - - /// Retrieves ProofLogItems with options for filtering and pagination - pub fn get_proof_log_items( - &self, - only_get_errored: bool, - range: Range<u64>, - ) -> rusqlite::Result<Vec<ProofLogItem>> { - let conn = self.conn.lock().unwrap(); - - // Build the query based on the only_get_errored flag - let mut query = String::from( - "SELECT request_type, request_bytes, path_query_bytes, height, time_ms, proof_bytes, error FROM proof_log", - ); - - if only_get_errored { - query.push_str(" WHERE error IS NOT NULL"); - } - - query.push_str(" ORDER BY time_ms DESC LIMIT ? OFFSET ?"); - - let mut stmt = conn.prepare(&query)?; - - let proof_log_iter = - stmt.query_map(params![range.end - range.start, range.start], |row| { - let request_type_int: u8 = row.get(0)?; - let request_bytes: Vec<u8> = row.get(1)?; - let verification_path_query_bytes: Vec<u8> = row.get(2)?; - let height: u64 = row.get(3)?; - let time_ms: u64 = row.get(4)?; - let proof_bytes: Vec<u8> = row.get(5)?; - let error: Option<String> = row.get(6)?; - - // Convert u8 to RequestType - let request_type = RequestType::try_from(request_type_int).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - request_type_int as usize, - rusqlite::types::Type::Integer, - Box::new(std::fmt::Error), - ) - })?; - - Ok(ProofLogItem { - request_type, - request_bytes, - verification_path_query_bytes, - height, - time_ms, - proof_bytes, - error, - }) - })?; - - // Collect the results into a vector - let items: rusqlite::Result<Vec<ProofLogItem>> = proof_log_iter.collect(); - - items - } -} diff --git a/src/database/scheduled_votes.rs b/src/database/scheduled_votes.rs deleted file mode 100644 index 461709ead..000000000 --- a/src/database/scheduled_votes.rs +++ /dev/null @@ -1,259 +0,0 @@ -use crate::{ - backend_task::contested_names::ScheduledDPNSVote, context::AppContext, database::Database, -}; -use dash_sdk::{ - dpp::{ - platform_value::string_encoding::Encoding, - voting::vote_choices::resource_vote_choice::ResourceVoteChoice, - }, - platform::Identifier, -}; -use rusqlite::params; - -impl Database { - pub fn initialize_scheduled_votes_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - // Create the scheduled_votes table - conn.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), - FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_scheduled_votes_network ON scheduled_votes (network)", - [], - )?; - Ok(()) - } - - pub fn update_scheduled_votes_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - { - // Check if the foreign key already exists - let mut stmt = conn.prepare("PRAGMA foreign_key_list('scheduled_votes')")?; - let fk_exists = stmt - .query_map([], |row| row.get::<_, String>(2))? - .any(|table_name_result| table_name_result.ok().as_deref() == Some("identity")); - - if fk_exists { - // Foreign key already exists, no need to alter the table - return Ok(()); - } - } - - // SQLite doesn't directly support adding foreign keys to existing tables. - // We need to recreate the table. - - conn.execute("PRAGMA foreign_keys = OFF", [])?; - - // Rename existing table - conn.execute( - "ALTER TABLE scheduled_votes RENAME TO scheduled_votes_old", - [], - )?; - - // Create the new table with the foreign key constraint - conn.execute( - "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 - )", - [], - )?; - - // Copy data from old to new table. The v0.9.0 schema created - // scheduled_votes without a network column, so handle both cases. - let has_network: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('scheduled_votes_old') WHERE name='network'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - if has_network { - conn.execute( - "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) - SELECT identity_id, contested_name, vote_choice, time, executed, network - FROM scheduled_votes_old", - [], - )?; - } else { - conn.execute( - "INSERT INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) - SELECT identity_id, contested_name, vote_choice, time, executed, 'dash' - FROM scheduled_votes_old", - [], - )?; - } - - // Drop the old table - conn.execute("DROP TABLE scheduled_votes_old", [])?; - - conn.execute("PRAGMA foreign_keys = ON", [])?; - - Ok(()) - } - - pub fn insert_scheduled_votes( - &self, - app_context: &AppContext, - votes: &Vec<ScheduledDPNSVote>, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - for vote in votes { - let vote_choice = vote.choice.to_string(); - tx.execute( - "INSERT OR REPLACE INTO scheduled_votes (identity_id, contested_name, vote_choice, time, executed, network) VALUES (?, ?, ?, ?, 0, ?)", - params![vote.voter_id.as_slice(), vote.contested_name, vote_choice, vote.unix_timestamp, network], - )?; - } - tx.commit()?; - Ok(()) - } - - pub fn delete_scheduled_vote( - &self, - app_context: &AppContext, - identity_id: &[u8], - contested_name: &str, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM scheduled_votes WHERE identity_id = ? AND contested_name = ? AND network = ?", - params![identity_id, contested_name, network], - )?; - Ok(()) - } - - pub fn mark_vote_executed( - &self, - app_context: &AppContext, - identity_id: &[u8], - contested_name: String, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - self.execute( - "UPDATE scheduled_votes SET executed = 1 WHERE identity_id = ? AND contested_name = ? AND network = ?", - params![identity_id, contested_name, network], - )?; - Ok(()) - } - - pub fn get_scheduled_votes( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<Vec<ScheduledDPNSVote>> { - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT * FROM scheduled_votes WHERE network = ?")?; - let votes_iter = stmt.query_map(params![network], |row| { - let voter_id_bytes: Vec<u8> = row.get(0)?; - let contested_name: String = row.get(1)?; - let vote_choice_string: String = row.get(2)?; - let time: u64 = row.get(3)?; - let executed_successfully: bool = match row.get::<_, i64>(4)? { - 0 => false, - 1 => true, - other => { - tracing::warn!( - "Unexpected 'executed' value {} for scheduled vote '{}', treating as not executed", - other, - contested_name - ); - false - } - }; - - let vote_choice = match vote_choice_string.as_str() { - "Abstain" => ResourceVoteChoice::Abstain, - "Lock" => ResourceVoteChoice::Lock, - other => { - if let Some(inner) = other.strip_prefix("TowardsIdentity(") { - if let Some(inner) = inner.strip_suffix(')') { - let towards_id = inner.to_string(); - ResourceVoteChoice::TowardsIdentity( - Identifier::from_string(&towards_id, Encoding::Base58).map_err( - |e| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(e), - ) - }, - )?, - ) - } else { - return Err(rusqlite::Error::InvalidQuery); - } - } else { - return Err(rusqlite::Error::InvalidQuery); - } - } - }; - - let scheduled_vote = ScheduledDPNSVote { - voter_id: Identifier::from_bytes(&voter_id_bytes).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(e), - ) - })?, - contested_name, - choice: vote_choice, - unix_timestamp: time, - executed_successfully, - }; - - Ok(scheduled_vote) - })?; - - let scheduled_votes: rusqlite::Result<Vec<ScheduledDPNSVote>> = votes_iter.collect(); - scheduled_votes - } - - /// Clear all scheduled votes from the db - pub fn clear_all_scheduled_votes(&self, app_context: &AppContext) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - - conn.execute( - "DELETE FROM scheduled_votes WHERE network = ?", - params![network], - )?; - - Ok(()) - } - - pub fn clear_executed_scheduled_votes(&self, app_context: &AppContext) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - - conn.execute( - "DELETE FROM scheduled_votes WHERE executed = 1 AND network = ?", - params![network], - )?; - - Ok(()) - } -} diff --git a/src/database/settings.rs b/src/database/settings.rs index b1c51035d..180999f6f 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -1,81 +1,31 @@ +//! Residual `settings`-table accessors. +//! +//! User preferences (theme, network, ZMQ, evonode tools, …) moved to +//! the upstream k/v store at [`AppSettings::KV_KEY`] in commit C3 of +//! the data.db unwire. The selected-wallet pointer +//! (`selected_wallet_hash` / `selected_single_key_hash`) moved to the +//! per-network wallet k/v store at +//! [`SelectedWallet::KV_KEY`](crate::model::selected_wallet::SelectedWallet::KV_KEY) +//! in commit C4. What remains here is the small surface that the later +//! unwire steps still depend on: +//! +//! * `database_version` writer used by the migration runner (C10). +//! * The column-addition helpers used by the migration ladder so old +//! `data.db` files keep upgrading cleanly. These never create the +//! columns on a fresh install — they only run when migrating an +//! existing user from an old schema version, and the columns they +//! touch are then ignored at read time. +//! +//! [`AppSettings::KV_KEY`]: crate::model::settings::AppSettings::KV_KEY + use crate::database::Database; -use crate::database::initialization::DEFAULT_DB_VERSION; -use crate::model::password_info::PasswordInfo; -use crate::model::settings::UserMode; -use crate::ui::RootScreenType; -use crate::ui::theme::ThemeMode; -use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Result, params}; -use std::{path::PathBuf, str::FromStr}; - -/// Selected wallet hash and single key hash tuple for database storage. -pub type SelectedWalletHashes = (Option<[u8; 32]>, Option<[u8; 32]>); impl Database { - /// Inserts or updates the settings in the database. This method ensures that only one row exists. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - pub fn insert_or_update_settings( - &self, - network: Network, - start_root_screen: RootScreenType, - ) -> Result<()> { - let network_str = network.to_string(); - let screen_type_int = start_root_screen.to_int(); - self.execute( - "INSERT INTO settings (id, network, start_root_screen, database_version) - VALUES (1, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - network = excluded.network, - start_root_screen = excluded.start_root_screen", - params![network_str, screen_type_int, DEFAULT_DB_VERSION], - )?; - Ok(()) - } - - /// Updates the main password information in the settings table. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - pub fn update_main_password( - &self, - salt: &[u8], - nonce: &[u8], - password_check: &[u8], - ) -> Result<()> { - // Update the settings table with the provided salt, nonce, and password_check - self.execute( - "UPDATE settings - SET main_password_salt = ?, - main_password_nonce = ?, - password_check = ? - WHERE id = 1", - rusqlite::params![salt, nonce, password_check], - )?; - - Ok(()) - } - /// Updates the Dash Core execution settings in the settings table. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - pub fn update_dash_core_execution_settings( - &self, - custom_dash_qt_path: Option<PathBuf>, - overwrite_dash_conf: bool, - ) -> Result<()> { - let dash_qt_path = custom_dash_qt_path.map(|p| p.to_string_lossy().to_string()); - self.execute( - "UPDATE settings - SET custom_dash_qt_path = ?, - overwrite_dash_conf = ? - WHERE id = 1", - rusqlite::params![dash_qt_path, overwrite_dash_conf], - )?; - - Ok(()) - } - + /// Backfill `custom_dash_qt_path` / `overwrite_dash_conf` on an + /// 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<()> { - // Check if custom_dash_qt_path column exists let custom_dash_qt_path_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='custom_dash_qt_path'", [], @@ -89,7 +39,6 @@ impl Database { )?; } - // Check if overwrite_dash_conf column exists let overwrite_dash_conf_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='overwrite_dash_conf'", [], @@ -106,8 +55,9 @@ impl Database { Ok(()) } + /// 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<()> { - // Check if theme_preference column exists let theme_preference_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='theme_preference'", [], @@ -124,8 +74,9 @@ impl Database { Ok(()) } + /// 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<()> { - // Check if disable_zmq column exists let disable_zmq_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='disable_zmq'", [], @@ -141,38 +92,11 @@ impl Database { Ok(()) } - /// Updates the theme preference in the settings table. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - pub fn update_theme_preference(&self, theme_preference: ThemeMode) -> Result<()> { - let theme_str = match theme_preference { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - ThemeMode::System => "System", - }; - - self.execute( - "UPDATE settings - SET theme_preference = ? - WHERE id = 1", - rusqlite::params![theme_str], - )?; - - Ok(()) - } - - /// Updates the disable_zmq flag in the settings table. - pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { - self.execute( - "UPDATE settings SET disable_zmq = ? WHERE id = 1", - rusqlite::params![disable], - )?; - Ok(()) - } - /// Adds the core_backend_mode column to the settings table (migration for version 15). + /// Backfill `core_backend_mode` on an existing `settings` table. + /// 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<()> { - // Check if core_backend_mode column exists let column_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='core_backend_mode'", [], @@ -180,7 +104,6 @@ impl Database { )?; if !column_exists { - // Default to 1 (SPV mode) to match current app behavior conn.execute( "ALTER TABLE settings ADD COLUMN core_backend_mode INTEGER DEFAULT 1;", (), @@ -190,20 +113,29 @@ impl Database { Ok(()) } - /// Updates the core backend mode (SPV=1, RPC=0) in the settings table. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - pub fn update_core_backend_mode(&self, mode: u8) -> Result<()> { - self.execute( - "UPDATE settings SET core_backend_mode = ? WHERE id = 1", - rusqlite::params![mode], + /// Drop the retired `core_backend_mode` column from the `settings` table. + /// The RPC/SPV backend selector it held is gone (chain sync is SPV-only); + /// only pre-C3 DBs still carry the column. Existence-guarded and + /// 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 { + conn.execute("ALTER TABLE settings DROP COLUMN core_backend_mode;", ())?; + } + Ok(()) } - /// Adds onboarding-related columns to the settings table. + /// Backfill `onboarding_completed`, `show_evonode_tools`, and + /// `user_mode` on an existing `settings` table. Kept only for the + /// migration ladder. pub fn add_onboarding_columns(&self, conn: &rusqlite::Connection) -> Result<()> { - // Check and add onboarding_completed column let onboarding_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='onboarding_completed'", [], @@ -217,7 +149,6 @@ impl Database { )?; } - // Check and add show_evonode_tools column let evonode_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='show_evonode_tools'", [], @@ -231,7 +162,6 @@ impl Database { )?; } - // Check and add user_mode column (Beginner or Advanced) let user_mode_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='user_mode'", [], @@ -248,66 +178,7 @@ impl Database { Ok(()) } - /// Updates the onboarding completed flag in the settings table. - pub fn update_onboarding_completed(&self, completed: bool) -> Result<()> { - self.execute( - "UPDATE settings SET onboarding_completed = ? WHERE id = 1", - rusqlite::params![completed], - )?; - Ok(()) - } - - /// Updates the show_evonode_tools flag in the settings table. - pub fn update_show_evonode_tools(&self, show: bool) -> Result<()> { - self.execute( - "UPDATE settings SET show_evonode_tools = ? WHERE id = 1", - rusqlite::params![show], - )?; - Ok(()) - } - - /// Updates the user mode (Beginner/Advanced) in the settings table. - pub fn update_user_mode(&self, mode: &str) -> Result<()> { - self.execute( - "UPDATE settings SET user_mode = ? WHERE id = 1", - rusqlite::params![mode], - )?; - Ok(()) - } - - /// Updates the database version in the settings table. - pub fn update_database_version(&self, new_version: u16, conn: &Connection) -> Result<()> { - // Ensure the database version is updated - conn.execute( - "UPDATE settings - SET database_version = ? - WHERE id = 1", - params![new_version], - )?; - - Ok(()) - } - - /// Adds the use_local_spv_node column to the settings table. - pub fn add_use_local_spv_node_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='use_local_spv_node'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { - // Default to false - use DNS seed discovery by default - conn.execute( - "ALTER TABLE settings ADD COLUMN use_local_spv_node INTEGER DEFAULT 0;", - (), - )?; - } - - Ok(()) - } - - /// Adds the auto_start_spv column to the settings table. + /// 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'", @@ -316,7 +187,6 @@ impl Database { )?; if !column_exists { - // Default to false - don't auto-start SPV on startup conn.execute( "ALTER TABLE settings ADD COLUMN auto_start_spv INTEGER DEFAULT 0;", (), @@ -326,47 +196,7 @@ impl Database { Ok(()) } - /// Updates the use_local_spv_node flag in the settings table. - pub fn update_use_local_spv_node(&self, use_local: bool) -> Result<()> { - self.execute( - "UPDATE settings SET use_local_spv_node = ? WHERE id = 1", - rusqlite::params![use_local], - )?; - Ok(()) - } - - /// Gets the use_local_spv_node flag from the settings table. - pub fn get_use_local_spv_node(&self) -> Result<bool> { - let conn = self.conn.lock().unwrap(); - let result: Option<bool> = conn.query_row( - "SELECT use_local_spv_node FROM settings WHERE id = 1", - [], - |row| row.get(0), - )?; - Ok(result.unwrap_or(false)) - } - - /// Updates the auto_start_spv flag in the settings table. - pub fn update_auto_start_spv(&self, auto_start: bool) -> Result<()> { - self.execute( - "UPDATE settings SET auto_start_spv = ? WHERE id = 1", - rusqlite::params![auto_start], - )?; - Ok(()) - } - - /// Gets the auto_start_spv flag from the settings table. - pub fn get_auto_start_spv(&self) -> Result<bool> { - let conn = self.conn.lock().unwrap(); - let result: Option<bool> = conn.query_row( - "SELECT auto_start_spv FROM settings WHERE id = 1", - [], - |row| row.get(0), - )?; - Ok(result.unwrap_or(false)) // Default to false - } - - /// Adds the close_dash_qt_on_exit column to the settings table. + /// 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'", @@ -375,7 +205,6 @@ impl Database { )?; if !column_exists { - // Default to true - close Dash-Qt on exit by default conn.execute( "ALTER TABLE settings ADD COLUMN close_dash_qt_on_exit INTEGER DEFAULT 1;", (), @@ -385,40 +214,43 @@ impl Database { Ok(()) } - /// Updates the close_dash_qt_on_exit flag in the settings table. - pub fn update_close_dash_qt_on_exit(&self, close_on_exit: bool) -> Result<()> { - self.execute( - "UPDATE settings SET close_dash_qt_on_exit = ? WHERE id = 1", - rusqlite::params![close_on_exit], + /// Updates the database version in the settings table. Used by the + /// migration runner — every other settings row stays untouched. + pub fn update_database_version(&self, new_version: u16, conn: &Connection) -> Result<()> { + conn.execute( + "UPDATE settings + SET database_version = ? + WHERE id = 1", + params![new_version], )?; Ok(()) } - /// Gets the close_dash_qt_on_exit flag from the settings table. - pub fn get_close_dash_qt_on_exit(&self) -> Result<bool> { - let conn = self.conn.lock().unwrap(); - let result: Option<bool> = conn.query_row( - "SELECT close_dash_qt_on_exit FROM settings WHERE id = 1", - [], - |row| row.get(0), - )?; - Ok(result.unwrap_or(true)) // Default to true + /// Drop dead `settings` columns left behind by withdrawn features. + /// + /// Currently removes `dashpay_dip14_quarantine_active`. Existence-guarded + /// and idempotent — safe to re-run. + pub fn drop_dead_settings_columns(&self, conn: &rusqlite::Connection) -> Result<()> { + const DEAD_COLUMNS: &[&str] = &["dashpay_dip14_quarantine_active"]; + for col in DEAD_COLUMNS { + let exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name = ?1", + rusqlite::params![col], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if exists { + conn.execute(&format!("ALTER TABLE settings DROP COLUMN {col};"), ())?; + } + } + Ok(()) } - /// Ensures all required columns exist in the settings table. - /// This handles the case where an old database has a settings table with missing columns. + /// Ensure the columns the residual settings layer still depends on + /// exist on an upgraded `data.db`. Only `database_version` is + /// required here — user-preference columns were unwired in C3 and + /// the selected-wallet pair was unwired in C4. Their backfill + /// helpers are reserved for the migration ladder only. pub fn ensure_settings_columns_exist(&self, conn: &Connection) -> Result<()> { - self.add_custom_dash_qt_columns(conn)?; - self.add_theme_preference_column(conn)?; - self.add_disable_zmq_column(conn)?; - self.add_core_backend_mode_column(conn)?; - self.add_onboarding_columns(conn)?; - self.add_use_local_spv_node_column(conn)?; - self.add_auto_start_spv_column(conn)?; - self.add_close_dash_qt_on_exit_column(conn)?; - self.add_selected_wallet_columns_if_missing(conn)?; - - // Ensure database_version column exists let version_column_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='database_version'", [], @@ -434,416 +266,4 @@ impl Database { Ok(()) } - - /// Adds selected wallet hash columns if they don't exist. - pub fn add_selected_wallet_columns_if_missing(&self, conn: &Connection) -> Result<()> { - let wallet_hash_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_wallet_hash'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !wallet_hash_exists { - conn.execute( - "ALTER TABLE settings ADD COLUMN selected_wallet_hash BLOB DEFAULT NULL;", - (), - )?; - } - - let single_key_hash_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_single_key_hash'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !single_key_hash_exists { - conn.execute( - "ALTER TABLE settings ADD COLUMN selected_single_key_hash BLOB DEFAULT NULL;", - (), - )?; - } - - Ok(()) - } - - /// Gets the selected wallet hashes from the settings table. - /// Returns (selected_wallet_hash, selected_single_key_hash). - pub fn get_selected_wallet_hashes(&self) -> Result<SelectedWalletHashes> { - let conn = self.conn.lock().unwrap(); - let result = conn.query_row( - "SELECT selected_wallet_hash, selected_single_key_hash FROM settings WHERE id = 1", - [], - |row| { - let wallet_hash: Option<Vec<u8>> = row.get(0)?; - let single_key_hash: Option<Vec<u8>> = row.get(1)?; - - // Convert Vec<u8> to [u8; 32] if present and valid length - let wallet_hash_arr = wallet_hash.and_then(|v| { - if v.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&v); - Some(arr) - } else { - None - } - }); - - let single_key_hash_arr = single_key_hash.and_then(|v| { - if v.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&v); - Some(arr) - } else { - None - } - }); - - Ok((wallet_hash_arr, single_key_hash_arr)) - }, - ); - - match result { - Ok(hashes) => Ok(hashes), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok((None, None)), - Err(e) => Err(e), - } - } - - /// Updates the selected wallet hash in the settings table. - pub fn update_selected_wallet_hash(&self, hash: Option<&[u8; 32]>) -> Result<()> { - self.execute( - "UPDATE settings SET selected_wallet_hash = ? WHERE id = 1", - params![hash.map(|h| h.as_slice())], - )?; - Ok(()) - } - - /// Updates the selected single key hash in the settings table. - pub fn update_selected_single_key_hash(&self, hash: Option<&[u8; 32]>) -> Result<()> { - self.execute( - "UPDATE settings SET selected_single_key_hash = ? WHERE id = 1", - params![hash.map(|h| h.as_slice())], - )?; - Ok(()) - } - - /// Retrieves the settings from the database. - /// - /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. - #[allow(clippy::type_complexity)] - pub fn get_settings( - &self, - ) -> Result< - Option<( - Network, - RootScreenType, - Option<PasswordInfo>, - Option<PathBuf>, - bool, - bool, - ThemeMode, - u8, - bool, // onboarding_completed - bool, // show_evonode_tools - UserMode, // user_mode - bool, // close_dash_qt_on_exit - )>, - > { - // Query the settings row - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT network, start_root_screen, password_check, main_password_salt, main_password_nonce, custom_dash_qt_path, overwrite_dash_conf, disable_zmq, theme_preference, core_backend_mode, onboarding_completed, show_evonode_tools, user_mode, close_dash_qt_on_exit FROM settings WHERE id = 1", - )?; - - let result = stmt.query_row([], |row| { - let network: String = row.get(0)?; - let start_root_screen: u32 = row.get(1)?; - let password_check: Option<Vec<u8>> = row.get(2)?; - let main_password_salt: Option<Vec<u8>> = row.get(3)?; - let main_password_nonce: Option<Vec<u8>> = row.get(4)?; - let custom_dash_qt_path: Option<String> = row.get(5)?; - let overwrite_dash_conf: Option<bool> = row.get(6)?; - let disable_zmq: Option<bool> = row.get(7)?; - let theme_preference: Option<String> = row.get(8)?; - let core_backend_mode: Option<u8> = row.get(9)?; - let onboarding_completed: Option<bool> = row.get(10)?; - let show_evonode_tools: Option<bool> = row.get(11)?; - let user_mode: Option<String> = row.get(12)?; - let close_dash_qt_on_exit: Option<bool> = row.get(13)?; - - // Combine the password-related fields if all are present, otherwise set to None - let password_data = match (password_check, main_password_salt, main_password_nonce) { - (Some(password_checker), Some(salt), Some(nonce)) => Some(PasswordInfo { - password_checker, - salt, - nonce, - }), - _ => None, - }; - - // Convert network from string to enum. - // Handle legacy "dash" value from databases created before the - // Network::Dash -> Network::Mainnet rename. - let parsed_network = match network.to_lowercase().as_str() { - "dash" => Network::Mainnet, - other => Network::from_str(other).map_err(|_| rusqlite::Error::InvalidQuery)?, - }; - - // Convert start_root_screen from int to enum - let root_screen_type = RootScreenType::from_int(start_root_screen) - .ok_or_else(|| rusqlite::Error::InvalidQuery)?; - - // Parse theme preference - let theme_mode = match theme_preference.as_deref() { - Some("Light") => ThemeMode::Light, - Some("Dark") => ThemeMode::Dark, - Some("System") | None => ThemeMode::System, // Default to System if missing - _ => ThemeMode::System, // Default to System for unknown values - }; - - // Parse user mode - let user_mode = match user_mode.as_deref() { - Some("Beginner") => UserMode::Beginner, - Some("Advanced") | None => UserMode::Advanced, // Default to Advanced - _ => UserMode::Advanced, - }; - - Ok(( - parsed_network, - root_screen_type, - password_data, - custom_dash_qt_path.map(PathBuf::from), - overwrite_dash_conf.unwrap_or(true), - disable_zmq.unwrap_or(false), - theme_mode, - core_backend_mode.unwrap_or(1), // Default to SPV (1) - onboarding_completed.unwrap_or(false), - show_evonode_tools.unwrap_or(false), - user_mode, - close_dash_qt_on_exit.unwrap_or(true), // Default to true - )) - }); - - match result { - Ok(settings) => Ok(Some(settings)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::test_helpers::create_test_database; - - #[test] - fn test_get_settings_empty_database() { - // A freshly initialized database should have default settings - let db = create_test_database().expect("Failed to create test database"); - - let settings = db.get_settings().expect("Failed to get settings"); - assert!( - settings.is_some(), - "Database should have default settings after initialization" - ); - - let (network, root_screen, password_info, _, _, _, theme, core_mode, _, _, _, _) = - settings.unwrap(); - // Default network is mainnet - assert_eq!(network, Network::Mainnet); - // Default start screen is RootScreenDashPayProfile (20) - assert_eq!(root_screen, RootScreenType::RootScreenDashPayProfile); - // No password set initially - assert!(password_info.is_none()); - // Default theme is System - assert_eq!(theme, ThemeMode::System); - // Default core mode is SPV (1) - assert_eq!(core_mode, 1); - } - - #[test] - fn test_insert_or_update_settings() { - let db = create_test_database().expect("Failed to create test database"); - - // Update to testnet and a different start screen - db.insert_or_update_settings(Network::Testnet, RootScreenType::RootScreenIdentities) - .expect("Failed to update settings"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.0, Network::Testnet); - assert_eq!(settings.1, RootScreenType::RootScreenIdentities); - } - - #[test] - fn test_update_theme_preference() { - let db = create_test_database().expect("Failed to create test database"); - - // Test Dark theme - db.update_theme_preference(ThemeMode::Dark) - .expect("Failed to update theme"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.6, ThemeMode::Dark); - - // Test Light theme - db.update_theme_preference(ThemeMode::Light) - .expect("Failed to update theme"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.6, ThemeMode::Light); - - // Test System theme - db.update_theme_preference(ThemeMode::System) - .expect("Failed to update theme"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.6, ThemeMode::System); - } - - #[test] - fn test_core_backend_mode_persistence() { - let db = create_test_database().expect("Failed to create test database"); - - // Default should be SPV (1) - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.7, 1); - - // Update to RPC mode (0) - db.update_core_backend_mode(0) - .expect("Failed to update core backend mode"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.7, 0); - - // Update back to SPV mode (1) - db.update_core_backend_mode(1) - .expect("Failed to update core backend mode"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.7, 1); - } - - #[test] - fn test_selected_wallet_hash_operations() { - let db = create_test_database().expect("Failed to create test database"); - - // Initially no wallet selected - let (wallet_hash, single_key_hash) = db - .get_selected_wallet_hashes() - .expect("Failed to get wallet hashes"); - assert!(wallet_hash.is_none()); - assert!(single_key_hash.is_none()); - - // Set a wallet hash - let test_hash: [u8; 32] = [0x42; 32]; - db.update_selected_wallet_hash(Some(&test_hash)) - .expect("Failed to update wallet hash"); - - let (wallet_hash, _) = db - .get_selected_wallet_hashes() - .expect("Failed to get wallet hashes"); - assert_eq!(wallet_hash, Some(test_hash)); - - // Set a single key hash - let single_key_test_hash: [u8; 32] = [0x24; 32]; - db.update_selected_single_key_hash(Some(&single_key_test_hash)) - .expect("Failed to update single key hash"); - - let (_, single_key_hash) = db - .get_selected_wallet_hashes() - .expect("Failed to get wallet hashes"); - assert_eq!(single_key_hash, Some(single_key_test_hash)); - - // Clear wallet hash - db.update_selected_wallet_hash(None) - .expect("Failed to clear wallet hash"); - - let (wallet_hash, _) = db - .get_selected_wallet_hashes() - .expect("Failed to get wallet hashes"); - assert!(wallet_hash.is_none()); - } - - #[test] - fn test_onboarding_and_user_mode_settings() { - let db = create_test_database().expect("Failed to create test database"); - - // Default onboarding is not completed - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert!(!settings.8); // onboarding_completed - assert!(!settings.9); // show_evonode_tools - - // Complete onboarding - db.update_onboarding_completed(true) - .expect("Failed to update onboarding"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert!(settings.8); - - // Enable evonode tools - db.update_show_evonode_tools(true) - .expect("Failed to update evonode tools"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert!(settings.9); - - // Update user mode to Beginner - db.update_user_mode("Beginner") - .expect("Failed to update user mode"); - - let settings = db.get_settings().expect("Failed to get settings").unwrap(); - assert_eq!(settings.10, UserMode::Beginner); - } - - #[test] - fn test_spv_settings() { - let db = create_test_database().expect("Failed to create test database"); - - // Test auto_start_spv (default false) - let auto_start = db - .get_auto_start_spv() - .expect("Failed to get auto_start_spv"); - assert!(!auto_start); - - db.update_auto_start_spv(true) - .expect("Failed to update auto_start_spv"); - let auto_start = db - .get_auto_start_spv() - .expect("Failed to get auto_start_spv"); - assert!(auto_start); - - // Test use_local_spv_node (default false) - let use_local = db - .get_use_local_spv_node() - .expect("Failed to get use_local_spv_node"); - assert!(!use_local); - - db.update_use_local_spv_node(true) - .expect("Failed to update use_local_spv_node"); - let use_local = db - .get_use_local_spv_node() - .expect("Failed to get use_local_spv_node"); - assert!(use_local); - } - - #[test] - fn test_close_dash_qt_on_exit() { - let db = create_test_database().expect("Failed to create test database"); - - // Default should be true - let close_on_exit = db - .get_close_dash_qt_on_exit() - .expect("Failed to get close_dash_qt_on_exit"); - assert!(close_on_exit); - - // Update to false - db.update_close_dash_qt_on_exit(false) - .expect("Failed to update close_dash_qt_on_exit"); - - let close_on_exit = db - .get_close_dash_qt_on_exit() - .expect("Failed to get close_dash_qt_on_exit"); - assert!(!close_on_exit); - } } diff --git a/src/database/shielded.rs b/src/database/shielded.rs deleted file mode 100644 index e36fcce3f..000000000 --- a/src/database/shielded.rs +++ /dev/null @@ -1,325 +0,0 @@ -use crate::database::Database; -use crate::model::wallet::WalletSeedHash; -use rusqlite::{Connection, params}; - -impl Database { - /// Create shielded pool tables (v28 migration). - pub(crate) fn create_shielded_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wallet_seed_hash BLOB NOT NULL, - note_data BLOB NOT NULL, - position INTEGER NOT NULL, - cmx BLOB NOT NULL, - nullifier BLOB NOT NULL, - block_height INTEGER NOT NULL, - is_spent INTEGER NOT NULL DEFAULT 0, - value INTEGER NOT NULL, - network TEXT NOT NULL, - UNIQUE(wallet_seed_hash, nullifier, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_shielded_notes_wallet_network - ON shielded_notes (wallet_seed_hash, network)", - [], - )?; - - Ok(()) - } - - /// Insert a shielded note into the database. - pub fn insert_shielded_note( - &self, - wallet_seed_hash: &WalletSeedHash, - note: &InsertShieldedNote<'_>, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT OR IGNORE INTO shielded_notes - (wallet_seed_hash, note_data, position, cmx, nullifier, block_height, value, network) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - wallet_seed_hash.as_slice(), - note.note_data, - note.position as i64, - note.cmx.as_slice(), - note.nullifier.as_slice(), - note.block_height as i64, - note.value as i64, - note.network, - ], - )?; - Ok(()) - } - - /// Get all unspent shielded notes for a wallet on a given network. - pub fn get_unspent_shielded_notes( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - ) -> rusqlite::Result<Vec<ShieldedNoteRow>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, note_data, position, cmx, nullifier, block_height, value - FROM shielded_notes - WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0 - ORDER BY position ASC", - )?; - - let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { - Ok(ShieldedNoteRow { - id: row.get(0)?, - note_data: row.get(1)?, - position: row.get::<_, i64>(2)? as u64, - cmx: { - let bytes: Vec<u8> = row.get(3)?; - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - arr - }, - nullifier: { - let bytes: Vec<u8> = row.get(4)?; - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - arr - }, - block_height: row.get::<_, i64>(5)? as u64, - value: row.get::<_, i64>(6)? as u64, - }) - })?; - - rows.collect() - } - - /// Get all shielded notes (spent and unspent) for a wallet on a given network. - pub fn get_all_shielded_notes( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - ) -> rusqlite::Result<Vec<ShieldedNoteRow>> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, note_data, position, cmx, nullifier, block_height, value - FROM shielded_notes - WHERE wallet_seed_hash = ?1 AND network = ?2 - ORDER BY position ASC", - )?; - - let rows = stmt.query_map(params![wallet_seed_hash.as_slice(), network], |row| { - Ok(ShieldedNoteRow { - id: row.get(0)?, - note_data: row.get(1)?, - position: row.get::<_, i64>(2)? as u64, - cmx: { - let bytes: Vec<u8> = row.get(3)?; - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - arr - }, - nullifier: { - let bytes: Vec<u8> = row.get(4)?; - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - arr - }, - block_height: row.get::<_, i64>(5)? as u64, - value: row.get::<_, i64>(6)? as u64, - }) - })?; - - rows.collect() - } - - /// Mark a shielded note as spent by its nullifier. - pub fn mark_shielded_note_spent( - &self, - wallet_seed_hash: &WalletSeedHash, - nullifier: &[u8; 32], - network: &str, - ) -> rusqlite::Result<usize> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE shielded_notes SET is_spent = 1 - WHERE wallet_seed_hash = ?1 AND nullifier = ?2 AND network = ?3", - params![wallet_seed_hash.as_slice(), nullifier.as_slice(), network], - ) - } - - /// Delete all shielded notes for a wallet (used by resync). - pub fn delete_shielded_notes( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - ) -> rusqlite::Result<usize> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM shielded_notes WHERE wallet_seed_hash = ?1 AND network = ?2", - params![wallet_seed_hash.as_slice(), network], - ) - } - - /// Clear all commitment tree data from the shared database. - /// - /// Handles fresh installs where grovedb creates these tables lazily — - /// each DELETE is skipped if the table does not exist yet. - pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - for table in &[ - "commitment_tree_shards", - "commitment_tree_cap", - "commitment_tree_checkpoints", - "commitment_tree_checkpoint_marks_removed", - ] { - let exists: bool = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - [table], - |row| row.get::<_, i32>(0).map(|c| c > 0), - )?; - if exists { - conn.execute(&format!("DELETE FROM {table}"), [])?; - } - } - Ok(()) - } - - /// Create the shielded_wallet_meta table (v29 migration). - pub(crate) fn create_shielded_wallet_meta_table( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS shielded_wallet_meta ( - wallet_seed_hash BLOB NOT NULL, - network TEXT NOT NULL, - last_nullifier_sync_height INTEGER NOT NULL DEFAULT 0, - last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (wallet_seed_hash, network), - FOREIGN KEY (wallet_seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE - )", - [], - )?; - Ok(()) - } - - /// Migration: Add last_nullifier_sync_timestamp column (v30). - pub(crate) fn add_nullifier_sync_timestamp_column( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - let table_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='shielded_wallet_meta'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if table_exists { - let has_column: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('shielded_wallet_meta') WHERE name='last_nullifier_sync_timestamp'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !has_column { - conn.execute( - "ALTER TABLE shielded_wallet_meta ADD COLUMN last_nullifier_sync_timestamp INTEGER NOT NULL DEFAULT 0", - [], - )?; - } - } - - Ok(()) - } - - /// Get the last nullifier sync height and timestamp for a wallet on a given network. - pub fn get_nullifier_sync_info( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - ) -> Result<(u64, u64), String> { - let conn = self.conn.lock().unwrap(); - let result = conn.query_row( - "SELECT last_nullifier_sync_height, last_nullifier_sync_timestamp FROM shielded_wallet_meta - WHERE wallet_seed_hash = ?1 AND network = ?2", - params![wallet_seed_hash.as_slice(), network], - |row| { - let height: i64 = row.get(0)?; - let timestamp: i64 = row.get(1)?; - Ok((height as u64, timestamp as u64)) - }, - ); - match result { - Ok(info) => Ok(info), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok((0, 0)), - Err(e) => Err(format!("Failed to get nullifier sync info: {e}")), - } - } - - /// Set the last nullifier sync height and timestamp for a wallet on a given network. - pub fn set_nullifier_sync_info( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - height: u64, - timestamp: u64, - ) -> Result<(), String> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT OR REPLACE INTO shielded_wallet_meta - (wallet_seed_hash, network, last_nullifier_sync_height, last_nullifier_sync_timestamp) - VALUES (?1, ?2, ?3, ?4)", - params![ - wallet_seed_hash.as_slice(), - network, - height as i64, - timestamp as i64 - ], - ) - .map_err(|e| format!("Failed to set nullifier sync info: {e}"))?; - Ok(()) - } - - /// Get total shielded balance (sum of unspent note values) for a wallet. - pub fn get_shielded_balance( - &self, - wallet_seed_hash: &WalletSeedHash, - network: &str, - ) -> rusqlite::Result<u64> { - let conn = self.conn.lock().unwrap(); - let result: i64 = conn - .query_row( - "SELECT COALESCE(SUM(value), 0) FROM shielded_notes - WHERE wallet_seed_hash = ?1 AND network = ?2 AND is_spent = 0", - params![wallet_seed_hash.as_slice(), network], - |row| row.get(0), - ) - .unwrap_or(0); - Ok(result as u64) - } -} - -/// Parameters for inserting a shielded note. -pub struct InsertShieldedNote<'a> { - pub note_data: &'a [u8], - pub position: u64, - pub cmx: &'a [u8; 32], - pub nullifier: &'a [u8; 32], - pub block_height: u64, - pub value: u64, - pub network: &'a str, -} - -/// Row data for a shielded note from the database. -pub struct ShieldedNoteRow { - pub id: i64, - pub note_data: Vec<u8>, - pub position: u64, - pub cmx: [u8; 32], - pub nullifier: [u8; 32], - pub block_height: u64, - pub value: u64, -} diff --git a/src/database/single_key_wallet.rs b/src/database/single_key_wallet.rs index c12ad4318..5cda285b1 100644 --- a/src/database/single_key_wallet.rs +++ b/src/database/single_key_wallet.rs @@ -1,12 +1,12 @@ -//! Database operations for single key wallets +//! LEGACY — Database operations for the Decision-#7 single-key carve-out. +//! +//! Persists the [`crate::model::wallet::single_key::SingleKeyWallet`] runtime +//! type and reads its UTXOs via [`crate::database::utxo`]. Retained (spending +//! gated) until single-key moves onto the upstream wallet runtime. The LIVE +//! imported-key metadata sidecar is [`crate::model::single_key`]. use crate::database::Database; -use crate::model::wallet::single_key::{ - ClosedSingleKey, SingleKeyData, SingleKeyHash, SingleKeyWallet, -}; -use dash_sdk::dpp::dashcore::{Address, Network, PublicKey}; -use rusqlite::{Connection, params}; -use std::collections::HashMap; +use rusqlite::Connection; impl Database { /// Initialize the single key wallet table @@ -38,258 +38,36 @@ impl Database { Ok(()) } +} - /// Store a single key wallet in the database - pub fn store_single_key_wallet( - &self, - wallet: &SingleKeyWallet, - network: Network, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT OR REPLACE INTO single_key_wallet ( - key_hash, - encrypted_private_key, - salt, - nonce, - public_key, - address, - alias, - uses_password, - network, - confirmed_balance, - unconfirmed_balance, - total_balance, - core_wallet_name - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - wallet.key_hash.as_slice(), - wallet.encrypted_private_key(), - wallet.salt(), - wallet.nonce(), - wallet.public_key.to_bytes().as_slice(), - wallet.address.to_string(), - wallet.alias.as_deref(), - wallet.uses_password as i32, - network.to_string(), - wallet.confirmed_balance as i64, - wallet.unconfirmed_balance as i64, - wallet.total_balance as i64, - wallet.core_wallet_name.as_deref(), - ], - )?; - Ok(()) - } - - /// Get all single key wallets for a network - pub fn get_single_key_wallets( - &self, - network: Network, - ) -> rusqlite::Result<Vec<SingleKeyWallet>> { - let mut wallets = { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT - key_hash, - encrypted_private_key, - salt, - nonce, - public_key, - address, - alias, - uses_password, - confirmed_balance, - unconfirmed_balance, - total_balance, - core_wallet_name - FROM single_key_wallet - WHERE network = ?1", - )?; - - let rows = stmt.query_map(params![network.to_string()], |row| { - let key_hash_vec: Vec<u8> = row.get(0)?; - let encrypted_private_key: Vec<u8> = row.get(1)?; - let salt: Vec<u8> = row.get(2)?; - let nonce: Vec<u8> = row.get(3)?; - let public_key_bytes: Vec<u8> = row.get(4)?; - let address_str: String = row.get(5)?; - let alias: Option<String> = row.get(6)?; - let uses_password: i32 = row.get(7)?; - let confirmed_balance: i64 = row.get(8)?; - let unconfirmed_balance: i64 = row.get(9)?; - let total_balance: i64 = row.get(10)?; - let core_wallet_name: Option<String> = row.get(11)?; - - Ok(( - key_hash_vec, - encrypted_private_key, - salt, - nonce, - public_key_bytes, - address_str, - alias, - uses_password, - confirmed_balance, - unconfirmed_balance, - total_balance, - core_wallet_name, - )) - })?; - - let mut wallets = Vec::new(); - - for row_result in rows { - let ( - key_hash_vec, - encrypted_private_key, - salt, - nonce, - public_key_bytes, - address_str, - alias, - uses_password, - confirmed_balance, - unconfirmed_balance, - total_balance, - core_wallet_name, - ) = row_result?; - - // Parse key hash - let key_hash: SingleKeyHash = key_hash_vec.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Invalid key hash length".to_string()) - })?; - - // Parse public key - let public_key = PublicKey::from_slice(&public_key_bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid public key: {}", e)) - })?; - - // Parse address - let address = address_str - .parse::<Address<_>>() - .map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid address: {}", e)) - })? - .require_network(network) - .map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Wrong network for address: {}", - e - )) - })?; - - let closed_key = ClosedSingleKey { - key_hash, - encrypted_private_key, - salt, - nonce, - }; - - let wallet = SingleKeyWallet { - private_key_data: SingleKeyData::Closed(closed_key), - uses_password: uses_password != 0, - public_key, - address, - alias, - key_hash, - confirmed_balance: confirmed_balance as u64, - unconfirmed_balance: unconfirmed_balance as u64, - total_balance: total_balance as u64, - utxos: HashMap::new(), - core_wallet_name, - }; - - wallets.push(wallet); - } - - wallets - }; // conn and stmt dropped here - - // Load UTXOs for each wallet - let network_str = network.to_string(); - for wallet in &mut wallets { - if let Ok(utxo_list) = - self.get_utxos_by_address(&wallet.address.to_string(), &network_str) - { - wallet.utxos = utxo_list.into_iter().collect(); - } - } - - Ok(wallets) - } - - /// Remove a single key wallet from the database - pub fn remove_single_key_wallet( - &self, - key_hash: &SingleKeyHash, - network: Network, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM single_key_wallet WHERE key_hash = ?1 AND network = ?2", - params![key_hash.as_slice(), network.to_string()], - )?; - Ok(()) - } - - /// Update balances for a single key wallet - pub fn update_single_key_wallet_balances( - &self, - key_hash: &SingleKeyHash, - confirmed_balance: u64, - unconfirmed_balance: u64, - total_balance: u64, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE single_key_wallet SET - confirmed_balance = ?1, - unconfirmed_balance = ?2, - total_balance = ?3 - WHERE key_hash = ?4", - params![ - confirmed_balance as i64, - unconfirmed_balance as i64, - total_balance as i64, - key_hash.as_slice(), - ], - )?; - Ok(()) - } - - /// Update the Dash Core wallet name for a single key wallet. - /// - /// Returns `Ok(true)` if exactly one row was updated, `Ok(false)` if no - /// matching wallet was found (0 rows), or `Err` on database errors - /// (including the unexpected case of >1 rows affected). - pub fn set_single_key_wallet_core_wallet_name( - &self, - key_hash: &SingleKeyHash, - core_wallet_name: Option<&str>, - ) -> rusqlite::Result<bool> { - let conn = self.conn.lock().unwrap(); - let rows = conn.execute( - "UPDATE single_key_wallet SET core_wallet_name = ?1 WHERE key_hash = ?2", - params![core_wallet_name, key_hash.as_slice()], - )?; - match rows { - 0 => Ok(false), - 1 => Ok(true), - n => Err(rusqlite::Error::StatementChangedRows(n)), - } - } - - /// Update alias for a single key wallet - pub fn update_single_key_wallet_alias( - &self, - key_hash: &SingleKeyHash, - alias: Option<&str>, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE single_key_wallet SET alias = ?1 WHERE key_hash = ?2", - params![alias, key_hash.as_slice()], - )?; - Ok(()) +/// Decision-#7 single-key carve-out regression lane (release-blocking). +/// +/// Pins the Decision-#7 stub error so a regression that silently re-enables +/// single-key spends — or changes the user-facing message — fails CI. +#[cfg(test)] +mod single_key_carveout_regression { + use crate::backend_task::error::TaskError; + + #[test] + fn decision_7_stub_still_surfaces_single_key_unsupported() { + // The stub error variant is the load-bearing Decision-#7 contract. + // It is fieldless, so a structural match fully pins it; the + // user-facing message is asserted verbatim so a regression that + // weakens the disclosure fails here. + let err = TaskError::SingleKeyWalletsUnsupported; + assert!(matches!(err, TaskError::SingleKeyWalletsUnsupported)); + let msg = TaskError::SingleKeyWalletsUnsupported.to_string(); + assert!( + msg.contains("Single-key wallets are not supported in this version"), + "stub message must state the capability is unsupported: {msg}" + ); + assert!( + msg.contains("preserved") && msg.contains("future update"), + "stub message must reassure data is preserved and will return: {msg}" + ); + assert!( + msg.contains("HD (recovery-phrase) wallet"), + "stub message must give the user a concrete alternative: {msg}" + ); } } diff --git a/src/database/test_helpers.rs b/src/database/test_helpers.rs index ca75e2733..ef76f1af1 100644 --- a/src/database/test_helpers.rs +++ b/src/database/test_helpers.rs @@ -4,7 +4,6 @@ //! in unit and integration tests throughout the codebase. use crate::database::Database; -use std::path::PathBuf; use tempfile::TempDir; /// Creates an in-memory SQLite database for testing. @@ -21,10 +20,12 @@ use tempfile::TempDir; /// ``` pub fn create_test_database() -> rusqlite::Result<Database> { let db = Database::new(":memory:")?; - // Initialize tables using the standard initialization path - // Note: We use a dummy path since :memory: doesn't use the file system - let dummy_path = PathBuf::from(":memory:"); - db.initialize(&dummy_path)?; + // Force-create the legacy wallet-family schema so domain tests + // (`database::utxo`, `database::wallet`, …) keep working after + // T-DEV-01 gated those tables out of fresh installs. We bypass + // `initialize` because that path now skips them for truly-fresh DBs. + db.create_tables(true)?; + db.set_default_version()?; Ok(db) } @@ -52,7 +53,10 @@ pub fn create_temp_database() -> rusqlite::Result<(Database, TempDir)> { })?; let db_path = temp_dir.path().join("test_data.db"); let db = Database::new(&db_path)?; - db.initialize(&db_path)?; + // Same rationale as `create_test_database`: force the full legacy + // schema so file-backed tests still see wallet-family tables. + db.create_tables(true)?; + db.set_default_version()?; Ok((db, temp_dir)) } @@ -61,10 +65,90 @@ pub fn create_temp_database() -> rusqlite::Result<(Database, TempDir)> { /// Useful when you need to control the exact location of the database file. pub fn create_database_at_path(path: &std::path::Path) -> rusqlite::Result<Database> { let db = Database::new(path)?; - db.initialize(path)?; + db.create_tables(true)?; + db.set_default_version()?; Ok(db) } +/// Insert an unprotected HD wallet row into the legacy `wallet` table, the +/// pre-PR-#860 on-disk shape the `FinishUnwire` migration drains. Lets tests +/// (including the network e2e suite, which cannot depend on `rusqlite`) stage +/// a "migrated-on-disk" wallet without raw SQL. +/// +/// `encrypted_seed` carries the verbatim 64-byte seed (salt/nonce stay empty +/// for an unprotected wallet); `epk_encoded` is the BIP44 ECDSA +/// account-0 extended-public-key bytes the W2 fund-routing gate matches. +pub fn seed_legacy_unprotected_hd_wallet_row( + db: &Database, + seed_hash: &[u8; 32], + encrypted_seed: &[u8; 64], + epk_encoded: &[u8], + alias: &str, + network: dash_sdk::dpp::dashcore::Network, +) -> rusqlite::Result<()> { + db.execute( + "INSERT INTO wallet ( + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, + uses_password, password_hint, network, core_wallet_name + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0, NULL, ?7, NULL)", + rusqlite::params![ + seed_hash.as_slice(), + encrypted_seed.as_slice(), + Vec::<u8>::new(), + Vec::<u8>::new(), + epk_encoded, + alias, + network.to_string(), + ], + )?; + Ok(()) +} + +/// Insert a password-protected HD wallet row into the legacy `wallet` table — +/// the `uses_password=1` sibling of [`seed_legacy_unprotected_hd_wallet_row`]. +/// Lets tests stage a "migrated-on-disk" protected wallet whose seed stays +/// encrypted until the user unlocks it, so the cold-start migration's deferral +/// of upstream registration for still-locked protected wallets can be covered. +/// +/// `encrypted_seed`/`salt`/`nonce` are the AES-GCM envelope quartet produced by +/// [`encrypt_message`](crate::model::wallet::encryption::encrypt_message) for +/// the seed under the user's password: a 16-byte Argon2 salt and a 12-byte GCM +/// nonce, as the migration's `crypto_field_lengths_ok` gate requires +/// for a protected row. `epk_encoded` is the BIP44 ECDSA account-0 +/// extended-public-key bytes the W2 fund-routing gate matches. +#[allow(clippy::too_many_arguments)] +pub fn seed_legacy_protected_hd_wallet_row( + db: &Database, + seed_hash: &[u8; 32], + encrypted_seed: &[u8], + salt: &[u8], + nonce: &[u8], + epk_encoded: &[u8], + alias: &str, + password_hint: Option<&str>, + network: dash_sdk::dpp::dashcore::Network, +) -> rusqlite::Result<()> { + db.execute( + "INSERT INTO wallet ( + seed_hash, encrypted_seed, salt, nonce, + master_ecdsa_bip44_account_0_epk, alias, is_main, + uses_password, password_hint, network, core_wallet_name + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1, ?7, ?8, NULL)", + rusqlite::params![ + seed_hash.as_slice(), + encrypted_seed, + salt, + nonce, + epk_encoded, + alias, + password_hint, + network.to_string(), + ], + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -83,12 +167,8 @@ mod tests { "Should create temporary database successfully" ); - let (db, temp_dir) = result.unwrap(); + let (_db, temp_dir) = result.unwrap(); let db_path = temp_dir.path().join("test_data.db"); assert!(db_path.exists(), "Database file should exist"); - - // Verify database is functional - let settings = db.get_settings(); - assert!(settings.is_ok(), "Should be able to query settings"); } } diff --git a/src/database/tokens.rs b/src/database/tokens.rs deleted file mode 100644 index dbd25a425..000000000 --- a/src/database/tokens.rs +++ /dev/null @@ -1,754 +0,0 @@ -use bincode::{self, config::standard}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::data_contract::TokenConfiguration; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; -use dash_sdk::platform::{DataContract, Identifier}; -use dash_sdk::query_types::IndexMap; -use rusqlite::Connection; -use rusqlite::OptionalExtension; -use rusqlite::params; -use tracing::warn; - -use super::Database; -use crate::context::AppContext; -use crate::ui::tokens::tokens_screen::{ - IdentityTokenBalance, IdentityTokenIdentifier, TokenInfo, TokenInfoWithDataContract, -}; - -impl Database { - pub fn initialize_token_table(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> { - // Create the token table - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_token_network ON token (network)", - [], - )?; - Ok(()) - } - - pub fn get_token_config_for_id( - &self, - token_id: &Identifier, - app_context: &AppContext, - ) -> rusqlite::Result<Option<TokenConfiguration>> { - let network = app_context.network.to_string(); - let token_id_bytes = token_id.to_vec(); - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT token_config - FROM token - WHERE id = ? AND network = ?", - )?; - - let token_config_bytes: Option<Vec<u8>> = stmt - .query_row(params![token_id_bytes, network], |row| row.get(0)) - .optional()?; - - match token_config_bytes { - Some(bytes) => { - match bincode::decode_from_slice::<TokenConfiguration, _>(&bytes, standard()) { - Ok((config, _)) => Ok(Some(config)), - Err(_) => Ok(None), - } - } - None => Ok(None), - } - } - - pub fn get_contract_id_by_token_id( - &self, - token_id: &Identifier, - app_context: &AppContext, - ) -> rusqlite::Result<Option<Identifier>> { - let network = app_context.network.to_string(); - let token_id_bytes = token_id.to_vec(); - - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT data_contract_id - FROM token - WHERE id = ? AND network = ?", - )?; - - let contract_id_bytes: Option<Vec<u8>> = stmt - .query_row(params![token_id_bytes, network], |row| row.get(0)) - .optional()?; - - match contract_id_bytes { - Some(bytes) => { - Ok(Some(Identifier::from_vec(bytes).map_err(|e| { - rusqlite::Error::ToSqlConversionFailure(Box::new(e)) - })?)) - } - None => Ok(None), - } - } - - pub fn insert_token( - &self, - token_id: &Identifier, - token_alias: &str, - token_config: &[u8], - data_contract_id: &Identifier, - token_position: u16, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let token_id_bytes = token_id.to_vec(); - let data_contract_bytes = data_contract_id.to_vec(); - - // Collect identities before acquiring the connection lock for the transaction - let identities = { - let wallets = app_context.wallets.read().unwrap(); - self.get_local_qualified_identities(app_context, &wallets)? - }; - - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - - tx.execute( - "INSERT INTO token - (id, token_alias, token_config, data_contract_id, token_position, network) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - token_alias = excluded.token_alias, - token_config = excluded.token_config, - data_contract_id = excluded.data_contract_id, - token_position = excluded.token_position, - network = excluded.network", - params![ - token_id_bytes, - token_alias, - token_config, - data_contract_bytes, - token_position, - network - ], - )?; - - // Insert an identity token balance of 0 for each identity for this token - for identity in &identities { - let identity_id_bytes = identity.identity.id().to_vec(); - tx.execute( - "INSERT INTO identity_token_balances - (token_id, identity_id, balance, network) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(token_id, identity_id, network) DO NOTHING", - params![token_id_bytes, identity_id_bytes, 0u64, network], - )?; - } - - tx.commit()?; - - Ok(()) - } - - /// Drops the identity_token_balances table (if necessary to enforce schema update) - pub fn drop_identity_token_balances_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - conn.execute("DROP TABLE IF EXISTS identity_token_balances", [])?; - - Ok(()) - } - - /// Creates the identity_token_balances table if it doesn't already exist - pub fn initialize_identity_token_balances_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_identity_token_balances_network ON identity_token_balances (network)", - [], - )?; - Ok(()) - } - - pub fn insert_identity_token_balance( - &self, - token_identifier: &Identifier, - identity_id: &Identifier, - balance: u64, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let token_id_bytes = token_identifier.to_vec(); - let identity_id_bytes = identity_id.to_vec(); - - self.execute( - "INSERT INTO identity_token_balances - (token_id, identity_id, balance, network) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(token_id, identity_id, network) DO UPDATE SET - balance = excluded.balance", - params![token_id_bytes, identity_id_bytes, balance, network], - )?; - - Ok(()) - } - - /// Retrieves all known tokens as a map from token ID to `TokenInfoWithDataContract`. - /// - /// This includes decoding the `token_config` and deserializing the `DataContract` using `versioned_deserialize`. - pub fn get_all_known_tokens_with_data_contract( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<IndexMap<Identifier, TokenInfoWithDataContract>> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT - token.id, - token.token_alias, - token.token_config, - token.data_contract_id, - token.token_position, - contract.contract - FROM token - JOIN contract - ON token.data_contract_id = contract.contract_id - AND contract.network = token.network - WHERE token.network = ? - ORDER BY token.token_alias ASC", - )?; - - let rows = stmt.query_map(params![network], |row| { - let token_config_bytes: Vec<u8> = row.get(2)?; - let raw_contract_bytes: Vec<u8> = row.get(5)?; - - // Decode token config - let token_cfg = bincode::decode_from_slice::<TokenConfiguration, _>( - &token_config_bytes, - standard(), - ) - .map(|(cfg, _)| cfg) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - - // Deserialize DataContract using versioned_deserialize - let data_contract = DataContract::versioned_deserialize( - &raw_contract_bytes, - false, - app_context.platform_version(), - ) - .map_err(|e| { - tracing::error!("Failed to deserialize DataContract: {}", e); - rusqlite::Error::ToSqlConversionFailure(Box::new(e)) - })?; - - let token_id = Identifier::from_vec(row.get(0)?).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Failed to parse token ID: {}", e)) - })?; - - Ok(( - token_id, - row.get::<_, String>(1)?, - token_cfg, - data_contract, - row.get::<_, u16>(4)?, - )) - })?; - - let mut result = IndexMap::new(); - - for row in rows { - let (token_id, token_name, token_cfg, data_contract, token_position) = row?; - result.insert( - token_id, - TokenInfoWithDataContract { - token_id, - token_name, - data_contract, - token_position, - token_configuration: token_cfg, - description: None, - }, - ); - } - - Ok(result) - } - - /// Retrieves all known tokens as a map from token ID to `TokenInfo`. - /// - /// Now also fetches and decodes the **`token_config`** blob. - #[allow(dead_code)] // May be used for token overview displays without contract data - pub fn get_all_known_tokens( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<IndexMap<Identifier, TokenInfo>> { - let network = app_context.network.to_string(); - let conn = self.conn.lock().unwrap(); - - // -- 1. query id / alias / config / contract / position ──────────────── - let mut stmt = conn.prepare( - "SELECT id, - token_alias, - token_config, - data_contract_id, - token_position - FROM token - WHERE network = ? - ORDER BY token_alias ASC", - )?; - - // -- 2. map each row, decoding `token_config` with bincode -───────────── - let rows = stmt.query_map(params![network], |row| { - let bytes: Vec<u8> = row.get(2)?; // token_config blob - let cfg = bincode::decode_from_slice::<TokenConfiguration, _>(&bytes, standard()) - .map(|(c, _)| c) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - - Ok(( - Identifier::from_vec(row.get(0)?), - row.get::<_, String>(1)?, - cfg, // decoded config - Identifier::from_vec(row.get(3)?), - row.get::<_, u16>(4)?, - )) - })?; - - // -- 3. build the IndexMap result ─────────────────────────────────────── - let mut result = IndexMap::new(); - - for row in rows { - let (token_id_res, token_alias, token_cfg, contract_id_res, pos) = row?; - - let token_id = token_id_res.map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Failed to parse token ID: {}", e)) - })?; - let data_contract_id = contract_id_res.map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Failed to parse contract ID: {}", e)) - })?; - - result.insert( - token_id, - TokenInfo { - token_id, - token_name: token_alias, - data_contract_id, - token_position: pos, - token_configuration: token_cfg, - description: None, - }, - ); - } - - Ok(result) - } - - pub fn get_identity_token_balances( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<IndexMap<IdentityTokenIdentifier, IdentityTokenBalance>> { - let network = app_context.network.to_string(); - - let rows_data = { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT b.token_id, t.token_alias, t.token_config, b.identity_id, b.balance, t.data_contract_id, t.token_position - FROM identity_token_balances AS b - JOIN token AS t ON b.token_id = t.id - WHERE b.network = ?", - )?; - - let rows = stmt.query_map(params![network], |row| { - let config = standard(); - let bytes: Vec<u8> = row.get(2)?; - let token_config: Result<(TokenConfiguration, _), _> = - bincode::decode_from_slice(&bytes, config); - Ok(( - Identifier::from_vec(row.get(0)?), - row.get(1)?, - token_config, - Identifier::from_vec(row.get(3)?), - row.get(4)?, - Identifier::from_vec(row.get(5)?), - row.get(6)?, - )) - })?; - - let mut temp = Vec::new(); - for row in rows { - temp.push(row?); - } - temp - }; - - let mut result = IndexMap::new(); - for ( - token_id_res, - token_name, - token_config, - identity_id_res, - balance, - data_contract_id_res, - token_position, - ) in rows_data - { - let token_id = token_id_res.map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to parse token_identifier: {}", - e - )) - })?; - let token_config = token_config.map(|(cfg, _)| cfg).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to decode token_config: {}", - e - )) - })?; - let identity_id = identity_id_res.map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Failed to parse identity_id: {}", e)) - })?; - let data_contract_id = data_contract_id_res.map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to parse data_contract_id: {}", - e - )) - })?; - - let identity_token_balance = IdentityTokenBalance { - token_id, - token_alias: token_name, - token_config, - identity_id, - balance, - estimated_unclaimed_rewards: None, - data_contract_id, - token_position, - }; - - result.insert( - IdentityTokenIdentifier { - identity_id, - token_id, - }, - identity_token_balance, - ); - } - - Ok(result) - } - - /// Removes a token and all associated entries (balances, order) by `token_id`. - /// - /// This will cascade delete from `identity_token_balances` and `token_order` due to foreign key constraints. - pub fn remove_token( - &self, - token_id: &Identifier, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let token_id_bytes = token_id.to_vec(); - - self.execute( - "DELETE FROM token WHERE id = ? AND network = ?", - params![token_id_bytes, network], - )?; - - Ok(()) - } - - pub fn remove_token_balance( - &self, - token_identifier: &Identifier, - identity_id: &Identifier, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - let network = app_context.network.to_string(); - let token_identifier_vec = token_identifier.to_vec(); - let identity_id_vec = identity_id.to_vec(); - - self.execute( - "DELETE FROM identity_token_balances - WHERE token_id = ? AND identity_id = ? AND network = ?", - params![token_identifier_vec, identity_id_vec, network], - )?; - - // Also remove from the order table - self.execute( - "DELETE FROM token_order - WHERE token_id = ? AND identity_id = ?", - params![token_identifier_vec, identity_id_vec], - )?; - - Ok(()) - } - - /// (Re)creates the `token_order` table with proper foreign keys. - pub fn initialize_token_order_table( - &self, - conn: &rusqlite::Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - [], - )?; - - Ok(()) - } - - /// Saves the user’s custom identity order (the entire list). - /// This method overwrites whatever was there before. - pub fn save_token_order(&self, all_ids: Vec<(Identifier, Identifier)>) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - let tx = conn.unchecked_transaction()?; - - // Clear existing rows - tx.execute("DELETE FROM token_order", [])?; - - // Insert each ID with a numeric pos = 0..N - for (pos, (token_id, identity_id)) in all_ids.iter().enumerate() { - let token_id_bytes = token_id.to_vec(); - let identity_id_bytes = identity_id.to_vec(); - tx.execute( - "INSERT INTO token_order (pos, token_id, identity_id) - VALUES (?1, ?2, ?3)", - params![pos as i64, token_id_bytes, identity_id_bytes], - )?; - } - - tx.commit()?; - Ok(()) - } - - /// Loads the custom identity order from the DB, returning a list of Identifiers in the stored order. - /// If there's no data, returns an empty Vec. - pub fn load_token_order(&self) -> rusqlite::Result<Vec<(Identifier, Identifier)>> { - let conn = self.conn.lock().unwrap(); - - // Read all rows sorted by pos - let mut stmt = conn.prepare( - "SELECT token_id, identity_id FROM token_order - ORDER BY pos ASC", - )?; - - let mut rows = stmt.query([])?; - let mut result = Vec::new(); - - while let Some(row) = rows.next()? { - let token_id_bytes: Vec<u8> = row.get(0)?; - let identity_id_bytes: Vec<u8> = row.get(1)?; - // Convert from raw bytes to an Identifier - if let Ok(token_id) = Identifier::from_vec(token_id_bytes) { - if let Ok(identity_id) = Identifier::from_vec(identity_id_bytes) { - result.push((token_id, identity_id)); - } else { - warn!("Failed to parse identity ID from token_order table, skipping"); - } - } else { - warn!("Failed to parse token ID from token_order table, skipping"); - } - } - - Ok(result) - } - - /// Deletes all local tokens in Devnet variants and Regtest. - pub fn delete_all_local_tokens_in_all_devnets_and_regtest( - &self, - conn: &Connection, - ) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM token WHERE network LIKE 'devnet%' OR network = 'regtest'", - [], - )?; - - Ok(()) - } - - /// Deletes all local tokens and related entries (identity_token_balances, token_order) in Devnet. - pub fn delete_all_local_tokens_in_devnet( - &self, - app_context: &AppContext, - ) -> rusqlite::Result<()> { - if app_context.network != Network::Devnet { - return Ok(()); - } - let network = app_context.network.to_string(); - - let conn = self.conn.lock().unwrap(); - - // Delete tokens and cascade deletions in related tables due to foreign keys - conn.execute("DELETE FROM token WHERE network = ?", params![network])?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::database::test_helpers::create_test_database; - use dash_sdk::platform::Identifier; - - fn insert_test_contract(db: &crate::database::Database, id: &Identifier) { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO contract (contract_id, contract, alias, network) VALUES (?, ?, NULL, 'testnet')", - rusqlite::params![id.to_vec(), vec![0u8; 16]], - ).unwrap(); - } - - fn insert_test_token( - db: &crate::database::Database, - token_id: &Identifier, - contract_id: &Identifier, - ) { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO token (id, token_alias, token_config, data_contract_id, token_position, network) VALUES (?, 'Test Token', ?, ?, 0, 'testnet')", - rusqlite::params![token_id.to_vec(), vec![0u8; 32], contract_id.to_vec()], - ).unwrap(); - } - - fn insert_test_identity(db: &crate::database::Database, id: &Identifier) { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO identity (id, is_local, network) VALUES (?, 1, 'testnet')", - rusqlite::params![id.to_vec()], - ) - .unwrap(); - } - - #[test] - fn test_save_and_load_token_order() { - let db = create_test_database().unwrap(); - - let contract_id = Identifier::random(); - insert_test_contract(&db, &contract_id); - - let token1 = Identifier::random(); - let token2 = Identifier::random(); - let id1 = Identifier::random(); - let id2 = Identifier::random(); - - insert_test_token(&db, &token1, &contract_id); - insert_test_token(&db, &token2, &contract_id); - insert_test_identity(&db, &id1); - insert_test_identity(&db, &id2); - - let order = vec![(token1, id1), (token2, id2)]; - db.save_token_order(order.clone()).unwrap(); - - let loaded = db.load_token_order().unwrap(); - assert_eq!(loaded.len(), 2); - assert_eq!(loaded[0], order[0]); - assert_eq!(loaded[1], order[1]); - } - - #[test] - fn test_save_token_order_replaces_previous() { - let db = create_test_database().unwrap(); - - let contract_id = Identifier::random(); - insert_test_contract(&db, &contract_id); - - let t1 = Identifier::random(); - let t2 = Identifier::random(); - let i1 = Identifier::random(); - let i2 = Identifier::random(); - - insert_test_token(&db, &t1, &contract_id); - insert_test_token(&db, &t2, &contract_id); - insert_test_identity(&db, &i1); - insert_test_identity(&db, &i2); - - db.save_token_order(vec![(t1, i1), (t2, i2)]).unwrap(); - db.save_token_order(vec![(t2, i2)]).unwrap(); - - let loaded = db.load_token_order().unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0], (t2, i2)); - } - - #[test] - fn test_load_empty_token_order() { - let db = create_test_database().unwrap(); - let loaded = db.load_token_order().unwrap(); - assert!(loaded.is_empty()); - } - - #[test] - fn test_delete_all_tokens_in_devnets_and_regtest() { - let db = create_test_database().unwrap(); - - let testnet_contract = Identifier::random(); - let devnet_contract = Identifier::random(); - let testnet_token = Identifier::random(); - let devnet_token = Identifier::random(); - - // Insert contracts for different networks - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO contract (contract_id, contract, alias, network) VALUES (?, ?, NULL, 'testnet')", - rusqlite::params![testnet_contract.to_vec(), vec![0u8; 16]], - ).unwrap(); - conn.execute( - "INSERT INTO contract (contract_id, contract, alias, network) VALUES (?, ?, NULL, 'devnet-test')", - rusqlite::params![devnet_contract.to_vec(), vec![0u8; 16]], - ).unwrap(); - } - - // Insert tokens for different networks - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO token (id, token_alias, token_config, data_contract_id, token_position, network) VALUES (?, 'T1', ?, ?, 0, 'testnet')", - rusqlite::params![testnet_token.to_vec(), vec![0u8; 16], testnet_contract.to_vec()], - ).unwrap(); - conn.execute( - "INSERT INTO token (id, token_alias, token_config, data_contract_id, token_position, network) VALUES (?, 'T2', ?, ?, 0, 'devnet-test')", - rusqlite::params![devnet_token.to_vec(), vec![0u8; 16], devnet_contract.to_vec()], - ).unwrap(); - } - - { - let conn = db.conn.lock().unwrap(); - db.delete_all_local_tokens_in_all_devnets_and_regtest(&conn) - .unwrap(); - } - - let conn = db.conn.lock().unwrap(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM token", [], |row| row.get(0)) - .unwrap(); - assert_eq!(count, 1); // Only testnet token remains - } -} diff --git a/src/database/top_ups.rs b/src/database/top_ups.rs deleted file mode 100644 index a1ab9654c..000000000 --- a/src/database/top_ups.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::database::Database; -use rusqlite::{OptionalExtension, params}; - -impl Database { - pub fn initialize_top_up_table(&self, conn: &rusqlite::Connection) -> rusqlite::Result<()> { - // Create the top_up table - conn.execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - [], - )?; - Ok(()) - } - - #[allow(dead_code)] // May be used for generating sequential top-up indices - pub fn get_next_top_up_index(&self, identity_id: &[u8]) -> rusqlite::Result<u64> { - let conn = self.conn.lock().unwrap(); - let max_index: Option<u64> = conn - .query_row( - "SELECT MAX(top_up_index) FROM top_up WHERE identity_id = ?", - params![identity_id], - |row| row.get(0), - ) - .optional()?; - Ok(max_index.unwrap_or(0) + 1) - } - - pub fn insert_top_up( - &self, - identity_id: &[u8], - top_up_index: u32, - amount: u64, - ) -> rusqlite::Result<()> { - self.execute( - "INSERT INTO top_up (identity_id, top_up_index, amount) VALUES (?, ?, ?)", - params![identity_id, top_up_index, amount], - )?; - Ok(()) - } -} diff --git a/src/database/utxo.rs b/src/database/utxo.rs index 0063309f4..3fb698c78 100644 --- a/src/database/utxo.rs +++ b/src/database/utxo.rs @@ -1,23 +1,16 @@ use crate::database::Database; use dash_sdk::dashcore_rpc::dashcore::{OutPoint, ScriptBuf, TxOut, Txid}; use dash_sdk::dpp::dashcore::hashes::Hash; +#[cfg(test)] use dash_sdk::dpp::dashcore::{Address, Network}; use rusqlite::params; impl Database { - /// Deletes a UTXO from the database given its OutPoint and network. - pub fn drop_utxo(&self, outpoint: &OutPoint, network: &str) -> rusqlite::Result<()> { - let txid_bytes = outpoint.txid.as_byte_array(); // &[u8; 32] - let vout = outpoint.vout as i64; // i64 - - self.execute( - "DELETE FROM utxos WHERE txid = ? AND vout = ? AND network = ?", - params![txid_bytes, vout, network], - )?; - - Ok(()) - } - + /// Test-only fixture: seeds a UTXO row so the still-live + /// `get_utxos_by_address` read path can be exercised. Production no + /// longer writes the legacy `utxos` table — upstream owns wallet-UTXO + /// state. + #[cfg(test)] pub(crate) fn insert_utxo( &self, txid: &[u8], @@ -47,41 +40,37 @@ impl Database { &self, address: &str, network: &str, - ) -> Result<Vec<(OutPoint, TxOut)>, String> { - let conn = self.conn.lock().map_err(|e| e.to_string())?; + ) -> rusqlite::Result<Vec<(OutPoint, TxOut)>> { + let conn = self.locked_conn(); - let mut stmt = conn - .prepare( - "SELECT txid, vout, value, script_pubkey FROM utxos + let mut stmt = conn.prepare( + "SELECT txid, vout, value, script_pubkey FROM utxos WHERE address = ? AND network = ?", - ) - .map_err(|e| e.to_string())?; + )?; - let tx_out_iter = stmt - .query_map(params![address, network], |row| { - let txid_bytes: Vec<u8> = row.get(0)?; - let vout: u32 = row.get(1)?; - let value: u64 = row.get(2)?; - let script_pubkey_bytes: Vec<u8> = row.get(3)?; + let tx_out_iter = stmt.query_map(params![address, network], |row| { + let txid_bytes: Vec<u8> = row.get(0)?; + let vout: u32 = row.get(1)?; + let value: u64 = row.get(2)?; + let script_pubkey_bytes: Vec<u8> = row.get(3)?; - let txid = Txid::from_slice(&txid_bytes) - .map_err(|e| rusqlite::Error::UserFunctionError(Box::new(e)))?; - let outpoint = OutPoint { txid, vout }; + let txid = Txid::from_slice(&txid_bytes) + .map_err(|e| rusqlite::Error::UserFunctionError(Box::new(e)))?; + let outpoint = OutPoint { txid, vout }; - let script_pubkey = ScriptBuf::from_bytes(script_pubkey_bytes); + let script_pubkey = ScriptBuf::from_bytes(script_pubkey_bytes); - let tx_out = TxOut { - value, - script_pubkey, - }; + let tx_out = TxOut { + value, + script_pubkey, + }; - Ok((outpoint, tx_out)) - }) - .map_err(|e| e.to_string())?; + Ok((outpoint, tx_out)) + })?; let mut utxos = Vec::new(); for utxo in tx_out_iter { - utxos.push(utxo.map_err(|e| e.to_string())?); + utxos.push(utxo?); } Ok(utxos) @@ -178,43 +167,6 @@ mod tests { assert_eq!(utxos[0].1.value, 100_000_000); // Original value preserved } - #[test] - fn test_drop_utxo() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let address = create_test_address(network); - let txid = create_test_txid(); - let script_pubkey = address.script_pubkey(); - - // Insert a UTXO - db.insert_utxo( - txid.as_byte_array(), - 0, - &address, - 100_000_000, - script_pubkey.as_bytes(), - network, - ) - .expect("Failed to insert UTXO"); - - // Verify it exists - let utxos = db - .get_utxos_by_address(&address.to_string(), &network.to_string()) - .expect("Failed to get UTXOs"); - assert_eq!(utxos.len(), 1); - - // Drop the UTXO - let outpoint = OutPoint { txid, vout: 0 }; - db.drop_utxo(&outpoint, &network.to_string()) - .expect("Failed to drop UTXO"); - - // Verify it's gone - let utxos = db - .get_utxos_by_address(&address.to_string(), &network.to_string()) - .expect("Failed to get UTXOs"); - assert_eq!(utxos.len(), 0); - } - #[test] fn test_utxo_network_filtering() { let db = create_test_database().expect("Failed to create test database"); diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 841a5221c..1b7007d70 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -2,273 +2,76 @@ use crate::database::{CorruptedBlobError, Database}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{ AddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, - TransactionStatus, Wallet, WalletSeed, WalletTransaction, + Wallet, WalletSeed, }; use dash_sdk::dashcore_rpc::dashcore::Address; -use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::address::{NetworkChecked, NetworkUnchecked}; -use dash_sdk::dpp::dashcore::consensus::{deserialize, serialize}; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{ - self, BlockHash, InstantLock, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid, -}; +use dash_sdk::dpp::dashcore::{self, Network}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; use rusqlite::{Connection, params}; use std::collections::{BTreeMap, HashMap}; use std::str::FromStr; impl Database { - /// Insert a new wallet into the wallet table - pub fn store_wallet(&self, wallet: &Wallet, network: &Network) -> rusqlite::Result<()> { - self.store_wallet_with_addresses(wallet, network, &[]) - } - - /// Atomically persist a wallet row and its known addresses in a single - /// database transaction. Prevents partial persistence where the wallet - /// is stored but addresses are lost on failure. - pub fn store_wallet_with_addresses( - &self, - wallet: &Wallet, - network: &Network, - addresses: &[( - &Address, - &DerivationPath, - DerivationPathReference, - DerivationPathType, - )], - ) -> rusqlite::Result<()> { - let network_str = network.to_string(); - - let master_ecdsa_bip44_account_0_epk_bytes = - wallet.master_bip44_ecdsa_extended_public_key.encode(); - - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - - tx.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, network, confirmed_balance, unconfirmed_balance, total_balance, core_wallet_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - wallet.seed_hash(), - wallet.encrypted_seed_slice(), - wallet.salt(), - wallet.nonce(), - master_ecdsa_bip44_account_0_epk_bytes, - wallet.alias.clone(), - wallet.is_main as i32, - wallet.uses_password, - wallet.password_hint().clone(), - network_str, - wallet.confirmed_balance as i64, - wallet.unconfirmed_balance as i64, - wallet.total_balance as i64, - wallet.core_wallet_name.as_deref(), - ], - )?; - - let seed_hash = wallet.seed_hash(); - for (address, derivation_path, path_reference, path_type) in addresses { - let checked_addr = check_address_for_network(address.as_unchecked().clone(), network)?; - tx.execute( - "INSERT OR IGNORE INTO wallet_addresses - (seed_hash, address, derivation_path, path_reference, path_type, balance) - VALUES (?, ?, ?, ?, ?, NULL)", - params![ - seed_hash, - checked_addr.to_string(), - derivation_path.to_string(), - *path_reference as u32, - path_type.bits(), - ], - )?; - } - - tx.commit() - } - - /// Update the Dash Core wallet name for an HD wallet. - /// - /// Returns `Ok(true)` if exactly one row was updated, `Ok(false)` if no - /// matching wallet was found (0 rows), or `Err` on database errors - /// (including the unexpected case of >1 rows affected). - pub fn set_wallet_core_wallet_name( - &self, - seed_hash: &[u8; 32], - core_wallet_name: Option<&str>, - ) -> rusqlite::Result<bool> { - let conn = self.conn.lock().unwrap(); - let rows = conn.execute( - "UPDATE wallet SET core_wallet_name = ? WHERE seed_hash = ?", - params![core_wallet_name, seed_hash], - )?; - match rows { - 0 => Ok(false), - 1 => Ok(true), - n => Err(rusqlite::Error::StatementChangedRows(n)), - } - } - - /// Update the alias of a wallet based on the seed. - /// If the alias is `None`, it sets the alias to NULL in the database. - pub fn set_wallet_alias( - &self, - seed_hash: &[u8; 32], - new_alias: Option<String>, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "UPDATE wallet SET alias = ? WHERE seed_hash = ?", - params![new_alias, seed_hash], - )?; - - Ok(()) - } - /// Remove a wallet and all associated records from the database. /// - /// This clears dependent records (addresses, utxos, asset locks, identity links) - /// to keep the database consistent before deleting the wallet itself. + /// This clears dependent records (addresses, utxos, identity links) to keep + /// the database consistent before deleting the wallet itself. + /// + /// The legacy wallet-family tables (`wallet`, `wallet_addresses`, `utxos`) + /// are gated out of the fresh-install schema — they live in the upstream + /// persistor now — so every statement is existence-guarded, mirroring + /// [`Self::clear_network_data`]. On a fresh install each guard is a clean + /// no-op and the caller proceeds to wipe the wallet's secret-bearing state; + /// an unguarded statement would error on the first missing table and abort + /// removal before that wipe (the F17/F20 leak). pub fn remove_wallet(&self, seed_hash: &[u8; 32], network: &Network) -> rusqlite::Result<()> { let network_str = network.to_string(); - let mut conn = self.conn.lock().unwrap(); + let mut conn = self.locked_conn(); let tx = conn.transaction()?; - let mut address_stmt = - tx.prepare("SELECT address FROM wallet_addresses WHERE seed_hash = ?")?; - let address_rows = - address_stmt.query_map(params![seed_hash], |row| row.get::<_, String>(0))?; - let mut addresses = Vec::new(); - for address in address_rows { - addresses.push(address?); + if self.table_exists(&tx, "wallet_addresses")? && self.table_exists(&tx, "utxos")? { + let mut address_stmt = + tx.prepare("SELECT address FROM wallet_addresses WHERE seed_hash = ?")?; + let address_rows = + address_stmt.query_map(params![seed_hash], |row| row.get::<_, String>(0))?; + let mut addresses = Vec::new(); + for address in address_rows { + addresses.push(address?); + } + drop(address_stmt); + + for address in addresses { + tx.execute( + "DELETE FROM utxos WHERE address = ? AND network = ?", + params![address, &network_str], + )?; + } } - drop(address_stmt); - for address in addresses { + // The `identity` table survives in the fresh schema but carries a + // `FOREIGN KEY (wallet) REFERENCES wallet(seed_hash)`. With foreign + // keys enforced, writing the `wallet` column resolves that reference, + // so the UPDATE errors with "no such table: wallet" when the legacy + // `wallet` table is absent. There is nothing to unlink without a + // `wallet` table anyway, so gate the UPDATE on both tables existing. + if self.table_exists(&tx, "identity")? && self.table_exists(&tx, "wallet")? { tx.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - params![address, &network_str], + "UPDATE identity SET wallet = NULL, wallet_index = NULL WHERE wallet = ? AND network = ?", + params![seed_hash, &network_str], )?; } - tx.execute( - "UPDATE identity SET wallet = NULL, wallet_index = NULL WHERE wallet = ? AND network = ?", - params![seed_hash, &network_str], - )?; - - tx.execute( - "DELETE FROM wallet WHERE seed_hash = ? AND network = ?", - params![seed_hash, &network_str], - )?; - - tx.commit() - } - - /// Update only the alias and is_main fields of a wallet - #[allow(dead_code)] // May be used for batch wallet metadata updates - pub fn update_wallet_alias_and_main( - &self, - seed_hash: &[u8; 32], - new_alias: Option<String>, - is_main: bool, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet SET alias = ?, is_main = ? WHERE seed_hash = ?", - params![new_alias, is_main as i32, seed_hash], - )?; - Ok(()) - } - - /// Add a new address to a wallet with optional balance. - /// If the address already exists, it does nothing. - #[allow(clippy::too_many_arguments)] - pub fn add_address_if_not_exists( - &self, - seed_hash: &[u8; 32], - address: &Address, - network: &Network, - derivation_path: &DerivationPath, - path_reference: DerivationPathReference, - path_type: DerivationPathType, - balance: Option<u64>, - ) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - - let address = check_address_for_network(address.as_unchecked().clone(), network)?; - - // Step 1: Check if the address already exists for the given seed. - let mut stmt = conn.prepare( - "SELECT COUNT(1) FROM wallet_addresses - WHERE seed_hash = ? AND address = ?", - )?; - let count: u32 = - stmt.query_row(params![seed_hash, address.to_string()], |row| row.get(0))?; - - // Step 2: If the address doesn't exist, insert it. - if count == 0 { - conn.execute( - "INSERT INTO wallet_addresses - (seed_hash, address, derivation_path, path_reference, path_type, balance) - VALUES (?, ?, ?, ?, ?, ?)", - params![ - seed_hash, - address.to_string(), - derivation_path.to_string(), - path_reference as u32, - path_type.bits(), - balance, - ], + if self.table_exists(&tx, "wallet")? { + tx.execute( + "DELETE FROM wallet WHERE seed_hash = ? AND network = ?", + params![seed_hash, &network_str], )?; } - Ok(()) - } - - /// Update the balance of an existing address. - pub fn update_address_balance( - &self, - seed_hash: &[u8; 32], - address: &Address, - new_balance: u64, - ) -> rusqlite::Result<()> { - let rows_affected = self.execute( - "UPDATE wallet_addresses - SET balance = ? - WHERE seed_hash = ? AND address = ?", - params![new_balance, seed_hash, address.to_string()], - )?; - - if rows_affected == 0 { - Err(rusqlite::Error::QueryReturnedNoRows) - } else { - Ok(()) - } - } - /// Add a balance to an existing address. - pub fn add_to_address_balance( - &self, - seed_hash: &[u8; 32], - address: &Address, - additional_balance: u64, - ) -> rusqlite::Result<()> { - let rows_affected = self.execute( - "UPDATE wallet_addresses - SET balance = balance + ? - WHERE seed_hash = ? AND address = ?", - params![additional_balance, seed_hash, address.to_string()], - )?; - - if rows_affected == 0 { - Err(rusqlite::Error::QueryReturnedNoRows) - } else { - Ok(()) - } + tx.commit() } /// Migration: Add balance columns to wallet table (version 16). @@ -298,21 +101,6 @@ impl Database { Ok(()) } - /// Update the wallet's balance fields in the database. - pub fn update_wallet_balances( - &self, - seed_hash: &[u8; 32], - confirmed_balance: u64, - unconfirmed_balance: u64, - total_balance: u64, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet SET confirmed_balance = ?, unconfirmed_balance = ?, total_balance = ? WHERE seed_hash = ?", - params![confirmed_balance as i64, unconfirmed_balance as i64, total_balance as i64, seed_hash], - )?; - Ok(()) - } - /// 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 @@ -332,48 +120,7 @@ impl Database { Ok(()) } - /// Ensures all required columns exist in wallet-related tables. - /// This handles the case where old tables exist with missing columns. - pub fn ensure_wallet_columns_exist(&self, conn: &Connection) -> rusqlite::Result<()> { - // Check if wallet_addresses table exists before trying to add columns - let wallet_addresses_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='wallet_addresses'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if wallet_addresses_exists { - self.add_address_total_received_column(conn)?; - } - - // Check if wallet table exists and add balance columns if needed - let wallet_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='wallet'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if wallet_exists { - self.add_wallet_balance_columns(conn)?; - } - - Ok(()) - } - - /// Update the total_received for an address. - pub fn update_address_total_received( - &self, - seed_hash: &[u8; 32], - address: &Address, - total_received: u64, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet_addresses SET total_received = ? WHERE seed_hash = ? AND address = ?", - params![total_received as i64, seed_hash, address.to_string()], - )?; - Ok(()) - } - + /// Schema bootstrap for the `wallet_transactions` table. pub fn initialize_wallet_transactions_table(&self, conn: &Connection) -> rusqlite::Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS wallet_transactions ( @@ -404,77 +151,6 @@ impl Database { Ok(()) } - /// Replace all persisted transactions for a wallet+network with the provided set. - /// - /// Uses `INSERT OR REPLACE` so that when upstream returns the same txid - /// twice (e.g. as mempool + confirmed), the last-written version wins. - /// Callers should sort confirmed entries after unconfirmed to ensure the - /// confirmed version takes precedence. - pub fn replace_wallet_transactions( - &self, - seed_hash: &[u8; 32], - network: &Network, - transactions: &[WalletTransaction], - ) -> rusqlite::Result<()> { - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - let network_str = network.to_string(); - - tx.execute( - "DELETE FROM wallet_transactions WHERE seed_hash = ?1 AND network = ?2", - params![seed_hash, &network_str], - )?; - - if transactions.is_empty() { - tx.commit()?; - return Ok(()); - } - - { - let mut insert_stmt = tx.prepare( - "INSERT OR REPLACE INTO wallet_transactions ( - seed_hash, - txid, - network, - timestamp, - height, - block_hash, - net_amount, - fee, - label, - is_ours, - raw_transaction, - status - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - )?; - - for transaction in transactions { - let tx_bytes = serialize(&transaction.transaction); - let block_hash_bytes: Option<Vec<u8>> = transaction - .block_hash - .as_ref() - .map(|hash| hash.as_raw_hash().as_byte_array().to_vec()); - let fee = transaction.fee.map(|f| f as i64); - insert_stmt.execute(params![ - seed_hash, - <dash_sdk::dpp::dashcore::Txid as AsRef<[u8]>>::as_ref(&transaction.txid), - &network_str, - transaction.timestamp as i64, - transaction.height.map(|h| h as i64), - block_hash_bytes.as_deref(), - transaction.net_amount, - fee, - transaction.label.as_deref(), - transaction.is_ours, - tx_bytes, - transaction.status as u8, - ])?; - } - } - - tx.commit() - } - /// Retrieve all wallets for a specific network, including their addresses, balances, and known addresses. /// /// Stops on the first corrupted identity blob and returns an error for @@ -483,11 +159,11 @@ impl Database { /// funds. pub fn get_wallets(&self, network: &Network) -> rusqlite::Result<Vec<Wallet>> { let network_str = network.to_string(); - let conn = self.conn.lock().unwrap(); + let conn = self.locked_conn(); tracing::trace!("step 1: retrieve all wallets for the given network"); let mut stmt = conn.prepare( - "SELECT seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, confirmed_balance, unconfirmed_balance, total_balance, core_wallet_name FROM wallet WHERE network = ?", + "SELECT seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, core_wallet_name FROM wallet WHERE network = ?", )?; let mut wallets_map: BTreeMap<[u8; 32], Wallet> = BTreeMap::new(); @@ -502,10 +178,7 @@ impl Database { let is_main: bool = row.get(6)?; let uses_password: bool = row.get(7)?; let password_hint: Option<String> = row.get(8)?; - let confirmed_balance: i64 = row.get::<_, Option<i64>>(9)?.unwrap_or(0); - let unconfirmed_balance: i64 = row.get::<_, Option<i64>>(10)?.unwrap_or(0); - let total_balance: i64 = row.get::<_, Option<i64>>(11)?.unwrap_or(0); - let core_wallet_name: Option<String> = row.get(12)?; + let core_wallet_name: Option<String> = row.get(9)?; // Reconstruct the extended public keys let master_ecdsa_extended_public_key = @@ -539,16 +212,19 @@ impl Database { let wallet_seed = if uses_password { WalletSeed::Closed(closed_wallet_seed) } else { + // Unprotected wallets load as Open (verified) carrying no + // plaintext seed; validate the verbatim 64-byte envelope so a + // corrupt blob surfaces here rather than at first sign. + if encrypted_seed.len() != 64 { + return Err(rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Blob, + Box::new(CorruptedBlobError( + "Seed should be 64 bytes for open wallet".to_string(), + )), + )); + } WalletSeed::Open(OpenWalletSeed { - seed: encrypted_seed.try_into().map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Blob, - Box::new(CorruptedBlobError( - "Seed should be 64 bytes for open wallet".to_string(), - )), - ) - })?, wallet_info: closed_wallet_seed, }) }; @@ -567,20 +243,14 @@ impl Database { wallet_seed, uses_password, master_bip44_ecdsa_extended_public_key: master_ecdsa_extended_public_key, - address_balances: BTreeMap::new(), - address_total_received: BTreeMap::new(), + // Not persisted: re-derived just-in-time from the seed on + // first unlock (see `Wallet::ensure_platform_payment_account_xpub`). + platform_payment_account_xpub: None, known_addresses: BTreeMap::new(), watched_addresses: BTreeMap::new(), - unused_asset_locks: vec![], alias, identities: HashMap::new(), - utxos: HashMap::new(), - transactions: Vec::new(), is_main, - confirmed_balance: confirmed_balance as u64, - unconfirmed_balance: unconfirmed_balance as u64, - total_balance: total_balance as u64, - spv_balance_known: false, platform_address_info: BTreeMap::new(), core_wallet_name, }, @@ -677,44 +347,41 @@ impl Database { })?; tracing::trace!("step 3: add addresses, balances, and known addresses to wallets"); - for row in address_rows { - if row.is_err() { - continue; - } + for (index, row) in address_rows.enumerate() { + // A single corrupt address row must not abort loading the whole + // wallet, but skipping silently could hide a missing address and + // lead to lost funds — so log loudly and continue, matching the + // wallet_backend hydration skip-logging discipline. let ( seed_array, address, derivation_path, - balance, + _balance, path_reference, path_type, - total_received, - ) = row?; + _total_received, + ) = match row { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!( + address_row_index = index, + error = ?e, + "Skipping corrupt wallet address row while loading wallets from the database" + ); + continue; + } + }; if let Some(wallet) = wallets_map.get_mut(&seed_array) { // Canonicalize Platform addresses to avoid duplicate representations let canonical_address = Wallet::canonical_address(&address, *network); - // Update the address balance if available. - if let Some(balance) = balance { - wallet - .address_balances - .insert(canonical_address.clone(), balance); - } - // Update total received if available. - if let Some(total_received) = total_received { - wallet - .address_total_received - .insert(canonical_address.clone(), total_received); - } - // Add the address to the `known_addresses` map. wallet .known_addresses .insert(canonical_address.clone(), derivation_path.clone()); tracing::trace!( address = ?canonical_address, - network = address.network().to_string(), - expected_network = network.to_string(), + network = network.to_string(), "loaded address from database"); // Add the address to the `watched_addresses` map with AddressInfo. @@ -729,212 +396,9 @@ impl Database { } } - tracing::trace!("step 4: retrieve UTXOs for each wallet and add them to the wallets"); - let mut utxo_stmt = conn.prepare( - "SELECT txid, vout, address, value, script_pubkey FROM utxos WHERE network = ?", - )?; - - let utxo_rows = utxo_stmt.query_map([network_str.clone()], |row| { - let txid: Vec<u8> = row.get(0)?; - let vout: i64 = row.get(1)?; - let address: String = row.get(2)?; - let value: i64 = row.get(3)?; - let script_pubkey: Vec<u8> = row.get(4)?; - - let address = Address::from_str(&address) - .map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Invalid UTXO address format '{}': {}", - address, e - )) - })? - .assume_checked(); - - let outpoint = OutPoint { - txid: Txid::from_slice(&txid).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid UTXO txid: {}", e)) - })?, - vout: vout as u32, - }; - let tx_out = TxOut { - value: value as u64, - script_pubkey: ScriptBuf::from_bytes(script_pubkey), - }; - Ok((address, outpoint, tx_out)) - })?; - - tracing::trace!("step 5: add the UTXOs to the corresponding wallets."); - for row in utxo_rows { - let (address, outpoint, tx_out) = row?; - - for wallet in wallets_map.values_mut() { - if wallet.known_addresses.contains_key(&address) { - wallet - .utxos - .entry(address.clone()) - .or_insert_with(HashMap::new) - .insert(outpoint, tx_out.clone()); - } - } - } - tracing::trace!("step 6: load asset lock transactions for each wallet"); - let mut asset_lock_stmt = conn.prepare( - "SELECT wallet, amount, transaction_data, instant_lock_data, chain_locked_height FROM asset_lock_transaction where identity_id IS NULL AND network = ?", - )?; - - let asset_lock_rows = asset_lock_stmt.query_map([network.to_string()], |row| { - let wallet_seed: Vec<u8> = row.get(0)?; - let amount: Duffs = row.get(1)?; - let tx_data: Vec<u8> = row.get(2)?; - let islock_data: Option<Vec<u8>> = row.get(3)?; - let chain_locked_height: Option<CoreBlockHeight> = row.get(4)?; - - let wallet_seed_hash_array: [u8; 32] = wallet_seed.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Wallet seed should be 32 bytes".to_string()) - })?; - let tx: Transaction = deserialize(&tx_data).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize asset lock transaction: {}", - e - )) - })?; - - // Ensure the transaction payload is AssetLockPayloadType - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - else { - return Err(rusqlite::Error::InvalidParameterName( - "Expected AssetLockPayloadType in special_transaction_payload".to_string(), - )); - }; - - // Get the first credit output - let first = - payload - .credit_outputs - .first() - .ok_or(rusqlite::Error::InvalidParameterName( - "Expected at least one credit output in asset lock".to_string(), - ))?; - - let address = Address::from_script(&first.script_pubkey, *network).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to derive address from credit output: {}", - e - )) - })?; - - let (islock, proof) = if let Some(islock_bytes) = islock_data { - // Deserialize the InstantLock - let is_lock: InstantLock = deserialize(&islock_bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize InstantLock: {}", - e - )) - })?; - ( - Some(is_lock.clone()), - Some(AssetLockProof::Instant(InstantAssetLockProof::new( - is_lock, - tx.clone(), - 0, - ))), - ) - } else if let Some(chain_locked_height) = chain_locked_height { - ( - None, - Some(AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: chain_locked_height, - out_point: OutPoint::new(tx.txid(), 0), - })), - ) - } else { - (None, None) - }; - - Ok((wallet_seed_hash_array, tx, address, amount, islock, proof)) - })?; - - tracing::trace!("step 7: add the asset lock transactions to the wallet"); - for row in asset_lock_rows { - let (wallet_seed, tx, address, amount, islock, proof) = row?; - - if let Some(wallet) = wallets_map.get_mut(&wallet_seed) { - wallet - .unused_asset_locks - .push((tx, address, amount, islock, proof)); - } - } - - tracing::trace!("step 7: load wallet transactions for each wallet"); - let mut tx_stmt = conn.prepare( - "SELECT seed_hash, txid, timestamp, height, block_hash, net_amount, fee, label, is_ours, raw_transaction, status - FROM wallet_transactions WHERE network = ? ORDER BY timestamp DESC", - )?; - - let tx_rows = tx_stmt.query_map([network_str.clone()], |row| { - let seed_hash: Vec<u8> = row.get(0)?; - let txid_bytes: Vec<u8> = row.get(1)?; - let timestamp: i64 = row.get(2)?; - let height: Option<i64> = row.get(3)?; - let block_hash_bytes: Option<Vec<u8>> = row.get(4)?; - let net_amount: i64 = row.get(5)?; - let fee: Option<i64> = row.get(6)?; - let label: Option<String> = row.get(7)?; - let is_ours: bool = row.get(8)?; - let raw_transaction: Vec<u8> = row.get(9)?; - let status_u8: u8 = row.get(10)?; - - let seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Seed hash should be 32 bytes".to_string()) - })?; - let txid = Txid::from_slice(&txid_bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid transaction txid: {}", e)) - })?; - let transaction: Transaction = deserialize(&raw_transaction).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "Failed to deserialize transaction: {}", - e - )) - })?; - let block_hash = block_hash_bytes - .as_ref() - .map(|bytes| { - BlockHash::from_slice(bytes).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!("Invalid block hash: {}", e)) - }) - }) - .transpose()?; - let fee = fee.map(|f| f as u64); - let height = height.map(|h| h as u32); - - Ok(( - seed_hash_array, - WalletTransaction { - txid, - transaction, - timestamp: timestamp as u64, - height, - block_hash, - net_amount, - fee, - label, - is_ours, - status: TransactionStatus::from_u8(status_u8), - }, - )) - })?; - - for row in tx_rows { - let (seed_hash, transaction) = row?; - if let Some(wallet) = wallets_map.get_mut(&seed_hash) { - wallet.transactions.push(transaction); - } - } - tracing::trace!( network = network_str, - "step 8: retrieve identities for wallets" + "step 4: retrieve identities for wallets" ); let mut identity_stmt = conn.prepare( "SELECT data, wallet, wallet_index FROM identity WHERE network = ? AND wallet IS NOT NULL AND wallet_index IS NOT NULL", @@ -990,192 +454,21 @@ impl Database { } } - tracing::trace!( - network = network_str, - "step 9: retrieve platform address info for wallets" - ); - // Load platform address info for each wallet (using existing connection to avoid deadlock) - let mut platform_stmt = conn.prepare( - "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", - )?; - let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { - let seed_hash: Vec<u8> = row.get(0)?; - let address_str: String = row.get(1)?; - let balance: i64 = row.get(2)?; - let nonce: i64 = row.get(3)?; - let seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { - rusqlite::Error::InvalidParameterName("Seed hash should be 32 bytes".to_string()) - })?; - Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) - })?; - - for row in platform_rows { - if let Ok((seed_hash, address_str, balance, nonce)) = row - && let Some(wallet) = wallets_map.get_mut(&seed_hash) - && let Ok(address) = Address::<NetworkUnchecked>::from_str(&address_str) - { - let address_checked = address.require_network(*network).map_err(|e| { - tracing::error!(address = %address_str, error = ?e, "Failed to validate Platform address for network"); - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Text, - Box::new(std::fmt::Error), - ) - })?; - let canonical_address = Wallet::canonical_address(&address_checked, *network); + // Platform per-address balances + sync cursor are owned by the upstream + // coordinator; DET holds no at-rest copy and warm-starts from the + // coordinator's first push. - wallet.platform_address_info.insert( - canonical_address, - crate::model::wallet::PlatformAddressInfo { balance, nonce }, - ); - } - } - - // Convert the BTreeMap into a Vec of Wallets. Ok(wallets_map.into_values().collect()) } - /// Store or update Platform address balance and nonce. - pub fn set_platform_address_info( - &self, - seed_hash: &[u8; 32], - address: &Address, - balance: u64, - nonce: u32, - network: &Network, - ) -> rusqlite::Result<()> { - let network_str = network.to_string(); - let canonical_address = Wallet::canonical_address(address, *network); - let address_str = canonical_address.to_string(); - let updated_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - self.execute( - "INSERT INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(seed_hash, address, network) DO UPDATE SET - balance = excluded.balance, - nonce = excluded.nonce, - updated_at = excluded.updated_at", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at - ], - )?; - Ok(()) - } - - /// Get Platform address balance and nonce for a specific address - pub fn get_platform_address_info( - &self, - seed_hash: &[u8; 32], - address: &Address, - network: &Network, - ) -> rusqlite::Result<Option<(u64, u32)>> { - let conn = self.conn.lock().unwrap(); - let network_str = network.to_string(); - let canonical_address = Wallet::canonical_address(address, *network); - let address_str = canonical_address.to_string(); - - let mut stmt = conn.prepare( - "SELECT balance, nonce FROM platform_address_balances - WHERE seed_hash = ? AND address = ? AND network = ?", - )?; - - let result = stmt.query_row(params![seed_hash, address_str, network_str], |row| { - let balance: i64 = row.get(0)?; - let nonce: i64 = row.get(1)?; - Ok((balance as u64, nonce as u32)) - }); - - match result { - Ok(info) => Ok(Some(info)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - /// Get all Platform address balances for a wallet - pub fn get_all_platform_address_info( - &self, - seed_hash: &[u8; 32], - network: &Network, - ) -> rusqlite::Result<Vec<(Address, u64, u32)>> { - let conn = self.conn.lock().unwrap(); - let network_str = network.to_string(); - - let mut stmt = conn.prepare( - "SELECT address, balance, nonce FROM platform_address_balances - WHERE seed_hash = ? AND network = ?", - )?; - - let rows = stmt.query_map(params![seed_hash, network_str], |row| { - let address_str: String = row.get(0)?; - let balance: i64 = row.get(1)?; - let nonce: i64 = row.get(2)?; - Ok((address_str, balance as u64, nonce as u32)) - })?; - - let mut results = Vec::new(); - for row in rows { - let (address_str, balance, nonce) = row?; - if let Ok(address) = Address::<NetworkUnchecked>::from_str(&address_str) { - let address_checked = address.require_network(*network).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - let canonical_address = Wallet::canonical_address(&address_checked, *network); - results.push((canonical_address, balance, nonce)); - } - } - - Ok(results) - } - - /// Delete Platform address balances for a wallet (used when removing wallet) - pub fn delete_platform_address_info( - &self, - seed_hash: &[u8; 32], - network: &Network, - ) -> rusqlite::Result<()> { - let network_str = network.to_string(); - self.execute( - "DELETE FROM platform_address_balances WHERE seed_hash = ? AND network = ?", - params![seed_hash, network_str], - )?; - Ok(()) - } - - /// Clear ALL Platform address balances for a network (developer tool) - pub fn clear_all_platform_address_info(&self, network: &Network) -> rusqlite::Result<usize> { - let network_str = network.to_string(); - self.execute( - "DELETE FROM platform_address_balances WHERE network = ?", - params![network_str], - ) - } - - /// Clear ALL Platform addresses entirely for a network (developer tool) - /// This removes both the addresses from wallet_addresses and their balances from platform_address_balances + /// Clear all Platform receive addresses for a network (developer tool). + /// + /// Operates on the `wallet_addresses` table only. The per-wallet + /// k/v slots holding balance + nonce and the sync cursor are cleared + /// by the caller (see the network-chooser dev-tool button). pub fn clear_all_platform_addresses(&self, network: &Network) -> rusqlite::Result<usize> { let network_str = network.to_string(); - let conn = self.conn.lock().unwrap(); - - // Delete from platform_address_balances - conn.execute( - "DELETE FROM platform_address_balances WHERE network = ?", - params![network_str], - )?; + let conn = self.locked_conn(); // Delete platform addresses from wallet_addresses (path_reference = 16 is PlatformPayment) // We need to join with wallet table to filter by network @@ -1188,84 +481,29 @@ impl Database { Ok(deleted) } - - /// Get the last platform sync timestamp and sync height for a wallet. - /// Returns (last_sync_timestamp, last_sync_height) or (0, 0) if not set. - pub fn get_platform_sync_info(&self, seed_hash: &[u8; 32]) -> rusqlite::Result<(u64, u64)> { - let conn = self.conn.lock().unwrap(); - conn.query_row( - "SELECT last_platform_full_sync, last_platform_sync_checkpoint FROM wallet WHERE seed_hash = ?", - params![seed_hash], - |row| { - let last_sync: i64 = row.get(0)?; - let sync_height: i64 = row.get(1)?; - Ok((last_sync as u64, sync_height as u64)) - }, - ) - } - - /// Set the platform sync timestamp and sync height for a wallet. - /// - /// Note: The `sync_height` value (SDK's `new_sync_height`) is stored in the - /// `last_platform_sync_checkpoint` SQL column. The column was not renamed to - /// avoid an extra DB migration, but it now represents a block height rather - /// than the old checkpoint concept. - pub fn set_platform_sync_info( - &self, - seed_hash: &[u8; 32], - last_sync_timestamp: u64, - sync_height: u64, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ? WHERE seed_hash = ?", - params![last_sync_timestamp as i64, sync_height as i64, seed_hash], - )?; - Ok(()) - } } -/// Ensure the address is valid for the given network and -/// update its network if necessary. +/// Ensure the address is valid for the given network. +/// +/// Consumes the address and returns it as `Address<NetworkChecked>`. /// -/// Consumes the address and returns a new Address with the correct network. +/// Note: `Address` no longer stores a full `Network` value — only an +/// `AddressPrefix` (Mainnet / Testnet / RegtestBech32). The old +/// post-`require_network` fixup that re-created the address with +/// `Address::new(Devnet, …)` was a workaround for the stored-Network +/// design: testnet and devnet share the same prefix, so +/// `require_network(Devnet)` for a testnet-prefixed address already +/// returns the correct representation. No fixup is needed. fn check_address_for_network( address_unchecked: Address<NetworkUnchecked>, network: &Network, ) -> Result<Address<NetworkChecked>, WalletError> { - let address_checked = address_unchecked + address_unchecked .require_network(*network) .inspect_err(|e| { tracing::error!("address is not valid for the network: {}", e); - })?; - - // For devnet/regtest addresses, require_network() accepts testnet addresses; we need to overwrite it here in case there is - // a mismatch to match the network we are using. - // - // See also logic in [`Address::is_valid_for_network()`]. - match address_checked.network() { - // When the address is correct, do nothing - address_network if network == address_network => Ok(address_checked), - // For devnet/regtest addresses, address type can default to testnet, require_network() accepts this; - // we need to overwrite it with correct network. - Network::Testnet if network == &Network::Devnet || network == &Network::Regtest => { - Ok(Address::new(*network, address_checked.payload().clone())) - } - // other cases, like mainnet or testnet, return an error on mismatch - address_network => { - tracing::error!(address = ?address_checked, - network = address_network.to_string(), - required_network = network.to_string(), - "address has invalid network set"); - - Err(WalletError::AddressError( - dashcore::address::Error::NetworkValidation { - required: *network, - found: *address_checked.network(), - address: address_checked.as_unchecked().clone(), - }, - )) - } - } + }) + .map_err(WalletError::AddressError) } #[derive(thiserror::Error, Debug)] @@ -1292,6 +530,31 @@ pub enum WalletError { #[source] source: dash_sdk::dpp::dashcore::sighash::Error, }, + + /// A freshly derived public key could not be parsed. + #[error( + "Could not read a wallet key. The wallet may be corrupted — try re-importing your recovery phrase." + )] + PublicKeyParse(#[source] Box<dash_sdk::dpp::dashcore::key::Error>), + + /// The derivation path for a wallet account type could not be built. + #[error( + "Could not derive a wallet key. The wallet may be corrupted — try re-importing your recovery phrase." + )] + AccountDerivationPath(#[source] Box<dash_sdk::dpp::key_wallet::Error>), + + /// A derived address could not be converted for platform use. + #[error("Could not prepare a wallet address for platform use. Please retry.")] + PlatformAddressConversion(#[source] Box<dash_sdk::dpp::ProtocolError>), + + /// A derived address did not validate for the wallet's network. + #[error( + "The wallet address {address} did not match the {network} network. Switch to the correct network and try again." + )] + AddressNetworkMismatch { + address: dash_sdk::dpp::dashcore::Address, + network: Network, + }, } impl From<WalletError> for rusqlite::Error { @@ -1307,13 +570,6 @@ mod tests { use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPrivKey}; - use std::str::FromStr; - - fn create_test_address(network: Network) -> Address { - let pubkey_bytes = [0x02; 33]; - let pubkey = dash_sdk::dpp::dashcore::PublicKey::from_slice(&pubkey_bytes).unwrap(); - Address::p2pkh(&pubkey, network) - } fn create_test_seed_hash() -> [u8; 32] { let mut hash = [0u8; 32]; @@ -1437,451 +693,4 @@ mod tests { _ => panic!("unexpected error variant: {}", err), } } - - #[test] - fn test_wallet_balance_update() { - let db = create_test_database().expect("Failed to create test database"); - let seed_hash = create_test_seed_hash(); - - // We need to insert a wallet first (simplified - using raw SQL for test setup) - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], // Dummy encrypted seed - vec![0u8; 16], // Dummy salt - vec![0u8; 12], // Dummy nonce - vec![0u8; 78], // Dummy extended public key - ], - ) - .expect("Failed to insert test wallet"); - } - - // Update balances - db.update_wallet_balances(&seed_hash, 1_000_000, 500_000, 1_500_000) - .expect("Failed to update wallet balances"); - - // Verify via raw query (since get_wallets is complex) - let conn = db.conn.lock().unwrap(); - let (confirmed, unconfirmed, total): (i64, i64, i64) = conn - .query_row( - "SELECT confirmed_balance, unconfirmed_balance, total_balance FROM wallet WHERE seed_hash = ?", - rusqlite::params![seed_hash.as_slice()], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("Failed to query balances"); - - assert_eq!(confirmed, 1_000_000); - assert_eq!(unconfirmed, 500_000); - assert_eq!(total, 1_500_000); - } - - #[test] - fn test_platform_address_info() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - let address = create_test_address(network); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Initially no platform address info - let info = db - .get_platform_address_info(&seed_hash, &address, &network) - .expect("Failed to get platform address info"); - assert!(info.is_none()); - - // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) - .expect("Failed to set platform address info"); - - // Retrieve it - let info = db - .get_platform_address_info(&seed_hash, &address, &network) - .expect("Failed to get platform address info") - .expect("Expected platform address info"); - - assert_eq!(info.0, 10_000_000); // balance - assert_eq!(info.1, 5); // nonce - - // Update it - db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network) - .expect("Failed to update platform address info"); - - let info = db - .get_platform_address_info(&seed_hash, &address, &network) - .expect("Failed to get platform address info") - .expect("Expected platform address info"); - - assert_eq!(info.0, 20_000_000); - assert_eq!(info.1, 10); - } - - #[test] - fn test_get_all_platform_address_info() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Add multiple platform addresses using the same valid pubkey base but with different addresses - // by modifying the address string directly in the database - let base_address = create_test_address(network); - for i in 0..3u8 { - // Insert directly with modified address string to avoid secp256k1 key generation issues - let addr_str = format!("{}_{}", base_address, i); - let conn = db.conn.lock().unwrap(); - let updated_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - conn.execute( - "INSERT OR REPLACE INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at) - VALUES (?, ?, ?, ?, ?, ?)", - rusqlite::params![ - seed_hash.as_slice(), - addr_str, - (i as i64 + 1) * 1_000_000, - i as i64, - network.to_string(), - updated_at - ], - ) - .expect("Failed to insert platform address info"); - } - - // Get all addresses (note: the addresses won't parse correctly, but the function should still return 0 valid entries) - // This tests that the function handles the case gracefully - let all_info = db - .get_all_platform_address_info(&seed_hash, &network) - .expect("Failed to get all platform address info"); - - // The modified addresses won't parse, so we expect 0 results - // This is actually testing the error handling path - assert_eq!(all_info.len(), 0); - } - - #[test] - fn test_get_all_platform_address_info_valid() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Add a single valid platform address using the helper function - let address = create_test_address(network); - db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network) - .expect("Failed to set platform address info"); - - // Get all addresses - let all_info = db - .get_all_platform_address_info(&seed_hash, &network) - .expect("Failed to get all platform address info"); - - assert_eq!(all_info.len(), 1); - assert_eq!(all_info[0].1, 5_000_000); // balance - assert_eq!(all_info[0].2, 3); // nonce - } - - #[test] - fn test_delete_platform_address_info() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - let address = create_test_address(network); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) - .expect("Failed to set platform address info"); - - // Verify it exists - let info = db - .get_platform_address_info(&seed_hash, &address, &network) - .expect("Failed to get platform address info"); - assert!(info.is_some()); - - // Delete all platform address info for the wallet - db.delete_platform_address_info(&seed_hash, &network) - .expect("Failed to delete platform address info"); - - // Should be gone - let info = db - .get_platform_address_info(&seed_hash, &address, &network) - .expect("Failed to get platform address info"); - assert!(info.is_none()); - } - - #[test] - fn test_platform_sync_info() { - let db = create_test_database().expect("Failed to create test database"); - let seed_hash = create_test_seed_hash(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Initial sync info should be zeros - let (last_sync, sync_height) = db - .get_platform_sync_info(&seed_hash) - .expect("Failed to get platform sync info"); - assert_eq!(last_sync, 0); - assert_eq!(sync_height, 0); - - // Set sync info - let timestamp = 1700000000u64; - let height = 100000u64; - db.set_platform_sync_info(&seed_hash, timestamp, height) - .expect("Failed to set platform sync info"); - - let (last_sync, sync_height) = db - .get_platform_sync_info(&seed_hash) - .expect("Failed to get platform sync info"); - assert_eq!(last_sync, timestamp); - assert_eq!(sync_height, height); - } - - #[test] - fn test_set_wallet_alias() { - let db = create_test_database().expect("Failed to create test database"); - let seed_hash = create_test_seed_hash(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Set alias - db.set_wallet_alias(&seed_hash, Some("My Wallet".to_string())) - .expect("Failed to set wallet alias"); - - // Verify - let conn = db.conn.lock().unwrap(); - let alias: Option<String> = conn - .query_row( - "SELECT alias FROM wallet WHERE seed_hash = ?", - rusqlite::params![seed_hash.as_slice()], - |row| row.get(0), - ) - .expect("Failed to query alias"); - assert_eq!(alias, Some("My Wallet".to_string())); - - drop(conn); - - // Clear alias - db.set_wallet_alias(&seed_hash, None) - .expect("Failed to clear wallet alias"); - - let conn = db.conn.lock().unwrap(); - let alias: Option<String> = conn - .query_row( - "SELECT alias FROM wallet WHERE seed_hash = ?", - rusqlite::params![seed_hash.as_slice()], - |row| row.get(0), - ) - .expect("Failed to query alias"); - assert!(alias.is_none()); - } - - #[test] - fn test_address_balance_operations() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - let address = create_test_address(network); - let derivation_path = DerivationPath::from_str("m/44'/1'/0'/0/0").unwrap(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Add address - db.add_address_if_not_exists( - &seed_hash, - &address, - &network, - &derivation_path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - Some(1_000_000), - ) - .expect("Failed to add address"); - - // Update address balance - db.update_address_balance(&seed_hash, &address, 2_000_000) - .expect("Failed to update address balance"); - - // Add to address balance - db.add_to_address_balance(&seed_hash, &address, 500_000) - .expect("Failed to add to address balance"); - - // Verify final balance - let conn = db.conn.lock().unwrap(); - let balance: i64 = conn - .query_row( - "SELECT balance FROM wallet_addresses WHERE seed_hash = ? AND address = ?", - rusqlite::params![seed_hash.as_slice(), address.to_string()], - |row| row.get(0), - ) - .expect("Failed to query balance"); - assert_eq!(balance, 2_500_000); - } - - #[test] - fn test_update_address_total_received() { - let db = create_test_database().expect("Failed to create test database"); - let network = Network::Testnet; - let seed_hash = create_test_seed_hash(); - let address = create_test_address(network); - let derivation_path = DerivationPath::from_str("m/44'/1'/0'/0/0").unwrap(); - - // Insert test wallet first - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) - VALUES (?, ?, ?, ?, ?, 0, 'testnet')", - rusqlite::params![ - seed_hash.as_slice(), - vec![0u8; 64], - vec![0u8; 16], - vec![0u8; 12], - vec![0u8; 78], - ], - ) - .expect("Failed to insert test wallet"); - } - - // Add address - db.add_address_if_not_exists( - &seed_hash, - &address, - &network, - &derivation_path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - None, - ) - .expect("Failed to add address"); - - // Update total received - db.update_address_total_received(&seed_hash, &address, 10_000_000) - .expect("Failed to update total received"); - - // Verify - let conn = db.conn.lock().unwrap(); - let total_received: i64 = conn - .query_row( - "SELECT total_received FROM wallet_addresses WHERE seed_hash = ? AND address = ?", - rusqlite::params![seed_hash.as_slice(), address.to_string()], - |row| row.get(0), - ) - .expect("Failed to query total_received"); - assert_eq!(total_received, 10_000_000); - } } diff --git a/src/lib.rs b/src/lib.rs index 9ba4b4ec2..597cdbc3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,11 @@ pub mod app; pub mod app_dir; pub mod backend_task; +pub mod boot; pub mod bundled; -pub mod components; pub mod config; pub mod context; pub mod context_provider; -pub mod context_provider_spv; pub mod cpu_compatibility; pub mod database; pub mod logging; @@ -15,8 +14,8 @@ pub mod mcp; pub mod model; pub mod platform; pub mod sdk_wrapper; -pub mod spv; pub mod ui; pub mod utils; +pub mod wallet_backend; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/logging.rs b/src/logging.rs index 43cf7387a..9b0376662 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -3,12 +3,40 @@ use chrono::{Duration, Local}; use std::backtrace::Backtrace; use std::fs; use std::panic; +use std::path::{Path, PathBuf}; use std::sync::Once; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use tracing::{error, info}; use tracing_subscriber::EnvFilter; static INIT_LOGGER: Once = Once::new(); +/// Duplicate of the real terminal stderr (fd 2), saved before it is redirected +/// to the crash sidecar. Lets [`report_startup_failure_to_terminal`] surface a +/// user-facing notice on the terminal even while fd 2 points at the log file. +/// `-1` means no handle was preserved (capture skipped or `dup` failed). +static ORIGINAL_STDERR_FD: AtomicI32 = AtomicI32::new(-1); + +/// Whether the tracing subscriber writes to the on-disk log file (`det.log`). +/// +/// `false` means the file could not be created and the logger fell back to +/// stderr. In that case stderr must stay attached to the terminal so the +/// fallback logs remain visible, and [`capture_stderr_to_file`] must not +/// redirect it. +static LOGGER_USES_FILE: AtomicBool = AtomicBool::new(false); + +/// Number of days a rotated log file is kept before cleanup removes it. +const LOG_RETENTION_DAYS: i64 = 7; + +/// Whether stderr may be redirected to the crash sidecar file. +/// +/// Returns `false` when the logger fell back to stderr, so redirecting would +/// hide the only visible logs. This guards the no-op-on-fallback contract of +/// [`capture_stderr_to_file`]. +fn should_capture_stderr() -> bool { + LOGGER_USES_FILE.load(Ordering::SeqCst) +} + pub fn initialize_logger() { INIT_LOGGER.call_once(|| { initialize_logger_internal(); @@ -36,6 +64,9 @@ fn initialize_logger_internal() { .with_ansi(false) .finish(); let set = tracing::subscriber::set_global_default(subscriber).is_ok(); + if set { + LOGGER_USES_FILE.store(true, Ordering::SeqCst); + } (set, Some(app_user_data_file_path("det.log").ok())) } Err(e) => { @@ -102,25 +133,272 @@ fn initialize_logger_internal() { } } +/// Redirects the process's stderr (fd 2) to a persistent sidecar file so +/// abnormal terminations leave evidence on disk. +/// +/// The tracing panic hook already records catchable Rust panics to `det.log`, +/// but native crashes — `SIGSEGV`/`SIGABRT` from FFI, `abort()` (including a +/// `panic = abort` double-panic), allocation-failure aborts ("memory allocation +/// of N bytes failed") and OOM — write to stderr or nowhere. In a GUI launch +/// there is no terminal, so that output is lost. Pointing stderr at +/// `det-stderr.log` captures all of it. +/// +/// Call once, early in GUI startup, after [`initialize_logger`] and before the +/// eframe/tokio runtime starts. CLI/stdio entry points should not call this: +/// their stderr is used for interactive diagnostics or carries protocol output. +/// +/// No-op (with a warning) when the logger fell back to stderr — redirecting +/// then would swallow the fallback logs. On non-Unix targets this is currently +/// a best-effort no-op; see the inline note. +pub fn capture_stderr_to_file() { + if !should_capture_stderr() { + tracing::warn!( + "Logging is using the stderr fallback; skipping crash-stderr capture to keep logs visible" + ); + return; + } + + let path = match app_user_data_file_path("det-stderr.log") { + Ok(path) => path, + Err(e) => { + tracing::warn!(error = %e, "Could not resolve crash-stderr log path; capture disabled"); + return; + } + }; + + if let Some(parent) = path.parent() { + rotate_log_in_dir(parent, "det-stderr"); + } + + redirect_stderr_to(&path); +} + +#[cfg(unix)] +fn redirect_stderr_to(path: &Path) { + use std::os::fd::AsRawFd; + + let file = match fs::File::create(path) { + Ok(file) => file, + Err(e) => { + tracing::warn!(error = %e, "Could not open crash-stderr log file; capture disabled"); + return; + } + }; + + // fd 2 is about to point at the sidecar; keep a handle to the real terminal + // so a startup-failure notice can still reach the user (see ORIGINAL_STDERR_FD). + // SAFETY: `dup` only reads the fd table; STDERR_FILENO (2) is valid here. + let saved_fd = unsafe { nix::libc::dup(nix::libc::STDERR_FILENO) }; + if saved_fd >= 0 { + ORIGINAL_STDERR_FD.store(saved_fd, Ordering::SeqCst); + } + + // SAFETY: `dup2` only manipulates the file-descriptor table. `file`'s fd is + // valid for the duration of the call, and `STDERR_FILENO` (2) is the + // standard error descriptor. On success fd 2 is rebound to the sidecar + // file; on failure (-1) stderr is left unchanged and we report the error. + let dup_ok = unsafe { nix::libc::dup2(file.as_raw_fd(), nix::libc::STDERR_FILENO) != -1 }; + + if dup_ok { + // Keep the file alive so fd 2 stays backed by it for the whole run. + std::mem::forget(file); + info!(stderr_log = ?path, "Crash stderr capture enabled"); + } else { + let err = std::io::Error::last_os_error(); + tracing::warn!(error = %err, "Could not redirect stderr to crash-stderr log file"); + } +} + +#[cfg(not(unix))] +fn redirect_stderr_to(_path: &Path) { + // TODO: Capture stderr on Windows (e.g. SetStdHandle on a CreateFileW + // handle, or freopen). Until then native crashes that write to stderr are + // not captured on this target. + tracing::warn!( + "Crash stderr capture is only implemented on Unix; native crash output may be lost on this platform" + ); +} + +/// Installs a handler for the synchronous fatal signals (`SIGSEGV`, `SIGABRT`, +/// `SIGBUS`, `SIGILL`, `SIGFPE`) that writes a one-line marker to stderr before +/// the process dies, then lets the default handler take over. +/// +/// A bare native fault from FFI (grovedb, secp, prover, zmq…) terminates the +/// process with nothing on stderr, so [`capture_stderr_to_file`] alone cannot +/// see it. This handler closes that gap: it records *which* signal fired so the +/// sidecar log shows a crash happened even when there is no other output. +/// +/// The handler is async-signal-safe — it only calls `write(2)` on a fixed +/// byte string, no allocation, no locks, no `tracing`. It is registered with +/// `SA_RESETHAND`, so after it returns the default disposition is restored and +/// the faulting instruction (or `abort()`) re-raises the signal to produce the +/// normal crash / core dump. +/// +/// Call once, after [`capture_stderr_to_file`], so the marker lands in the +/// sidecar file rather than a lost terminal. Best-effort no-op on non-Unix. +pub fn install_fatal_signal_handler() { + install_fatal_signal_handler_impl(); +} + +/// Maps a fatal signal number to a fixed `'static` marker line written to +/// stderr from the signal handler. Pure match over constants — no allocation, +/// no formatting — so it stays async-signal-safe when called from the handler. +#[cfg(unix)] +fn marker_for_signal(sig: nix::libc::c_int) -> &'static [u8] { + use nix::sys::signal::Signal; + match Signal::try_from(sig) { + Ok(Signal::SIGSEGV) => b"\nFATAL: SIGSEGV (segmentation fault)\n", + Ok(Signal::SIGABRT) => b"\nFATAL: SIGABRT (abort)\n", + Ok(Signal::SIGBUS) => b"\nFATAL: SIGBUS (bus error)\n", + Ok(Signal::SIGILL) => b"\nFATAL: SIGILL (illegal instruction)\n", + Ok(Signal::SIGFPE) => b"\nFATAL: SIGFPE (arithmetic error)\n", + _ => b"\nFATAL: unexpected signal\n", + } +} + +#[cfg(unix)] +fn install_fatal_signal_handler_impl() { + use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; + + extern "C" fn handle_fatal(sig: nix::libc::c_int) { + let marker = marker_for_signal(sig); + // SAFETY: `write` is async-signal-safe. `marker` is a 'static byte + // string, valid for the call. The result is intentionally ignored — + // there is nothing safe to do on failure inside a signal handler. + unsafe { + nix::libc::write( + nix::libc::STDERR_FILENO, + marker.as_ptr() as *const nix::libc::c_void, + marker.len(), + ); + } + // SA_RESETHAND restored the default handler; returning re-raises the + // fault (or lets abort() proceed) for the normal crash / core dump. + } + + // SA_RESETHAND: one-shot, then default disposition re-raises the crash. + // SA_NODEFER: don't block the signal during the handler, so a fault while + // the default handler is being restored still terminates cleanly. + let action = SigAction::new( + SigHandler::Handler(handle_fatal), + SaFlags::SA_RESETHAND | SaFlags::SA_NODEFER, + SigSet::empty(), + ); + + for signal in [ + Signal::SIGSEGV, + Signal::SIGABRT, + Signal::SIGBUS, + Signal::SIGILL, + Signal::SIGFPE, + ] { + // SAFETY: the handler is async-signal-safe (single `write`), so it is + // sound to install for these synchronous fatal signals. + if let Err(e) = unsafe { sigaction(signal, &action) } { + tracing::warn!(error = %e, ?signal, "Could not install fatal-signal handler"); + } + } +} + +#[cfg(not(unix))] +fn install_fatal_signal_handler_impl() { + // TODO: Install a fatal-exception filter on Windows + // (AddVectoredExceptionHandler / SetUnhandledExceptionFilter) to record + // access violations. Not yet implemented on this target. + tracing::warn!( + "Fatal-signal capture is only implemented on Unix; native faults may leave no marker on this platform" + ); +} + +/// Builds the calm, generic notice shown on the terminal when startup fails. +/// +/// Pure function: names what happened and lists each provided log path on its +/// own line, with no raw error text, no jargon, and no support redirect. Side +/// effect free so it can be unit-tested directly. +fn startup_failure_message(log_paths: &[PathBuf]) -> String { + let mut message = String::from("Dash Evo Tool failed to start.\n"); + if log_paths.is_empty() { + message.push_str("Please try again, and report the problem if it keeps happening.\n"); + return message; + } + message.push_str( + "Details were written to the log files below. Please check them, or include them when reporting the problem:\n", + ); + for path in log_paths { + message.push_str(" "); + message.push_str(&path.display().to_string()); + message.push('\n'); + } + message +} + +/// Writes a generic startup-failure notice to the real terminal. +/// +/// Best-effort: resolves the log paths, builds the message, and writes it to the +/// preserved terminal stderr (fd 2 is redirected to the sidecar during a normal +/// run). Never panics; write errors are ignored. +pub fn report_startup_failure_to_terminal() { + let log_paths: Vec<PathBuf> = ["det.log", "det-stderr.log"] + .into_iter() + .filter_map(|name| app_user_data_file_path(name).ok()) + .collect(); + let message = startup_failure_message(&log_paths); + write_to_terminal(message.as_bytes()); +} + +/// Writes `bytes` to the real terminal, best-effort. +#[cfg(unix)] +fn write_to_terminal(bytes: &[u8]) { + // Use the preserved terminal handle if we captured one; otherwise fd 2 is + // still the terminal (capture skipped or failed), so write straight to it. + let saved = ORIGINAL_STDERR_FD.load(Ordering::SeqCst); + let fd = if saved >= 0 { + saved + } else { + nix::libc::STDERR_FILENO + }; + // SAFETY: `write` only touches the given fd and reads `bytes` for its length. + // A single best-effort write is enough for a short message; failures are + // ignored because there is nothing useful to do at terminating startup. + unsafe { + nix::libc::write(fd, bytes.as_ptr() as *const nix::libc::c_void, bytes.len()); + } +} + +/// Writes `bytes` to the terminal, best-effort. +#[cfg(not(unix))] +fn write_to_terminal(bytes: &[u8]) { + // stderr is not redirected on non-Unix targets, so a plain write reaches the + // terminal. Lossy UTF-8 is fine here — the message is ASCII. + eprint!("{}", String::from_utf8_lossy(bytes)); +} + fn rotate_log_file() { let Ok(log_path) = app_user_data_file_path("det.log") else { return; }; + let Some(parent) = log_path.parent() else { + return; + }; + rotate_log_in_dir(parent, "det"); +} + +/// Rotates `{stem}.log` in `dir` to a timestamped name and removes rotated +/// copies of the same stem older than [`LOG_RETENTION_DAYS`]. +fn rotate_log_in_dir(dir: &Path, stem: &str) { + let log_path = dir.join(format!("{stem}.log")); if log_path.exists() { let ts = fs::metadata(&log_path) .and_then(|m| m.modified()) .map(chrono::DateTime::<Local>::from) .unwrap_or_else(|_| Local::now()) .timestamp(); - let rotated = log_path.with_file_name(format!("det.{ts:010}.log")); + let rotated = dir.join(rotated_name(stem, ts)); let _ = fs::rename(&log_path, rotated); } - let Some(parent) = log_path.parent() else { - return; - }; - let cutoff = (Local::now() - Duration::days(7)).timestamp(); - let Ok(entries) = fs::read_dir(parent) else { + let cutoff = (Local::now() - Duration::days(LOG_RETENTION_DAYS)).timestamp(); + let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { @@ -128,16 +406,196 @@ fn rotate_log_file() { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; - let Some(ts_str) = name - .strip_prefix("det.") - .and_then(|s| s.strip_suffix(".log")) - else { - continue; - }; - if let Ok(ts) = ts_str.parse::<i64>() + if let Some(ts) = parse_rotated_ts(name, stem) && ts < cutoff { let _ = fs::remove_file(path); } } } + +/// File name for a rotated log: `{stem}.{ts:010}.log`. +fn rotated_name(stem: &str, ts: i64) -> String { + format!("{stem}.{ts:010}.log") +} + +/// Parses the timestamp out of a rotated log file name produced by +/// [`rotated_name`], returning `None` for names that don't match the stem. +fn parse_rotated_ts(name: &str, stem: &str) -> Option<i64> { + name.strip_prefix(&format!("{stem}.")) + .and_then(|s| s.strip_suffix(".log")) + .and_then(|s| s.parse::<i64>().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn rotated_name_is_zero_padded() { + assert_eq!(rotated_name("det", 42), "det.0000000042.log"); + assert_eq!( + rotated_name("det-stderr", 1_700_000_000), + "det-stderr.1700000000.log" + ); + } + + #[test] + fn parse_rotated_ts_round_trips() { + let name = rotated_name("det", 1_234_567_890); + assert_eq!(parse_rotated_ts(&name, "det"), Some(1_234_567_890)); + } + + #[test] + fn parse_rotated_ts_rejects_wrong_stem() { + // A det-stderr rotation must not be parsed under the "det" stem, + // otherwise the two logs would clean up each other's files. + let name = rotated_name("det-stderr", 1_700_000_000); + assert_eq!(parse_rotated_ts(&name, "det"), None); + } + + #[test] + fn parse_rotated_ts_rejects_non_matching_names() { + assert_eq!(parse_rotated_ts("det.log", "det"), None); + assert_eq!(parse_rotated_ts("det.notanumber.log", "det"), None); + assert_eq!(parse_rotated_ts("unrelated.txt", "det"), None); + } + + #[test] + fn rotate_log_in_dir_moves_current_log_aside() { + let dir = tempfile::tempdir().expect("tempdir"); + let current = dir.path().join("det-stderr.log"); + fs::write(&current, b"old contents").expect("write"); + + rotate_log_in_dir(dir.path(), "det-stderr"); + + assert!(!current.exists(), "current log should be renamed away"); + let rotated: Vec<_> = fs::read_dir(dir.path()) + .expect("read_dir") + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| parse_rotated_ts(n, "det-stderr").is_some()) + .collect(); + assert_eq!(rotated.len(), 1, "exactly one rotated file expected"); + } + + #[test] + fn rotate_log_in_dir_removes_only_old_same_stem_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let old_ts = (Local::now() - Duration::days(LOG_RETENTION_DAYS + 1)).timestamp(); + let fresh_ts = Local::now().timestamp(); + + let old = dir.path().join(rotated_name("det-stderr", old_ts)); + let fresh = dir.path().join(rotated_name("det-stderr", fresh_ts)); + // A different stem with an old timestamp must survive. + let other_stem = dir.path().join(rotated_name("det", old_ts)); + fs::write(&old, b"x").expect("write old"); + fs::write(&fresh, b"x").expect("write fresh"); + fs::write(&other_stem, b"x").expect("write other"); + + rotate_log_in_dir(dir.path(), "det-stderr"); + + assert!(!old.exists(), "old same-stem rotation should be deleted"); + assert!(fresh.exists(), "fresh same-stem rotation should be kept"); + assert!( + other_stem.exists(), + "other-stem rotation must not be touched" + ); + } + + #[test] + fn rotate_log_in_dir_noop_without_current_log() { + let dir = tempfile::tempdir().expect("tempdir"); + // No det-stderr.log present; rotation should not create anything. + rotate_log_in_dir(dir.path(), "det-stderr"); + let count = fs::read_dir(dir.path()).expect("read_dir").count(); + assert_eq!(count, 0); + } + + #[cfg(unix)] + #[test] + fn marker_for_signal_names_each_fatal_signal() { + use nix::sys::signal::Signal; + + for (signal, needle) in [ + (Signal::SIGSEGV, "SIGSEGV"), + (Signal::SIGABRT, "SIGABRT"), + (Signal::SIGBUS, "SIGBUS"), + (Signal::SIGILL, "SIGILL"), + (Signal::SIGFPE, "SIGFPE"), + ] { + let marker = marker_for_signal(signal as nix::libc::c_int); + let text = std::str::from_utf8(marker).expect("marker is utf8"); + assert!( + text.contains(needle), + "marker for {signal:?} should contain {needle:?}, got {text:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn marker_for_signal_falls_back_for_unknown() { + // SIGUSR1 is not one of the handled fatal signals, so it must hit the + // generic fallback rather than naming a specific fatal signal. + let marker = marker_for_signal(nix::libc::SIGUSR1); + let text = std::str::from_utf8(marker).expect("marker is utf8"); + assert!(text.contains("unexpected signal"), "got {text:?}"); + } + + #[test] + fn startup_failure_message_states_failure_and_lists_each_path() { + let paths = vec![ + PathBuf::from("/tmp/data/det.log"), + PathBuf::from("/tmp/data/det-stderr.log"), + ]; + let message = startup_failure_message(&paths); + + assert!( + message.contains("failed to start"), + "message should state the app failed to start, got {message:?}" + ); + for path in &paths { + assert!( + message.contains(&path.display().to_string()), + "message should list {path:?}, got {message:?}" + ); + } + // No raw error text and no "contact support" redirect (project rule). + assert!( + !message.to_lowercase().contains("contact support"), + "message must not redirect to support, got {message:?}" + ); + } + + #[test] + fn startup_failure_message_omits_path_list_when_empty() { + let message = startup_failure_message(&[]); + assert!( + message.contains("failed to start"), + "message should still state the failure, got {message:?}" + ); + } + + #[test] + fn should_capture_stderr_tracks_logger_uses_file() { + // LOGGER_USES_FILE is a process-global static shared with other tests + // in this binary; save and restore it so this test is order-independent. + let saved = LOGGER_USES_FILE.load(Ordering::SeqCst); + + LOGGER_USES_FILE.store(false, Ordering::SeqCst); + assert!( + !should_capture_stderr(), + "must not capture when logging fell back to stderr" + ); + + LOGGER_USES_FILE.store(true, Ordering::SeqCst); + assert!( + should_capture_stderr(), + "must capture when logging owns the det.log file" + ); + + LOGGER_USES_FILE.store(saved, Ordering::SeqCst); + } +} diff --git a/src/main.rs b/src/main.rs index 5c07da0a7..ea738f721 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,16 +2,22 @@ use dash_evo_tool::*; -use crate::app_dir::{app_user_data_dir_path, create_app_user_data_directory_if_not_exists}; +use crate::boot::prepare_environment; use crate::cpu_compatibility::check_cpu_compatibility; -use crate::logging::initialize_logger; +use crate::logging::{ + capture_stderr_to_file, install_fatal_signal_handler, report_startup_failure_to_terminal, +}; fn main() -> eframe::Result<()> { - create_app_user_data_directory_if_not_exists() - .expect("Failed to create app user_data directory"); - let app_data_dir = - app_user_data_dir_path().expect("Failed to get app user_data directory path"); - initialize_logger(); + // Single owner of dir/env/logger setup; runs again inside `BootApp` boot, + // where the logger's `Once` guard makes the repeat a no-op. + let app_data_dir = prepare_environment().expect("Failed to prepare app environment"); + // Redirect stderr to a sidecar file so native crashes (SIGSEGV, abort, OOM) + // that bypass the tracing panic hook still leave evidence on disk, then + // mark which fatal signal fired. Both must run before the eframe/tokio + // runtime starts. + capture_stderr_to_file(); + install_fatal_signal_handler(); tracing::info!( version = VERSION, data_dir = %app_data_dir.display(), @@ -26,7 +32,16 @@ fn main() -> eframe::Result<()> { .expect("multi-threading runtime cannot be initialized"); // Run the native application - runtime.block_on(start(&app_data_dir)) + let result = runtime.block_on(start(&app_data_dir)); + if let Err(e) = &result { + // Full technical detail to det.log; the returned Err's Debug repr still + // reaches the redirected det-stderr.log via default termination. + tracing::error!(error = ?e, "Dash Evo Tool failed to start"); + // Generic, actionable notice to the real terminal (fd 2 is redirected + // to the sidecar log, so this writes to the preserved original stderr). + report_startup_failure_to_terminal(); + } + result } fn load_icon() -> egui::IconData { @@ -64,6 +79,6 @@ async fn start(app_data_dir: &std::path::Path) -> Result<(), eframe::Error> { eframe::run_native( &format!("Dash Evo Tool v{}", VERSION), native_options, - Box::new(|cc| Ok(Box::new(crate::app::AppState::new(cc.egui_ctx.clone())?))), + Box::new(|cc| Ok(Box::new(crate::boot::BootApp::new(cc.egui_ctx.clone())?))), ) } diff --git a/src/mcp/error.rs b/src/mcp/error.rs index 20a8c89ec..4734ae458 100644 --- a/src/mcp/error.rs +++ b/src/mcp/error.rs @@ -15,18 +15,32 @@ pub enum McpToolError { NetworkMismatch { expected: String, actual: String }, #[error("SPV sync incomplete — please wait and retry")] SpvSyncFailed, + /// Cold-start storage migration did not complete within the wait + /// window. Returned when `ensure_spv_synced` times out waiting for + /// the migration that runs after the wallet backend is first wired. + #[error("Wallet storage is still starting up. Please wait a moment and retry.")] + StorageNotReady, #[error("Backend task failed: {0}")] TaskFailed(#[source] TaskError), #[error("{0}")] Internal(String), } +impl From<crate::model::masternode_input::MasternodeInputError> for McpToolError { + fn from(e: crate::model::masternode_input::MasternodeInputError) -> Self { + McpToolError::InvalidParam { + message: e.to_string(), + } + } +} + /// MCP error codes for each variant (using JSON-RPC custom code range). const CODE_WALLET_NOT_FOUND: i32 = -32001; const CODE_INVALID_PARAM: i32 = -32602; // standard JSON-RPC invalid params const CODE_NETWORK_MISMATCH: i32 = -32002; const CODE_SPV_SYNC_FAILED: i32 = -32003; const CODE_TASK_FAILED: i32 = -32004; +const CODE_STORAGE_NOT_READY: i32 = -32005; const CODE_INTERNAL: i32 = -32603; // standard JSON-RPC internal error impl From<McpToolError> for McpError { @@ -36,6 +50,7 @@ impl From<McpToolError> for McpError { McpToolError::InvalidParam { .. } => (CODE_INVALID_PARAM, e.to_string(), None), McpToolError::NetworkMismatch { .. } => (CODE_NETWORK_MISMATCH, e.to_string(), None), McpToolError::SpvSyncFailed => (CODE_SPV_SYNC_FAILED, e.to_string(), None), + McpToolError::StorageNotReady => (CODE_STORAGE_NOT_READY, e.to_string(), None), McpToolError::TaskFailed(task_err) => { // Include the full Debug error chain so MCP clients can see // the underlying cause (e.g. SDK/DAPI errors) instead of just diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 7ec440bcf..eb94ebc9b 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -19,14 +19,47 @@ mod tests; pub use config::McpConfig; /// Start the MCP server over stdin/stdout. +/// +/// Runs until the stdio transport closes (client disconnects), then drains +/// the wallet-backend persister before returning. This does **not** prevent +/// the coordinator timer-wheel panic that occurs on Tokio runtime drop — +/// `shutdown_wallet_backend` does not join coordinator OS threads. +/// `std::process::exit` in the caller is the deterministic mitigation. #[cfg(feature = "cli")] pub async fn start_stdio() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { use rmcp::ServiceExt; let service = server::DashMcpService::new_lazy(); - let server = service.serve(rmcp::transport::stdio()).await?; - server.waiting().await?; - Ok(()) + // Keep a clone so we can access the context after `serve()` moves `service`. + // `DashMcpService` is cheaply cloneable (all fields are `Arc`-wrapped). + let service_for_shutdown = service.clone(); + + // S6: capture the server result WITHOUT short-circuiting via `?` so that + // `shutdown_wallet_backend` is ALWAYS called regardless of whether `serve` + // or `waiting` returns an error. The `?` is deferred to after the shutdown. + // + // `serve().await` errors with `ServerInitializeError`; `waiting().await` + // errors with `JoinError` and succeeds with `QuitReason` — both are + // mapped to the function's `Box<dyn Error + Send + Sync>` return type. + let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = async { + let server = service + .serve(rmcp::transport::stdio()) + .await + .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?; + server + .waiting() + .await + .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) }) + .map(|_| ()) + } + .await; + + // Quiesce the wallet backend (coordinator threads) before returning. + // The caller's runtime is still alive at this point; the shutdown must + // complete here, inside `block_on`, NOT during runtime drop. + service_for_shutdown.shutdown_wallet_backend().await; + + result } /// Start the MCP server over HTTP (embedded in GUI app). @@ -50,12 +83,7 @@ pub async fn start_http_server( let mcp_service = StreamableHttpService::new( move || Ok(DashMcpService::new_shared(ctx.clone())), LocalSessionManager::default().into(), - { - StreamableHttpServerConfig { - cancellation_token: cancel.clone(), - ..Default::default() - } - }, + StreamableHttpServerConfig::default().with_cancellation_token(cancel.clone()), ); let health = Router::new().route("/health", axum::routing::get(|| async { "OK" })); diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 469c4aa44..3652c03f0 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -1,20 +1,27 @@ //! Parameter resolution helpers for MCP tools. use crate::context::AppContext; -use crate::context::connection_status::OverallConnectionState; use crate::mcp::error::McpToolError; use crate::mcp::server::network_display_name; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::spv_status::SpvStatus; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; use std::sync::{Arc, RwLock}; -/// Poll interval for waiting on SPV connection -- matches ConnectionStatus throttle. -const SPV_WAIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); /// Initial SPV sync (headers, masternodes, filters, blocks) can take several minutes. const SPV_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); +/// Maximum wait for a cold-start storage migration to complete. +/// +/// Migration is fast (typically < 5 s) but bounded at 60 s to guard +/// against a hung migrator. If this elapses, `ensure_storage_ready` +/// returns [`McpToolError::StorageNotReady`] so the caller gets an +/// actionable error rather than a mysterious `WalletStorageNotReady` +/// from deep inside `run_backend_task`. +const STORAGE_MIGRATION_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + /// Verify that the expected network matches the server's active network. /// /// If `expected` is `None`, validation is skipped (backwards compatible). @@ -34,16 +41,21 @@ pub(crate) fn verify_network( Ok(()) } -/// Verify network is provided and matches (mandatory for destructive ops). +/// Verify network is provided (non-blank) and matches (mandatory for +/// destructive ops). +/// +/// A missing or blank `network` is rejected as "required" rather than compared +/// against the active network — an empty string means "not provided". pub(crate) fn require_network( app_context: &AppContext, network: Option<&str>, ) -> Result<(), McpToolError> { - let Some(expected) = network else { - return Err(McpToolError::InvalidParam { + let expected = network + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| McpToolError::InvalidParam { message: "The 'network' parameter is required for fund-sending operations to prevent accidental cross-network transfers.".to_owned(), - }); - }; + })?; let actual = network_display_name(app_context.network()); if !expected.eq_ignore_ascii_case(actual) { return Err(McpToolError::NetworkMismatch { @@ -91,20 +103,71 @@ pub(crate) fn wallet(ctx: &AppContext, wallet_id: &str) -> Result<WalletSeedHash } /// Get the `Arc<RwLock<Wallet>>` for a given seed hash. +/// +/// Delegates the lookup + poison recovery to [`AppContext::wallet_arc`] (the +/// single source of truth) and re-wraps its only failure — +/// [`TaskError::WalletNotFound`] — as the id-bearing [`McpToolError`] MCP +/// clients expect. pub(crate) fn wallet_arc( ctx: &AppContext, seed_hash: WalletSeedHash, ) -> Result<Arc<RwLock<crate::model::wallet::Wallet>>, McpToolError> { - let wallets = ctx.wallets.read().unwrap_or_else(|e| e.into_inner()); - wallets - .get(&seed_hash) - .cloned() - .ok_or_else(|| McpToolError::WalletNotFound { + ctx.wallet_arc(&seed_hash) + .map_err(|_| McpToolError::WalletNotFound { id: hex::encode(seed_hash), }) } -/// Wait for SPV to reach fully-synced (green) state. +/// Poll until the cold-start storage migration is no longer running. +/// +/// On a fresh standalone process, `ensure_wallet_backend_and_start_spv` +/// kicks off a legacy-data migration before the backend is fully usable. +/// `AppContext::run_backend_task` short-circuits all wallet-touching tasks +/// while `migration_status().state().is_running()`, returning +/// [`TaskError::WalletStorageNotReady`]. By waiting here (still inside +/// `ensure_spv_synced`, before SPV wait), we turn that fast-fail into a +/// transparent pause — the tool appears to "just work" on a cold start. +/// +/// Fast exit: returns immediately if migration is already done (the common +/// case after the first gated tool has already waited). +/// +/// Terminal states `Idle`, `Success`, and `Failed` all pass through — +/// `Failed` is surfaced to the user via the migration banner; the tool +/// proceeds and will fail with whatever backend error it encounters there. +async fn ensure_storage_ready(ctx: &Arc<AppContext>) -> Result<(), McpToolError> { + let migration = ctx.migration_status(); + // Fast path — not running; nothing to wait for. + if !migration.state().is_running() { + return Ok(()); + } + + tracing::info!("Waiting for cold-start storage migration to complete…"); + + let poll = async { + loop { + if !migration.state().is_running() { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }; + + match tokio::time::timeout(STORAGE_MIGRATION_WAIT_TIMEOUT, poll).await { + Ok(result) => { + tracing::info!("Cold-start storage migration complete."); + result + } + Err(_elapsed) => { + tracing::warn!( + timeout_secs = STORAGE_MIGRATION_WAIT_TIMEOUT.as_secs(), + "Timed out waiting for cold-start storage migration" + ); + Err(McpToolError::StorageNotReady) + } + } +} + +/// Wait for SPV to reach the `Running` state (chain headers + filters synced). /// /// Required for **all wallet-facing tools** — both core-chain (UTXOs, sending /// Dash) and platform queries (address balances, withdrawals). Even DAPI-only @@ -114,33 +177,106 @@ pub(crate) fn wallet_arc( /// /// Only tools that make no network calls (e.g. `core_wallets_list`, /// `network_info`, `tool_describe`) skip this gate. -pub(crate) async fn ensure_spv_synced(ctx: &AppContext) -> Result<(), McpToolError> { - let deadline = tokio::time::Instant::now() + SPV_WAIT_TIMEOUT; - loop { - let _ = ctx.connection_status.trigger_refresh(ctx); - let state = ctx.connection_status.overall_state(); - if state == OverallConnectionState::Synced { - return Ok(()); +/// +/// Wires the wallet backend and starts chain sync on first call before waiting — +/// neither standalone (stdio) boot, the HTTP context swap, nor the +/// post-network-switch path eagerly wires the backend the way the GUI does, so +/// this is the single chokepoint that makes SPV actually start for every gated +/// tool. Both steps are idempotent, so repeated tool calls are cheap. +/// +/// Also waits for any in-progress cold-start storage migration to finish +/// (see [`ensure_storage_ready`]) before polling SPV state — this prevents +/// the `WalletStorageNotReady` fast-fail that `run_backend_task` applies +/// while migration is mid-flight. +/// +/// ## Why `SpvStatus::Running`, not `OverallConnectionState::Synced` +/// +/// `OverallConnectionState::Synced` requires both SPV running **and** +/// `dapi_available == true`. In headless / MCP mode the DAPI availability +/// counter is only refreshed by the frame-loop `trigger_refresh` call, which +/// does not run here. Waiting for `Synced` would therefore block indefinitely +/// in headless mode even after the chain is fully synced — the symptom that +/// motivated this fix. `SpvStatus::Running` is push-based and sufficient: all +/// proof-verifying SDK calls only require a synced chain, not a live DAPI +/// counter at the `ensure_spv_synced` callsite. +pub(crate) async fn ensure_spv_synced(ctx: &Arc<AppContext>) -> Result<(), McpToolError> { + // A throwaway `TaskResult` sender: MCP/CLI has no GUI event loop consuming + // it, so the receiver is dropped. The `EventBridge` only does non-blocking + // `try_send`, so a closed channel is harmless. Mirrors `dispatch::dispatch_task`. + let (tx, _) = tokio::sync::mpsc::channel::<crate::app::TaskResult>(32); + let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, egui::Context::default()); + if let Err(e) = ctx.ensure_wallet_backend_and_start_spv(sender).await { + tracing::warn!(error = %e, "wallet backend wiring / SPV start failed before sync wait"); + return Err(McpToolError::TaskFailed(e)); + } + + // S7: In standalone/headless MCP mode the GUI frame-loop never runs, so + // `MigrationTask::FinishUnwire` is never dispatched from `AppState`. + // Dispatch it here (idempotent — returns immediately if the sentinel file + // exists or there are no legacy rows) so `ensure_storage_ready` can see a + // terminal state instead of always fast-pathing through `Idle`. + { + use crate::backend_task::migration::MigrationTask; + if let Err(e) = ctx.run_migration_task(MigrationTask::FinishUnwire).await { + // Log but do not fail — the migration failing should not prevent the + // tool from proceeding; the user will get an actionable error if the + // backend task itself later rejects due to missing data. + tracing::warn!( + error = ?e, + "Standalone cold-start migration (FinishUnwire) failed; proceeding anyway" + ); } - if state == OverallConnectionState::Error { - return Err(McpToolError::SpvSyncFailed); + } + + // Wait for cold-start storage migration before polling SPV state. + // `run_backend_task` rejects wallet-touching tasks while migration is + // running; ensuring it finishes here makes cold-start tool calls + // wait transparently rather than bouncing with WalletStorageNotReady. + ensure_storage_ready(ctx).await?; + + // Subscribe BEFORE reading the current value so no transition is lost + // between the `ensure_wallet_backend_and_start_spv` call above and the + // first `borrow_and_update` below. borrow_and_update marks the current + // value "seen", so the loop never spins — each iteration always sleeps on + // a real change. + let mut rx = ctx.connection_status().subscribe_spv_status(); + + let wait = async { + loop { + let status = *rx.borrow_and_update(); + match status { + SpvStatus::Running => return Ok(()), + SpvStatus::Error => return Err(McpToolError::SpvSyncFailed), + // Idle / Starting / Syncing / Stopping / Stopped — keep waiting. + _ => {} + } + // changed() returns Err only if the sender is dropped, which is + // app-lifetime, so this is effectively unreachable in practice. + if rx.changed().await.is_err() { + return Err(McpToolError::SpvSyncFailed); + } } - if tokio::time::Instant::now() >= deadline { + }; + + match tokio::time::timeout(SPV_WAIT_TIMEOUT, wait).await { + Ok(result) => result, + Err(_elapsed) => { tracing::warn!( - "SPV sync timed out after {} seconds (state: {state:?})", - SPV_WAIT_TIMEOUT.as_secs() + "SPV sync timed out after {} seconds (status: {:?})", + SPV_WAIT_TIMEOUT.as_secs(), + ctx.connection_status().spv_status() ); - return Err(McpToolError::SpvSyncFailed); + Err(McpToolError::SpvSyncFailed) } - tokio::time::sleep(SPV_WAIT_POLL_INTERVAL).await; } } -/// Validate amount for sending operations. -pub(crate) fn validate_amount(amount_duffs: u64) -> Result<(), McpToolError> { - if amount_duffs == 0 { +/// Reject a zero send amount. `unit_label` names the JSON parameter's unit +/// (e.g. `"duffs"` or `"credits"`) so the message points at the right field. +pub(crate) fn validate_positive_amount(amount: u64, unit_label: &str) -> Result<(), McpToolError> { + if amount == 0 { return Err(McpToolError::InvalidParam { - message: "amount_duffs must be greater than zero".to_owned(), + message: format!("amount_{unit_label} must be greater than zero"), }); } Ok(()) @@ -191,13 +327,3 @@ pub(crate) fn qualified_identity( ), }) } - -/// Validate amount in credits for sending operations. -pub(crate) fn validate_credits(amount_credits: u64) -> Result<(), McpToolError> { - if amount_credits == 0 { - return Err(McpToolError::InvalidParam { - message: "amount_credits must be greater than zero".to_owned(), - }); - } - Ok(()) -} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 0e5828009..85536956c 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1,8 +1,8 @@ //! MCP service definition — DashMcpService struct, context providers, and ServerHandler impl. use crate::context::AppContext; +use crate::mcp::error::McpToolError; use crate::mcp::tools; -use crate::spv::CoreBackendMode; use rmcp::handler::server::tool::{ToolCallContext, ToolRouter}; use rmcp::model::*; use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, service::RequestContext}; @@ -39,6 +39,20 @@ impl ContextHolder { Self::Standalone(swap) => swap.store(Some(ctx)), } } + + /// Whether this holder needs lazy initialization before the first load. + /// Only standalone (stdio/CLI) contexts start empty; shared (HTTP) contexts + /// are pre-populated by the GUI. A `match` stays correct whether or not the + /// `mcp` feature compiles in the `Shared` variant — unlike an `if let`, which + /// becomes an irrefutable pattern in a `cli`-only build. + #[cfg(feature = "cli")] + fn needs_lazy_init(&self) -> bool { + match self { + #[cfg(feature = "mcp")] + Self::Shared(_) => false, + Self::Standalone(_) => true, + } + } } /// MCP service backed by the app's context. @@ -84,28 +98,32 @@ impl DashMcpService { } } - /// Get the current AppContext. + /// Get the current AppContext, returning a tool-native [`McpToolError`]. /// /// In HTTP mode, loads from the shared ArcSwap (always initialized). - /// In stdio/CLI mode, initializes on first call, then loads. - pub(crate) async fn ctx(&self) -> Result<Arc<AppContext>, McpError> { + /// In stdio/CLI mode, initializes on first call, then loads. Tool invokers + /// call this so the error propagates with `?` and is converted to the wire + /// error once at the router boundary — no lossy `McpError` → string round-trip. + pub(crate) async fn tool_ctx(&self) -> Result<Arc<AppContext>, McpToolError> { #[cfg(feature = "cli")] - if let ContextHolder::Standalone(_) = &self.ctx { + if self.ctx.needs_lazy_init() { let ctx_holder = self.ctx.clone(); self.init_guard .get_or_try_init(|| async { let app_context = init_app_context().await.map_err(|e| { tracing::error!("MCP context initialization failed: {e}"); - McpError::internal_error("Failed to initialize application context", None) + McpToolError::Internal( + "Failed to initialize application context".to_owned(), + ) })?; ctx_holder.store(app_context); - Ok::<(), McpError>(()) + Ok::<(), McpToolError>(()) }) .await?; } self.ctx .load() - .ok_or_else(|| McpError::internal_error("AppContext not initialized", None)) + .ok_or_else(|| McpToolError::Internal("AppContext not initialized".to_owned())) } /// Replace the active context. Used by `network_switch` to point the @@ -114,6 +132,90 @@ impl DashMcpService { self.ctx.store(new_ctx); } + /// Drain the wallet backend's persister before process exit. + /// + /// Called from the standalone stdio serve path (`start_stdio`), inside + /// `block_on` while the Tokio runtime is still alive. Ensures any in-flight + /// `TokenBalanceChangeSet` / `PlatformWalletChangeSet` persister writes issued + /// by the coordinator sync loops complete before the process exits. + /// + /// ## Why this does NOT stop the coordinator timer panic + /// + /// Each coordinator (`identity-sync`, `platform-address-sync`, `shielded-sync`) + /// runs on a **dedicated OS thread** that calls [`Handle::block_on`]. Their + /// inner loop ends with: + /// + /// ```text + /// tokio::select! { + /// _ = tokio::time::sleep(interval) => {} // panics if runtime shut down + /// _ = cancel.cancelled() => break, + /// } + /// ``` + /// + /// `backend.shutdown()` → `quiesce()` cancels the tokens and waits for + /// `is_syncing == false`, but **does not join the OS threads** — it returns + /// as soon as the last persister write completes. At that point the coordinator + /// threads are still alive and may poll `sleep(interval)` in `select!`. + /// + /// `tokio::select!` picks arms in **random order** for fairness. If + /// `sleep(interval)` is polled before `cancel.cancelled()` (which is ready + /// immediately) while the Tokio runtime is shutting down, `Sleep::poll` + /// panics: *"A Tokio 1.x context was found, but it is being shutdown."* + /// + /// A `tokio::time::sleep` grace period was tried and also fails: DAPI retries + /// that are already in flight when `quiesce()` returns can extend past any + /// fixed sleep window. + /// + /// **The deterministic fix** is `std::process::exit` in the CLI entry points + /// (`run_stdio_server`, `run_headless`, and the one-shot tool path in `main`), + /// applied after the tool result is flushed to stdout. `process::exit` + /// reclaims all OS threads before they can poll the shutting-down timer wheel. + /// The upstream fix (storing and joining the OS thread's `JoinHandle` in + /// `quiesce()`) would be the correct library-level solution. + /// + /// ## Graceful teardown — plan for when upstream delivers + /// + /// Once `WalletBackend::quiesce()` (or a new `shutdown_and_join()` variant) + /// joins the coordinator OS threads before returning, the `process::exit` + /// stopgap can be removed from all three CLI call-sites. The replacement + /// would look like: + /// + /// ```text + /// // TODO(graceful-teardown): remove process::exit once WalletBackend exposes + /// // coordinator JoinHandles and quiesce() joins them before returning. + /// + /// // 1. Quiesce persister writes AND join all coordinator OS threads. + /// backend.shutdown_and_join().await; + /// + /// // 2. At this point NO coordinator thread holds a Tokio timer registration, + /// // so the runtime can be dropped (or allowed to fall off the stack) + /// // without triggering the "context is being shutdown" panic. + /// drop(runtime); // or just let it fall out of scope + /// + /// // 3. Return normally — no hard-exit required. + /// return result; + /// ``` + /// + /// Call-sites to update when the upstream fix lands: + /// - `src/bin/det_cli/connect.rs` — `run_stdio_server()` + /// - `src/bin/det_cli/main.rs` — one-shot tool path in `main()` + /// - `src/bin/det_cli/headless.rs` — `run_headless()` + /// + /// ## Safe to call unconditionally + /// + /// - Context never initialized → `ctx.load()` returns `None` → no-op. + /// - Context init'd, backend never wired → `wallet_backend()` returns + /// `Err(WalletBackendNotYetWired)` → no-op (no coordinators were started). + #[cfg(feature = "cli")] + pub async fn shutdown_wallet_backend(&self) { + let Some(ctx) = self.ctx.load() else { return }; + let Ok(backend) = ctx.wallet_backend() else { + return; + }; + // Drain in-flight persister writes. Does not join coordinator threads. + backend.shutdown().await; + } + /// Build the tool router using trait-based tool composition. pub fn tool_router() -> ToolRouter<Self> { ToolRouter::new() @@ -121,6 +223,7 @@ impl DashMcpService { .with_async_tool::<tools::network::NetworkReinitSdk>() .with_async_tool::<tools::network::NetworkSwitch>() .with_async_tool::<tools::wallet::ListWalletsTool>() + .with_async_tool::<tools::wallet::ImportWallet>() .with_async_tool::<tools::wallet::GenerateReceiveAddress>() .with_async_tool::<tools::wallet::WalletBalancesQuery>() .with_async_tool::<tools::wallet::FetchPlatformBalances>() @@ -133,12 +236,20 @@ impl DashMcpService { .with_async_tool::<tools::identity::IdentityCreditsTransfer>() .with_async_tool::<tools::identity::IdentityCreditsWithdraw>() .with_async_tool::<tools::identity::IdentityCreditsToAddress>() + // Masternode / evonode tools + .with_async_tool::<tools::masternode::MasternodeIdentityLoad>() + .with_async_tool::<tools::masternode::MasternodeCreditsWithdraw>() // Shielded tools .with_async_tool::<tools::shielded::ShieldedShieldFromCore>() .with_async_tool::<tools::shielded::ShieldedShieldFromPlatform>() .with_async_tool::<tools::shielded::ShieldedTransferTool>() .with_async_tool::<tools::shielded::ShieldedUnshield>() .with_async_tool::<tools::shielded::ShieldedWithdrawTool>() + // Shielded read/control tools (Phase G — agent self-verification) + .with_async_tool::<tools::shielded::ShieldedInit>() + .with_async_tool::<tools::shielded::ShieldedSync>() + .with_async_tool::<tools::shielded::ShieldedBalanceGet>() + .with_async_tool::<tools::shielded::ShieldedAddressGet>() } } @@ -216,11 +327,32 @@ pub async fn init_app_context() -> Result<Arc<AppContext>, McpError> { db.initialize(&db_file_path) .map_err(|e| McpError::internal_error(format!("db init: {e}"), None))?; - let network = db - .get_settings() + let app_kv = AppContext::open_app_kv(&data_dir) + .map_err(|e| McpError::internal_error(format!("app k/v open: {e}"), None))?; + let secret_store = AppContext::open_secret_store(&data_dir).map_err(|e| { + // A legacy passphrase-protected vault can only be unlocked through the + // GUI's boot prompt — name the real cause and the path forward instead + // of the generic "another copy is running" lock message. + if e.is_secret_store_wrong_passphrase() { + McpError::internal_error( + "Your saved keys are protected by a passphrase set in an earlier version. \ + Open the Dash Evo Tool desktop app and enter the passphrase to unlock them, \ + then run this command again." + .to_string(), + None, + ) + } else { + McpError::internal_error(format!("secret store open: {e}"), None) + } + })?; + let network = app_kv + .get::<crate::model::settings::AppSettings>( + crate::wallet_backend::DetScope::Global, + crate::model::settings::AppSettings::KV_KEY, + ) .ok() .flatten() - .map(|(network, ..)| network) + .map(|s| s.network) .unwrap_or(Network::Mainnet); let subtasks = Arc::new(TaskManager::new()); @@ -243,10 +375,14 @@ pub async fn init_app_context() -> Result<Arc<AppContext>, McpError> { data_dir, network, db, - None, // no wallet passwords in MCP server subtasks, connection_status, egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + config.developer_mode.unwrap_or(false), + )), ) .ok_or_else(|| { McpError::internal_error( @@ -255,22 +391,11 @@ pub async fn init_app_context() -> Result<Arc<AppContext>, McpError> { ) })?; - // Headless mode has no Dash Core RPC credentials — force SPV backend so - // wallet tools work without a local node. This is defence-in-depth even - // after the v34 migration: a user could point `det_cli` at a GUI data dir - // where someone explicitly chose RPC. We flip the in-memory mode only - // (volatile) so the GUI's saved preference is never overwritten. - if app_context.core_backend_mode() != CoreBackendMode::Spv { - tracing::info!("Headless mode: forcing SPV backend (was RPC)"); - app_context.set_core_backend_mode_volatile(CoreBackendMode::Spv); - } - - if let Err(e) = app_context.start_spv() { - tracing::warn!("SPV start failed (wallet tools may not work): {e}"); - } else { - tracing::info!("SPV client started, wallets loading in background"); - } - + // Chain sync is SPV-only (owned by upstream platform-wallet). Starting it + // here would fast-fail: the wallet backend is not wired yet at boot. SPV is + // instead wired-then-started lazily by `resolve::ensure_spv_synced` on the + // first gated tool call — the single chokepoint that also covers the HTTP + // context swap and the post-network-switch path. Ok(app_context) } diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 3209c27ee..5974259ca 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -7,7 +7,7 @@ use crate::mcp::resolve; #[test] fn zero_amount_rejected() { - let result = resolve::validate_amount(0); + let result = resolve::validate_positive_amount(0, "duffs"); assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -19,8 +19,8 @@ fn zero_amount_rejected() { #[test] fn positive_amount_accepted() { - assert!(resolve::validate_amount(1).is_ok()); - assert!(resolve::validate_amount(100_000_000).is_ok()); + assert!(resolve::validate_positive_amount(1, "duffs").is_ok()); + assert!(resolve::validate_positive_amount(100_000_000, "duffs").is_ok()); } // ── Address validation ───────────────────────────────────────── @@ -120,6 +120,7 @@ fn error_codes_are_distinct() { actual: "b".into(), }, McpToolError::SpvSyncFailed, + McpToolError::StorageNotReady, McpToolError::Internal("x".into()), ]; @@ -131,8 +132,8 @@ fn error_codes_are_distinct() { }) .collect(); - // WalletNotFound, NetworkMismatch, SpvSyncFailed should have unique custom codes - let custom_codes: Vec<i32> = vec![codes[0], codes[2], codes[3]]; + // WalletNotFound, NetworkMismatch, SpvSyncFailed, StorageNotReady should have unique custom codes + let custom_codes: Vec<i32> = vec![codes[0], codes[2], codes[3], codes[4]]; let unique: std::collections::HashSet<i32> = custom_codes.iter().copied().collect(); assert_eq!( unique.len(), @@ -140,3 +141,30 @@ fn error_codes_are_distinct() { "Custom error codes must be distinct: {custom_codes:?}" ); } + +#[test] +fn storage_not_ready_display_is_actionable() { + let err = McpToolError::StorageNotReady; + let msg = err.to_string(); + assert!( + msg.contains("starting up") || msg.contains("wait") || msg.contains("retry"), + "StorageNotReady message should direct the user to wait/retry; got: {msg}" + ); +} + +#[test] +fn storage_not_ready_has_dedicated_error_code() { + use rmcp::ErrorData as McpError; + + let spv: McpError = McpToolError::SpvSyncFailed.into(); + let storage: McpError = McpToolError::StorageNotReady.into(); + assert_ne!( + spv.code.0, storage.code.0, + "StorageNotReady must have a different code from SpvSyncFailed" + ); + // Custom range: must not collide with standard JSON-RPC codes + assert!( + storage.code.0 < -32000 || storage.code.0 == -32005, + "StorageNotReady code should be in the custom range" + ); +} diff --git a/src/mcp/tools/identity.rs b/src/mcp/tools/identity.rs index 2dc21a096..088afcaf6 100644 --- a/src/mcp/tools/identity.rs +++ b/src/mcp/tools/identity.rs @@ -77,12 +77,9 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTopup { service: &DashMcpService, param: IdentityTopupParams, ) -> Result<IdentityTopupOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_amount(param.amount_duffs)?; + resolve::validate_positive_amount(param.amount_duffs, "duffs")?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; let qi = resolve::qualified_identity(&ctx, &param.identity_id)?; @@ -118,7 +115,7 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTopup { identity_id: identity_id_str, amount_duffs: param.amount_duffs, estimated_fee: fee_result.estimated_fee, - actual_fee: fee_result.actual_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), }) } other => Err(McpToolError::Internal(format!( @@ -189,12 +186,9 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTopupFromPlatform { service: &DashMcpService, param: IdentityTopupFromPlatformParams, ) -> Result<IdentityTopupFromPlatformOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends @@ -262,7 +256,7 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTopupFromPlatform { identity_id: identity_id_str, amount_credits: param.amount_credits, estimated_fee: fee_result.estimated_fee, - actual_fee: fee_result.actual_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), }) } other => Err(McpToolError::Internal(format!( @@ -336,12 +330,9 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTransfer { service: &DashMcpService, param: IdentityTransferParams, ) -> Result<IdentityTransferOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends let _seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; @@ -373,7 +364,7 @@ impl AsyncTool<DashMcpService> for IdentityCreditsTransfer { to_identity_id: param.to_identity_id, amount_credits: param.amount_credits, estimated_fee: fee_result.estimated_fee, - actual_fee: fee_result.actual_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), }) } other => Err(McpToolError::Internal(format!( @@ -447,17 +438,17 @@ impl AsyncTool<DashMcpService> for IdentityCreditsWithdraw { service: &DashMcpService, param: IdentityWithdrawParams, ) -> Result<IdentityWithdrawOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; resolve::validate_address(&param.to_address)?; - // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, - // not Core UTXO spends - let _seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; + // The backend calls `Identity::fetch_by_identifier` and reads the identity nonce — + // both require a synced chain. Gate before `dispatch_task` so the withdrawal + // never races ahead of chain sync. + resolve::ensure_spv_synced(&ctx).await?; + + let _seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; let qi = resolve::qualified_identity(&ctx, &param.identity_id)?; let core_address = param @@ -491,7 +482,7 @@ impl AsyncTool<DashMcpService> for IdentityCreditsWithdraw { to_address: param.to_address, amount_credits: param.amount_credits, estimated_fee: fee_result.estimated_fee, - actual_fee: fee_result.actual_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), }) } other => Err(McpToolError::Internal(format!( @@ -564,24 +555,34 @@ impl AsyncTool<DashMcpService> for IdentityCreditsToAddress { service: &DashMcpService, param: IdentityToAddressParams, ) -> Result<IdentityToAddressOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends let _seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; let qi = resolve::qualified_identity(&ctx, &param.identity_id)?; - let (platform_addr, _network) = + let platform_addr = dash_sdk::dpp::address_funds::PlatformAddress::from_bech32m_string(&param.to_address) .map_err(|e| McpToolError::InvalidParam { message: format!("Invalid Platform address: {e}"), })?; + // Gate the destination against the ACTIVE network. The `network` param + // only pins which network is active; it does NOT validate the + // destination, so a mainnet `dash1…` address could otherwise be paid on + // testnet (or vice-versa), misdirecting credits. Checked after parsing + // so a malformed address still reports "invalid", not "wrong network". + crate::model::address::validate_platform_address_for_network( + &param.to_address, + ctx.network(), + ) + .map_err(|e| McpToolError::InvalidParam { + message: e.to_string(), + })?; + let mut outputs = BTreeMap::new(); outputs.insert(platform_addr, param.amount_credits); @@ -602,7 +603,7 @@ impl AsyncTool<DashMcpService> for IdentityCreditsToAddress { to_address: param.to_address, amount_credits: param.amount_credits, estimated_fee: fee_result.estimated_fee, - actual_fee: fee_result.actual_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), }) } other => Err(McpToolError::Internal(format!( diff --git a/src/mcp/tools/masternode.rs b/src/mcp/tools/masternode.rs new file mode 100644 index 000000000..badbf63eb --- /dev/null +++ b/src/mcp/tools/masternode.rs @@ -0,0 +1,962 @@ +//! Masternode / evonode MCP tools: identity load and credit withdrawal. +//! +//! Two tools live here, extracted from `identity.rs` (H2 refactor): +//! +//! * [`MasternodeIdentityLoad`] (`masternode_identity_load`) — bind an +//! evonode/masternode identity by ProTxHash and private keys. +//! * [`MasternodeCreditsWithdraw`] (`masternode_credits_withdraw`) — withdraw +//! Platform credits to a Core address using owner- or transfer-mode signing. + +use std::borrow::Cow; + +use dash_sdk::dpp::identity::Purpose; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use rmcp::handler::server::router::tool::{AsyncTool, ToolBase}; +use rmcp::model::ToolAnnotations; +use rmcp::schemars; +use serde::{Deserialize, Serialize}; + +use crate::backend_task::identity::{IdentityInputToLoad, IdentityTask}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::mcp::dispatch::dispatch_task; +use crate::mcp::error::McpToolError; +use crate::mcp::resolve; +use crate::mcp::server::DashMcpService; +use crate::model::masternode_input::{self, KeyMode}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::secret::Secret; + +// --------------------------------------------------------------------------- +// MasternodeIdentityLoad — load a masternode/evonode identity by ProTxHash +// --------------------------------------------------------------------------- + +/// Load a masternode or evonode identity by ProTxHash and private keys. +pub struct MasternodeIdentityLoad; + +/// Parameters for [`MasternodeIdentityLoad`]. +/// +/// Private keys are typed as [`Secret`] so they are mlock-ed and +/// zeroize-on-drop from the moment they are deserialized — no intermediate +/// plain-`String` window exists before or during the SPV wait. +#[derive(Deserialize, schemars::JsonSchema, Default)] +pub struct MasternodeIdentityLoadParams { + /// ProTxHash of the masternode/evonode (its identity ID). Accepts hex (the + /// canonical encoding) or Base58. + pub pro_tx_hash: String, + /// Node type: "masternode" or "evonode". + pub node_type: String, + /// Owner private key (WIF or 64-char hex). Bound as the OWNER key. At + /// least one of the owner or payout private key is required. + #[serde(default)] + pub owner_private_key: Secret, + /// Voting private key (WIF or 64-char hex). Optional; binds the voter + /// identity. Does not enable a withdrawal on its own. + #[serde(default)] + pub voting_private_key: Secret, + /// Payout/transfer private key (WIF or 64-char hex). Bound as the + /// TRANSFER key. At least one of the owner or payout private key is + /// required. + #[serde(default)] + pub payout_private_key: Secret, + /// Optional human-readable name; trimmed, empty falls back to a DPNS name. + #[serde(default)] + pub alias: String, + /// Expected network (required so keys and addresses bind to the right + /// chain). + pub network: String, +} + +// Hand-written so the three private keys never reach a log sink or the MCP +// error `data` payload — Secret's own Debug already redacts, but the struct +// debug must also render non-secret fields readably. +impl std::fmt::Debug for MasternodeIdentityLoadParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MasternodeIdentityLoadParams") + .field("pro_tx_hash", &self.pro_tx_hash) + .field("node_type", &self.node_type) + .field("owner_private_key", &self.owner_private_key) + .field("voting_private_key", &self.voting_private_key) + .field("payout_private_key", &self.payout_private_key) + .field("alias", &self.alias) + .field("network", &self.network) + .finish() + } +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct MasternodeIdentityLoadOutput { + identity_id: String, + node_type: String, + alias: Option<String>, + owner_key_loaded: bool, + voting_key_loaded: bool, + payout_key_loaded: bool, + /// Withdrawal key modes this identity supports ("owner" / "transfer"). + available_withdrawal_keys: Vec<String>, + /// Registered payout address (the OWNER-mode withdrawal destination), if + /// any. + payout_address: Option<String>, + dpns_names: Vec<String>, +} + +impl ToolBase for MasternodeIdentityLoad { + type Parameter = MasternodeIdentityLoadParams; + type Output = MasternodeIdentityLoadOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "masternode_identity_load".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Load a masternode or evonode identity by ProTxHash, binding its \ + owner/voting/payout private keys (WIF or hex) and persisting it \ + locally for the withdraw tool. The output reports which keys \ + loaded, the available withdrawal modes, and the registered payout \ + address. The 'network' parameter is required." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(false) + .open_world(true), + ) + } +} + +impl AsyncTool<DashMcpService> for MasternodeIdentityLoad { + async fn invoke( + service: &DashMcpService, + param: MasternodeIdentityLoadParams, + ) -> Result<MasternodeIdentityLoadOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + + // ── Cheap pre-flight validation (before the SPV wait) ─────────────── + // Network presence/match, node type, key presence, and ProTxHash + // format are all pure checks that reject without touching the network. + resolve::require_network(&ctx, Some(&param.network))?; + let identity_type = masternode_input::parse_node_type(&param.node_type)?; + // Keys are `Secret`-typed; presence check uses `is_blank()` so no + // expose_secret() call is needed here. + masternode_input::require_at_least_one_signing_key( + &param.owner_private_key, + &param.payout_private_key, + )?; + // S2: validate the ProTxHash format BEFORE the SPV wait so a malformed + // hash is rejected immediately rather than after a 10-min sync. + masternode_input::decode_identity_id(&param.pro_tx_hash)?; + + // ── SPV gate ───────────────────────────────────────────────────────── + // Identity load fetches over DAPI; proof verification needs a synced + // chain. + resolve::ensure_spv_synced(&ctx).await?; + + let input = IdentityInputToLoad { + identity_id_input: param.pro_tx_hash, + identity_type, + alias_input: param.alias.trim().to_owned(), + // Keys are moved out of the Secret wrapper directly into the + // backend input — no intermediate plain-String copy. + voting_private_key_input: param.voting_private_key, + owner_private_key_input: param.owner_private_key, + payout_address_private_key_input: param.payout_private_key, + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + // FR-8 load-time key encryption is GUI-only this iteration + // (requirements §2.3): this tool is a confirmed keyless (Tier-1) + // entry point, so it loads unprotected. + // TODO: headless password parity — accept an optional encryption + // password param and thread it here once MCP secret handling is + // designed. + encryption_password: None, + // Headless load uses overwrite/upsert semantics: a repeat load + // REPLACES the stored node's keys with those supplied in this call. + // Keys omitted this time are dropped — this is a full replace, not a + // merge, so a partial-key repeat load is destructive. Duplicate + // rejection and key-preserving merge are GUI-form affordances, not a + // headless contract. + // TODO: headless merge parity — expose a load-mode param so callers + // can request MergeIntoExisting (key-preserving) once MCP secret + // handling for Tier-2 nodes is designed. + load_mode: crate::backend_task::identity::IdentityLoadMode::Overwrite, + }; + + let task = BackendTask::IdentityTask(IdentityTask::LoadIdentity(input)); + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + let BackendTaskSuccessResult::LoadedIdentity(qi) = result else { + return Err(McpToolError::Internal(format!( + "Unexpected task result: {result:?}" + ))); + }; + + let mut owner_key_loaded = false; + let mut payout_key_loaded = false; + let mut available_withdrawal_keys = Vec::new(); + for key in qi.available_withdrawal_keys() { + match key.identity_public_key.purpose() { + Purpose::OWNER => { + // Guard: push the mode string only on first OWNER key seen so + // identities with multiple OWNER keys don't yield duplicates. + if !owner_key_loaded { + available_withdrawal_keys.push(KeyMode::Owner.to_string()); + } + owner_key_loaded = true; + } + Purpose::TRANSFER => { + if !payout_key_loaded { + available_withdrawal_keys.push(KeyMode::Transfer.to_string()); + } + payout_key_loaded = true; + } + _ => {} + } + } + + Ok(MasternodeIdentityLoadOutput { + identity_id: qi.identity.id().to_string(Encoding::Base58), + node_type: identity_type.to_string().to_ascii_lowercase(), + alias: qi.alias.clone(), + owner_key_loaded, + voting_key_loaded: qi.associated_voter_identity.is_some(), + payout_key_loaded, + available_withdrawal_keys, + payout_address: qi + .masternode_payout_address(ctx.network()) + .map(|addr| addr.to_string()), + dpns_names: qi.dpns_names.iter().map(|d| d.name.clone()).collect(), + }) + } +} + +// --------------------------------------------------------------------------- +// MasternodeCreditsWithdraw — masternode-aware Platform credits → Core +// --------------------------------------------------------------------------- + +/// Withdraw a masternode or evonode identity's Platform credits to Core, +/// honouring the two key modes (owner → payout-forced, transfer → any Core +/// address). +pub struct MasternodeCreditsWithdraw; + +#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] +pub struct MasternodeCreditsWithdrawParams { + /// Base58 identity ID of the loaded masternode/evonode identity. + pub identity_id: String, + /// Key mode: "owner" (destination forced to the payout address) or + /// "transfer" (withdraw to any Core address). + pub key_mode: String, + /// Core address to receive the withdrawal. Required for "transfer" mode; + /// forbidden for "owner" mode (the destination is the registered payout + /// address). + #[serde(default)] + pub to_address: String, + /// Amount in credits to withdraw (must be greater than zero). + pub amount_credits: u64, + /// Expected network (required for destructive operations). + pub network: String, +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct MasternodeCreditsWithdrawOutput { + pub identity_id: String, + /// The key mode used: "owner" or "transfer" (canonical via [`KeyMode`]). + pub key_mode: String, + /// The Core address the funds were actually sent to (the payout address in + /// owner mode, the caller's address in transfer mode). + pub to_address: String, + pub amount_credits: u64, + pub estimated_fee: u64, + pub actual_fee: u64, +} + +/// Reject a caller-supplied address in owner mode. +/// +/// An owner-key withdrawal always goes to the registered payout address, so a +/// supplied `to_address` is a contradiction surfaced as an error, not ignored. +fn reject_owner_address_contradiction(to_address: &str) -> Result<(), McpToolError> { + if !to_address.trim().is_empty() { + return Err(McpToolError::InvalidParam { + message: "An owner-key withdrawal always goes to the registered payout address. \ + Remove 'to_address', or use key_mode=transfer to choose an address." + .to_owned(), + }); + } + Ok(()) +} + +/// Parse a transfer-mode destination as a Core address (format only). +/// +/// Rejects empties, Platform bech32m addresses (via `is_platform_address_string`, +/// not the weaker first-char check), and non-Core strings. The caller enforces +/// the network match on the returned `NetworkUnchecked` address. +fn parse_transfer_core_address( + to_address: &str, +) -> Result< + dash_sdk::dashcore_rpc::dashcore::Address< + dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked, + >, + McpToolError, +> { + let trimmed = to_address.trim(); + if trimmed.is_empty() { + return Err(McpToolError::InvalidParam { + message: "A transfer withdrawal needs a Core address. \ + Provide 'to_address' with a Core address." + .to_owned(), + }); + } + if crate::model::address::is_platform_address_string(trimmed) { + return Err(McpToolError::InvalidParam { + message: "Enter a valid Core address — Platform addresses cannot receive withdrawals." + .to_owned(), + }); + } + trimmed + .parse::<dash_sdk::dashcore_rpc::dashcore::Address< + dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked, + >>() + .map_err(|_| McpToolError::InvalidParam { + message: "Enter a valid Core address — that address could not be read.".to_owned(), + }) +} + +/// The resolved key + destination for a masternode credit withdrawal. +#[derive(Debug)] +struct WithdrawalPlan { + /// Signing key resolved from the identity's available withdrawal keys. + key_id: dash_sdk::dpp::identity::KeyID, + /// Destination passed to the state transition. `None` in owner mode — + /// Platform consensus forces the registered payout address; `Some(addr)` + /// in transfer mode. + dispatch_address: Option<dash_sdk::dpp::dashcore::Address>, + /// Destination the funds actually reach, echoed to the caller: the + /// registered payout address in owner mode, the caller's address in + /// transfer mode. + echo_address: dash_sdk::dpp::dashcore::Address, +} + +/// Resolve the signing key and destination for a withdrawal (pure, no network). +/// +/// Owner mode dispatches `None` (Platform consensus forces the registered payout +/// address — matching the GUI), validating only that a payout address exists and +/// echoing it back. Transfer mode dispatches and echoes the caller's Core +/// address after a format + active-network check. +/// +/// # Errors +/// +/// [`McpToolError::InvalidParam`] when the mode's key is not loaded, owner mode +/// has no registered payout address, or the transfer address is missing, +/// malformed, a Platform address, or for the wrong network. +fn resolve_withdrawal_plan( + qi: &QualifiedIdentity, + key_mode: KeyMode, + to_address: &str, + network: dash_sdk::dpp::dashcore::Network, +) -> Result<WithdrawalPlan, McpToolError> { + let purpose = match key_mode { + KeyMode::Owner => Purpose::OWNER, + KeyMode::Transfer => Purpose::TRANSFER, + }; + let key_id = qi + .available_withdrawal_keys() + .into_iter() + .find(|k| k.identity_public_key.purpose() == purpose) + .map(|k| k.identity_public_key.id()) + .ok_or_else(|| McpToolError::InvalidParam { + message: match key_mode { + KeyMode::Owner => "The owner key (owner_private_key) needed for owner-mode \ + withdrawals is not loaded. Re-run masternode-identity-load \ + with owner_private_key included." + .to_owned(), + KeyMode::Transfer => "The payout key (payout_private_key) needed for \ + transfer-mode withdrawals is not loaded. Re-run \ + masternode-identity-load with payout_private_key included." + .to_owned(), + }, + })?; + + match key_mode { + KeyMode::Owner => { + let payout = qi.masternode_payout_address(network).ok_or_else(|| { + McpToolError::InvalidParam { + message: "This identity has no registered payout address, so an owner-key \ + withdrawal has no destination. Use key_mode=transfer with a Core \ + address." + .to_owned(), + } + })?; + Ok(WithdrawalPlan { + key_id, + dispatch_address: None, + echo_address: payout, + }) + } + KeyMode::Transfer => { + let checked = parse_transfer_core_address(to_address)? + .require_network(network) + .map_err(|_| McpToolError::InvalidParam { + message: "The Core address does not match the active network. \ + Use an address for the active network." + .to_owned(), + })?; + Ok(WithdrawalPlan { + key_id, + dispatch_address: Some(checked.clone()), + echo_address: checked, + }) + } + } +} + +impl ToolBase for MasternodeCreditsWithdraw { + type Parameter = MasternodeCreditsWithdrawParams; + type Output = MasternodeCreditsWithdrawOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "masternode_credits_withdraw".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Withdraw a loaded masternode/evonode identity's Platform credits to \ + Core. With key_mode=owner the destination is forced to the \ + registered payout address; with key_mode=transfer you choose any \ + Core address. The 'network' parameter is required." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(true) + .idempotent(false) + .open_world(true), + ) + } +} + +impl AsyncTool<DashMcpService> for MasternodeCreditsWithdraw { + async fn invoke( + service: &DashMcpService, + param: MasternodeCreditsWithdrawParams, + ) -> Result<MasternodeCreditsWithdrawOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + + // Cheap validation first, before the SPV wait. + resolve::require_network(&ctx, Some(&param.network))?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; + let key_mode = masternode_input::parse_key_mode(&param.key_mode)?; + + // Surface the owner+address contradiction before resolving the identity + // so it fires even for a not-yet-loaded identity. + if key_mode == KeyMode::Owner { + reject_owner_address_contradiction(&param.to_address)?; + } + + // Parse identity ID (pure — no backend access). + let identity_id = masternode_input::decode_identity_id(&param.identity_id)?; + + // Wire the wallet backend and wait for a synced chain BEFORE any + // backend-touching call. + resolve::ensure_spv_synced(&ctx).await?; + + // Resolve the loaded identity. A not-found points at the load tool. + let qi = ctx + .get_identity_by_id(&identity_id) + .map_err(|e| McpToolError::Internal(e.to_string()))? + .ok_or_else(|| McpToolError::InvalidParam { + message: format!( + "This identity is not loaded yet: {}. \ + Run masternode-identity-load with the ProTxHash and keys first.", + param.identity_id + ), + })?; + + // Resolve the signing key and destination per mode (pure, no network). + let plan = resolve_withdrawal_plan(&qi, key_mode, &param.to_address, ctx.network())?; + + let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity( + qi, + plan.dispatch_address, + param.amount_credits, + Some(plan.key_id), + )); + let result = dispatch_task(&ctx, task) + .await + .map_err(McpToolError::TaskFailed)?; + + let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = result else { + return Err(McpToolError::Internal(format!( + "Unexpected task result: {result:?}" + ))); + }; + + Ok(MasternodeCreditsWithdrawOutput { + identity_id: param.identity_id, + // S3: use KeyMode Display — single source of truth for "owner"/"transfer". + key_mode: key_mode.to_string(), + // Echo the destination the funds actually go to (the registered + // payout address in owner mode), so the caller always learns where. + to_address: plan.echo_address.to_string(), + amount_credits: param.amount_credits, + estimated_fee: fee_result.estimated_fee, + actual_fee: fee_result.actual_fee.unwrap_or(fee_result.estimated_fee), + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + // ── Tool A param Debug redaction (TC-MN-010) ────────────────────────── + // + // The single most important security test: the params `Debug` must never + // surface any of the three private keys, because `McpToolError::TaskFailed` + // serializes `{task_err:?}` into the MCP error `data` payload. + + #[test] + fn load_params_debug_redacts_every_private_key() { + let params = MasternodeIdentityLoadParams { + pro_tx_hash: "PROTX_HASH_VALUE".to_owned(), + node_type: "evonode".to_owned(), + owner_private_key: Secret::new("OWNER_SECRET_VALUE"), + voting_private_key: Secret::new("VOTING_SECRET_VALUE"), + payout_private_key: Secret::new("PAYOUT_SECRET_VALUE"), + alias: "my-node".to_owned(), + network: "testnet".to_owned(), + }; + + let debug = format!("{params:?}"); + + // No key sentinel may appear. + assert!( + !debug.contains("OWNER_SECRET_VALUE"), + "owner key leaked: {debug}" + ); + assert!( + !debug.contains("VOTING_SECRET_VALUE"), + "voting key leaked: {debug}" + ); + assert!( + !debug.contains("PAYOUT_SECRET_VALUE"), + "payout key leaked: {debug}" + ); + // Each key field renders as the Secret redaction marker. + assert!( + debug.contains("Secret(***)"), + "keys must render as Secret(***): {debug}" + ); + // Non-secret fields stay readable. + assert!( + debug.contains("PROTX_HASH_VALUE"), + "pro_tx_hash visible: {debug}" + ); + assert!(debug.contains("evonode"), "node_type visible: {debug}"); + assert!(debug.contains("my-node"), "alias visible: {debug}"); + assert!(debug.contains("testnet"), "network visible: {debug}"); + } + + // ── Key-format policy delegation (TC-MN-011) ────────────────────────── + // + // The tool feeds keys straight into Secret -> IdentityInputToLoad -> + // the backend `verify_key_input`, which is the single source of truth for + // the length policy. The tool adds no competing length check. + #[test] + fn load_params_accept_any_key_length_without_local_check() { + for key in ["", "tooshort", &"a".repeat(63), &"f".repeat(64)] { + let params = MasternodeIdentityLoadParams { + pro_tx_hash: "x".to_owned(), + node_type: "masternode".to_owned(), + owner_private_key: Secret::new(key), + network: "testnet".to_owned(), + ..Default::default() + }; + // Construction never validates key length. + assert_eq!(params.owner_private_key.expose_secret(), key); + } + } + + // ── Discoverability & schema (TC-MN-015) ────────────────────────────── + + fn schema_property_names(tool: &rmcp::model::Tool) -> Vec<String> { + tool.input_schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() + } + + #[test] + fn load_tool_registered_with_expected_annotations_and_schema() { + let router = DashMcpService::tool_router(); + let tool = router + .get("masternode_identity_load") + .expect("masternode_identity_load must be registered in tool_router"); + + let ann = tool + .annotations + .as_ref() + .expect("tool must carry annotations"); + assert_eq!(ann.read_only_hint, Some(false)); + assert_eq!(ann.destructive_hint, Some(false)); + assert_eq!(ann.idempotent_hint, Some(false)); + assert_eq!(ann.open_world_hint, Some(true)); + + let props = schema_property_names(tool); + for expected in [ + "pro_tx_hash", + "node_type", + "owner_private_key", + "voting_private_key", + "payout_private_key", + "alias", + "network", + ] { + assert!( + props.iter().any(|p| p == expected), + "schema must expose '{expected}', got {props:?}" + ); + } + } + + // ── Tool B pure pre-flight checks (TC-MN-031/032/033/034/035) ───────── + + #[test] + fn withdraw_amount_zero_rejected() { + // TC-MN-032 — delegated to the shared resolve::validate_positive_amount. + let err = resolve::validate_positive_amount(0, "credits").unwrap_err(); + assert!(err.to_string().contains("greater than zero"), "got: {err}"); + assert!(resolve::validate_positive_amount(1, "credits").is_ok()); + } + + #[test] + fn owner_mode_supplied_address_rejected() { + // TC-MN-033 — a non-empty address in owner mode is a contradiction. + let err = + reject_owner_address_contradiction("yQ9JNCT4S9zVHaKYbr1FUY4YkUMYxSzWAj").unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!( + err.to_string().contains("registered payout address"), + "got: {err}" + ); + // Empty / whitespace-only address is the expected owner-mode input. + assert!(reject_owner_address_contradiction("").is_ok()); + assert!(reject_owner_address_contradiction(" ").is_ok()); + } + + #[test] + fn transfer_mode_missing_address_rejected() { + // TC-MN-034 — transfer mode requires an address. + for empty in ["", " "] { + let err = parse_transfer_core_address(empty).unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!(err.to_string().contains("Core address"), "got: {err}"); + } + } + + #[test] + fn transfer_mode_invalid_address_rejected() { + // TC-MN-035 — the address is actually parsed, not first-char checked. + let err = parse_transfer_core_address("not-an-address").unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!(err.to_string().contains("could not be read"), "got: {err}"); + } + + #[test] + fn transfer_mode_platform_address_rejected_via_guard() { + // TC-MN-031 / TC-MN-046 — Platform bech32m addresses are rejected by + // is_platform_address_string, NOT the weaker first-char check. + for platform in ["dash1qwer1234", "tdash1qwer1234"] { + let err = parse_transfer_core_address(platform).unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!( + err.to_string() + .contains("Platform addresses cannot receive"), + "for {platform}: {err}" + ); + } + } + + /// Derive a real, checksum-valid testnet P2PKH address from a fixed key. + fn sample_core_address() -> String { + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; + + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[7u8; 32]).expect("valid secret key"); + let privkey = PrivateKey::new(sk, Network::Testnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + Address::p2pkh(&pubkey, Network::Testnet).to_string() + } + + #[test] + fn transfer_mode_valid_core_address_accepted() { + let addr = sample_core_address(); + assert!( + parse_transfer_core_address(&addr).is_ok(), + "derived address {addr} should parse" + ); + } + + // ── Tool B discoverability & schema (TC-MN-043) ─────────────────────── + + #[test] + fn withdraw_tool_registered_with_expected_annotations_and_schema() { + let router = DashMcpService::tool_router(); + let tool = router + .get("masternode_credits_withdraw") + .expect("masternode_credits_withdraw must be registered in tool_router"); + + let ann = tool + .annotations + .as_ref() + .expect("tool must carry annotations"); + assert_eq!(ann.read_only_hint, Some(false)); + assert_eq!(ann.destructive_hint, Some(true)); + assert_eq!(ann.idempotent_hint, Some(false)); + assert_eq!(ann.open_world_hint, Some(true)); + + let props = schema_property_names(tool); + for expected in [ + "identity_id", + "key_mode", + "to_address", + "amount_credits", + "network", + ] { + assert!( + props.iter().any(|p| p == expected), + "schema must expose '{expected}', got {props:?}" + ); + } + } + + // ── TaskFailed data payload never leaks key material (TC-MN-061) ────── + + #[test] + fn secret_debug_never_reveals_plaintext() { + let secret = Secret::new("OWNER_SECRET_WIF_VALUE"); + let debug = format!("{secret:?}"); + assert_eq!(debug, "Secret(***)"); + assert!(!debug.contains("OWNER_SECRET_WIF_VALUE")); + } + + #[test] + fn task_failed_data_payload_excludes_key_material() { + use crate::backend_task::error::TaskError; + use rmcp::ErrorData as McpError; + + let err = McpToolError::TaskFailed(TaskError::KeyInputValidationFailed( + crate::model::key_input::KeyInputError::UnsupportedLength { + key_name: "Owner".to_owned(), + }, + )); + + let mcp: McpError = err.into(); + let message = mcp.message.to_string(); + let data = mcp.data.map(|v| v.to_string()).unwrap_or_default(); + + // The typed variant name belongs in the debug `data` payload (for MCP + // clients / developers) but must NOT appear in the user-facing `message` + // (which uses Display, not Debug). + const VARIANT: &str = "KeyInputValidationFailed"; + assert!( + data.contains(VARIANT), + "data should carry the typed variant for diagnostics: {data}" + ); + assert!( + !message.contains(VARIANT), + "variant name must not leak into the user-facing message: {message}" + ); + } + + // ── resolve_withdrawal_plan: owner→None / transfer→Some ────────────── + + use dash_sdk::dpp::identity::IdentityPublicKey; + use dash_sdk::dpp::version::PlatformVersion; + + fn masternode_fixture(with_owner: bool, with_payout: bool) -> QualifiedIdentity { + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, PrivateKeyTarget}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::platform::{Identifier, Identity}; + + let pv = PlatformVersion::latest(); + let mut identity = + Identity::create_basic_identity(Identifier::default(), pv).expect("basic identity"); + let mut key_storage = KeyStorage::default(); + let mut public_keys = BTreeMap::new(); + + if with_owner { + let (owner_key, owner_secret) = + IdentityPublicKey::random_masternode_owner_key(0, Some(1), pv).expect("owner key"); + public_keys.insert(owner_key.id(), owner_key.clone()); + key_storage.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, owner_key.id()), + ( + QualifiedIdentityPublicKey::from(owner_key), + PrivateKeyData::Clear(owner_secret), + ), + ); + } + if with_payout { + let (payout_key, payout_secret) = + IdentityPublicKey::random_masternode_transfer_key(1, Some(2), pv) + .expect("transfer key"); + public_keys.insert(payout_key.id(), payout_key.clone()); + key_storage.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, payout_key.id()), + ( + QualifiedIdentityPublicKey::from(payout_key), + PrivateKeyData::Clear(payout_secret), + ), + ); + } + identity.set_public_keys(public_keys); + + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Evonode, + alias: None, + private_keys: key_storage, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + fn testnet_core_address() -> String { + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[9u8; 32]).expect("valid secret key"); + let pk = PublicKey::from_private_key(&secp, &PrivateKey::new(sk, Network::Testnet)); + Address::p2pkh(&pk, Network::Testnet).to_string() + } + + #[test] + fn owner_mode_dispatches_none_and_echoes_payout() { + use dash_sdk::dpp::dashcore::Network; + let qi = masternode_fixture(true, true); + let plan = resolve_withdrawal_plan(&qi, KeyMode::Owner, "", Network::Testnet) + .expect("owner plan resolves"); + assert!( + plan.dispatch_address.is_none(), + "owner mode must dispatch None, got {:?}", + plan.dispatch_address + ); + let expected = qi + .masternode_payout_address(Network::Testnet) + .expect("fixture has a payout address"); + assert_eq!(plan.echo_address, expected); + } + + #[test] + fn transfer_mode_dispatches_and_echoes_the_caller_address() { + use dash_sdk::dpp::dashcore::Network; + let qi = masternode_fixture(false, true); + let addr = testnet_core_address(); + let plan = resolve_withdrawal_plan(&qi, KeyMode::Transfer, &addr, Network::Testnet) + .expect("transfer plan resolves"); + let dispatched = plan + .dispatch_address + .expect("transfer mode dispatches Some"); + assert_eq!(dispatched.to_string(), addr); + assert_eq!(plan.echo_address.to_string(), addr); + } + + #[test] + fn owner_mode_key_not_loaded_rejected_naming_param() { + use dash_sdk::dpp::dashcore::Network; + let qi = masternode_fixture(false, true); + let err = resolve_withdrawal_plan(&qi, KeyMode::Owner, "", Network::Testnet).unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!( + err.to_string().contains("owner_private_key"), + "message names the param: {err}" + ); + } + + #[test] + fn transfer_mode_key_not_loaded_rejected_naming_param() { + use dash_sdk::dpp::dashcore::Network; + let qi = masternode_fixture(true, false); + let addr = testnet_core_address(); + let err = + resolve_withdrawal_plan(&qi, KeyMode::Transfer, &addr, Network::Testnet).unwrap_err(); + assert!( + err.to_string().contains("payout_private_key"), + "message names the param: {err}" + ); + } + + #[test] + fn owner_mode_no_payout_address_rejected() { + use dash_sdk::dpp::dashcore::Network; + let qi = masternode_fixture(true, false); + let err = resolve_withdrawal_plan(&qi, KeyMode::Owner, "", Network::Testnet).unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!( + err.to_string().contains("no registered payout address"), + "got: {err}" + ); + } + + #[test] + fn transfer_mode_cross_network_address_rejected() { + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[3u8; 32]).expect("valid secret key"); + let pk = PublicKey::from_private_key(&secp, &PrivateKey::new(sk, Network::Mainnet)); + let mainnet_addr = Address::p2pkh(&pk, Network::Mainnet).to_string(); + + let qi = masternode_fixture(false, true); + let err = resolve_withdrawal_plan(&qi, KeyMode::Transfer, &mainnet_addr, Network::Testnet) + .unwrap_err(); + assert!(matches!(err, McpToolError::InvalidParam { .. })); + assert!( + err.to_string() + .contains("does not match the active network"), + "got: {err}" + ); + } + + // ── S3: KeyMode Display is the single source of truth for strings ───── + + #[test] + fn key_mode_display_matches_parse_roundtrip() { + use crate::model::masternode_input::parse_key_mode; + // The Display impl and parse_key_mode must agree on the canonical strings. + assert_eq!(KeyMode::Owner.to_string(), "owner"); + assert_eq!(KeyMode::Transfer.to_string(), "transfer"); + assert_eq!(parse_key_mode("owner").unwrap(), KeyMode::Owner); + assert_eq!(parse_key_mode("transfer").unwrap(), KeyMode::Transfer); + } +} diff --git a/src/mcp/tools/meta.rs b/src/mcp/tools/meta.rs index b9d819313..3a85663f5 100644 --- a/src/mcp/tools/meta.rs +++ b/src/mcp/tools/meta.rs @@ -57,7 +57,7 @@ impl AsyncTool<DashMcpService> for DescribeTool { service: &DashMcpService, param: ToolNameParams, ) -> Result<DescribeToolOutput, McpToolError> { - // INTENTIONAL(PROJ-003): tool_describe uses meta naming rather than domain_object_action + // Deliberately: tool_describe uses meta naming rather than domain_object_action // convention — it's a meta-tool that describes other tools, not a domain operation. let tool_def = service diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 5355da3c1..b1366a0fe 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -1,6 +1,7 @@ //! Per-domain MCP tool implementations. pub mod identity; +pub mod masternode; pub mod meta; pub mod network; pub mod platform; diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 371aad0b0..34d4cff09 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -55,10 +55,7 @@ impl AsyncTool<DashMcpService> for NetworkTool { service: &DashMcpService, _param: EmptyParams, ) -> Result<NetworkOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; let active = network_display_name(ctx.network()).to_owned(); let config = crate::config::Config::load_from(ctx.data_dir()) @@ -125,10 +122,7 @@ impl AsyncTool<DashMcpService> for NetworkReinitSdk { service: &DashMcpService, param: ReinitSdkParams, ) -> Result<ReinitSdkOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; let task = BackendTask::ReinitCoreClientAndSdk; @@ -218,10 +212,7 @@ impl AsyncTool<DashMcpService> for NetworkSwitch { ) -> Result<NetworkSwitchOutput, McpToolError> { let target = parse_network(&param.network)?; - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; // Already on the target network — no-op. if ctx.network() == target { @@ -247,6 +238,15 @@ impl AsyncTool<DashMcpService> for NetworkSwitch { spv_started, .. } => { + // S5: drain the OUTGOING context's wallet backend before + // replacing it. The old `ctx` (still in scope above) is the + // context that is being evicted; the new `context` is the one + // being installed. Draining here — at the swap callsite — + // ensures every network switch leaves no orphaned WalletBackend, + // regardless of how many switches happen in a session. + if let Ok(backend) = ctx.wallet_backend() { + backend.shutdown().await; + } service.swap_context(context); Ok(NetworkSwitchOutput { active: network_display_name(target).to_owned(), diff --git a/src/mcp/tools/platform.rs b/src/mcp/tools/platform.rs index c3442dfca..ae22e39a6 100644 --- a/src/mcp/tools/platform.rs +++ b/src/mcp/tools/platform.rs @@ -7,7 +7,12 @@ use rmcp::model::ToolAnnotations; use rmcp::schemars; use serde::{Deserialize, Serialize}; -use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::prelude::Identifier; + +use crate::backend_task::platform_info::{ + PlatformInfoTaskRequestType, PlatformInfoTaskResult, WithdrawalRecord, +}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::mcp::dispatch::dispatch_task; use crate::mcp::error::McpToolError; @@ -21,14 +26,18 @@ use crate::mcp::server::DashMcpService; /// Query withdrawal documents from Platform. pub struct QueryWithdrawals; -// TODO: Add pagination support (limit/start_after params) once -// PlatformInfoTaskRequestType variants accept query parameters. -// Currently the backend hardcodes limit=50 in DocumentQuery. #[derive(Debug, Deserialize, schemars::JsonSchema, Default)] pub struct QueryWithdrawalsParams { - /// Which withdrawals to query: "queued" (default) or "completed" + /// Which withdrawals to query: "queued" (default) or "completed". #[serde(default = "default_queued")] pub status: String, + /// Maximum number of withdrawals to return (1–100). Defaults to 50. + #[serde(default)] + pub limit: Option<u32>, + /// Continuation cursor: the `document_id` from a previous response's + /// `next_cursor`. Returns the page of withdrawals that follow it. + #[serde(default)] + pub start_after: Option<String>, /// Expected network (e.g. "mainnet", "testnet"). If provided, the request fails when it /// doesn't match the server's active network. #[serde(default)] @@ -39,13 +48,52 @@ fn default_queued() -> String { "queued".to_string() } -// TODO: Return structured withdrawal data (amount, status, address, timestamps) -// instead of a pre-formatted text string. Requires a new backend result variant -// that returns Vec<WithdrawalDocument> rather than TextResult(String). +/// One withdrawal in the structured response. Amounts are in credits (atomic +/// units, 1 Dash = 100_000_000_000 credits); timestamps are Unix milliseconds. +#[derive(Serialize, schemars::JsonSchema)] +pub struct WithdrawalEntry { + /// Withdrawal document id (base58). Also the page cursor for `start_after`. + pub document_id: String, + /// Identity that requested the withdrawal (base58). + pub owner_id: String, + /// Amount in credits (atomic units). + pub amount_credits: u64, + /// Status: "queued", "pooled", "broadcasted", "complete", or "expired". + pub status: String, + /// Destination Dash address, or null when the output script is non-standard. + pub address: Option<String>, + /// Sequential on-chain transaction index, when present. + pub transaction_index: Option<u64>, + /// Document creation time (Unix ms), when present. + pub created_at_ms: Option<u64>, + /// Document last-update time (Unix ms), when present. + pub updated_at_ms: Option<u64>, +} + #[derive(Serialize, schemars::JsonSchema)] pub struct QueryWithdrawalsOutput { - /// Human-readable withdrawal information - pub info: String, + /// One entry per matching withdrawal document. + pub withdrawals: Vec<WithdrawalEntry>, + /// Sum of every returned entry's `amount_credits`. + pub total_amount_credits: u64, + /// Pass as the next request's `start_after` to fetch the following page. + /// Null when no further results are available. + pub next_cursor: Option<String>, +} + +impl From<WithdrawalRecord> for WithdrawalEntry { + fn from(r: WithdrawalRecord) -> Self { + WithdrawalEntry { + document_id: r.document_id.to_string(Encoding::Base58), + owner_id: r.owner_id.to_string(Encoding::Base58), + amount_credits: r.amount_credits, + status: r.status, + address: r.address, + transaction_index: r.transaction_index, + created_at_ms: r.created_at_ms, + updated_at_ms: r.updated_at_ms, + } + } } impl ToolBase for QueryWithdrawals { @@ -60,7 +108,10 @@ impl ToolBase for QueryWithdrawals { fn description() -> Option<Cow<'static, str>> { Some( "Query withdrawal documents from Platform. \ - Pass status=\"queued\" (default) or status=\"completed\"." + Pass status=\"queued\" (default) for in-queue withdrawals \ + (queued, pooled, or broadcasted) or status=\"completed\" for \ + finished ones (complete or expired). \ + Use limit and start_after for pagination." .into(), ) } @@ -75,15 +126,12 @@ impl AsyncTool<DashMcpService> for QueryWithdrawals { service: &DashMcpService, params: QueryWithdrawalsParams, ) -> Result<QueryWithdrawalsOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::verify_network(&ctx, params.network.as_deref())?; - let request = match params.status.as_str() { - "completed" | "complete" => PlatformInfoTaskRequestType::RecentlyCompletedWithdrawals, - "queued" | "" => PlatformInfoTaskRequestType::CurrentWithdrawalsInQueue, + let completed = match params.status.as_str() { + "completed" | "complete" => true, + "queued" | "" => false, other => { return Err(McpToolError::InvalidParam { message: format!( @@ -93,15 +141,39 @@ impl AsyncTool<DashMcpService> for QueryWithdrawals { } }; + let start_after = params + .start_after + .as_deref() + .map(|cursor| { + Identifier::from_string(cursor, Encoding::Base58).map_err(|_| { + McpToolError::InvalidParam { + message: format!("Invalid start_after cursor: {cursor}"), + } + }) + }) + .transpose()?; + + let request = PlatformInfoTaskRequestType::Withdrawals { + completed, + limit: params.limit, + start_after, + }; + let task = BackendTask::PlatformInfo(request); let result = dispatch_task(&ctx, task) .await .map_err(McpToolError::TaskFailed)?; match result { - BackendTaskSuccessResult::PlatformInfo(PlatformInfoTaskResult::TextResult(text)) => { - Ok(QueryWithdrawalsOutput { info: text }) - } + BackendTaskSuccessResult::PlatformInfo(PlatformInfoTaskResult::Withdrawals { + records, + total_amount_credits, + next_cursor, + }) => Ok(QueryWithdrawalsOutput { + withdrawals: records.into_iter().map(WithdrawalEntry::from).collect(), + total_amount_credits, + next_cursor: next_cursor.map(|id| id.to_string(Encoding::Base58)), + }), other => Err(McpToolError::Internal(format!( "Unexpected result variant: {other:?}" ))), diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 6f70a7b47..47d6a486a 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -13,6 +13,7 @@ use crate::mcp::dispatch::dispatch_task; use crate::mcp::error::McpToolError; use crate::mcp::resolve; use crate::mcp::server::DashMcpService; +use crate::mcp::tools::WalletIdParams; // --------------------------------------------------------------------------- // ShieldedShieldFromCore (Core -> Shielded via asset lock) @@ -29,8 +30,6 @@ pub struct ShieldFromCoreParams { pub amount_duffs: u64, /// Expected network (required for destructive operations) pub network: String, - /// Optional Core address to fund from (restricts UTXO selection to this address) - pub source_address: Option<String>, } #[derive(Serialize, schemars::JsonSchema)] @@ -73,39 +72,16 @@ impl AsyncTool<DashMcpService> for ShieldedShieldFromCore { service: &DashMcpService, param: ShieldFromCoreParams, ) -> Result<ShieldFromCoreOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_amount(param.amount_duffs)?; + resolve::validate_positive_amount(param.amount_duffs, "duffs")?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; resolve::ensure_spv_synced(&ctx).await?; - let source_address = param - .source_address - .map(|addr_str| { - resolve::validate_address(&addr_str)?; - addr_str - .parse::<dash_sdk::dashcore_rpc::dashcore::Address< - dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked, - >>() - .map_err(|_| McpToolError::InvalidParam { - message: "The source Core address is invalid.".to_owned(), - })? - .require_network(ctx.network()) - .map_err(|_| McpToolError::InvalidParam { - message: "The source Core address does not match the active network." - .to_owned(), - }) - }) - .transpose()?; - let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs: param.amount_duffs, - source_address, }); let result = dispatch_task(&ctx, task) @@ -159,8 +135,8 @@ impl ToolBase for ShieldedShieldFromPlatform { fn description() -> Option<Cow<'static, str>> { Some( - "Shield credits from a Platform address into the shielded pool. \ - Auto-selects the highest-balance Platform address. \ + "Shield credits from the wallet's Platform balance into the shielded pool. \ + The wallet selects the input addresses. \ The 'network' parameter is required." .into(), ) @@ -182,55 +158,38 @@ impl AsyncTool<DashMcpService> for ShieldedShieldFromPlatform { service: &DashMcpService, param: ShieldFromPlatformParams, ) -> Result<ShieldFromPlatformOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; - // Auto-select highest-balance platform address and verify sufficient balance - let from_address = { + // Pre-flight: verify the wallet's total platform balance can cover the + // amount. The upstream coordinator selects the actual input addresses — + // DET no longer picks a single `from_address`. + { let wallet_arc = resolve::wallet_arc(&ctx, seed_hash)?; let wallet = wallet_arc.read().unwrap_or_else(|e| e.into_inner()); - let best = wallet + let total: u64 = wallet .platform_address_info - .iter() - .filter_map(|(addr, info)| { - if info.balance > 0 { - dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr.clone()) - .ok() - .map(|pa| (pa, info.balance)) - } else { - None - } - }) - .max_by_key(|(_, balance)| *balance) - .ok_or_else(|| McpToolError::InvalidParam { - message: "No Platform addresses with balance found".to_owned(), - })?; - - if best.1 < param.amount_credits { + .values() + .map(|info| info.balance) + .sum(); + if total < param.amount_credits { return Err(McpToolError::InvalidParam { message: format!( - "Insufficient platform balance. Highest address has {} credits but {} required.", - best.1, param.amount_credits + "Insufficient platform balance. Total available is {} credits but {} required.", + total, param.amount_credits ), }); } + } - best.0 - }; - - let task = BackendTask::ShieldedTask(ShieldedTask::ShieldCredits { + let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromBalance { seed_hash, amount: param.amount_credits, - from_address, - nonce_override: None, }); let result = dispatch_task(&ctx, task) @@ -307,19 +266,16 @@ impl AsyncTool<DashMcpService> for ShieldedTransferTool { service: &DashMcpService, param: ShieldedTransferParams, ) -> Result<ShieldedTransferOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; let recipient_bytes = dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&param.to_address) - .map(|(addr, _)| addr.to_raw_bytes().to_vec()) + .map(|addr| addr.to_raw_bytes().to_vec()) .map_err(|e| McpToolError::InvalidParam { message: format!("Invalid shielded address: {e}"), })?; @@ -405,17 +361,14 @@ impl AsyncTool<DashMcpService> for ShieldedUnshield { service: &DashMcpService, param: ShieldedUnshieldParams, ) -> Result<ShieldedUnshieldOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, // not Core UTXO spends let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; - let (platform_addr, _network) = + let platform_addr = dash_sdk::dpp::address_funds::PlatformAddress::from_bech32m_string(&param.to_address) .map_err(|e| McpToolError::InvalidParam { message: format!("Invalid Platform address: {e}"), @@ -504,12 +457,9 @@ impl AsyncTool<DashMcpService> for ShieldedWithdrawTool { service: &DashMcpService, param: ShieldedWithdrawParams, ) -> Result<ShieldedWithdrawOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(&param.network))?; - resolve::validate_credits(param.amount_credits)?; + resolve::validate_positive_amount(param.amount_credits, "credits")?; resolve::validate_address(&param.to_address)?; // INTENTIONAL: no SPV sync needed — this tool dispatches a Platform state transition // (withdrawal is queued on Platform and settles after confirmation) @@ -551,3 +501,262 @@ impl AsyncTool<DashMcpService> for ShieldedWithdrawTool { } } } + +// --------------------------------------------------------------------------- +// ShieldedInit (warm prover + bind shielded keys) +// --------------------------------------------------------------------------- + +/// Warm the Orchard prover and bind shielded keys for a wallet. +pub struct ShieldedInit; + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ShieldedInitOutput { + /// Whether the Halo2 proving key finished building (ready for spends). + prover_ready: bool, + /// Whether the wallet's Orchard keys are bound to the shielded coordinator. + shielded_bound: bool, +} + +impl ToolBase for ShieldedInit { + type Parameter = WalletIdParams; + type Output = ShieldedInitOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "shielded_init".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Prepare a wallet for shielded operations: bind its Orchard keys and \ + warm the proving key (~30s on first call). Idempotent — safe to call \ + repeatedly. Run this once before shielding or transferring." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +impl AsyncTool<DashMcpService> for ShieldedInit { + async fn invoke( + service: &DashMcpService, + param: WalletIdParams, + ) -> Result<ShieldedInitOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + resolve::verify_network(&ctx, param.network.as_deref())?; + let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; + + // Binding rehydrates from the local shielded store and does not need a + // fully-synced chain, but the wallet backend must be wired so the + // coordinator exists and the wallet resolves. `ensure_spv_synced` is the + // single MCP chokepoint that wires it (idempotent on later calls). + resolve::ensure_spv_synced(&ctx).await?; + + let backend = ctx.wallet_backend().map_err(McpToolError::TaskFailed)?; + + // Bind first (fast), then warm the proving key (~30s). A successful bind + // guarantees the wallet is bound; report it directly. + backend + .ensure_shielded_bound_jit(&seed_hash) + .await + .map_err(McpToolError::TaskFailed)?; + let prover_ready = backend.warm_shielded_prover().await; + + Ok(ShieldedInitOutput { + prover_ready, + shielded_bound: true, + }) + } +} + +// --------------------------------------------------------------------------- +// ShieldedSync (force a coordinator sync, return fresh balance) +// --------------------------------------------------------------------------- + +/// Force an immediate shielded sync and return the post-sync balance. +pub struct ShieldedSync; + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ShieldedSyncOutput { + shielded_credits: u64, + shielded_duffs: u64, +} + +impl ToolBase for ShieldedSync { + type Parameter = WalletIdParams; + type Output = ShieldedSyncOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "shielded_sync".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Force an immediate shielded sync pass and return the wallet's \ + post-sync shielded balance (credits and duffs). Use this to verify \ + a balance change after a shield, transfer, unshield, or withdraw." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(true), + ) + } +} + +impl AsyncTool<DashMcpService> for ShieldedSync { + async fn invoke( + service: &DashMcpService, + param: WalletIdParams, + ) -> Result<ShieldedSyncOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + resolve::verify_network(&ctx, param.network.as_deref())?; + let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; + + resolve::ensure_spv_synced(&ctx).await?; + + let backend = ctx.wallet_backend().map_err(McpToolError::TaskFailed)?; + // `sync_now` fires `on_shielded_sync_completed` synchronously, so the + // push snapshot is fresh by the time this returns (Phase E writer). + backend.sync_shielded_now(true).await; + + Ok(ShieldedSyncOutput { + shielded_credits: ctx.shielded_balance_credits(&seed_hash), + shielded_duffs: ctx.shielded_balance_duffs(&seed_hash), + }) + } +} + +// --------------------------------------------------------------------------- +// ShieldedBalanceGet (read the push snapshot, no sync) +// --------------------------------------------------------------------------- + +/// Read the wallet's shielded balance from the push snapshot (no sync). +pub struct ShieldedBalanceGet; + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ShieldedBalanceGetOutput { + shielded_credits: u64, + shielded_duffs: u64, +} + +impl ToolBase for ShieldedBalanceGet { + type Parameter = WalletIdParams; + type Output = ShieldedBalanceGetOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "shielded_balance_get".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Read the wallet's shielded balance (credits and duffs) from the last \ + synced snapshot without triggering a sync. Returns zero when the \ + wallet has never synced. Use shielded_sync to refresh first." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some(ToolAnnotations::default().read_only(true).open_world(false)) + } +} + +impl AsyncTool<DashMcpService> for ShieldedBalanceGet { + async fn invoke( + service: &DashMcpService, + param: WalletIdParams, + ) -> Result<ShieldedBalanceGetOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + resolve::verify_network(&ctx, param.network.as_deref())?; + let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; + + // INTENTIONAL: a pure snapshot read — no SPV gate, no sync. The figure + // reflects the last completed shielded sync (zero if none). + Ok(ShieldedBalanceGetOutput { + shielded_credits: ctx.shielded_balance_credits(&seed_hash), + shielded_duffs: ctx.shielded_balance_duffs(&seed_hash), + }) + } +} + +// --------------------------------------------------------------------------- +// ShieldedAddressGet (default Orchard receive address) +// --------------------------------------------------------------------------- + +/// Return the wallet's default shielded (Orchard) receive address. +pub struct ShieldedAddressGet; + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ShieldedAddressGetOutput { + /// Bech32m Orchard address (dash1z.../tdash1z...). + address: String, +} + +impl ToolBase for ShieldedAddressGet { + type Parameter = WalletIdParams; + type Output = ShieldedAddressGetOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "shielded_address_get".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Return the wallet's default shielded (Orchard) receive address as a \ + bech32m string, usable as the recipient for a shielded transfer. \ + Run shielded_init first if the wallet is not yet bound." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some(ToolAnnotations::default().read_only(true).open_world(false)) + } +} + +impl AsyncTool<DashMcpService> for ShieldedAddressGet { + async fn invoke( + service: &DashMcpService, + param: WalletIdParams, + ) -> Result<ShieldedAddressGetOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + resolve::verify_network(&ctx, param.network.as_deref())?; + let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; + + let backend = ctx.wallet_backend().map_err(McpToolError::TaskFailed)?; + let raw = backend + .shielded_default_address(&seed_hash, 0) + .await + .map_err(McpToolError::TaskFailed)? + .ok_or_else(|| McpToolError::InvalidParam { + message: "This wallet has no shielded address yet. \ + Run shielded_init first to bind its shielded keys." + .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()); + + Ok(ShieldedAddressGetOutput { address }) + } +} diff --git a/src/mcp/tools/wallet.rs b/src/mcp/tools/wallet.rs index 01b95f45d..67c6d9741 100644 --- a/src/mcp/tools/wallet.rs +++ b/src/mcp/tools/wallet.rs @@ -8,6 +8,7 @@ use rmcp::schemars; use serde::{Deserialize, Serialize}; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::error::TaskError; use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::mcp::dispatch::dispatch_task; @@ -61,21 +62,12 @@ impl AsyncTool<DashMcpService> for GenerateReceiveAddress { service: &DashMcpService, param: WalletIdParams, ) -> Result<GenerateReceiveAddressOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; resolve::ensure_spv_synced(&ctx).await?; - if ctx.spv_manager.wallet_id_for_seed(seed_hash).is_none() { - return Err(McpToolError::Internal( - "Wallet is not loaded into SPV. Please retry in a moment.".to_string(), - )); - } - let task = BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }); let result = dispatch_task(&ctx, task) .await @@ -134,23 +126,28 @@ impl AsyncTool<DashMcpService> for WalletBalancesQuery { service: &DashMcpService, param: WalletIdParams, ) -> Result<WalletBalancesOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; resolve::ensure_spv_synced(&ctx).await?; let wallet_arc = resolve::wallet_arc(&ctx, seed_hash)?; - let wallet = wallet_arc.read().unwrap_or_else(|e| e.into_inner()); + let alias = wallet_arc + .read() + .unwrap_or_else(|e| e.into_inner()) + .alias + .clone(); + + // Balances come from the display-only WalletBackend snapshot (P4a); + // upstream owns chain UTXO/balance bookkeeping. + let balance = ctx.snapshot_balance(&seed_hash); Ok(WalletBalancesOutput { - alias: wallet.alias.clone(), - total_duffs: wallet.total_balance_duffs(), - confirmed_duffs: wallet.confirmed_balance_duffs(), - unconfirmed_duffs: wallet.unconfirmed_balance_duffs(), + alias, + total_duffs: balance.total, + confirmed_duffs: balance.confirmed, + unconfirmed_duffs: balance.unconfirmed, }) } } @@ -222,23 +219,13 @@ impl AsyncTool<DashMcpService> for SendCoreFunds { service: &DashMcpService, param: SendFundsParams, ) -> Result<SendFundsOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; - - // Network is mandatory for destructive operations - if param.network.is_empty() { - return Err(McpToolError::InvalidParam { - message: "The 'network' parameter must not be empty. \ - Use \"mainnet\", \"testnet\", \"devnet\", or \"local\"." - .to_owned(), - }); - } + let ctx = service.tool_ctx().await?; + + // Network is mandatory for destructive operations. resolve::require_network(&ctx, Some(&param.network))?; // Validate inputs before dispatching - resolve::validate_amount(param.amount_duffs)?; + resolve::validate_positive_amount(param.amount_duffs, "duffs")?; resolve::validate_address(&param.address)?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; @@ -252,8 +239,6 @@ impl AsyncTool<DashMcpService> for SendCoreFunds { address: param.address, amount_duffs: param.amount_duffs, }], - subtract_fee_from_amount: false, - memo: None, override_fee: None, }; @@ -335,10 +320,7 @@ impl AsyncTool<DashMcpService> for FetchPlatformBalances { service: &DashMcpService, param: WalletIdParams, ) -> Result<PlatformBalancesOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::verify_network(&ctx, param.network.as_deref())?; let seed_hash = resolve::wallet(&ctx, &param.wallet_id)?; @@ -372,6 +354,133 @@ impl AsyncTool<DashMcpService> for FetchPlatformBalances { } } +// --------------------------------------------------------------------------- +// ImportWallet (BIP-39 mnemonic -> registered wallet) +// --------------------------------------------------------------------------- + +/// Import a wallet from a BIP-39 recovery phrase. +pub struct ImportWallet; + +#[derive(Deserialize, schemars::JsonSchema, Default)] +pub struct ImportWalletParams { + /// BIP-39 recovery phrase (12 or 24 words, space-separated) + pub mnemonic: String, + /// Expected network (required so addresses are derived for the right chain) + pub network: String, + /// Optional human-readable wallet name + #[serde(default)] + pub alias: Option<String>, +} + +// Hand-written so the recovery phrase can never reach a log sink. A derived +// `Debug` would print the mnemonic verbatim, and the BIP-39 phrase is the +// highest-value secret in the app (full, irreversible wallet compromise). +impl std::fmt::Debug for ImportWalletParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportWalletParams") + .field("mnemonic", &"<redacted>") + .field("network", &self.network) + .field("alias", &self.alias) + .finish() + } +} + +#[derive(Serialize, schemars::JsonSchema)] +pub struct ImportWalletOutput { + seed_hash: String, + alias: Option<String>, + /// True when this seed was already present (the import was a no-op). + already_imported: bool, +} + +impl ToolBase for ImportWallet { + type Parameter = ImportWalletParams; + type Output = ImportWalletOutput; + type Error = McpToolError; + + fn name() -> Cow<'static, str> { + "core_wallet_import".into() + } + + fn description() -> Option<Cow<'static, str>> { + Some( + "Import a wallet from a BIP-39 recovery phrase and register it on the \ + active network, returning its seed hash. Imports unprotected (no \ + passphrase) for headless use. Idempotent: re-importing the same \ + phrase is a no-op that returns the existing seed hash. \ + The 'network' parameter is required." + .into(), + ) + } + + fn annotations() -> Option<ToolAnnotations> { + Some( + ToolAnnotations::default() + .read_only(false) + .destructive(false) + .idempotent(true) + .open_world(false), + ) + } +} + +impl AsyncTool<DashMcpService> for ImportWallet { + async fn invoke( + service: &DashMcpService, + param: ImportWalletParams, + ) -> Result<ImportWalletOutput, McpToolError> { + let ctx = service.tool_ctx().await?; + + resolve::require_network(&ctx, Some(&param.network))?; + + // Hold the phrase in a zeroizing buffer so the cleartext seed words are + // scrubbed from memory on drop rather than lingering in a freed String. + // `bip39` is built with its `zeroize` feature, so the parsed `Mnemonic` + // scrubs its word indices on drop too. + let mnemonic_phrase = zeroize::Zeroizing::new(param.mnemonic); + let mnemonic = bip39::Mnemonic::parse_normalized(mnemonic_phrase.trim()).map_err(|e| { + McpToolError::InvalidParam { + message: format!("The recovery phrase is not valid: {e}"), + } + })?; + // The derived 64-byte HD seed is the spend secret; keep it zeroizing so + // it never outlives this call in freed heap/stack memory. + let seed = zeroize::Zeroizing::new(mnemonic.to_seed("")); + + let alias = param + .alias + .as_deref() + .map(str::trim) + .filter(|a| !a.is_empty()) + .map(str::to_owned); + + let wallet = + crate::model::wallet::Wallet::new_from_seed(*seed, ctx.network(), alias.clone(), None) + .map_err(|e| McpToolError::TaskFailed(e.into()))?; + // Capture the seed hash before `register_wallet` consumes the wallet so + // the already-imported branch can still report it. + let seed_hash = wallet.seed_hash(); + + match ctx.register_wallet( + wallet, + &seed, + crate::model::wallet::birth_height::WalletOrigin::Imported, + ) { + Ok((hash, _)) => Ok(ImportWalletOutput { + seed_hash: hex::encode(hash), + alias, + already_imported: false, + }), + Err(TaskError::WalletAlreadyImported) => Ok(ImportWalletOutput { + seed_hash: hex::encode(seed_hash), + alias, + already_imported: true, + }), + Err(e) => Err(McpToolError::TaskFailed(e)), + } + } +} + // --------------------------------------------------------------------------- // ListWalletsTool // --------------------------------------------------------------------------- @@ -413,10 +522,7 @@ impl AsyncTool<DashMcpService> for ListWalletsTool { service: &DashMcpService, param: NetworkParams, ) -> Result<ListWalletsOutput, McpToolError> { - let ctx = service - .ctx() - .await - .map_err(|e| McpToolError::Internal(e.to_string()))?; + let ctx = service.tool_ctx().await?; resolve::verify_network(&ctx, param.network.as_deref())?; let wallets = ctx.wallets.read().unwrap_or_else(|e| e.into_inner()); let entries: Vec<WalletEntry> = wallets diff --git a/src/model/address.rs b/src/model/address.rs index 9f6d7bbc7..861125ff0 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -1,21 +1,99 @@ use dash_sdk::dashcore_rpc::dashcore::Address; -#[cfg(test)] use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; use dash_sdk::dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET, PlatformAddress}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; +/// A destination address whose bech32m network prefix does not match the active +/// network — e.g. a mainnet `dash1…` address used on testnet. +/// +/// Funds-safety guard: sending to a cross-network address would misdirect +/// credits, so both the GUI and the MCP tools reject it up front. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error( + "This address belongs to a different network. Please check you are using the correct network." +)] +pub struct AddressNetworkMismatch; + +/// The bech32m human-readable prefix that Platform and Orchard addresses use on +/// `network` (`dash` on mainnet, `tdash` otherwise). The two families share the +/// HRP; an Orchard address additionally begins its data section with `z`. +fn platform_hrp_for_network(network: Network) -> &'static str { + match network { + Network::Mainnet => PLATFORM_HRP_MAINNET, + _ => PLATFORM_HRP_TESTNET, + } +} + +/// Case-insensitive ASCII prefix test that never panics on multi-byte input. +fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool { + let (s, prefix) = (s.as_bytes(), prefix.as_bytes()); + s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) +} + +/// Validate that a bech32m **Platform** address is for `network` by its prefix. +/// +/// The single source of truth for Platform address↔network checks. Parses the +/// human-readable prefix (`dash1…` mainnet, `tdash1…` testnet, case-insensitive) +/// and rejects a cross-network address. It does not validate the address body — +/// callers still parse it with `PlatformAddress::from_bech32m_string`. +/// +/// # Errors +/// +/// Returns [`AddressNetworkMismatch`] when the prefix is not the one `network` +/// expects (including any input that is not a recognized Platform prefix). +pub fn validate_platform_address_for_network( + address: &str, + network: Network, +) -> Result<(), AddressNetworkMismatch> { + let expected = format!("{}1", platform_hrp_for_network(network)); + if starts_with_ignore_ascii_case(address.trim(), &expected) { + Ok(()) + } else { + Err(AddressNetworkMismatch) + } +} + +/// Validate that a bech32m **Orchard** (shielded) address is for `network`. +/// +/// The Orchard twin of [`validate_platform_address_for_network`]: same HRP, but +/// the data section begins with `z` (`dash1z…` mainnet, `tdash1z…` testnet, +/// case-insensitive). Does not validate the address body — callers still parse +/// it with `OrchardAddress::from_bech32m_string`. +/// +/// # Errors +/// +/// Returns [`AddressNetworkMismatch`] when the prefix is not the one `network` +/// expects. +pub fn validate_orchard_address_for_network( + address: &str, + network: Network, +) -> Result<(), AddressNetworkMismatch> { + let expected = format!("{}1z", platform_hrp_for_network(network)); + if starts_with_ignore_ascii_case(address.trim(), &expected) { + Ok(()) + } else { + Err(AddressNetworkMismatch) + } +} + /// Checks if a string looks like a Platform address (bech32m with dash/tdash HRP per DIP-18). /// /// This checks whether the string starts with a known Platform HRP followed by the /// bech32 separator '1'. It does NOT fully validate the address — use /// `PlatformAddress::from_bech32m_string()` for that. +/// +/// Uses byte-level comparison throughout (`as_bytes()`) to avoid the panic that +/// `s[..hrp.len()]` would produce for non-ASCII (multi-byte UTF-8) input whose +/// HRP-length byte offset falls inside a multi-byte codepoint. pub fn is_platform_address_string(s: &str) -> bool { + let bytes = s.as_bytes(); for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { - if s.len() > hrp.len() - && s[..hrp.len()].eq_ignore_ascii_case(hrp) - && s.as_bytes()[hrp.len()] == b'1' + let hrp_bytes = hrp.as_bytes(); + if bytes.len() > hrp_bytes.len() + && bytes[..hrp_bytes.len()].eq_ignore_ascii_case(hrp_bytes) + && bytes[hrp_bytes.len()] == b'1' { return true; } @@ -88,6 +166,15 @@ impl AddressKind { return Some(AddressKind::Shielded); } + // 1b. Shielded raw hex form (network-agnostic): 43 bytes = 86 hex chars. + // Unambiguous — far too long for a Base58 Core address or Identity ID, + // and it cannot start with the `dash1`/`tdash1` Platform HRP. + if trimmed.len() == SHIELDED_ADDRESS_RAW_LEN * 2 + && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Some(AddressKind::Shielded); + } + // 2. Platform (Bech32m per DIP-18, but NOT shielded — already excluded above) if is_platform_address_string(trimmed) { return Some(AddressKind::Platform); @@ -233,6 +320,31 @@ impl std::fmt::Display for ValidatedAddress { } } +/// Raw byte length of an Orchard shielded address (recipient payload). +pub const SHIELDED_ADDRESS_RAW_LEN: usize = 43; + +/// Parse a shielded (Orchard) recipient into its raw 43-byte form. +/// +/// Accepts either the canonical Bech32m encoding (`dash1z…` mainnet, +/// `tdash1z…` testnet) or a raw hex string of exactly +/// [`SHIELDED_ADDRESS_RAW_LEN`] bytes. Returns `None` for any input that is +/// neither. This is the single source of truth for turning a shielded +/// recipient string into the bytes a `ShieldedTransfer` task needs, shared by +/// the send screen's dispatch and any validation path so the two cannot +/// diverge. +pub fn parse_shielded_recipient(input: &str) -> Option<Vec<u8>> { + use dash_sdk::dpp::address_funds::OrchardAddress; + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(addr) = OrchardAddress::from_bech32m_string(trimmed) { + return Some(addr.to_raw_bytes().to_vec()); + } + let bytes = hex::decode(trimmed).ok()?; + (bytes.len() == SHIELDED_ADDRESS_RAW_LEN).then_some(bytes) +} + /// Truncate an address string for display, showing a prefix and suffix /// separated by an ellipsis. /// @@ -255,6 +367,49 @@ pub fn truncate_address(addr: &str, prefix_len: usize, suffix_len: usize) -> Str mod tests { use super::*; + #[test] + fn parse_shielded_recipient_accepts_exact_length_hex() { + let raw = vec![0xABu8; SHIELDED_ADDRESS_RAW_LEN]; + let hex_str = hex::encode(&raw); + assert_eq!(parse_shielded_recipient(&hex_str), Some(raw.clone())); + // Surrounding whitespace is tolerated. + assert_eq!( + parse_shielded_recipient(&format!(" {hex_str} ")), + Some(raw) + ); + } + + #[test] + fn parse_shielded_recipient_rejects_wrong_length_hex() { + // One byte short and one byte long — both invalid. + assert_eq!( + parse_shielded_recipient(&hex::encode(vec![0u8; SHIELDED_ADDRESS_RAW_LEN - 1])), + None + ); + assert_eq!( + parse_shielded_recipient(&hex::encode(vec![0u8; SHIELDED_ADDRESS_RAW_LEN + 1])), + None + ); + } + + #[test] + fn detect_classifies_43_byte_hex_as_shielded() { + let hex_str = hex::encode(vec![0x11u8; SHIELDED_ADDRESS_RAW_LEN]); + assert_eq!(AddressKind::detect(&hex_str), Some(AddressKind::Shielded)); + // Wrong length is not a shielded address. + let short = hex::encode(vec![0x11u8; SHIELDED_ADDRESS_RAW_LEN - 1]); + assert_ne!(AddressKind::detect(&short), Some(AddressKind::Shielded)); + } + + #[test] + fn parse_shielded_recipient_rejects_empty_and_garbage() { + assert_eq!(parse_shielded_recipient(""), None); + assert_eq!(parse_shielded_recipient(" "), None); + assert_eq!(parse_shielded_recipient("not-an-address"), None); + // Bech32m for a different address family is not a shielded recipient. + assert_eq!(parse_shielded_recipient("dash1qexampleplatform"), None); + } + #[test] fn address_kind_display_names() { assert_eq!(AddressKind::Core.display_name(), "Wallet address"); @@ -399,4 +554,110 @@ mod tests { fn detect_garbage_returns_none() { assert_eq!(AddressKind::detect("not-an-address"), None); } + + // --- Platform/Orchard address↔network validators --- + + #[test] + fn platform_address_matches_its_network() { + assert!(validate_platform_address_for_network("dash1qwer1234", Network::Mainnet).is_ok()); + assert!(validate_platform_address_for_network("tdash1qwer1234", Network::Testnet).is_ok()); + } + + #[test] + fn platform_address_wrong_network_is_typed_mismatch() { + // Mainnet address on testnet, and testnet address on mainnet. + assert_eq!( + validate_platform_address_for_network("dash1qwer1234", Network::Testnet), + Err(AddressNetworkMismatch) + ); + assert_eq!( + validate_platform_address_for_network("tdash1qwer1234", Network::Mainnet), + Err(AddressNetworkMismatch) + ); + } + + #[test] + fn orchard_address_matches_its_network() { + assert!(validate_orchard_address_for_network("dash1zqwer1234", Network::Mainnet).is_ok()); + assert!(validate_orchard_address_for_network("tdash1zqwer1234", Network::Testnet).is_ok()); + } + + #[test] + fn orchard_address_wrong_network_is_typed_mismatch() { + assert_eq!( + validate_orchard_address_for_network("dash1zqwer1234", Network::Testnet), + Err(AddressNetworkMismatch) + ); + assert_eq!( + validate_orchard_address_for_network("tdash1zqwer1234", Network::Mainnet), + Err(AddressNetworkMismatch) + ); + } + + #[test] + fn platform_validator_rejects_orchard_prefix_as_wrong_family_on_wrong_network() { + // A mainnet Orchard address (`dash1z…`) is not a testnet Platform address. + assert_eq!( + validate_platform_address_for_network("dash1zqwer1234", Network::Testnet), + Err(AddressNetworkMismatch) + ); + } + + #[test] + fn network_mismatch_message_is_user_facing() { + // The Display string is the calm, jargon-free banner both layers show. + assert_eq!( + AddressNetworkMismatch.to_string(), + "This address belongs to a different network. Please check you are using the correct network." + ); + } + + #[test] + fn network_validators_ignore_surrounding_whitespace_and_case() { + assert!(validate_platform_address_for_network(" DASH1QWER ", Network::Mainnet).is_ok()); + assert!(validate_orchard_address_for_network(" TDASH1ZQWER ", Network::Testnet).is_ok()); + } + + // --- is_platform_address_string pitfall guard (TC-MN-031) --- + // + // The masternode withdraw tool rejects Platform destinations with this + // guard, NOT the weaker first-char `resolve::validate_address`. A `dash1…` + // / `tdash1…` string must be flagged as Platform so it cannot slip through + // as a Core address. + + #[test] + fn platform_address_string_detects_bech32m_hrp() { + assert!(is_platform_address_string("dash1qwer1234")); + assert!(is_platform_address_string("tdash1qwer1234")); + } + + #[test] + fn platform_address_string_rejects_core_addresses() { + // Mainnet (X) and testnet (y) Core addresses are not Platform addresses. + assert!(!is_platform_address_string( + "XqHiz9VVXfjBnET2z6aZ9j5LKyuGNv3byP" + )); + assert!(!is_platform_address_string( + "yQ9JNCT4S9zVHaKYbr1FUY4YkUMYxSzWAj" + )); + } + + // TC-MN-B1 — non-ASCII input must never panic. + // + // The old implementation did `s[..hrp.len()]` which is byte-slicing a + // `&str`; that panics when byte offset `hrp.len()` (4 for "dash", 5 for + // "tdash") falls inside a multi-byte UTF-8 codepoint. The fix switches + // to `as_bytes()` throughout. This regression test must never panic. + #[test] + fn platform_address_string_non_ascii_does_not_panic() { + // "日本ABC" — each CJK char is 3 UTF-8 bytes; total 9 bytes. + // The old code: s.len()=9 > hrp.len()=4, then s[..4] panics because + // byte 4 is inside "本". + assert!(!is_platform_address_string("日本ABC")); + // Also test strings that are shorter than the HRP in chars but longer + // in bytes (no panic even without the length guard being satisfied). + assert!(!is_platform_address_string("日")); + // Non-ASCII that starts "like" a HRP (ASCII bytes matching then UTF-8) + assert!(!is_platform_address_string("dash\u{00E9}test")); + } } diff --git a/src/model/amount.rs b/src/model/amount.rs index 2c3dd51e8..a4208439d 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -1,7 +1,5 @@ use bincode::{Decode, Encode}; use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Duffs, TokenAmount}; -use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; -use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Display}; @@ -31,8 +29,16 @@ pub struct Amount { } impl PartialOrd for Amount { + /// Orders two amounts by their underlying value, but only when they refer to + /// the same token (matching unit name and decimal places). Amounts of + /// different tokens are not comparable and yield `None`, keeping ordering + /// consistent with equality and preventing meaningless cross-token compares. fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { - Some(self.value.cmp(&other.value)) + if self.is_same_token(other) { + Some(self.value.cmp(&other.value)) + } else { + None + } } } @@ -81,18 +87,6 @@ impl Amount { } } - /// Creates a new Amount configured for a specific token. - /// - /// This extracts the decimal places and token alias from the token configuration - /// and creates an Amount with the specified value. - pub fn from_token( - token_info: &crate::ui::tokens::tokens_screen::IdentityTokenInfo, - value: TokenAmount, - ) -> Self { - let decimal_places = token_info.token_config.conventions().decimals(); - Self::new(value, decimal_places).with_unit_name(&token_info.token_alias) - } - /// Creates a new Amount based on a floating-point value. /// /// Note that this is imprecise due to floating-point representation. Prefer using [Amount::new]. @@ -357,47 +351,6 @@ impl AsRef<Amount> for Amount { } } -/// Conversion implementations for token types -impl From<&crate::ui::tokens::tokens_screen::IdentityTokenBalance> for Amount { - /// Converts an IdentityTokenBalance to an Amount. - /// - /// The decimal places are automatically determined from the token configuration, - /// and the token alias is used as the unit name. - fn from(token_balance: &crate::ui::tokens::tokens_screen::IdentityTokenBalance) -> Self { - let decimal_places = token_balance.token_config.conventions().decimals(); - Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) - } -} - -impl From<crate::ui::tokens::tokens_screen::IdentityTokenBalance> for Amount { - /// Converts an owned IdentityTokenBalance to an Amount. - fn from(token_balance: crate::ui::tokens::tokens_screen::IdentityTokenBalance) -> Self { - Self::from(&token_balance) - } -} - -impl From<&crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions> for Amount { - /// Converts an IdentityTokenBalanceWithActions to an Amount. - /// - /// The decimal places are automatically determined from the token configuration, - /// and the token alias is used as the unit name. - fn from( - token_balance: &crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions, - ) -> Self { - let decimal_places = token_balance.token_config.conventions().decimals(); - Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) - } -} - -impl From<crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions> for Amount { - /// Converts an owned IdentityTokenBalanceWithActions to an Amount. - fn from( - token_balance: crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions, - ) -> Self { - Self::from(&token_balance) - } -} - /// Helper function to convert f64 to u64, with checks for overflow. /// It rounds the value to the nearest u64, ensuring it is within bounds. fn checked_round(value: f64) -> Result<u64, String> { @@ -659,6 +612,27 @@ mod tests { assert_eq!(format!("{}", multi_word_unit), "100 US Dollar"); } + #[test] + fn test_partial_cmp_same_token_orders_by_value() { + let a = Amount::new(100, 8).with_unit_name("BTC"); + let b = Amount::new(200, 8).with_unit_name("BTC"); + assert!(a < b); + assert!(b > a); + assert_eq!(a.partial_cmp(&a), Some(std::cmp::Ordering::Equal)); + } + + #[test] + fn test_partial_cmp_different_token_is_incomparable() { + let btc = Amount::new(100, 8).with_unit_name("BTC"); + let usd = Amount::new(100, 8).with_unit_name("USD"); + let btc_other_decimals = Amount::new(100, 2).with_unit_name("BTC"); + + // Different unit name or decimal places => not comparable, so every + // ordering query yields `None` and std's comparison operators are false. + assert_eq!(btc.partial_cmp(&usd), None); + assert_eq!(btc.partial_cmp(&btc_other_decimals), None); + } + #[test] fn test_to_string_without_unit() { // Test amount without unit diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index aea6556a9..f30666b7b 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -15,13 +15,9 @@ pub enum ContestState { } impl ContestState { - #[allow(dead_code)] // May be used for UI state validation - #[allow(clippy::match_like_matches_macro)] + /// Whether the contest still accepts votes — `Joinable` or `Ongoing`. pub fn state_is_votable(&self) -> bool { - match self { - ContestState::Joinable | ContestState::Ongoing => true, - _ => false, - } + matches!(self, ContestState::Joinable | ContestState::Ongoing) } } @@ -38,6 +34,30 @@ pub struct ContestedName { pub my_votes: BTreeMap<(Identifier, PrivateKeyTarget, KeyID), ResourceVoteChoice>, } +impl ContestedName { + /// Whether `voter_id` still has an actionable vote to cast on this contest: + /// the contest is in a votable state and the voter has not already recorded + /// a vote on it. Drives the Masternodes card DPNS status line (§10.1). + pub fn is_open_for_voter(&self, voter_id: &Identifier) -> bool { + self.state.state_is_votable() && !self.my_votes.keys().any(|(id, _, _)| id == voter_id) + } +} + +/// Per-node DPNS voting summary shown on the Masternodes card grid. +/// +/// Composed by a display-layer read of existing contest + scheduled-vote state +/// (no new backend concept). Feeds the count-first status line: open contests +/// take precedence, then a pending scheduled vote, then "no open contests" +/// (requirements §10.1). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MasternodeContestSummary { + /// Number of open contests this node can still vote on. + pub open_contest_count: usize, + /// Whether the node has at least one pending (not-yet-executed) scheduled + /// vote, reusing the DPNS Scheduled Votes screen's existing state. + pub has_scheduled_vote: bool, +} + #[derive(Debug, Encode, Decode, Clone)] pub struct Contestant { pub id: Identifier, @@ -49,3 +69,63 @@ pub struct Contestant { pub created_at_core_block_height: Option<CoreBlockHeight>, pub document_id: Identifier, } + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + + fn contest(state: ContestState) -> ContestedName { + ContestedName { + normalized_contested_name: "alice".to_string(), + contestants: None, + locked_votes: None, + abstain_votes: None, + awarded_to: None, + end_time: None, + state, + last_updated: None, + my_votes: BTreeMap::new(), + } + } + + #[test] + fn open_for_voter_when_votable_and_not_yet_voted() { + let voter = Identifier::from([7u8; 32]); + assert!(contest(ContestState::Ongoing).is_open_for_voter(&voter)); + assert!(contest(ContestState::Joinable).is_open_for_voter(&voter)); + } + + #[test] + fn not_open_when_state_not_votable() { + let voter = Identifier::from([7u8; 32]); + assert!(!contest(ContestState::Locked).is_open_for_voter(&voter)); + assert!(!contest(ContestState::Unknown).is_open_for_voter(&voter)); + assert!( + !contest(ContestState::WonBy(Identifier::from([9u8; 32]))).is_open_for_voter(&voter) + ); + } + + #[test] + fn not_open_when_voter_already_voted() { + let voter = Identifier::from([7u8; 32]); + let mut c = contest(ContestState::Ongoing); + c.my_votes.insert( + (voter, PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0), + ResourceVoteChoice::Abstain, + ); + assert!(!c.is_open_for_voter(&voter)); + } + + #[test] + fn open_when_a_different_voter_already_voted() { + let voter = Identifier::from([7u8; 32]); + let other = Identifier::from([8u8; 32]); + let mut c = contest(ContestState::Ongoing); + c.my_votes.insert( + (other, PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0), + ResourceVoteChoice::Abstain, + ); + assert!(c.is_open_for_voter(&voter)); + } +} diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs new file mode 100644 index 000000000..f09f22622 --- /dev/null +++ b/src/model/dashpay.rs @@ -0,0 +1,396 @@ +//! DashPay domain types shared by the `WalletBackend` adapter, backend tasks, +//! and the UI. Pure data — no I/O, no SDK calls. + +use serde::{Deserialize, Serialize}; + +/// DashPay profile data — the local snapshot of an identity's published profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredProfile { + pub identity_id: Vec<u8>, + pub display_name: Option<String>, + pub bio: Option<String>, + pub avatar_url: Option<String>, + pub avatar_hash: Option<Vec<u8>>, + pub avatar_fingerprint: Option<Vec<u8>>, + pub avatar_bytes: Option<Vec<u8>>, + pub public_message: Option<String>, + pub created_at: i64, + pub updated_at: i64, +} + +/// Relationship state of a DashPay contact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContactStatus { + Pending, + Accepted, + Blocked, +} + +/// Direction of a DashPay contact request relative to the local identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContactRequestDirection { + Sent, + Received, +} + +/// Lifecycle state of a DashPay contact request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContactRequestStatus { + Pending, + Accepted, + Rejected, + Expired, +} + +/// Direction of a DashPay payment relative to the local identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentDirection { + Sent, + Received, +} + +/// Lifecycle state of a DashPay payment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentStatus { + Pending, + Confirmed, + Failed, +} + +/// DashPay contact — an accepted or pending relationship between two identities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContact { + pub owner_identity_id: Vec<u8>, + pub contact_identity_id: Vec<u8>, + pub username: Option<String>, + pub display_name: Option<String>, + pub avatar_url: Option<String>, + pub public_message: Option<String>, + pub contact_status: ContactStatus, + pub created_at: i64, + pub updated_at: i64, + pub last_seen: Option<i64>, +} + +/// DashPay contact request — pending, accepted, rejected, or expired. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContactRequest { + pub id: i64, + pub from_identity_id: Vec<u8>, + pub to_identity_id: Vec<u8>, + pub to_username: Option<String>, + pub account_label: Option<String>, + pub request_type: ContactRequestDirection, + pub status: ContactRequestStatus, + pub created_at: i64, + pub responded_at: Option<i64>, + pub expires_at: Option<i64>, +} + +/// DashPay payment record. `amount` is in credits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredPayment { + pub id: i64, + pub tx_id: String, + pub from_identity_id: Vec<u8>, + pub to_identity_id: Vec<u8>, + pub amount: i64, + pub memo: Option<String>, + pub payment_type: PaymentDirection, + pub status: PaymentStatus, + pub created_at: i64, + pub confirmed_at: Option<i64>, +} + +/// DashPay contact address index tracking per DIP-0015. +/// +/// Tracks address indices used for sending to / receiving from a specific +/// contact, plus how many addresses have been registered with the bloom filter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactAddressIndex { + pub owner_identity_id: Vec<u8>, + pub contact_identity_id: Vec<u8>, + /// Next address index to use when sending TO this contact. + pub next_send_index: u32, + /// Highest address index seen when receiving FROM this contact (for bloom filter). + pub highest_receive_index: u32, + /// Number of addresses registered in the bloom filter for this contact. + pub bloom_registered_count: u32, +} + +/// A received output observed on-chain that may be an incoming DashPay +/// contact payment. +/// +/// The [`EventBridge`](crate::wallet_backend::EventBridge) extracts these +/// from freshly-seen wallet transactions and hands them to the +/// detect-match-record path, which resolves each `address` against the +/// per-identity DashPay address map. Outputs whose address is not a +/// registered contact-receiving address are ignored — this carries every +/// received output, not only the DashPay ones, so the detector owns the +/// matching decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedIncomingOutput { + /// Hex transaction id of the observed transaction. + pub txid: String, + /// Index of this output within the transaction (the `vout`). A single + /// transaction can pay two different contact addresses, so the payment is + /// keyed by `(txid, vout)` — `txid` alone would let the second output + /// overwrite the first. + pub vout: u32, + /// Base58 receiving address the output paid into. + pub address: String, + /// Output value in duffs. + pub amount_duffs: u64, +} + +/// Build the storage key that distinguishes each output of one transaction. +/// +/// Upstream `record_dashpay_payment` keys its payment map by an opaque `tx_id` +/// `String` with last-write-wins semantics, so two contact outputs in the same +/// transaction would collide on the bare txid. Keying by `"{txid}:{vout}"` +/// keeps every output a distinct record while remaining an idempotent upsert +/// per `(txid, vout)` on re-scan. +pub fn payment_storage_key(txid: &str, vout: u32) -> String { + format!("{txid}:{vout}") +} + +/// Recover the bare transaction id from a [`payment_storage_key`]. +/// +/// Splits on the last `:` and validates that the suffix is a `vout` integer; +/// returns the input unchanged when it carries no `:vout` suffix (a plain txid, +/// e.g. a legacy or sent-payment record keyed by txid alone). The transaction +/// id itself never contains a `:`, so the last-colon split is unambiguous. +pub fn payment_txid_from_storage_key(key: &str) -> &str { + match key.rsplit_once(':') { + Some((txid, vout)) if !vout.is_empty() && vout.bytes().all(|b| b.is_ascii_digit()) => txid, + _ => key, + } +} + +/// A cached avatar image, stored DET-side so avatars survive offline and are +/// not re-fetched from the network on every contact view. +/// +/// Keyed by the avatar URL. The raw image `bytes` are validated before +/// caching, so a cache hit can be decoded directly. `sha256` is the content +/// hash used to detect a changed image at the same URL (cache invalidation), +/// and `fetched_at_ms` is the wall-clock fetch time for age-based eviction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedAvatar { + /// Raw, already-validated image bytes. + pub bytes: Vec<u8>, + /// SHA-256 of `bytes` — detects a content change at the same URL. + pub sha256: Vec<u8>, + /// Unix milliseconds at fetch time, for age-based invalidation. + pub fetched_at_ms: i64, +} + +/// DET-local private contact memo (nickname / notes / hidden flag). +/// +/// Mirrors the legacy `contact_private_info` SQLite row shape but lives +/// entirely in the per-network k/v sidecar. No upstream counterpart — +/// DashPay carries this state encrypted in `contactInfo` documents, and +/// DET keeps a local plaintext snapshot for offline-friendly display. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContactPrivateInfo { + pub nickname: String, + pub notes: String, + pub is_hidden: bool, +} + +/// Maximum `displayName` length in Unicode characters (DIP-0015). Empty is legal. +pub const MAX_DISPLAY_NAME_CHARS: usize = 25; +/// Maximum `publicMessage` (bio) length in Unicode characters (DIP-0015). +pub const MAX_BIO_CHARS: usize = 140; +/// Maximum `avatarUrl` length in Unicode characters (DIP-0015). +pub const MAX_AVATAR_URL_CHARS: usize = 2048; + +/// A single DashPay profile field violation, returned by [`validate_profile_fields`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileFieldError { + /// Display name exceeds [`MAX_DISPLAY_NAME_CHARS`]. + DisplayNameTooLong { len: usize, max: usize }, + /// Bio exceeds [`MAX_BIO_CHARS`]. + BioTooLong { len: usize, max: usize }, + /// Avatar URL exceeds [`MAX_AVATAR_URL_CHARS`]. + AvatarUrlTooLong { len: usize, max: usize }, + /// Avatar URL is set but does not start with `http://` or `https://`. + AvatarUrlInvalidScheme, +} + +impl ProfileFieldError { + /// User-facing, i18n-ready message describing the violation. + pub fn message(&self) -> String { + match self { + ProfileFieldError::DisplayNameTooLong { len, max } => { + format!("Display name is {len} characters, but the maximum is {max}.") + } + ProfileFieldError::BioTooLong { len, max } => { + format!("Bio is {len} characters, but the maximum is {max}.") + } + ProfileFieldError::AvatarUrlTooLong { len, max } => { + format!("Avatar URL is {len} characters, but the maximum is {max}.") + } + ProfileFieldError::AvatarUrlInvalidScheme => { + "Avatar URL must start with http:// or https://.".to_string() + } + } + } +} + +/// Validate DashPay profile fields per DIP-0015, the single source of truth for +/// both the profile editors and the backend enforcement layer. +/// +/// Lengths are measured in Unicode characters (`chars().count()`), matching the +/// protocol. Every field may be empty — an unset display name, bio, or avatar is +/// valid. The avatar URL is trimmed before its scheme and length are checked. +/// Returns every violation found (empty when the input is valid). +pub fn validate_profile_fields( + display_name: &str, + bio: &str, + avatar_url: &str, +) -> Vec<ProfileFieldError> { + let mut errors = Vec::new(); + + let name_len = display_name.chars().count(); + if name_len > MAX_DISPLAY_NAME_CHARS { + errors.push(ProfileFieldError::DisplayNameTooLong { + len: name_len, + max: MAX_DISPLAY_NAME_CHARS, + }); + } + + let bio_len = bio.chars().count(); + if bio_len > MAX_BIO_CHARS { + errors.push(ProfileFieldError::BioTooLong { + len: bio_len, + max: MAX_BIO_CHARS, + }); + } + + let url = avatar_url.trim(); + let url_len = url.chars().count(); + if url_len > MAX_AVATAR_URL_CHARS { + errors.push(ProfileFieldError::AvatarUrlTooLong { + len: url_len, + max: MAX_AVATAR_URL_CHARS, + }); + } + if !url.is_empty() && !url.starts_with("http://") && !url.starts_with("https://") { + errors.push(ProfileFieldError::AvatarUrlInvalidScheme); + } + + errors +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn storage_key_distinguishes_outputs_of_one_tx() { + let a = payment_storage_key("abc123", 0); + let b = payment_storage_key("abc123", 1); + assert_ne!(a, b, "two outputs of one tx must produce distinct keys"); + assert_eq!(a, "abc123:0"); + assert_eq!(b, "abc123:1"); + } + + #[test] + fn storage_key_round_trips_to_bare_txid() { + let key = payment_storage_key("deadbeef", 7); + assert_eq!(payment_txid_from_storage_key(&key), "deadbeef"); + } + + #[test] + fn bare_txid_without_vout_suffix_is_returned_unchanged() { + // A sent-payment / legacy record keyed by txid alone has no ":vout". + assert_eq!(payment_txid_from_storage_key("plainTxid"), "plainTxid"); + } + + #[test] + fn non_numeric_suffix_is_not_treated_as_vout() { + // Defensive: a colon followed by non-digits is not a vout suffix, so the + // whole string is the txid (txids themselves never contain a colon). + assert_eq!(payment_txid_from_storage_key("tx:abc"), "tx:abc"); + assert_eq!(payment_txid_from_storage_key("tx:"), "tx:"); + } + + #[test] + fn valid_profile_fields_report_no_errors() { + assert!( + validate_profile_fields("Alex", "A short bio.", "https://example.com/a.png").is_empty() + ); + } + + #[test] + fn empty_profile_fields_are_legal() { + // Every field is optional per DIP-0015 — an empty display name, bio, and + // avatar URL together are a valid (blank) profile. + assert!(validate_profile_fields("", "", "").is_empty()); + } + + #[test] + fn display_name_over_limit_is_rejected() { + let name = "a".repeat(MAX_DISPLAY_NAME_CHARS + 1); + let errors = validate_profile_fields(&name, "", ""); + assert_eq!( + errors, + vec![ProfileFieldError::DisplayNameTooLong { + len: MAX_DISPLAY_NAME_CHARS + 1, + max: MAX_DISPLAY_NAME_CHARS, + }] + ); + } + + #[test] + fn length_is_measured_in_characters_not_bytes() { + // 25 multi-byte characters are within the 25-character limit even though + // the byte length far exceeds it — the check must use character count. + let name: String = "é".repeat(MAX_DISPLAY_NAME_CHARS); + assert!( + name.len() > MAX_DISPLAY_NAME_CHARS, + "precondition: bytes exceed chars" + ); + assert!(validate_profile_fields(&name, "", "").is_empty()); + } + + #[test] + fn bio_over_limit_is_rejected() { + let bio = "b".repeat(MAX_BIO_CHARS + 1); + assert_eq!( + validate_profile_fields("", &bio, ""), + vec![ProfileFieldError::BioTooLong { + len: MAX_BIO_CHARS + 1, + max: MAX_BIO_CHARS, + }] + ); + } + + #[test] + fn avatar_url_over_limit_is_rejected() { + let url = format!("https://example.com/{}", "x".repeat(MAX_AVATAR_URL_CHARS)); + let errors = validate_profile_fields("", "", &url); + assert!(errors.contains(&ProfileFieldError::AvatarUrlTooLong { + len: url.chars().count(), + max: MAX_AVATAR_URL_CHARS, + })); + } + + #[test] + fn avatar_url_without_http_scheme_is_rejected() { + assert_eq!( + validate_profile_fields("", "", "ftp://example.com/a.png"), + vec![ProfileFieldError::AvatarUrlInvalidScheme] + ); + } + + #[test] + fn avatar_url_scheme_check_ignores_surrounding_whitespace() { + assert!(validate_profile_fields("", "", " https://example.com/a.png ").is_empty()); + } +} diff --git a/src/backend_task/dashpay/dip14_derivation.rs b/src/model/dashpay_derivation/dip14.rs similarity index 75% rename from src/backend_task/dashpay/dip14_derivation.rs rename to src/model/dashpay_derivation/dip14.rs index fbe99ecfe..923a1fc36 100644 --- a/src/backend_task/dashpay/dip14_derivation.rs +++ b/src/model/dashpay_derivation/dip14.rs @@ -10,6 +10,8 @@ use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ExtendedPrivKey, ExtendedPubKey, Fingerprint}; use dash_sdk::platform::Identifier; +use super::DerivationError; + /// Perform DIP-14 compliant 256-bit child key derivation for private keys /// /// This implements CKDpriv256 as specified in DIP-0014: @@ -19,7 +21,7 @@ pub fn ckd_priv_256( parent_key: &ExtendedPrivKey, index: &[u8; 32], // 256-bit index hardened: bool, -) -> Result<ExtendedPrivKey, String> { +) -> Result<ExtendedPrivKey, DerivationError> { let secp = Secp256k1::new(); // Check if this is a compatibility mode derivation (index < 2^32) @@ -61,14 +63,10 @@ pub fn ckd_priv_256( let (i_l, i_r) = hmac_bytes.split_at(32); // Parse I_L as a private key and add to parent key - let i_l_key = SecretKey::from_slice(i_l) - .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; + let i_l_key = SecretKey::from_slice(i_l)?; // k_i = parse_256(I_L) + k_par (mod n) - let child_key = parent_key - .private_key - .add_tweak(&i_l_key.into()) - .map_err(|e| format!("Failed to add tweak to parent key: {}", e))?; + let child_key = parent_key.private_key.add_tweak(&i_l_key.into())?; // Chain code is I_R (32 bytes) let mut chain_code_bytes = [0u8; 32]; @@ -84,103 +82,39 @@ pub fn ckd_priv_256( network: parent_key.network, depth: parent_key.depth + 1, parent_fingerprint, - child_number: index_to_child_number(index, hardened)?, + child_number: index_to_child_number(index, hardened), private_key: child_key, chain_code: child_chain_code, }) } -/// Perform DIP-14 compliant 256-bit child key derivation for public keys -/// -/// This implements CKDpub256 as specified in DIP-0014: -/// - Only works for non-hardened derivation -/// - For indices < 2^32, uses standard BIP32 derivation for compatibility -/// - For indices >= 2^32, uses 256-bit derivation with ser_256(i) -pub fn ckd_pub_256( - parent_key: &ExtendedPubKey, - index: &[u8; 32], // 256-bit index - hardened: bool, -) -> Result<ExtendedPubKey, String> { - if hardened { - return Err("Cannot derive hardened child from extended public key".to_string()); - } - - let secp = Secp256k1::new(); - - // Check if this is a compatibility mode derivation (index < 2^32) - let is_compatibility_mode = is_index_less_than_2_32(index); - - // Prepare HMAC data - let mut hmac_engine = HmacEngine::<sha512::Hash>::new(&parent_key.chain_code.to_bytes()); - - // Non-hardened derivation: ser_P(K_par) || ser(i) - hmac_engine.input(&parent_key.public_key.serialize()); - - if is_compatibility_mode { - // Use ser_32(i) for compatibility - hmac_engine.input(&index[28..32]); - } else { - // Use ser_256(i) for full 256-bit - hmac_engine.input(index); - } - - let hmac_result = Hmac::<sha512::Hash>::from_engine(hmac_engine); - let hmac_bytes = hmac_result.to_byte_array(); - - // Split into I_L (first 32 bytes) and I_R (last 32 bytes) - let (i_l, i_r) = hmac_bytes.split_at(32); - - // Parse I_L as a secret key for the tweak - let i_l_key = SecretKey::from_slice(i_l) - .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; - - // K_i = point(parse_256(I_L)) + K_par - let child_pubkey = parent_key - .public_key - .add_exp_tweak(&secp, &i_l_key.into()) - .map_err(|e| format!("Failed to add tweak to parent public key: {}", e))?; - - // Chain code is I_R (32 bytes) - let mut chain_code_bytes = [0u8; 32]; - chain_code_bytes.copy_from_slice(i_r); - let child_chain_code = ChainCode::from(chain_code_bytes); - - // Create the child extended public key - Ok(ExtendedPubKey { - network: parent_key.network, - depth: parent_key.depth + 1, - parent_fingerprint: parent_key.parent_fingerprint, - child_number: index_to_child_number(index, false)?, - public_key: child_pubkey, - chain_code: child_chain_code, - }) -} - /// Derive DashPay incoming funds extended public key using DIP-14 compliant derivation -/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) +/// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) +/// +/// The coin type is selected by `network` (5' on Mainnet, 1' on +/// Testnet/Devnet/Regtest) so this receive-side scanning xpub matches the +/// send-side xpub the contact pays into on every network. pub fn derive_dashpay_incoming_xpub_dip14( master_seed: &[u8], network: Network, account: u32, sender_id: &Identifier, recipient_id: &Identifier, -) -> Result<ExtendedPubKey, String> { +) -> Result<ExtendedPubKey, DerivationError> { + use crate::model::wallet::coin_type_for_network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use std::str::FromStr; // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; + let master_xprv = ExtendedPrivKey::new_master(network, master_seed)?; - // Build derivation path for the base: m/9'/5'/15'/account' - let base_path = DerivationPath::from_str(&format!("m/9'/5'/15'/{}'", account)) - .map_err(|e| format!("Invalid derivation path: {}", e))?; + // Build derivation path for the base: m/9'/coin'/15'/account' + let coin_type = coin_type_for_network(network); + let base_path = DerivationPath::from_str(&format!("m/9'/{coin_type}'/15'/{account}'"))?; // Derive to the account level using standard BIP32 let secp = Secp256k1::new(); - let account_xprv = master_xprv - .derive_priv(&secp, &base_path) - .map_err(|e| format!("Failed to derive account key: {}", e))?; + let account_xprv = master_xprv.derive_priv(&secp, &base_path)?; // Now use DIP-14 256-bit derivation for the identity levels // Derive: account_key/(sender_id) @@ -213,7 +147,7 @@ fn is_index_less_than_2_32(index: &[u8; 32]) -> bool { fn index_to_child_number( index: &[u8; 32], hardened: bool, -) -> Result<dash_sdk::dpp::key_wallet::bip32::ChildNumber, String> { +) -> dash_sdk::dpp::key_wallet::bip32::ChildNumber { use dash_sdk::dpp::key_wallet::bip32::ChildNumber; // For compatibility with existing ChildNumber structure, @@ -233,12 +167,11 @@ fn index_to_child_number( if hardened { // Set the hardened bit (bit 31) num |= 0x80000000; - Ok(ChildNumber::from(num)) } else { // Clear bit 31 to ensure it's within normal range num &= 0x7FFFFFFF; - Ok(ChildNumber::from(num)) } + ChildNumber::from(num) } /// Calculate fingerprint for a public key (first 4 bytes of HASH160) @@ -371,7 +304,7 @@ mod tests { let xpub = xpub.unwrap(); // Verify the derivation depth is correct (base path + 2 identity levels) - // m/9'/5'/15'/0'/(sender)/(recipient) = depth 6 + // m/9'/1'/15'/0'/(sender)/(recipient) on testnet = depth 6 assert_eq!(xpub.depth, 6); } } diff --git a/src/backend_task/dashpay/hd_derivation.rs b/src/model/dashpay_derivation/hd.rs similarity index 85% rename from src/backend_task/dashpay/hd_derivation.rs rename to src/model/dashpay_derivation/hd.rs index 6db0e179f..9b38fbd8b 100644 --- a/src/backend_task/dashpay/hd_derivation.rs +++ b/src/model/dashpay_derivation/hd.rs @@ -6,26 +6,28 @@ use dash_sdk::dpp::key_wallet::bip32::{ use dash_sdk::platform::Identifier; use std::str::FromStr; -// Import our DIP-14 compliant derivation functions -use super::dip14_derivation::derive_dashpay_incoming_xpub_dip14; +use super::DerivationError; +use super::dip14::derive_dashpay_incoming_xpub_dip14; /// DashPay auto-accept proof feature index - use the constant from dip9 if available const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; /// Derive the DashPay incoming funds extended public key for a contact relationship -/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) +/// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) /// /// This creates a unique derivation path for each contact relationship, -/// allowing for unique payment addresses between any two identities. +/// allowing for unique payment addresses between any two identities. The coin +/// type is selected per network (5' on Mainnet, 1' on Testnet/Devnet/Regtest) +/// so the scanning xpub matches the send-side xpub published to contacts. /// -/// This function now uses DIP-14 compliant 256-bit derivation for identity IDs. +/// This function uses DIP-14 compliant 256-bit derivation for identity IDs. pub fn derive_dashpay_incoming_xpub( master_seed: &[u8], network: Network, account: u32, sender_id: &Identifier, recipient_id: &Identifier, -) -> Result<ExtendedPubKey, String> { +) -> Result<ExtendedPubKey, DerivationError> { // Use the DIP-14 compliant implementation derive_dashpay_incoming_xpub_dip14(master_seed, network, account, sender_id, recipient_id) } @@ -35,16 +37,11 @@ pub fn derive_dashpay_incoming_xpub( pub fn derive_payment_address( contact_xpub: &ExtendedPubKey, index: u32, -) -> Result<dash_sdk::dpp::dashcore::Address, String> { +) -> Result<dash_sdk::dpp::dashcore::Address, DerivationError> { let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); // Derive the specific address key - let address_key = contact_xpub - .derive_pub( - &secp, - &[ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid index: {}", e))?], - ) - .map_err(|e| format!("Failed to derive address key: {}", e))?; + let address_key = contact_xpub.derive_pub(&secp, &[ChildNumber::from_normal_idx(index)?])?; // Convert to Dash address // The ExtendedPubKey's public_key is a secp256k1::PublicKey @@ -56,26 +53,6 @@ pub fn derive_payment_address( Ok(address) } -/// Convert an Identifier to a ChildNumber for compatibility with existing code -/// Note: This is only used for backwards compatibility. The actual DIP-14 -/// compliant derivation is handled in the dip14_derivation module. -#[allow(dead_code)] -fn identity_to_child_number(id: &Identifier, hardened: bool) -> Result<ChildNumber, String> { - let id_bytes = id.to_buffer(); - - // Take last 4 bytes for ChildNumber representation - // This is just for storage/display purposes, actual derivation uses full 256-bit - let mut index_bytes = [0u8; 4]; - index_bytes.copy_from_slice(&id_bytes[28..32]); - let index = u32::from_be_bytes(index_bytes); - - if hardened { - ChildNumber::from_hardened_idx(index).map_err(|e| format!("Invalid hardened index: {}", e)) - } else { - ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid normal index: {}", e)) - } -} - /// Generate the extended public key data for a contact request /// Returns (parent_fingerprint, chain_code, public_key_bytes) #[allow(clippy::type_complexity)] @@ -85,7 +62,7 @@ pub fn generate_contact_xpub_data( account: u32, sender_id: &Identifier, recipient_id: &Identifier, -) -> Result<([u8; 4], [u8; 32], [u8; 33]), String> { +) -> Result<([u8; 4], [u8; 32], [u8; 33]), DerivationError> { // Derive the extended public key for this contact let xpub = derive_dashpay_incoming_xpub(master_seed, network, account, sender_id, recipient_id)?; @@ -101,27 +78,26 @@ pub fn generate_contact_xpub_data( } /// Derive auto-accept proof key according to DIP-0015 -/// Path: m/9'/5'/16'/timestamp' +/// Path: m/9'/coin'/16'/timestamp' pub fn derive_auto_accept_key( master_seed: &[u8], network: Network, timestamp: u32, -) -> Result<ExtendedPrivKey, String> { +) -> Result<ExtendedPrivKey, DerivationError> { + use crate::model::wallet::coin_type_for_network; + // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed) - .map_err(|e| format!("Failed to create master key: {}", e))?; + let master_xprv = ExtendedPrivKey::new_master(network, master_seed)?; - // Build derivation path: m/9'/5'/16'/timestamp' + // Build derivation path: m/9'/coin'/16'/timestamp' + let coin_type = coin_type_for_network(network); let path = DerivationPath::from_str(&format!( - "m/9'/5'/{}'/{}'", - DASHPAY_AUTO_ACCEPT_FEATURE, timestamp - )) - .map_err(|e| format!("Invalid derivation path: {}", e))?; + "m/9'/{coin_type}'/{DASHPAY_AUTO_ACCEPT_FEATURE}'/{timestamp}'" + ))?; // Derive the key - let auto_accept_key = master_xprv - .derive_priv(&dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), &path) - .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; + let auto_accept_key = + master_xprv.derive_priv(&dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), &path)?; Ok(auto_accept_key) } @@ -263,7 +239,7 @@ mod tests { // Verify the key was derived correctly assert_eq!(key.network, network); - assert_eq!(key.depth, 4); // m/9'/5'/16'/timestamp' = depth 4 + assert_eq!(key.depth, 4); // m/9'/1'/16'/timestamp' on testnet = depth 4 } #[test] @@ -392,8 +368,14 @@ mod tests { ) .expect("Should derive xpub for mainnet"); - // Keys should be the same but network should differ + // Coin type differs by network (1' testnet, 5' mainnet), so the keys + // themselves must differ — not just the network tag. assert_eq!(xpub_testnet.network, Network::Testnet); assert_eq!(xpub_mainnet.network, Network::Mainnet); + assert_ne!( + xpub_testnet.public_key.serialize(), + xpub_mainnet.public_key.serialize(), + "different coin types must produce different keys" + ); } } diff --git a/src/model/dashpay_derivation/mod.rs b/src/model/dashpay_derivation/mod.rs new file mode 100644 index 000000000..4363ffe6a --- /dev/null +++ b/src/model/dashpay_derivation/mod.rs @@ -0,0 +1,28 @@ +//! Pure DashPay HD key derivation (DIP-14 / DIP-15). +//! +//! Stateless derivation of contact payment keys and auto-accept proof keys +//! from a wallet seed. No `AppContext`, `Sdk`, database, or `BackendTask` +//! dependency — the single source of truth for DashPay derivation math. + +pub mod dip14; +pub mod hd; + +pub use hd::{ + calculate_account_reference, derive_auto_accept_key, derive_dashpay_incoming_xpub, + derive_payment_address, generate_contact_xpub_data, +}; + +use thiserror::Error; + +/// Failure while deriving a DashPay HD key. +#[derive(Debug, Error)] +pub enum DerivationError { + /// A BIP-32 extended-key operation failed (master creation, path parsing, + /// or child derivation). + #[error("The wallet keys could not be derived. The wallet data may be invalid.")] + Bip32(#[from] dash_sdk::dpp::key_wallet::bip32::Error), + + /// A secp256k1 operation on derived key material failed. + #[error("The wallet key material could not be processed. The wallet data may be invalid.")] + Secp256k1(#[from] dash_sdk::dpp::dashcore::secp256k1::Error), +} diff --git a/src/model/dpns.rs b/src/model/dpns.rs index e590a2872..06f1cd9b5 100644 --- a/src/model/dpns.rs +++ b/src/model/dpns.rs @@ -31,18 +31,20 @@ pub fn normalize_dpns_label(input: &str) -> String { /// Returns the bare label portion, or the full input if no suffix is present. pub fn strip_dash_suffix(input: &str) -> &str { let trimmed = input.trim(); - let suffix_start = trimmed.len().saturating_sub(DASH_SUFFIX.len()); - if trimmed.len() > DASH_SUFFIX.len() - && trimmed - .get(suffix_start..) - .is_some_and(|tail| tail.eq_ignore_ascii_case(DASH_SUFFIX)) - { - &trimmed[..suffix_start] + if has_dash_suffix(trimmed) { + // `has_dash_suffix` guarantees a `.dash` tail longer than the suffix, so + // this ASCII-boundary slice is always valid. + &trimmed[..trimmed.len() - DASH_SUFFIX.len()] } else { trimmed } } +/// The DPNS input carries a domain suffix other than `.dash`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("A DPNS name must end in .dash or be a plain label.")] +pub struct NonDashDomainError; + /// Validate that a DPNS username-or-ID input has an acceptable format. /// /// Returns `Ok(())` for: @@ -50,11 +52,12 @@ pub fn strip_dash_suffix(input: &str) -> &str { /// - `.dash` names (case-insensitive): `"alice.dash"`, `"Alice.DASH"` /// - Identity IDs (no dots): `"4EfA..."` /// -/// Returns `Err(input)` for inputs with non-`.dash` domains: `"alice.foo"`, `"alice.com"` -pub fn validate_dpns_input(input: &str) -> Result<(), String> { +/// Returns [`NonDashDomainError`] for inputs with non-`.dash` domains: +/// `"alice.foo"`, `"alice.com"`. +pub fn validate_dpns_input(input: &str) -> Result<(), NonDashDomainError> { let trimmed = input.trim(); if trimmed.contains('.') && !has_dash_suffix(trimmed) { - Err(trimmed.to_string()) + Err(NonDashDomainError) } else { Ok(()) } @@ -169,11 +172,11 @@ mod tests { #[test] fn validate_dpns_input_rejects_non_dash_domains() { - assert_eq!(validate_dpns_input("alice.foo"), Err("alice.foo".into())); - assert_eq!(validate_dpns_input("alice.com"), Err("alice.com".into())); + assert_eq!(validate_dpns_input("alice.foo"), Err(NonDashDomainError)); + assert_eq!(validate_dpns_input("alice.com"), Err(NonDashDomainError)); assert_eq!( validate_dpns_input(" alice.xyz "), - Err("alice.xyz".into()) + Err(NonDashDomainError) ); } } diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index a102f4793..4e3d5e4b3 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -12,9 +12,26 @@ //! performed by Platform. For accurate fees, use Platform's EstimateStateTransitionFee //! endpoint (when available). -use crate::model::amount::Amount; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; +use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Credits}; +use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::prelude::{AddressNonce, AssetLockProof}; +use dash_sdk::dpp::state_transition::StateTransitionEstimatedFeeValidation; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::v0::AddressFundingFromAssetLockTransitionV0; use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::withdrawal::Pooling; +use std::collections::BTreeMap; + +/// Maximum number of platform address inputs allowed per state transition. +pub(crate) const MAX_PLATFORM_INPUTS: usize = 16; + +/// Estimated serialized bytes per input (address + signature/witness data). +const ESTIMATED_BYTES_PER_INPUT: usize = 225; /// Storage fee constants from FEE_STORAGE_VERSION1 in rs-platform-version. /// These determine the cost of storing and processing data on Platform. @@ -83,6 +100,29 @@ impl Default for DataContractRegistrationFees { } } +/// Component counts describing a data contract, used for detailed fee estimation. +#[derive(Debug, Clone, Copy, Default)] +pub struct ContractComponents { + /// Serialized size of the contract in bytes. + pub contract_bytes: usize, + /// Number of document types defined in the contract. + pub document_type_count: usize, + /// Number of non-unique indexes across all document types. + pub non_unique_index_count: usize, + /// Number of unique indexes across all document types. + pub unique_index_count: usize, + /// Number of contested indexes across all document types. + pub contested_index_count: usize, + /// Whether the contract defines a token. + pub has_token: bool, + /// Whether the token uses perpetual distribution. + pub has_perpetual_distribution: bool, + /// Whether the token uses pre-programmed distribution. + pub has_pre_programmed_distribution: bool, + /// Number of search keywords registered for the contract. + pub search_keyword_count: usize, +} + /// Minimum fees for state transitions (in credits). /// Based on STATE_TRANSITION_MIN_FEES_VERSION1 from rs-platform-version. #[derive(Debug, Clone, Copy)] @@ -177,12 +217,6 @@ impl PlatformFeeEstimator { } } - /// Try to create from platform version (for future dynamic fee support) - pub fn from_platform_version(_platform_version: &PlatformVersion) -> Self { - // For now, use default fees. In future, could read from platform_version - Self::new() - } - /// Apply the fee multiplier to a base fee amount. /// Multiplier is in permille: 1000 = 1x, 1500 = 1.5x, 2000 = 2x fn apply_multiplier(&self, base_fee: u64) -> u64 { @@ -263,7 +297,7 @@ impl PlatformFeeEstimator { // - Per-output costs // We add a 50% buffer to account for any additional costs let base_fee_credits = self.estimate_credit_transfer_to_addresses(output_count); - let fee_duffs = base_fee_credits / 1000; // Convert credits to duffs + let fee_duffs = base_fee_credits / CREDITS_PER_DUFF; // Add 50% buffer and ensure minimum of 10,000 duffs based on observed behavior fee_duffs.saturating_add(fee_duffs / 2).max(10_000) } @@ -312,8 +346,6 @@ impl PlatformFeeEstimator { has_output: bool, key_count: usize, ) -> u64 { - // Estimated serialized bytes per input (address + signature/witness data) - const ESTIMATED_BYTES_PER_INPUT: usize = 225; // Estimated bytes for identity structure + keys const ESTIMATED_IDENTITY_BASE_BYTES: usize = 100; const ESTIMATED_BYTES_PER_KEY: usize = 50; @@ -374,8 +406,6 @@ impl PlatformFeeEstimator { /// This includes base cost, asset lock cost, input costs, storage-based fees, /// and a 20% safety buffer to account for fee variability. pub fn estimate_identity_topup_from_addresses(&self, input_count: usize) -> u64 { - // Estimated serialized bytes per input (address + signature/witness data) - const ESTIMATED_BYTES_PER_INPUT: usize = 225; // Estimated bytes for top-up transaction structure const ESTIMATED_TOPUP_BASE_BYTES: usize = 100; // Estimated seek operations for tree traversal @@ -406,6 +436,64 @@ impl PlatformFeeEstimator { total.saturating_add(total / 5) } + /// Resolve the actual fee paid by a wallet-funded identity top-up. + /// + /// A top-up converts `amount_duffs` of asset-lock value into + /// `amount_duffs × CREDITS_PER_DUFF` credits, less the Platform processing + /// fee. That fee is the shortfall between the credits the asset lock should + /// have minted and the balance the identity actually gained: + /// + /// ```text + /// actual_fee = expected_credits − (balance_after − balance_before) + /// ``` + /// + /// The subtraction is only meaningful when `balance_before` is the + /// identity's true pre-top-up balance. After a backend reload the caller may + /// hold a stale cached balance — too low (inflating the apparent increase + /// and collapsing the delta toward zero) or too high (the apparent increase + /// shrinks and the delta swells toward the full minted amount). Either skew + /// drifts the measured fee away from what the top-up actually cost, so the + /// measured fee is trusted only when it is physically possible **and** lands + /// in a plausible band relative to the deterministic estimate; otherwise the + /// estimate — the trustworthy value — is returned. + pub fn resolve_identity_topup_actual_fee( + &self, + amount_duffs: u64, + balance_before: u64, + balance_after: u64, + ) -> u64 { + let expected_credits = amount_duffs.saturating_mul(CREDITS_PER_DUFF); + let balance_increase = balance_after.saturating_sub(balance_before); + let delta_fee = expected_credits.saturating_sub(balance_increase); + + let estimate = self.estimate_identity_topup(); + + // Plausibility band for the measured fee. Three conditions must all hold: + // + // • `0 < delta_fee` — a real top-up always pays a non-zero Platform fee. + // A stale-LOW `balance_before` inflates the apparent increase to ≥100 % + // of the mint and collapses the delta to zero. + // • `delta_fee < expected_credits` — the fee can never exceed what the + // asset lock minted. A stale-HIGH `balance_before` makes the increase + // saturate to zero, swelling the delta to the full minted amount. + // • `delta_fee <= plausible_upper` — the deterministic estimate already + // over-states the fee (it bills the full asset-lock processing cost), + // so a real fee sits at or below it; `×2` leaves headroom for storage + // and epoch variance. A *partial*-stale `balance_before` yields a delta + // that is non-zero and below the mint yet grossly inflated past the + // estimate — caught here where the two boundary checks above miss it. + // + // The low side stays at `0 < delta_fee`: the estimate over-predicts, so a + // legitimately small real fee (well under the estimate) must not be + // rejected — no tighter lower bound is defensible. + let plausible_upper = estimate.saturating_mul(2); + if 0 < delta_fee && delta_fee < expected_credits && delta_fee <= plausible_upper { + delta_fee + } else { + estimate + } + } + /// Estimate fee for document batch transition pub fn estimate_document_batch(&self, transition_count: usize) -> u64 { let base_fee = self @@ -518,21 +606,21 @@ impl PlatformFeeEstimator { /// Estimate fee for data contract creation with detailed component counts. /// This provides the most accurate estimate by accounting for all registration fees. - #[allow(clippy::too_many_arguments)] - pub fn estimate_contract_create_detailed( - &self, - contract_bytes: usize, - document_type_count: usize, - non_unique_index_count: usize, - unique_index_count: usize, - contested_index_count: usize, - has_token: bool, - has_perpetual_distribution: bool, - has_pre_programmed_distribution: bool, - search_keyword_count: usize, - ) -> u64 { + pub fn estimate_contract_create_detailed(&self, components: ContractComponents) -> u64 { const ESTIMATED_SEEKS: usize = 20; + let ContractComponents { + contract_bytes, + document_type_count, + non_unique_index_count, + unique_index_count, + contested_index_count, + has_token, + has_perpetual_distribution, + has_pre_programmed_distribution, + search_keyword_count, + } = components; + let mut base_fee = self.registration_fees.base_contract_registration_fee; // Document type fees @@ -648,15 +736,19 @@ impl PlatformFeeEstimator { } } -/// Credits per DASH constant -/// 1 DASH = 100,000,000,000 credits (100 billion) -pub const CREDITS_PER_DASH: u64 = 100_000_000_000; +/// Credits per DASH: 1 DASH = 10^DASH_DECIMAL_PLACES credits (100 billion). +pub const CREDITS_PER_DASH: u64 = 10u64.pow(DASH_DECIMAL_PLACES as u32); /// Format credits as DASH for display pub fn format_credits_as_dash(credits: u64) -> String { Amount::dash_from_credits(credits).to_string() } +/// Format an amount in duffs as DASH for display. +pub fn format_duffs_as_dash(duffs: u64) -> String { + Amount::dash_from_duffs(duffs).to_string() +} + /// Format credits for display (with both credits and DASH) pub fn format_credits(credits: u64) -> String { let dash = credits as f64 / CREDITS_PER_DASH as f64; @@ -667,13 +759,332 @@ pub fn format_credits(credits: u64) -> String { } } +/// Calculate the estimated fee for a platform address funds transfer. +/// +/// Uses [`PlatformFeeEstimator`] for base costs (input/output fees) plus storage fees. +pub(crate) fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) -> u64 { + let inputs = input_count.max(1); + + // Base fee from Platform's min fee structure + // - 500,000 credits per input (address_funds_transfer_input_cost) + // - 6,000,000 credits per output (address_funds_transfer_output_cost) + let base_fee = estimator.estimate_address_funds_transfer(inputs, 1); + + // Add storage fees for serialized input bytes only + // (outputs don't add significant serialization overhead) + let estimated_bytes = inputs * ESTIMATED_BYTES_PER_INPUT; + let storage_fee = estimator.estimate_storage_based_fee(estimated_bytes, inputs); + + // Total with 20% safety buffer + let total = base_fee.saturating_add(storage_fee); + total.saturating_add(total / 5) +} + +/// Calculate the estimated fee for a Platform address withdrawal using a constructed state transition. +pub(crate) fn estimate_withdrawal_fee_from_transition( + platform_version: &PlatformVersion, + inputs: &BTreeMap<PlatformAddress, u64>, + output_script: &CoreScript, +) -> u64 { + let inputs_with_nonce: BTreeMap<PlatformAddress, (AddressNonce, Credits)> = inputs + .iter() + .map(|(addr, amount)| (*addr, (0, *amount))) + .collect(); + + let transition = AddressCreditWithdrawalTransition::V0(AddressCreditWithdrawalTransitionV0 { + inputs: inputs_with_nonce, + output: None, + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: output_script.clone(), + user_fee_increase: 0, + input_witnesses: Vec::new(), + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + +/// Calculate the estimated fee for funding a Platform address from an asset lock. +pub(crate) fn estimate_address_funding_fee_from_transition( + platform_version: &PlatformVersion, + destination: &PlatformAddress, +) -> u64 { + let mut outputs = BTreeMap::new(); + outputs.insert(*destination, None); + + let transition = + AddressFundingFromAssetLockTransition::V0(AddressFundingFromAssetLockTransitionV0 { + asset_lock_proof: AssetLockProof::default(), + inputs: BTreeMap::new(), + outputs, + fee_strategy: vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + user_fee_increase: 0, + ..Default::default() + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + +/// Result of allocating platform addresses for a transfer. +#[derive(Debug, Clone)] +pub(crate) struct AddressAllocationResult { + /// Map of platform address to amount to transfer from each + pub inputs: BTreeMap<PlatformAddress, u64>, + /// Index of the fee payer in BTreeMap iteration order + pub fee_payer_index: u16, + /// Estimated fee for this transaction + pub estimated_fee: u64, + /// Amount that couldn't be covered (0 if fully covered) + pub shortfall: u64, + /// Addresses sorted by balance descending (for UI display) + pub sorted_addresses: Vec<(PlatformAddress, Address, u64)>, +} + +/// Allocates platform addresses for a transfer, using a custom fee calculator. +pub(crate) fn allocate_platform_addresses_with_fee<F>( + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, + fee_for_inputs: F, +) -> AddressAllocationResult +where + F: Fn(&BTreeMap<PlatformAddress, u64>) -> u64, +{ + // Filter out the destination address if provided (protocol doesn't allow same address as input and output) + let filtered: Vec<_> = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .cloned() + .collect(); + + // Sort addresses by balance descending so the largest balance is used first + let mut sorted_addresses = filtered; + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + // Early return if no addresses available after filtering + if sorted_addresses.is_empty() { + return AddressAllocationResult { + inputs: BTreeMap::new(), + fee_payer_index: 0, + estimated_fee: fee_for_inputs(&BTreeMap::new()), + shortfall: amount_credits, + sorted_addresses: vec![], + }; + } + + // The highest-balance address (first in sorted order) will pay the fee + let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); + + let mut estimated_fee = fee_for_inputs(&BTreeMap::new()); + let mut inputs: BTreeMap<PlatformAddress, u64> = BTreeMap::new(); + + // Iterate until fee estimate stabilizes (input count affects fee) + for _ in 0..=MAX_PLATFORM_INPUTS { + inputs.clear(); + let mut remaining = amount_credits; + + for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { + if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { + break; + } + let is_fee_payer = idx == 0; + let available = if is_fee_payer { + balance.saturating_sub(estimated_fee) + } else { + *balance + }; + let use_amount = remaining.min(available); + if use_amount > 0 || is_fee_payer { + inputs.insert(*platform_addr, use_amount); + remaining = remaining.saturating_sub(use_amount); + } + } + + let new_fee = fee_for_inputs(&inputs); + if new_fee == estimated_fee { + break; + } + estimated_fee = new_fee; + } + + // Calculate shortfall (amount we couldn't allocate) + let total_allocated: u64 = inputs.values().sum(); + let allocation_shortfall = amount_credits.saturating_sub(total_allocated); + + // Check if fee payer can actually afford the fee from their remaining balance. + let fee_deficit = if let Some(fee_payer) = fee_payer_addr { + let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); + let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); + let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); + estimated_fee.saturating_sub(fee_payer_remaining) + } else { + estimated_fee + }; + + let shortfall = allocation_shortfall.saturating_add(fee_deficit); + + // Find the index of the fee payer in BTreeMap order (required by backend) + let fee_payer_index = fee_payer_addr + .and_then(|payer| { + inputs + .keys() + .enumerate() + .find(|(_, addr)| **addr == payer) + .map(|(idx, _)| idx as u16) + }) + .unwrap_or(0); + + AddressAllocationResult { + inputs, + fee_payer_index, + estimated_fee, + shortfall, + sorted_addresses, + } +} + +/// Allocates platform addresses for a transfer, selecting which addresses to use +/// and how much from each. +/// +/// Algorithm: +/// 1. Filters out the destination address (can't be both input and output) +/// 2. Sorts addresses by balance descending (largest first) +/// 3. The highest-balance address pays the fee +/// 4. Iteratively allocates until fee estimate converges +/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted +/// +/// Returns the allocation result with inputs, fee payer index, and any shortfall. +pub(crate) fn allocate_platform_addresses( + estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, +) -> AddressAllocationResult { + let max_inputs = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .count() + .min(MAX_PLATFORM_INPUTS); + + allocate_platform_addresses_with_fee(addresses, amount_credits, destination, |_| { + // Keep the legacy behavior: use a worst-case fee based on max possible inputs. + estimate_platform_fee(estimator, max_inputs.max(1)) + }) +} + +/// Estimate the Core (L1) network fee, in duffs, for a simple wallet send. +/// +/// Mirrors the upstream key-wallet `TransactionBuilder` used by +/// `WalletBackend::send_payment`: it builds at the default `FeeRate::normal()` +/// (1 duff per byte) and sizes a non-SegWit P2PKH transaction as +/// `10 + inputs × 148 + outputs × 34` bytes. A "Max" send spends every UTXO +/// into a single recipient output with no change, so pass the wallet's full +/// UTXO count as `num_inputs` and the recipient count as `num_outputs`. +/// +/// A 15% safety margin is added on top of the raw size-based fee so the +/// reserved amount comfortably covers the fee the builder actually charges +/// (which rounds up, and may vary slightly with real script sizes). Reserving +/// marginally more than needed leaves a few dust duffs in the wallet — always +/// safe — whereas under-reserving would make the send fail. +/// +/// `num_inputs` and `num_outputs` are clamped to a minimum of 1. +pub fn estimate_core_l1_send_fee_duffs(num_inputs: usize, num_outputs: usize) -> u64 { + const TX_BASE_BYTES: u64 = 10; + const BYTES_PER_INPUT: u64 = 148; + const BYTES_PER_OUTPUT: u64 = 34; + /// Default `FeeRate::normal()` in the upstream builder: 1 duff per byte. + const DUFFS_PER_BYTE: u64 = 1; + /// Extra headroom over the raw size estimate, in percent. + const SAFETY_MARGIN_PERCENT: u64 = 15; + + let inputs = num_inputs.max(1) as u64; + let outputs = num_outputs.max(1) as u64; + + let size_bytes = TX_BASE_BYTES + .saturating_add(inputs.saturating_mul(BYTES_PER_INPUT)) + .saturating_add(outputs.saturating_mul(BYTES_PER_OUTPUT)); + + let raw_fee = size_bytes.saturating_mul(DUFFS_PER_BYTE); + raw_fee.saturating_add(raw_fee.saturating_mul(SAFETY_MARGIN_PERCENT) / 100) +} + +/// Compute the maximum spendable amount, in duffs, for a Core "Max" send: +/// the whole balance minus the estimated L1 network fee. +/// +/// Returns `None` when the balance does not cover the estimated fee (i.e. +/// nothing is left to send). Callers should disable "Max" and show a calm +/// message in that case rather than producing an amount that would fail. +/// +/// `num_inputs` is the wallet's UTXO count and `num_outputs` the recipient +/// count; both are passed through to [`estimate_core_l1_send_fee_duffs`]. +pub fn core_max_send_amount_duffs( + balance_duffs: u64, + num_inputs: usize, + num_outputs: usize, +) -> Option<u64> { + let fee = estimate_core_l1_send_fee_duffs(num_inputs, num_outputs); + let spendable = balance_duffs.checked_sub(fee)?; + (spendable > 0).then_some(spendable) +} + +/// The duffs a Core "Max" send must reserve for the L1 network fee — the +/// difference between the spendable balance and [`core_max_send_amount_duffs`]. +/// +/// Returns `None` in lockstep with `core_max_send_amount_duffs`: when the +/// spendable balance cannot cover the fee there is no valid Max to reserve +/// against, so callers disable "Max" rather than show a reserve for a send +/// that would fail. +/// +/// `spendable_duffs` MUST be the spendable balance (confirmed + unconfirmed), +/// never the headline `total` — `total` counts immature coinbase and locked +/// CoinJoin funds the upstream `CoinSelector` rejects, so reserving against it +/// over-shoots the selectable set and the broadcast fails. +pub fn core_max_send_reserve_duffs( + spendable_duffs: u64, + num_inputs: usize, + num_outputs: usize, +) -> Option<u64> { + let max = core_max_send_amount_duffs(spendable_duffs, num_inputs, num_outputs)?; + Some(spendable_duffs.saturating_sub(max)) +} + /// Compute the exact shielded fee for a given number of Orchard actions. /// /// Wraps `compute_minimum_shielded_fee` from `dpp`. Use this to calculate /// the fee after note selection, when the action count is known. -pub fn shielded_fee_for_actions(num_actions: usize, platform_version: &PlatformVersion) -> u64 { +/// +/// Returns the fee in credits, or a boxed [`ProtocolError`] when the active +/// protocol version has no known shielded-fee formula. The error is boxed +/// because `ProtocolError` is large and this sits on a hot `Ok` path. +/// +/// [`ProtocolError`]: dash_sdk::dpp::ProtocolError +pub fn shielded_fee_for_actions( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result<u64, Box<dash_sdk::dpp::ProtocolError>> { use dash_sdk::dpp::shielded::compute_minimum_shielded_fee; - compute_minimum_shielded_fee(num_actions, platform_version) + compute_minimum_shielded_fee(num_actions, platform_version).map_err(Box::new) +} + +/// Fee headroom (credits) to reserve from the platform balance when shielding +/// from it, so a "Max" amount still leaves enough to pay the shield's platform +/// fee. `ShieldFromBalance` needs the shield fee on top of the shielded amount +/// out of the same balance, so this must reserve the two-action shielded fee +/// (scaled by the network multiplier) — not the far smaller plain +/// platform-transfer estimate. Falls back to `0` if the active protocol version +/// has no shielded-fee formula (the backend re-validates before dispatch). +pub fn shield_from_balance_fee_headroom( + platform_version: &PlatformVersion, + fee_multiplier_permille: u64, +) -> u64 { + let base_fee = shielded_fee_for_actions(2, platform_version).unwrap_or(0); + let multiplier = fee_multiplier_permille.max(1000); + base_fee.saturating_mul(multiplier) / 1000 } #[cfg(test)] @@ -694,6 +1105,124 @@ mod tests { assert_eq!(fee, 2_000_000 + 200_000_000 + 2 * 6_500_000); } + #[test] + fn test_identity_topup_actual_fee_uses_balance_delta_when_consistent() { + let estimator = PlatformFeeEstimator::new(); + // 500_000 duffs → 500_000_000 credits minted; a real top-up loses some + // to the processing fee, so the balance gains slightly less. + let amount_duffs = 500_000u64; + let balance_before = 1_000_000_000u64; + let processing_fee = 3_000_000u64; + let balance_after = balance_before + amount_duffs * CREDITS_PER_DUFF - processing_fee; + assert_eq!( + estimator.resolve_identity_topup_actual_fee( + amount_duffs, + balance_before, + balance_after, + ), + processing_fee, + "a consistent balance delta must report the real processing fee" + ); + } + + #[test] + fn test_identity_topup_actual_fee_falls_back_to_estimate_on_stale_balance() { + let estimator = PlatformFeeEstimator::new(); + // Stale (too-low) `balance_before` — e.g. after a backend reload — makes + // the apparent increase exceed the minted credits, so the naive delta + // collapses to zero. The helper must fall back to the estimate instead. + let amount_duffs = 500_000u64; + let stale_balance_before = 0u64; + let balance_after = 9_999_999_999u64; // far more than the lock could mint + let resolved = estimator.resolve_identity_topup_actual_fee( + amount_duffs, + stale_balance_before, + balance_after, + ); + assert_ne!(resolved, 0, "a top-up must never report a zero fee"); + assert_eq!( + resolved, + estimator.estimate_identity_topup(), + "the stale-balance fallback must be the deterministic estimate" + ); + } + + /// A stale-HIGH `balance_before` must fall back to the estimate. + /// + /// If the cached balance is *higher* than the post-top-up balance (e.g. + /// because it was read before a spend cleared on-chain), then + /// `balance_after.saturating_sub(balance_before)` underflows to 0 and + /// `delta_fee` equals the full minted amount — not a fee, just noise. + /// The helper must detect this invariant violation and return the estimate. + #[test] + fn test_identity_topup_actual_fee_falls_back_to_estimate_on_stale_high_balance() { + let estimator = PlatformFeeEstimator::new(); + let amount_duffs = 5_000_000u64; // 5M duffs → 5_000_000_000 credits minted + let expected_credits = amount_duffs * CREDITS_PER_DUFF; + // balance_before is stale-HIGH: the cached balance is higher than + // balance_after, so balance_increase saturates to 0 and delta_fee would + // equal the full minted amount without the guard. + let stale_balance_before = 10_000_000_000u64; + let balance_after = 5_000_000_000u64; // lower than before (stale-HIGH) + assert!( + balance_after < stale_balance_before, + "pre-condition: stale-HIGH scenario" + ); + let resolved = estimator.resolve_identity_topup_actual_fee( + amount_duffs, + stale_balance_before, + balance_after, + ); + assert_ne!( + resolved, expected_credits, + "stale-HIGH must not report the full minted amount as the fee" + ); + assert_eq!( + resolved, + estimator.estimate_identity_topup(), + "stale-HIGH must fall back to the deterministic estimate" + ); + } + + /// A *partial*-stale `balance_before` produces a delta that is non-zero and + /// below the minted amount — so it slips past the two boundary checks — yet + /// is grossly inflated relative to the real fee. The plausibility cap against + /// the deterministic estimate must catch it and fall back to the estimate. + #[test] + fn test_identity_topup_actual_fee_rejects_partial_stale_inflated_delta() { + let estimator = PlatformFeeEstimator::new(); + let amount_duffs = 5_000_000u64; // 5M duffs → 5_000_000_000 credits minted + let expected_credits = amount_duffs * CREDITS_PER_DUFF; + + // Truth: a ~3,000,000-credit processing fee on a large prior balance. + let true_before = 1_000_000_000u64; + let real_fee = 3_000_000u64; + let balance_after = true_before + expected_credits - real_fee; // freshly read + + // `balance_before` is PARTIAL-stale-HIGH: higher than truth by 3 billion, + // but not high enough to saturate the increase to zero. The naive delta is + // positive and below the mint, so the boundary checks alone accept it. + let stale_before = 4_000_000_000u64; + let naive_increase = balance_after - stale_before; + let naive_delta = expected_credits - naive_increase; + assert!( + naive_delta > 0 && naive_delta < expected_credits, + "pre-condition: the inflated delta slips past both boundary checks" + ); + assert!( + naive_delta > estimator.estimate_identity_topup() * 2, + "pre-condition: the inflated delta is grossly above the estimate" + ); + + let resolved = + estimator.resolve_identity_topup_actual_fee(amount_duffs, stale_before, balance_after); + assert_eq!( + resolved, + estimator.estimate_identity_topup(), + "a partial-stale inflated delta must fall back to the deterministic estimate" + ); + } + #[test] fn test_document_batch_estimate() { let estimator = PlatformFeeEstimator::new(); @@ -735,17 +1264,17 @@ mod tests { fn test_contract_create_detailed_with_token() { let estimator = PlatformFeeEstimator::new(); // Contract with a token - let fee = estimator.estimate_contract_create_detailed( - 500, // contract bytes - 1, // 1 document type - 1, // 1 non-unique index - 0, // 0 unique indexes - 0, // 0 contested indexes - true, // has token - false, // no perpetual distribution - false, // no pre-programmed distribution - 0, // 0 search keywords - ); + let fee = estimator.estimate_contract_create_detailed(ContractComponents { + contract_bytes: 500, + document_type_count: 1, + non_unique_index_count: 1, + unique_index_count: 0, + contested_index_count: 0, + has_token: true, + has_perpetual_distribution: false, + has_pre_programmed_distribution: false, + search_keyword_count: 0, + }); // Base: 0.1 DASH + Document type: 0.02 DASH + Index: 0.01 DASH + Token: 0.1 DASH // = 0.23 DASH + storage fees let expected_registration = 10_000_000_000 + 2_000_000_000 + 1_000_000_000 + 10_000_000_000; @@ -760,14 +1289,128 @@ mod tests { assert_eq!(format_credits_as_dash(100_000), "0.000001 DASH"); } + #[test] + fn test_core_l1_send_fee_matches_builder_size_model() { + // 1 input, 1 output (Max send: all funds to one recipient, no change). + // Upstream size = 10 + 148 + 34 = 192 bytes at 1 duff/byte = 192 duffs. + // With the 15% margin: 192 + floor(192 * 15 / 100) = 192 + 28 = 220. + assert_eq!(estimate_core_l1_send_fee_duffs(1, 1), 220); + + // 2 inputs, 1 output: 10 + 296 + 34 = 340 bytes → 340 + 51 = 391. + assert_eq!(estimate_core_l1_send_fee_duffs(2, 1), 391); + + // Fee grows with input count. + assert!(estimate_core_l1_send_fee_duffs(5, 1) > estimate_core_l1_send_fee_duffs(1, 1)); + } + + #[test] + fn test_core_l1_send_fee_clamps_to_minimum_one() { + // Zero inputs/outputs are clamped to 1 each — never a zero-byte tx. + assert_eq!( + estimate_core_l1_send_fee_duffs(0, 0), + estimate_core_l1_send_fee_duffs(1, 1) + ); + } + + #[test] + fn test_core_l1_send_fee_covers_actual_builder_fee() { + // The estimate must be >= the raw size-based fee the builder charges, + // so reserving it always leaves enough for the real fee. + for inputs in 1..=10 { + let raw_size = 10 + inputs as u64 * 148 + 34; // 1 output, no change + let estimate = estimate_core_l1_send_fee_duffs(inputs, 1); + assert!( + estimate >= raw_size, + "estimate {estimate} must cover raw fee {raw_size} for {inputs} inputs" + ); + } + } + + #[test] + fn test_core_max_send_amount_subtracts_fee() { + // Balance well above the fee: spendable = balance - fee. + let balance = 1_000_000_u64; + let fee = estimate_core_l1_send_fee_duffs(1, 1); + assert_eq!( + core_max_send_amount_duffs(balance, 1, 1), + Some(balance - fee) + ); + } + + #[test] + fn test_core_max_send_amount_edge_balance_at_or_below_fee() { + let fee = estimate_core_l1_send_fee_duffs(1, 1); + + // Balance exactly equal to the fee: nothing left to send. + assert_eq!(core_max_send_amount_duffs(fee, 1, 1), None); + // Balance below the fee: nothing left to send. + assert_eq!(core_max_send_amount_duffs(fee - 1, 1, 1), None); + // Zero balance: nothing left to send. + assert_eq!(core_max_send_amount_duffs(0, 1, 1), None); + + // One duff above the fee: exactly one spendable duff. + assert_eq!(core_max_send_amount_duffs(fee + 1, 1, 1), Some(1)); + } + + #[test] + fn test_core_max_send_reserve_complements_send_amount() { + // Reserve + send amount must reconstitute the spendable balance, and the + // reserve equals the estimated fee whenever a Max exists. + let spendable = 1_000_000_u64; + let fee = estimate_core_l1_send_fee_duffs(3, 1); + let send = core_max_send_amount_duffs(spendable, 3, 1).expect("covers fee"); + let reserve = core_max_send_reserve_duffs(spendable, 3, 1).expect("covers fee"); + assert_eq!(send + reserve, spendable); + assert_eq!(reserve, fee); + } + + #[test] + fn test_core_max_send_reserve_none_when_balance_below_fee() { + let fee = estimate_core_l1_send_fee_duffs(1, 1); + // In lockstep with core_max_send_amount_duffs: no Max → no reserve. + assert_eq!(core_max_send_reserve_duffs(fee, 1, 1), None); + assert_eq!(core_max_send_reserve_duffs(0, 1, 1), None); + assert_eq!(core_max_send_reserve_duffs(fee + 1, 1, 1), Some(fee)); + } + + #[test] + fn shield_from_balance_headroom_reserves_shielded_fee_not_transfer_fee() { + let platform_version = PlatformVersion::latest(); + let base_fee = shielded_fee_for_actions(2, platform_version).expect("known version"); + + // At the minimum (1000‰) multiplier the headroom equals the base fee. + let headroom = shield_from_balance_fee_headroom(platform_version, 1000); + assert_eq!(headroom, base_fee); + + // It must reserve the full shielded fee (>50M), an order of magnitude + // above the plain platform-transfer estimate — under-reserving here is + // what got a Max shield-from-platform rejected upstream. + assert!( + headroom > 50_000_000, + "shield-from-balance headroom must reserve the shielded fee: {headroom}" + ); + assert!(headroom > PlatformFeeEstimator::new().estimate_credit_transfer()); + + // Headroom scales with the multiplier and a sub-1000 multiplier is + // clamped up to 1000 so we never under-reserve. + assert_eq!( + shield_from_balance_fee_headroom(platform_version, 500), + base_fee + ); + assert_eq!( + shield_from_balance_fee_headroom(platform_version, 2000), + base_fee.saturating_mul(2000) / 1000 + ); + } + #[test] fn test_shielded_fee_for_actions() { let platform_version = PlatformVersion::latest(); - let fee_2 = shielded_fee_for_actions(2, platform_version); - let fee_3 = shielded_fee_for_actions(3, platform_version); - let fee_5 = shielded_fee_for_actions(5, platform_version); - let fee_10 = shielded_fee_for_actions(10, platform_version); + let fee_2 = shielded_fee_for_actions(2, platform_version).expect("known version"); + let fee_3 = shielded_fee_for_actions(3, platform_version).expect("known version"); + let fee_5 = shielded_fee_for_actions(5, platform_version).expect("known version"); + let fee_10 = shielded_fee_for_actions(10, platform_version).expect("known version"); // Fees should be positive and increase with action count assert!(fee_2 > 0, "fee for 2 actions should be positive"); @@ -794,4 +1437,124 @@ mod tests { "per-action cost should be roughly constant, got ratio {ratio}" ); } + + /// A distinct P2PKH platform address for the given seed byte. + fn pa(byte: u8) -> PlatformAddress { + PlatformAddress::P2pkh([byte; 20]) + } + + /// A placeholder Core address; the allocation logic passes it through + /// untouched, so any valid address stands in. + fn any_core_address() -> Address { + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::secp256k1::{ + PublicKey as SecpPublicKey, Secp256k1, SecretKey, + }; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).expect("valid secret key"); + let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&secp, &sk).serialize()) + .expect("valid pubkey"); + Address::p2pkh(&pubkey, Network::Testnet) + } + + fn addrs(balances: &[(u8, u64)]) -> Vec<(PlatformAddress, Address, u64)> { + let core = any_core_address(); + balances + .iter() + .map(|(byte, balance)| (pa(*byte), core.clone(), *balance)) + .collect() + } + + #[test] + fn allocate_covers_amount_from_single_address() { + let addresses = addrs(&[(1, 1000)]); + let result = allocate_platform_addresses_with_fee(&addresses, 500, None, |_| 100); + + assert_eq!( + result.shortfall, 0, + "fully funded transfer has no shortfall" + ); + assert_eq!(result.estimated_fee, 100); + assert_eq!(result.inputs.get(&pa(1)).copied(), Some(500)); + assert_eq!(result.fee_payer_index, 0); + } + + #[test] + fn allocate_converges_when_fee_depends_on_input_count() { + // Fee grows with input count, so the allocation loop must iterate until + // the fee estimate stabilizes rather than under-funding on the first pass. + let addresses = addrs(&[(1, 300), (2, 300)]); + let result = allocate_platform_addresses_with_fee(&addresses, 500, None, |inputs| { + inputs.len() as u64 * 10 + }); + + assert_eq!(result.estimated_fee, 20, "fee converged for two inputs"); + assert_eq!(result.inputs.len(), 2); + assert_eq!(result.inputs.values().sum::<u64>(), 500); + assert_eq!(result.shortfall, 0); + } + + #[test] + fn allocate_reports_shortfall_when_underfunded() { + let addresses = addrs(&[(1, 100)]); + let result = allocate_platform_addresses_with_fee(&addresses, 500, None, |_| 50); + + // 100 balance, 50 reserved for fee → only 50 allocatable against a 500 ask. + assert_eq!(result.estimated_fee, 50); + assert_eq!(result.inputs.get(&pa(1)).copied(), Some(50)); + assert_eq!(result.shortfall, 450); + } + + #[test] + fn allocate_picks_highest_balance_as_fee_payer_and_excludes_destination() { + let addresses = addrs(&[(1, 100), (2, 1000), (9, 5000)]); + let destination = pa(9); + let result = + allocate_platform_addresses_with_fee(&addresses, 200, Some(&destination), |_| 30); + + assert!( + !result.inputs.contains_key(&destination), + "destination must never be used as an input", + ); + // Highest remaining balance (pa(2)) sorts first and pays the fee. + assert_eq!( + result.sorted_addresses.first().map(|(a, _, _)| *a), + Some(pa(2)) + ); + assert_eq!(result.inputs.get(&pa(2)).copied(), Some(200)); + assert_eq!(result.shortfall, 0); + let fee_payer_key = result + .inputs + .keys() + .nth(result.fee_payer_index as usize) + .copied(); + assert_eq!( + fee_payer_key, + Some(pa(2)), + "fee_payer_index locates the fee payer" + ); + } + + #[test] + fn allocate_with_no_addresses_reports_full_shortfall() { + let result = allocate_platform_addresses_with_fee(&[], 500, None, |_| 10); + + assert!(result.inputs.is_empty()); + assert!(result.sorted_addresses.is_empty()); + assert_eq!(result.shortfall, 500); + assert_eq!(result.estimated_fee, 10); + } + + #[test] + fn allocate_with_estimator_uses_worst_case_platform_fee() { + let estimator = PlatformFeeEstimator::new(); + let addresses = addrs(&[(1, 10_000_000_000)]); + let result = allocate_platform_addresses(&estimator, &addresses, 1_000_000, None); + + assert_eq!(result.estimated_fee, estimate_platform_fee(&estimator, 1)); + assert_eq!(result.shortfall, 0); + assert_eq!(result.fee_payer_index, 0); + assert_eq!(result.inputs.get(&pa(1)).copied(), Some(1_000_000)); + } } diff --git a/src/model/grovestark_prover.rs b/src/model/grovestark_prover.rs index d132b1534..20e2e8979 100644 --- a/src/model/grovestark_prover.rs +++ b/src/model/grovestark_prover.rs @@ -1,19 +1,10 @@ -use dash_sdk::Sdk; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::identifier::Identifier; -use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyID, KeyType}; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::platform::documents::document_query::DocumentQuery; -use dash_sdk::platform::{ - Document, DriveDocumentQuery, Fetch, FetchMany, IdentityKeysQuery, IdentityPublicKey, -}; -use ed25519_dalek::{Signer, SigningKey}; -use grovestark::{ - GroveSTARK, PublicInputs, STARKConfig, STARKProof, create_witness_from_platform_proofs, -}; +//! Serializable GroveSTARK proof data. +//! +//! Pure data types plus their (de)serialization. Proof *generation* and +//! *verification* — which drive the SDK and the `grovestark` prover — live in +//! `backend_task::grovestark`. + use serde::{Deserialize, Serialize}; -use std::time::Instant; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProofDataOutput { @@ -38,493 +29,41 @@ pub struct ProofMetadata { pub security_level: u32, } -pub struct GroveSTARKProver { - prover: GroveSTARK, -} - -impl Default for GroveSTARKProver { - fn default() -> Self { - Self::new() - } -} - -impl GroveSTARKProver { - pub fn new() -> Self { - // Use GroveSTARK's default config - let config = STARKConfig::default(); - - Self { - prover: GroveSTARK::with_config(config), - } - } - - /// Generate a proof for document ownership - #[allow(clippy::too_many_arguments)] - pub async fn generate_proof( - &self, - sdk: &Sdk, - identity_id: &str, - contract_id: &str, - document_type: &str, - document_id: &str, - key_id: u32, - private_key: &[u8; 32], - public_key: &[u8; 32], - ) -> Result<ProofDataOutput, GroveSTARKError> { - if cfg!(debug_assertions) { - return Err(GroveSTARKError::UnsupportedBuild( - "GroveSTARK proof generation requires a release build (cargo run --release)" - .to_string(), - )); - } - - let start_time = Instant::now(); - - tracing::info!("Starting ZK proof generation"); - tracing::info!("Identity ID: {}", identity_id); - tracing::info!("Contract ID: {}", contract_id); - tracing::info!("Document Type: {}", document_type); - tracing::info!("Document ID: {}", document_id); - - // Step 1: Parse identifiers - tracing::debug!("Parsing identifiers..."); - let identity_identifier = - Identifier::from_string(identity_id, Encoding::Base58).map_err(|e| { - tracing::error!("Failed to parse identity ID: {}", e); - GroveSTARKError::InvalidIdentityId(e.to_string()) - })?; - let contract_identifier = Identifier::from_string(contract_id, Encoding::Base58) - .map_err(|e| GroveSTARKError::InvalidContractId(e.to_string()))?; - - // Step 2: Fetch specific key with proof using new SDK API - tracing::info!("Fetching specific key {} with proof...", key_id); - - // Create a query for the specific key - let specific_key_ids: Vec<KeyID> = vec![key_id]; - let keys_query = IdentityKeysQuery::new(identity_identifier, specific_key_ids); - - // Fetch only the specified key with proof - let (specific_keys, _metadata, key_proof) = - IdentityPublicKey::fetch_many_with_metadata_and_proof(sdk, keys_query, None) - .await - .map_err(|e| { - tracing::error!("Failed to fetch key with proof: {}", e); - GroveSTARKError::Platform(e.to_string()) - })?; - - // Verify the key exists in the identity - let identity_key = specific_keys - .get(&key_id) - .and_then(|maybe_key| maybe_key.as_ref()) - .ok_or_else(|| { - tracing::error!("Key {} not found for identity", key_id); - GroveSTARKError::PrivateKeyNotAvailable - })?; - - // Verify it's an EdDSA key - if identity_key.key_type() != KeyType::EDDSA_25519_HASH160 { - return Err(GroveSTARKError::InvalidProof( - "Key is not EdDSA type required for ZK proofs".to_string(), - )); - } - - // Use the public key passed from the UI (derived from private key) - let public_key_bytes = *public_key; - - // 3. KEY PROOF (Raw bytes) - tracing::info!("=== 3. KEY PROOF (Raw bytes) ==="); - tracing::info!("Key proof size: {} bytes", key_proof.grovedb_proof.len()); - tracing::info!("Key proof hex: {}", hex::encode(&key_proof.grovedb_proof)); - - // Additional key details - tracing::info!("Key ID: {}", key_id); - tracing::info!("Key type: {:?}", identity_key.key_type()); - tracing::info!("Key purpose: {:?}", identity_key.purpose()); - tracing::info!( - "Identity key data (hash160): {} bytes - {}", - identity_key.data().len(), - hex::encode(identity_key.data().to_vec()) - ); - - // Step 3: Fetch contract and create DocumentQuery - tracing::info!("Fetching contract..."); - let contract = dash_sdk::platform::DataContract::fetch(sdk, contract_identifier) - .await - .map_err(|e| { - tracing::error!("Failed to fetch contract: {}", e); - GroveSTARKError::Platform(e.to_string()) - })? - .ok_or_else(|| { - tracing::error!("Contract not found for ID: {}", contract_id); - GroveSTARKError::InvalidContractId("Contract not found".to_string()) - })?; - - let document_id_identifier = Identifier::from_string( - document_id, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ) - .map_err(|e| GroveSTARKError::Platform(e.to_string()))?; - - let query = DocumentQuery::new(contract, document_type) - .map_err(|e| GroveSTARKError::Platform(e.to_string()))? - .with_document_id(&document_id_identifier); - - tracing::info!("Fetching document with proof..."); - let (document_opt, _metadata, proof) = - Document::fetch_with_metadata_and_proof(sdk, query.clone(), None) - .await - .map_err(|e| { - tracing::error!("Failed to fetch document with proof: {}", e); - GroveSTARKError::Platform(e.to_string()) - })?; - - let document = document_opt.ok_or_else(|| { - tracing::error!("Document not found for ID: {}", document_id); - GroveSTARKError::DocumentNotFound - })?; - - // COMPREHENSIVE LOGGING FOR DEBUGGING - - // 1. REAL DOCUMENT (JSON format) - tracing::info!("=== 1. REAL DOCUMENT (JSON FORMAT) ==="); - if let Ok(json_value) = serde_json::to_value(&document) { - let json_pretty = serde_json::to_string_pretty(&json_value).unwrap_or_default(); - tracing::info!( - "Full JSON document as returned by Platform:\n{}", - json_pretty - ); - - // Also log specific fields we care about - if let Some(owner_id_value) = json_value.get("$ownerId") { - tracing::info!("$ownerId field in document: {}", owner_id_value); - } - if let Some(id_value) = json_value.get("$id") { - tracing::info!("$id field in document: {}", id_value); - } - if let Some(revision_value) = json_value.get("$revision") { - tracing::info!("$revision field in document: {}", revision_value); - } - } - - // For witness creation, we need proper serialization - let document_cbor = serde_json::to_vec(&document).map_err(|e| { - GroveSTARKError::SerializationError(format!("Failed to encode document: {}", e)) - })?; - - // 5. EXPECTED VALUES FOR VERIFICATION - let document_owner_id = document.owner_id(); - tracing::info!("=== 5. EXPECTED VALUES FOR VERIFICATION ==="); - tracing::info!( - "Document owner_id (base58): {}", - document_owner_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) - ); - tracing::info!( - "Document owner_id (hex): {}", - hex::encode(document_owner_id.to_buffer()) - ); - tracing::info!( - "Document owner_id (raw bytes): {:?}", - document_owner_id.to_buffer() - ); - - tracing::info!( - "Identity_id we're proving for (base58): {}", - identity_identifier - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) - ); - tracing::info!( - "Identity_id we're proving for (hex): {}", - hex::encode(identity_identifier.to_buffer()) - ); - tracing::info!( - "Identity_id we're proving for (raw bytes): {:?}", - identity_identifier.to_buffer() - ); - - // Ownership verification status - if document_owner_id == identity_identifier { - tracing::info!( - "✅ OWNER MATCH: Document owner matches proving identity - proof should succeed" - ); - } else { - tracing::warn!( - "⚠️ OWNER MISMATCH: Document owner does NOT match proving identity - proof should fail!" - ); - } - - // 2. DOCUMENT PROOF (Raw bytes) - tracing::info!("=== 2. DOCUMENT PROOF (Raw bytes) ==="); - tracing::info!("Document proof size: {} bytes", proof.grovedb_proof.len()); - tracing::info!("Document proof hex: {}", hex::encode(&proof.grovedb_proof)); - - // Step 4: Get current state root by verifying document proof - let drive_document_query: DriveDocumentQuery = (&query) - .try_into() - .map_err(|e: dash_sdk::error::Error| GroveSTARKError::Platform(e.to_string()))?; - let (state_root, _documents) = drive_document_query - .verify_proof(&proof.grovedb_proof, sdk.version()) - .map_err(|e| { - tracing::error!("Failed to verify document proof: {}", e); - GroveSTARKError::InvalidProof(e.to_string()) - })?; - - tracing::info!( - "Document proof root hash (hex): {}", - hex::encode(state_root) - ); - tracing::info!("Document proof root hash (raw bytes): {:?}", state_root); - - // Step 5: Create signing challenge - let challenge = create_challenge(&state_root, contract_id, document_id); - - // Step 6: Sign the challenge with Ed25519 (we don't use this signature in the new approach) - // The witness creation will handle the signing internally - - // Step 7: Log proof information - tracing::info!( - "Using separate proofs - key: {} bytes, document: {} bytes", - key_proof.grovedb_proof.len(), - proof.grovedb_proof.len() - ); - - // 6. OPTIONAL BUT HELPFUL - tracing::info!("=== 6. OPTIONAL BUT HELPFUL ==="); - tracing::info!("Contract ID (base58): {}", contract_id); - tracing::info!( - "Contract ID (hex): {}", - hex::encode(contract_identifier.to_buffer()) - ); - tracing::info!("Document Type: {}", document_type); - tracing::info!("Document ID (base58): {}", document_id); - tracing::info!( - "Document ID (hex): {}", - hex::encode(document_id_identifier.to_buffer()) - ); - tracing::info!("State root (hex): {}", hex::encode(state_root)); - tracing::info!("State root (raw bytes): {:?}", state_root); - - // Document CBOR details - tracing::info!("Document CBOR size: {} bytes", document_cbor.len()); - if document_cbor.len() <= 500 { - tracing::info!("Document CBOR (hex): {}", hex::encode(&document_cbor)); - } else { - tracing::info!( - "Document CBOR (first 500 bytes hex): {}", - hex::encode(&document_cbor[..500]) - ); - } - - // 4. EdDSA SIGNATURE COMPONENTS - tracing::info!("=== 4. EdDSA SIGNATURE COMPONENTS ==="); - - // Sign the challenge message - let signing_key = SigningKey::from_bytes(private_key); - let signature = signing_key.sign(&challenge); - let sig_bytes = signature.to_bytes(); - let mut signature_r = [0u8; 32]; - let mut signature_s = [0u8; 32]; - signature_r.copy_from_slice(&sig_bytes[0..32]); - signature_s.copy_from_slice(&sig_bytes[32..64]); - - tracing::info!("Signature R (hex): {}", hex::encode(signature_r)); - tracing::info!("Signature R (raw bytes): {:?}", signature_r); - tracing::info!("Signature S (hex): {}", hex::encode(signature_s)); - tracing::info!("Signature S (raw bytes): {:?}", signature_s); - tracing::info!("Public key (hex): {}", hex::encode(public_key_bytes)); - tracing::info!("Public key (raw bytes): {:?}", public_key_bytes); - tracing::info!("Message/Challenge (hex): {}", hex::encode(challenge)); - tracing::info!("Message/Challenge (raw bytes): {:?}", challenge); - - // Step 8: Use GroveSTARK's new platform proofs V2 API - tracing::info!("Creating witness with GroveSTARK platform proofs V2..."); - - let witness = create_witness_from_platform_proofs( - &proof.grovedb_proof, // Raw document proof from SDK - &key_proof.grovedb_proof, // Raw key proof from SDK - document_cbor.clone(), // Use the proper CBOR we created above - &public_key_bytes, // Public key bytes - &signature_r, // Signature R component - &signature_s, // Signature s component - &challenge, // Message to sign - ) - .map_err(|e| { - tracing::error!("GroveSTARK witness creation failed: {:?}", e); - GroveSTARKError::ProofGenerationFailed(format!( - "GroveSTARK witness creation failed: {:?}", - e - )) - })?; - - tracing::info!("Witness created successfully"); - - // Step 8: Prepare public inputs - let public_inputs = PublicInputs { - state_root, - contract_id: contract_identifier.to_buffer(), - message_hash: challenge, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| GroveSTARKError::TimeError(e.to_string()))? - .as_secs(), - }; - - // Step 9: Generate the STARK proof - tracing::info!("Generating STARK proof (this normally takes around 10 seconds)..."); - tracing::debug!("Rayon thread pool size: {}", rayon::current_num_threads()); - let proof = self - .prover - .prove(witness, public_inputs.clone()) - .map_err(|e| { - tracing::error!("STARK proof generation failed: {}", e); - GroveSTARKError::ProofGenerationFailed(e.to_string()) - })?; - - tracing::info!("STARK proof generated successfully"); - - // Step 10: Serialize the proof - let serialized_proof = serde_json::to_vec(&proof) - .map_err(|e| GroveSTARKError::SerializationError(e.to_string()))?; - - let generation_time = start_time.elapsed(); - tracing::info!( - "Total proof generation time: {:.2}s", - generation_time.as_secs_f32() - ); - - Ok(ProofDataOutput { - proof: serialized_proof.clone(), - public_inputs: PublicInputsData { - state_root: public_inputs.state_root, - contract_id: public_inputs.contract_id, - message_hash: public_inputs.message_hash, - timestamp: public_inputs.timestamp, - }, - metadata: ProofMetadata { - created_at: public_inputs.timestamp, - proof_size: serialized_proof.len(), - generation_time_ms: generation_time.as_millis() as u64, - security_level: 128, // Default security level - }, - }) - } - - /// Verify a proof - pub fn verify_proof(&self, proof_data: &ProofDataOutput) -> Result<bool, GroveSTARKError> { - if cfg!(debug_assertions) { - tracing::warn!("GroveSTARK proof verification attempted in debug build; aborting"); - return Err(GroveSTARKError::UnsupportedBuild( - "GroveSTARK proof verification requires a release build (cargo run --release)" - .to_string(), - )); - } - - // Step 1: Deserialize the proof - let stark_proof: STARKProof = serde_json::from_slice(&proof_data.proof) - .map_err(|e| GroveSTARKError::DeserializationError(e.to_string()))?; - - // Step 2: Reconstruct public inputs - let public_inputs = PublicInputs { - state_root: proof_data.public_inputs.state_root, - contract_id: proof_data.public_inputs.contract_id, - message_hash: proof_data.public_inputs.message_hash, - timestamp: proof_data.public_inputs.timestamp, - }; - - // Step 3: Verify the proof using GroveSTARK's verify method - self.prover - .verify(&stark_proof, &public_inputs) - .map_err(|e| GroveSTARKError::VerificationFailed(e.to_string())) - } +/// Failure to (de)serialize a [`ProofDataOutput`]. +#[derive(Debug, thiserror::Error)] +pub enum ProofSerializationError { + #[error("Could not serialize the proof data.")] + Serialize(#[source] serde_json::Error), + #[error("Could not read the proof data.")] + Deserialize(#[source] serde_json::Error), + #[error("The proof data is not valid base64.")] + Base64(#[source] base64::DecodeError), } impl ProofDataOutput { - /// Serialize the proof to JSON string - pub fn to_json_string(&self) -> Result<String, GroveSTARKError> { - serde_json::to_string(self).map_err(|e| GroveSTARKError::SerializationError(e.to_string())) + /// Serialize the proof to a JSON string. + pub fn to_json_string(&self) -> Result<String, ProofSerializationError> { + serde_json::to_string(self).map_err(ProofSerializationError::Serialize) } - /// Serialize the proof to base64-encoded JSON - pub fn to_base64(&self) -> Result<String, GroveSTARKError> { + /// Serialize the proof to base64-encoded JSON. + pub fn to_base64(&self) -> Result<String, ProofSerializationError> { use base64::{Engine as _, engine::general_purpose}; - let json_bytes = serde_json::to_vec(self) - .map_err(|e| GroveSTARKError::SerializationError(e.to_string()))?; + let json_bytes = serde_json::to_vec(self).map_err(ProofSerializationError::Serialize)?; Ok(general_purpose::STANDARD.encode(json_bytes)) } - /// Deserialize from base64-encoded JSON - pub fn from_base64(base64_str: &str) -> Result<Self, GroveSTARKError> { + /// Deserialize from base64-encoded JSON. + pub fn from_base64(base64_str: &str) -> Result<Self, ProofSerializationError> { use base64::{Engine as _, engine::general_purpose}; - let bytes = general_purpose::STANDARD.decode(base64_str).map_err(|e| { - GroveSTARKError::DeserializationError(format!("Base64 decode error: {}", e)) - })?; - serde_json::from_slice(&bytes) - .map_err(|e| GroveSTARKError::DeserializationError(e.to_string())) + let bytes = general_purpose::STANDARD + .decode(base64_str) + .map_err(ProofSerializationError::Base64)?; + serde_json::from_slice(&bytes).map_err(ProofSerializationError::Deserialize) } - /// Deserialize from JSON string - pub fn from_json_string(json_str: &str) -> Result<Self, GroveSTARKError> { - serde_json::from_str(json_str) - .map_err(|e| GroveSTARKError::DeserializationError(e.to_string())) + /// Deserialize from a JSON string. + pub fn from_json_string(json_str: &str) -> Result<Self, ProofSerializationError> { + serde_json::from_str(json_str).map_err(ProofSerializationError::Deserialize) } } - -/// Create a challenge message for signing -fn create_challenge(state_root: &[u8; 32], contract_id: &str, document_id: &str) -> [u8; 32] { - use sha2::{Digest, Sha256}; - - let mut hasher = Sha256::new(); - hasher.update(state_root); - hasher.update(contract_id.as_bytes()); - hasher.update(document_id.as_bytes()); - - let result = hasher.finalize(); - let mut hash = [0u8; 32]; - hash.copy_from_slice(&result); - hash -} - -#[derive(Debug, thiserror::Error)] -pub enum GroveSTARKError { - #[error("Platform error: {0}")] - Platform(String), - - #[error("Invalid identity ID: {0}")] - InvalidIdentityId(String), - - #[error("Invalid contract ID: {0}")] - InvalidContractId(String), - - #[error("Identity not found")] - IdentityNotFound, - - #[error("Document not found")] - DocumentNotFound, - - #[error("Private key not available")] - PrivateKeyNotAvailable, - - #[error("Proof generation failed: {0}")] - ProofGenerationFailed(String), - - #[error("Proof verification failed: {0}")] - VerificationFailed(String), - - #[error("Serialization error: {0}")] - SerializationError(String), - - #[error("Deserialization error: {0}")] - DeserializationError(String), - - #[error("Signing failed: {0}")] - SigningFailed(String), - - #[error("Invalid proof: {0}")] - InvalidProof(String), - - #[error("Time error: {0}")] - TimeError(String), - - #[error("{0}")] - UnsupportedBuild(String), -} diff --git a/src/model/identity_discovery.rs b/src/model/identity_discovery.rs new file mode 100644 index 000000000..4828f8b2b --- /dev/null +++ b/src/model/identity_discovery.rs @@ -0,0 +1,218 @@ +//! Pure, stateless decision logic for the rolling gap-limited identity scan. +//! +//! Identities are derived at wallet derivation indices that need not be +//! contiguous: a user may register identity 0, skip a few, then register at 8. +//! A fixed `0..=N` scan either misses high indices or wastes network round-trips +//! on a long empty tail. Instead the scan keeps a *rolling lookahead*: it keeps +//! probing while it is within [`IDENTITY_GAP_LIMIT`] indices of the highest +//! index that produced an identity, so each new discovery extends the window. +//! +//! This module owns only the stop/continue arithmetic so it can be unit-tested +//! without a wallet, an SDK, or the network. The async scan that calls it lives +//! in `backend_task/identity` (DET Module Placement Policy: pure decision logic +//! in `model/`, async business logic in `backend_task/`). + +/// Number of consecutive empty trailing indices the scan keeps probing past the +/// highest index that produced an identity. A higher value finds identities +/// registered after larger gaps at the cost of more network round-trips. +pub const IDENTITY_GAP_LIMIT: u32 = 5; + +/// Absolute ceiling on the number of indices a single scan will probe, +/// regardless of the rolling window. Bounds the network fan-out if a corrupt +/// cache or a hostile node keeps reporting "found" at every index, so a +/// background sweep can never issue unbounded fetches. +pub const IDENTITY_SCAN_HARD_CAP: u32 = 100; + +/// Highest wallet derivation index a user may type into the By-Wallet search to +/// seed the rolling scan. The scan cannot probe past [`IDENTITY_SCAN_HARD_CAP`] +/// regardless, so seeding beyond it is always a typo; rejecting it keeps a +/// fat-finger from launching a full hard-cap-deep scan. +pub const MAX_IDENTITY_SEARCH_INDEX: u32 = IDENTITY_SCAN_HARD_CAP - 1; + +/// Validate a user-typed By-Wallet search index. Pure (no `AppContext`/DB), so +/// the UI can call it for instant feedback while the single source of truth +/// lives in `model/`. +pub fn validate_search_index(index: u32) -> Result<u32, IdentitySearchIndexError> { + if index > MAX_IDENTITY_SEARCH_INDEX { + Err(IdentitySearchIndexError::TooLarge { + max: MAX_IDENTITY_SEARCH_INDEX, + }) + } else { + Ok(index) + } +} + +/// A By-Wallet search index failed validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum IdentitySearchIndexError { + /// The index exceeds [`MAX_IDENTITY_SEARCH_INDEX`]. + #[error("Enter an identity index between 0 and {max}, then try again.")] + TooLarge { max: u32 }, +} + +/// Decide whether the rolling gap-limited scan should probe `current_index`. +/// +/// `highest_found` is the highest index that has produced an identity so far in +/// this scan (`None` if nothing has been found yet). +/// +/// - With no hit yet (`None`), probing continues while +/// `current_index < IDENTITY_GAP_LIMIT` — i.e. indices `0..IDENTITY_GAP_LIMIT`. +/// - With a hit at `h` (`Some(h)`), probing continues while +/// `current_index <= h + IDENTITY_GAP_LIMIT`, so each new discovery rolls the +/// window forward by extending `h`. +/// +/// In every case probing stops once `current_index` reaches +/// [`IDENTITY_SCAN_HARD_CAP`], so the scan is bounded even if `highest_found` +/// keeps advancing. +pub fn should_continue_scan(current_index: u32, highest_found: Option<u32>) -> bool { + if current_index >= IDENTITY_SCAN_HARD_CAP { + return false; + } + match highest_found { + None => current_index < IDENTITY_GAP_LIMIT, + Some(highest) => current_index <= highest.saturating_add(IDENTITY_GAP_LIMIT), + } +} + +/// Outcome counts of one gap-limited discovery pass over a single wallet. +/// +/// `found` is the number of indices that resolved to an on-chain identity; +/// `stored` is how many of those were newly inserted or refreshed in the local +/// database. A wallet skipped because it was locked reports zero of each. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DiscoverySummary { + /// Indices that resolved to an on-chain identity during this pass. + pub found: u32, + /// Identities newly stored or refreshed in the local database. + pub stored: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Collect every index a scan would probe, given the indices at which an + /// identity is "found". Mirrors the real loop: start at 0, advance by 1, + /// extend `highest_found` whenever the current index is a hit. + fn probed_indices(hits: &[u32]) -> Vec<u32> { + let mut probed = Vec::new(); + let mut highest_found: Option<u32> = None; + let mut current = 0u32; + while should_continue_scan(current, highest_found) { + probed.push(current); + if hits.contains(&current) { + highest_found = Some(highest_found.map_or(current, |h| h.max(current))); + } + current += 1; + } + probed + } + + // ── Decision-table tests (the design's `should_continue_scan` checklist) ── + // These probe the pure function directly with an established `highest_found`, + // independent of whether a cold scan could organically reach that index. + + #[test] + fn no_hit_window_is_zero_until_gap_limit() { + // With nothing found, continue for 0..GAP and stop at GAP. + for i in 0..IDENTITY_GAP_LIMIT { + assert!(should_continue_scan(i, None), "index {i} should continue"); + } + assert!(!should_continue_scan(IDENTITY_GAP_LIMIT, None)); + } + + #[test] + fn hit_at_zero_extends_to_five() { + // Found at 0 ⇒ probe through 0+5=5 inclusive, stop at 6. + assert!(should_continue_scan(5, Some(0))); + assert!(!should_continue_scan(6, Some(0))); + } + + #[test] + fn hit_at_seven_extends_to_twelve() { + // Found at 7 ⇒ probe through 7+5=12 inclusive, stop at 13. + assert!(should_continue_scan(12, Some(7))); + assert!(!should_continue_scan(13, Some(7))); + } + + #[test] + fn hits_at_zero_and_twelve_roll_to_seventeen() { + // Window seeded by the latest hit at 12 ⇒ probe through 12+5=17. + assert!(should_continue_scan(17, Some(12))); + assert!(!should_continue_scan(18, Some(12))); + } + + // ── Rolling-simulation tests (the loop the backend actually runs) ────────── + + #[test] + fn empty_wallet_stops_at_gap_limit() { + // No hits: probe 0..GAP, then stop. + assert_eq!(probed_indices(&[]), vec![0, 1, 2, 3, 4]); + } + + #[test] + fn hit_at_zero_scans_to_gap_limit() { + // Found at 0 ⇒ window is 0..=0+5, probe 0..=5. + assert_eq!(probed_indices(&[0]), vec![0, 1, 2, 3, 4, 5]); + } + + #[test] + fn rolling_chain_extends_window_past_static_range() { + // A chain of hits inside each rolling window reaches index 8 — proving + // the lookahead finds identities a static `0..=5` scan would miss. + let probed = probed_indices(&[3, 8]); + assert!( + probed.contains(&8), + "rolling window must reach the hit at 8" + ); + assert_eq!(*probed.last().unwrap(), 13, "8+5 closes the window at 13"); + } + + #[test] + fn hard_cap_clamps_a_pathological_always_found_scan() { + // Every index is a hit ⇒ the rolling window never closes, but the hard + // cap stops the scan at IDENTITY_SCAN_HARD_CAP exclusive. + let all_hits: Vec<u32> = (0..200).collect(); + let probed = probed_indices(&all_hits); + assert_eq!(probed.len() as u32, IDENTITY_SCAN_HARD_CAP); + assert_eq!(*probed.last().unwrap(), IDENTITY_SCAN_HARD_CAP - 1); + } + + #[test] + fn stops_exactly_at_hard_cap_boundary() { + assert!(should_continue_scan( + IDENTITY_SCAN_HARD_CAP - 1, + Some(u32::MAX) + )); + assert!(!should_continue_scan( + IDENTITY_SCAN_HARD_CAP, + Some(u32::MAX) + )); + } + + #[test] + fn high_seed_never_overflows() { + // A pre-existing identity at u32::MAX must not panic via overflow in the + // `highest + GAP` arithmetic; the hard cap stops it regardless. + assert!(!should_continue_scan( + IDENTITY_SCAN_HARD_CAP, + Some(u32::MAX) + )); + } + + #[test] + fn validate_search_index_accepts_in_range_rejects_beyond_cap() { + assert_eq!(validate_search_index(0), Ok(0)); + assert_eq!( + validate_search_index(MAX_IDENTITY_SEARCH_INDEX), + Ok(MAX_IDENTITY_SEARCH_INDEX) + ); + assert_eq!( + validate_search_index(MAX_IDENTITY_SEARCH_INDEX + 1), + Err(IdentitySearchIndexError::TooLarge { + max: MAX_IDENTITY_SEARCH_INDEX + }) + ); + assert!(validate_search_index(u32::MAX).is_err()); + } +} diff --git a/src/model/identity_key_protection.rs b/src/model/identity_key_protection.rs new file mode 100644 index 000000000..d81d19c67 --- /dev/null +++ b/src/model/identity_key_protection.rs @@ -0,0 +1,44 @@ +//! Stateless password-format validation for identity-key protection (Tier-2). +//! +//! The single source of truth for the identity-key protection password policy, +//! reused by the backend seal path (authoritative enforcement) and by any UI +//! that wants instant feedback (FR-8 / §10.3). Delegates to the shared +//! single-key passphrase length rule so the minimum lives in one place. + +use crate::backend_task::error::TaskError; +use crate::model::secret::Secret; +use crate::model::wallet::passphrase::validate_single_key_passphrase; + +/// Validate an identity-key protection password against the backend policy. +/// +/// Reuses the single-key passphrase rule (the same minimum length the UI +/// shows). The confirmation match is a UI concern, so the password is passed as +/// its own confirmation here — only the length check is meaningful at this +/// layer. +/// +/// # Errors +/// +/// [`TaskError::SingleKeyPassphraseTooShort`] when the password is shorter than +/// the shared minimum length. +pub fn validate_protection_password(password: &Secret) -> Result<(), TaskError> { + let pw = password.expose_secret(); + validate_single_key_passphrase(pw, pw).map_err(TaskError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A too-short password is rejected with the typed error; a compliant one + /// passes — the same policy the backend seal path enforces. + #[test] + fn weak_password_is_rejected_compliant_accepted() { + let err = validate_protection_password(&Secret::new("short")).expect_err("too short"); + assert!( + matches!(err, TaskError::SingleKeyPassphraseTooShort { .. }), + "expected SingleKeyPassphraseTooShort, got {err:?}" + ); + validate_protection_password(&Secret::new("long-enough-password")) + .expect("compliant password accepted"); + } +} diff --git a/src/model/key_input.rs b/src/model/key_input.rs new file mode 100644 index 000000000..af7008b5b --- /dev/null +++ b/src/model/key_input.rs @@ -0,0 +1,163 @@ +//! Stateless private-key input parsing for identity loading. +//! +//! [`verify_key_input`] is the single source of truth for the raw private-key +//! format policy: an empty input means "not supplied", a 64-character input is +//! decoded as hex, and a 51- or 52-character input is decoded as WIF. It holds +//! no state — no `AppContext`, `Sdk`, DB, or `BackendTask` — so it is +//! exhaustively unit-testable without a network. Stateful enforcement (matching +//! the key against an identity's public keys) stays in the backend task. + +use crate::model::secret::Secret; +use dash_sdk::dashcore_rpc::dashcore::PrivateKey; +use thiserror::Error; + +/// Why a raw private-key input failed format validation. +/// +/// Each variant carries the human-readable key name so the message names which +/// field to fix; the `Display` text is a complete, actionable sentence. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum KeyInputError { + /// A 64-character input that is not valid hexadecimal. + #[error( + "The {key_name} key is the length of a hex key but is not valid hexadecimal. Please check the key and try again." + )] + NotHex { key_name: String }, + + /// A 51- or 52-character input that is not a valid WIF key. + #[error( + "The {key_name} key is the length of a WIF key but could not be read. Please check the key and try again." + )] + BadWif { key_name: String }, + + /// An input whose length matches neither the hex nor the WIF format. + #[error( + "The {key_name} key is not a valid length. Enter a 64-character hex key or a 51- or 52-character WIF key." + )] + UnsupportedLength { key_name: String }, +} + +/// Validate and decode a raw private-key input string into 32 secret bytes. +/// +/// The input is trimmed before inspection. Length decides the format: 64 chars +/// are decoded as hex, 51 or 52 chars as WIF. An empty input yields `Ok(None)`, +/// signalling the key was not supplied. +/// +/// # Errors +/// +/// Returns a [`KeyInputError`] naming `key_name` when the input has a hex/WIF +/// length but fails to decode, or when its length matches no supported format. +pub fn verify_key_input( + untrimmed_private_key: Secret, + key_name: &str, +) -> Result<Option<[u8; 32]>, KeyInputError> { + let private_key = untrimmed_private_key.expose_secret().trim(); + match private_key.len() { + 64 => hex::decode(private_key) + .ok() + .and_then(|decoded| decoded.try_into().ok()) + .map(Some) + .ok_or_else(|| KeyInputError::NotHex { + key_name: key_name.to_owned(), + }), + 51 | 52 => PrivateKey::from_wif(private_key) + .map(|key| Some(key.inner.secret_bytes())) + .map_err(|_| KeyInputError::BadWif { + key_name: key_name.to_owned(), + }), + 0 => Ok(None), + _ => Err(KeyInputError::UnsupportedLength { + key_name: key_name.to_owned(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dashcore_rpc::dashcore::Network; + + #[test] + fn empty_input_is_not_supplied() { + assert_eq!(verify_key_input(Secret::new(""), "Owner").unwrap(), None); + // Whitespace-only trims to empty and is also treated as "not supplied". + assert_eq!(verify_key_input(Secret::new(" "), "Owner").unwrap(), None); + } + + #[test] + fn valid_hex_decodes_to_bytes() { + let bytes = [7u8; 32]; + let hex_str = hex::encode(bytes); + assert_eq!( + verify_key_input(Secret::new(hex_str), "User Key").unwrap(), + Some(bytes) + ); + } + + #[test] + fn hex_length_but_not_hex_is_rejected() { + let err = verify_key_input(Secret::new("z".repeat(64)), "Voting").unwrap_err(); + assert_eq!( + err, + KeyInputError::NotHex { + key_name: "Voting".to_owned() + } + ); + assert!(err.to_string().contains("Voting")); + assert!(err.to_string().contains("hex")); + } + + #[test] + fn valid_wif_round_trips_to_secret_bytes() { + let bytes = [9u8; 32]; + let wif = PrivateKey::from_byte_array(&bytes, Network::Testnet) + .expect("valid private key") + .to_wif(); + // WIF length must land in the 51/52 branch for the round-trip to hold. + assert!( + matches!(wif.len(), 51 | 52), + "unexpected WIF length: {}", + wif.len() + ); + assert_eq!( + verify_key_input(Secret::new(wif), "Owner").unwrap(), + Some(bytes) + ); + } + + #[test] + fn wif_length_but_invalid_is_rejected() { + let err = verify_key_input(Secret::new("z".repeat(52)), "Payout Address").unwrap_err(); + assert_eq!( + err, + KeyInputError::BadWif { + key_name: "Payout Address".to_owned() + } + ); + assert!(err.to_string().contains("Payout Address")); + assert!(err.to_string().contains("WIF")); + } + + #[test] + fn unsupported_length_is_rejected() { + for bad in ["tooshort", &"a".repeat(63), &"b".repeat(65)] { + let err = verify_key_input(Secret::new(bad), "Owner").unwrap_err(); + assert_eq!( + err, + KeyInputError::UnsupportedLength { + key_name: "Owner".to_owned() + }, + "expected {bad:?} to be rejected as an unsupported length" + ); + } + } + + #[test] + fn surrounding_whitespace_is_trimmed_before_decoding() { + let bytes = [3u8; 32]; + let padded = format!(" {}\n", hex::encode(bytes)); + assert_eq!( + verify_key_input(Secret::new(padded), "User Key").unwrap(), + Some(bytes) + ); + } +} diff --git a/src/model/masternode_input.rs b/src/model/masternode_input.rs new file mode 100644 index 000000000..d5917bfb6 --- /dev/null +++ b/src/model/masternode_input.rs @@ -0,0 +1,339 @@ +//! Stateless input parsing for the headless masternode/evonode MCP tools. +//! +//! These helpers are the single source of truth for parsing the string +//! parameters of `masternode_identity_load` and +//! `masternode_credits_withdraw`: the node type, the withdrawal key +//! mode, the at-least-one-signing-key rule, and the ProTxHash decode. They hold +//! no state — no `AppContext`, `Sdk`, DB, or `BackendTask` — so they are +//! exhaustively unit-testable without a network. Stateful enforcement (key +//! presence on-chain, identity existence, network match) stays in the backend +//! task and the tool layer. + +use std::fmt; + +use crate::model::qualified_identity::IdentityType; +use crate::model::secret::Secret; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::prelude::Identifier; +use thiserror::Error; + +/// Why a masternode/evonode tool input failed to parse. +/// +/// Model-local so these pure validators carry no dependency on the MCP layer; +/// the tool boundary converts these into `McpToolError::InvalidParam`. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MasternodeInputError { + #[error("The 'node_type' must be \"masternode\" or \"evonode\".")] + InvalidNodeType, + #[error("The 'key_mode' must be \"owner\" or \"transfer\".")] + InvalidKeyMode, + #[error( + "Provide at least one of the owner or payout private key. \ + The owner key withdraws to the registered payout address; \ + the payout key withdraws to any address." + )] + NoSigningKey, + #[error( + "Could not read the identity ID: {input}. \ + Provide a 64-character hex ProTxHash or the Base58 identity ID \ + from masternode-identity-load." + )] + InvalidIdentityId { input: String }, +} + +/// Withdrawal key mode for the masternode credit-withdraw tool. +/// +/// `Owner` signs with the OWNER-purpose key and forces the destination to the +/// registered payout address; `Transfer` signs with the TRANSFER-purpose +/// (payout) key and withdraws to any Core address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyMode { + /// Owner key: destination forced to the registered payout address. + Owner, + /// Transfer/payout key: withdraws to any caller-supplied Core address. + Transfer, +} + +impl fmt::Display for KeyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyMode::Owner => f.write_str("owner"), + KeyMode::Transfer => f.write_str("transfer"), + } + } +} + +/// Parse the `node_type` parameter into an [`IdentityType`]. +/// +/// Accepts `"masternode"` or `"evonode"`, trimmed and case-insensitive. The +/// `User` type is never produced — this tool is masternode-specific by design. +/// +/// # Errors +/// +/// Returns [`MasternodeInputError::InvalidNodeType`] for `"user"` or any other +/// value. +pub fn parse_node_type(node_type: &str) -> Result<IdentityType, MasternodeInputError> { + match node_type.trim().to_ascii_lowercase().as_str() { + "masternode" => Ok(IdentityType::Masternode), + "evonode" => Ok(IdentityType::Evonode), + _ => Err(MasternodeInputError::InvalidNodeType), + } +} + +/// Parse the `key_mode` parameter into a [`KeyMode`]. +/// +/// Accepts `"owner"` or `"transfer"`, trimmed and case-insensitive. +/// +/// # Errors +/// +/// Returns [`MasternodeInputError::InvalidKeyMode`] for any other value. +pub fn parse_key_mode(key_mode: &str) -> Result<KeyMode, MasternodeInputError> { + match key_mode.trim().to_ascii_lowercase().as_str() { + "owner" => Ok(KeyMode::Owner), + "transfer" => Ok(KeyMode::Transfer), + _ => Err(MasternodeInputError::InvalidKeyMode), + } +} + +/// Require at least one of the owner or payout signing keys. +/// +/// A masternode identity loaded with neither signing key is watch-only and can +/// sign no withdrawal, so it is rejected. A voting key alone does not satisfy +/// the rule — it only binds the voter identity. Keys are considered present +/// when their trimmed value is non-empty (an empty string means "not supplied", +/// matching the backend's `verify_key_input`). +/// +/// # Errors +/// +/// Returns [`MasternodeInputError::NoSigningKey`] naming both keys and +/// explaining the two withdraw modes when neither is supplied. +pub fn require_at_least_one_signing_key( + owner_private_key: &Secret, + payout_private_key: &Secret, +) -> Result<(), MasternodeInputError> { + if owner_private_key.is_blank() && payout_private_key.is_blank() { + return Err(MasternodeInputError::NoSigningKey); + } + Ok(()) +} + +/// Decode a ProTxHash / identity ID accepting either Base58 or hex encoding. +/// +/// Mirrors the backend's `load_identity` parse order: Base58 first, then a hex +/// fallback. Both the canonical hex ProTxHash encoding and a Base58 identity ID +/// resolve to the same [`Identifier`]. +/// +/// # Errors +/// +/// Returns [`MasternodeInputError::InvalidIdentityId`] when the input parses as +/// neither. +pub fn decode_identity_id(input: &str) -> Result<Identifier, MasternodeInputError> { + Identifier::from_string(input, Encoding::Base58) + .or_else(|_| Identifier::from_string(input, Encoding::Hex)) + .map_err(|_| MasternodeInputError::InvalidIdentityId { + input: input.to_owned(), + }) +} + +/// Whether `input` is a syntactically valid ProTxHash / identity id — Base58 or +/// hex, shape-only (no on-chain existence check). Empty input is not valid. +/// +/// Single source of truth for the Masternodes load form's inline on-blur +/// ProTxHash validation (FR-4). Uses the same Base58-then-hex decode contract as +/// [`decode_identity_id`]; the backend load task performs the authoritative +/// existence and duplicate checks. +pub fn is_valid_pro_tx_hash(input: &str) -> bool { + // TODO: this duplicates the Base58-then-hex decode of `decode_identity_id`. + // Fold this into `decode_identity_id(input).is_ok()` once the McpToolError + // dependency in that function is acceptable at every call site. + let trimmed = input.trim(); + !trimmed.is_empty() + && (Identifier::from_string(trimmed, Encoding::Base58).is_ok() + || Identifier::from_string(trimmed, Encoding::Hex).is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + + // ── parse_node_type (TC-MN-001/002/003/004) ────────────────────────── + + #[test] + fn node_type_masternode_parses() { + assert_eq!( + parse_node_type("masternode").unwrap(), + IdentityType::Masternode + ); + } + + #[test] + fn node_type_evonode_parses() { + assert_eq!(parse_node_type("evonode").unwrap(), IdentityType::Evonode); + } + + #[test] + fn node_type_user_rejected() { + let err = parse_node_type("user").unwrap_err(); + assert_eq!(err, MasternodeInputError::InvalidNodeType); + assert_eq!( + err.to_string(), + "The 'node_type' must be \"masternode\" or \"evonode\"." + ); + } + + #[test] + fn node_type_trim_and_case_insensitive() { + // Trailing whitespace and mixed case are normalized, not rejected. + assert_eq!( + parse_node_type("MASTERNODE ").unwrap(), + IdentityType::Masternode + ); + assert_eq!(parse_node_type(" Evonode").unwrap(), IdentityType::Evonode); + } + + #[test] + fn node_type_garbage_rejected() { + for bad in ["evo", "", "node", "masternodes"] { + assert!( + matches!( + parse_node_type(bad), + Err(MasternodeInputError::InvalidNodeType) + ), + "expected {bad:?} to be rejected" + ); + } + } + + // ── parse_key_mode (TC-MN-030) ──────────────────────────────────────── + + #[test] + fn key_mode_owner_and_transfer_parse() { + assert_eq!(parse_key_mode("owner").unwrap(), KeyMode::Owner); + assert_eq!(parse_key_mode("transfer").unwrap(), KeyMode::Transfer); + } + + #[test] + fn key_mode_trim_and_case_insensitive() { + assert_eq!(parse_key_mode("OWNER ").unwrap(), KeyMode::Owner); + assert_eq!(parse_key_mode(" Transfer").unwrap(), KeyMode::Transfer); + } + + #[test] + fn key_mode_unknown_rejected() { + for bad in ["foo", ""] { + let err = parse_key_mode(bad).unwrap_err(); + assert_eq!( + err.to_string(), + "The 'key_mode' must be \"owner\" or \"transfer\".", + "for input {bad:?}" + ); + } + } + + // ── require_at_least_one_signing_key (TC-MN-008/009) ────────────────── + + #[test] + fn both_keys_absent_rejected_naming_both() { + let err = require_at_least_one_signing_key(&Secret::new(""), &Secret::new("")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("owner"), "message names owner key: {msg}"); + assert!(msg.contains("payout"), "message names payout key: {msg}"); + } + + #[test] + fn voting_key_alone_does_not_satisfy() { + // Voting key is not a parameter here — the rule sees only owner/payout. + // Both empty must still be rejected even when a voting key is set elsewhere. + assert!( + require_at_least_one_signing_key(&Secret::new(" "), &Secret::new(" ")).is_err() + ); + } + + #[test] + fn owner_key_alone_satisfies() { + assert!( + require_at_least_one_signing_key(&Secret::new("OWNER_WIF"), &Secret::new("")).is_ok() + ); + } + + #[test] + fn payout_key_alone_satisfies() { + assert!( + require_at_least_one_signing_key(&Secret::new(""), &Secret::new("PAYOUT_WIF")).is_ok() + ); + } + + // ── decode_identity_id — Base58/hex identifier parse (TC-MN-005/006/007) ── + // + // Used by the withdraw tool for `identity_id`. The load tool passes + // `pro_tx_hash` straight to the backend, which parses it identically; these + // pin the shared Base58-then-hex contract. + + #[test] + fn identity_id_hex_accepted() { + let id = Identifier::random(); + let hex = id.to_string(Encoding::Hex); + assert_eq!(decode_identity_id(&hex).unwrap(), id); + } + + #[test] + fn identity_id_base58_accepted_and_equals_hex_form() { + let id = Identifier::random(); + let base58 = id.to_string(Encoding::Base58); + let hex = id.to_string(Encoding::Hex); + let from_base58 = decode_identity_id(&base58).unwrap(); + let from_hex = decode_identity_id(&hex).unwrap(); + // Both encodings of the same identity decode to byte-identical IDs. + assert_eq!(from_base58, from_hex); + assert_eq!(from_base58.as_bytes(), id.as_bytes()); + } + + #[test] + fn identity_id_malformed_rejected() { + // "not-a-hash" and 63/65-char hex are neither valid Base58 nor valid hex + // identifiers. (A 64-char hex string is valid, so it is excluded here.) + for bad in ["not-a-hash", "", &"a".repeat(63), &"b".repeat(65)] { + assert!( + matches!( + decode_identity_id(bad), + Err(MasternodeInputError::InvalidIdentityId { .. }) + ), + "expected {bad:?} to be rejected" + ); + } + } + + // ── is_valid_pro_tx_hash — UI on-blur shape check (TC-FR4-08/09) ────── + + #[test] + fn pro_tx_hash_accepts_hex_and_base58() { + let id = Identifier::random(); + assert!(is_valid_pro_tx_hash(&id.to_string(Encoding::Hex))); + assert!(is_valid_pro_tx_hash(&id.to_string(Encoding::Base58))); + } + + #[test] + fn pro_tx_hash_accepts_surrounding_whitespace() { + let id = Identifier::random(); + let padded = format!(" {} ", id.to_string(Encoding::Hex)); + assert!(is_valid_pro_tx_hash(&padded)); + } + + #[test] + fn pro_tx_hash_rejects_empty_and_malformed() { + for bad in ["", " ", "not-a-hash", &"a".repeat(63), &"b".repeat(65)] { + assert!(!is_valid_pro_tx_hash(bad), "expected {bad:?} rejected"); + } + } + + #[test] + fn identity_id_error_states_what_to_do() { + // The error must carry a concrete self-resolution action: the two + // accepted formats and the tool that produces the canonical Base58 form. + let err = decode_identity_id("not-a-hash").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("64-character hex ProTxHash"), "got: {msg}"); + assert!(msg.contains("masternode-identity-load"), "got: {msg}"); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 08834a67c..57e568239 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,14 +1,26 @@ pub mod address; pub mod amount; pub mod contested_name; +pub mod dashpay; +pub mod dashpay_derivation; pub mod dpns; -pub mod feature_gate; pub mod fee_estimation; pub mod grovestark_prover; -pub mod password_info; -pub mod proof_log_item; +pub mod identity_discovery; +pub mod identity_key_protection; +pub mod key_input; +/// Stateless masternode/evonode input parsing and validation (ProTxHash, +/// node type). Pure logic with no mcp/cli dependency — used by both the +/// always-compiled GUI load form and the headless MCP tools, so it must never +/// be feature-gated. +pub mod masternode_input; pub mod qualified_contract; pub mod qualified_identity; +pub mod request_type; pub mod secret; +pub mod selected_identity; +pub mod selected_wallet; pub mod settings; +pub mod single_key; +pub mod spv_status; pub mod wallet; diff --git a/src/model/password_info.rs b/src/model/password_info.rs deleted file mode 100644 index 8ad9fe7a5..000000000 --- a/src/model/password_info.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[derive(Debug, Clone)] -pub struct PasswordInfo { - #[allow(dead_code)] // Used for password verification - pub password_checker: Vec<u8>, - #[allow(dead_code)] // Used for password hashing - pub salt: Vec<u8>, - #[allow(dead_code)] // Used for encryption - pub nonce: Vec<u8>, -} diff --git a/src/model/proof_log_item.rs b/src/model/proof_log_item.rs deleted file mode 100644 index 4517d4107..000000000 --- a/src/model/proof_log_item.rs +++ /dev/null @@ -1,96 +0,0 @@ -#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] -pub enum RequestType { - BroadcastStateTransition = 1, - GetIdentity = 2, - GetIdentityKeys = 3, - GetIdentitiesContractKeys = 4, - GetIdentityNonce = 5, - GetIdentityContractNonce = 6, - GetIdentityBalance = 7, - GetIdentitiesBalances = 8, - GetIdentityBalanceAndRevision = 9, - GetEvonodesProposedEpochBlocksByIds = 10, - GetEvonodesProposedEpochBlocksByRange = 11, - GetProofs = 12, - GetDataContract = 13, - GetDataContractHistory = 14, - GetDataContracts = 15, - GetDocuments = 16, - GetIdentityByPublicKeyHash = 17, - WaitForStateTransitionResult = 18, - GetConsensusParams = 19, - GetProtocolVersionUpgradeState = 20, - GetProtocolVersionUpgradeVoteStatus = 21, - GetEpochsInfo = 22, - GetContestedResources = 23, - GetContestedResourceVoteState = 24, - GetContestedResourceVotersForIdentity = 25, - GetContestedResourceIdentityVotes = 26, - GetVotePollsByEndDate = 27, - GetPrefundedSpecializedBalance = 28, - GetTotalCreditsInPlatform = 29, - GetPathElements = 30, - GetStatus = 31, - GetCurrentQuorumsInfo = 32, -} - -use std::convert::TryFrom; - -impl From<RequestType> for u8 { - fn from(request_type: RequestType) -> Self { - request_type as u8 - } -} - -impl TryFrom<u8> for RequestType { - type Error = (); - - fn try_from(value: u8) -> Result<Self, Self::Error> { - match value { - 1 => Ok(RequestType::BroadcastStateTransition), - 2 => Ok(RequestType::GetIdentity), - 3 => Ok(RequestType::GetIdentityKeys), - 4 => Ok(RequestType::GetIdentitiesContractKeys), - 5 => Ok(RequestType::GetIdentityNonce), - 6 => Ok(RequestType::GetIdentityContractNonce), - 7 => Ok(RequestType::GetIdentityBalance), - 8 => Ok(RequestType::GetIdentitiesBalances), - 9 => Ok(RequestType::GetIdentityBalanceAndRevision), - 10 => Ok(RequestType::GetEvonodesProposedEpochBlocksByIds), - 11 => Ok(RequestType::GetEvonodesProposedEpochBlocksByRange), - 12 => Ok(RequestType::GetProofs), - 13 => Ok(RequestType::GetDataContract), - 14 => Ok(RequestType::GetDataContractHistory), - 15 => Ok(RequestType::GetDataContracts), - 16 => Ok(RequestType::GetDocuments), - 17 => Ok(RequestType::GetIdentityByPublicKeyHash), - 18 => Ok(RequestType::WaitForStateTransitionResult), - 19 => Ok(RequestType::GetConsensusParams), - 20 => Ok(RequestType::GetProtocolVersionUpgradeState), - 21 => Ok(RequestType::GetProtocolVersionUpgradeVoteStatus), - 22 => Ok(RequestType::GetEpochsInfo), - 23 => Ok(RequestType::GetContestedResources), - 24 => Ok(RequestType::GetContestedResourceVoteState), - 25 => Ok(RequestType::GetContestedResourceVotersForIdentity), - 26 => Ok(RequestType::GetContestedResourceIdentityVotes), - 27 => Ok(RequestType::GetVotePollsByEndDate), - 28 => Ok(RequestType::GetPrefundedSpecializedBalance), - 29 => Ok(RequestType::GetTotalCreditsInPlatform), - 30 => Ok(RequestType::GetPathElements), - 31 => Ok(RequestType::GetStatus), - 32 => Ok(RequestType::GetCurrentQuorumsInfo), - _ => Err(()), - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct ProofLogItem { - pub request_type: RequestType, - pub request_bytes: Vec<u8>, - pub verification_path_query_bytes: Vec<u8>, - pub height: u64, - pub time_ms: u64, - pub proof_bytes: Vec<u8>, - pub error: Option<String>, -} diff --git a/src/model/qualified_contract.rs b/src/model/qualified_contract.rs index 643efb598..ff406a3f1 100644 --- a/src/model/qualified_contract.rs +++ b/src/model/qualified_contract.rs @@ -1,3 +1,4 @@ +use dash_sdk::dpp::data_contract::TokenContractPosition; use dash_sdk::platform::DataContract; #[derive(Debug, Clone, PartialEq)] @@ -5,3 +6,12 @@ pub struct QualifiedContract { pub contract: DataContract, pub alias: Option<String>, } + +/// Token-insertion policy for [`AppContext::insert_contract_if_not_exists`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::enum_variant_names)] +pub enum InsertTokensToo { + AllTokensShouldBeAdded, + NoTokensShouldBeAdded, + SomeTokensShouldBeAdded(Vec<TokenContractPosition>), +} diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 055b154a6..25fe42699 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -13,6 +13,17 @@ use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::{Arc, RwLock}; +use zeroize::Zeroizing; + +/// A resolved private key paired with its public-key metadata. The key bytes +/// are held in [`Zeroizing`] so they are wiped when the resolver's result is +/// dropped. +pub type ResolvedPrivateKey = (QualifiedIdentityPublicKey, Zeroizing<[u8; 32]>); + +/// A `(target, key_id)` map key paired with the raw 32-byte private key the +/// migration must store in the vault — see +/// [`KeyStorage::take_plaintext_for_vault`]. Bytes are [`Zeroizing`]. +pub type VaultBoundKey = ((PrivateKeyTarget, KeyID), Zeroizing<[u8; 32]>); #[derive(Debug, Clone, PartialEq)] pub struct WalletDerivationPath { @@ -96,61 +107,80 @@ impl<'de, C> BorrowDecode<'de, C> for WalletDerivationPath { fn borrow_decode<D: BorrowDecoder<'de, Context = C>>( decoder: &mut D, ) -> Result<Self, DecodeError> { - // Decode `wallet_seed_hash` - let wallet_seed_hash = WalletSeedHash::decode(decoder)?; - - // Decode the length of the `DerivationPath` - let path_len = usize::decode(decoder)?; - - // Decode each `ChildNumber` in the `DerivationPath` - let mut path = Vec::with_capacity(path_len); - for _ in 0..path_len { - let discriminant = u8::decode(decoder)?; - let child_number = match discriminant { - 0 => ChildNumber::Normal { - index: u32::decode(decoder)?, - }, - 1 => ChildNumber::Hardened { - index: u32::decode(decoder)?, - }, - 2 => ChildNumber::Normal256 { - index: <[u8; 32]>::decode(decoder)?, - }, - 3 => ChildNumber::Hardened256 { - index: <[u8; 32]>::decode(decoder)?, - }, - _ => return Err(DecodeError::OtherString("Invalid ChildNumber type".into())), - }; - path.push(child_number); - } - - let derivation_path = DerivationPath::from(path); - Ok(Self { - wallet_seed_hash, - derivation_path, - }) + Self::decode(decoder) } } -#[derive(Debug, Clone, Encode, Decode, PartialEq)] +#[derive(Clone, Encode, Decode, PartialEq)] pub enum PrivateKeyData { AlwaysClear([u8; 32]), // This is for keys that are MEDIUM security level Clear([u8; 32]), Encrypted(Vec<u8>), AtWalletDerivationPath(WalletDerivationPath), + /// The key's raw bytes live in the secret vault, fetched per-use through + /// the seam — never resident in this blob. A permanent in-memory + /// placeholder: the resolver reads the vault at sign time keyed by the + /// identity scope + the `(target, key_id)` BTreeMap key, so this variant + /// carries no bytes. + /// + /// Appended at the highest bincode index so blobs written before it + /// (discriminants 0–3) still decode unchanged (TS-RESID-02). + InVault, +} + +impl fmt::Debug for PrivateKeyData { + /// Redacting `Debug`: never prints raw plaintext private-key bytes. + /// + /// The `Clear`/`AlwaysClear` variants hold raw identity private keys, so + /// each is rendered as the variant name plus a SHA-256 fingerprint (for + /// distinguishing keys in logs) and the byte length — never the key + /// itself. `Encrypted` prints its length only. `KeyStorage`, + /// `QualifiedIdentity`, and everything else that derives `Debug` delegate + /// here, so redacting once protects the whole chain. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PrivateKeyData::Clear(data) => f + .debug_tuple("Clear") + .field(&format_args!("fingerprint={}", fingerprint(data))) + .finish(), + PrivateKeyData::AlwaysClear(data) => f + .debug_tuple("AlwaysClear") + .field(&format_args!("fingerprint={}", fingerprint(data))) + .finish(), + PrivateKeyData::Encrypted(data) => f + .debug_tuple("Encrypted") + .field(&format_args!("{} bytes", data.len())) + .finish(), + PrivateKeyData::AtWalletDerivationPath(path) => { + f.debug_tuple("AtWalletDerivationPath").field(path).finish() + } + PrivateKeyData::InVault => f.debug_tuple("InVault").finish(), + } + } +} + +/// Non-reversible fingerprint of secret key bytes for redacted `Debug`: +/// the first 8 bytes of their SHA-256, hex-encoded. Lets two distinct keys +/// be told apart in logs without exposing either. +fn fingerprint(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + hex::encode(&digest[..8]) } impl fmt::Display for PrivateKeyData { + /// Redacting `Display`: mirrors the `Debug` impl and never prints raw + /// plaintext private-key bytes for the `Clear`/`AlwaysClear` variants. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PrivateKeyData::Clear(data) => { - write!(f, "Clear({})", hex::encode(data)) + write!(f, "Clear(fingerprint={})", fingerprint(data)) } PrivateKeyData::Encrypted(data) => { write!(f, "Encrypted({} bytes)", data.len()) } PrivateKeyData::AlwaysClear(data) => { - write!(f, "Clear({})", hex::encode(data)) + write!(f, "AlwaysClear(fingerprint={})", fingerprint(data)) } PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { wallet_seed_hash: wallet_seed, @@ -163,6 +193,7 @@ impl fmt::Display for PrivateKeyData { derivation_path ) } + PrivateKeyData::InVault => write!(f, "InVault"), } } } @@ -251,120 +282,105 @@ impl From<BTreeMap<(PrivateKeyTarget, KeyID), (QualifiedIdentityPublicKey, Walle } impl KeyStorage { - // Allow dead_code: This method provides direct key access without password resolution, - // useful for cases where keys are already decrypted or for debugging purposes - #[allow(dead_code)] - pub fn get( + /// Seed-free resolution for keys that carry their own plaintext + /// ([`PrivateKeyData::Clear`] / [`PrivateKeyData::AlwaysClear`]). + /// + /// `Ok(None)` when the key is absent. Errors for keys that need a secret to + /// resolve ([`PrivateKeyData::Encrypted`], wallet-derived + /// [`PrivateKeyData::AtWalletDerivationPath`]); wallet-derived keys go + /// through the JIT chokepoint via + /// [`get_resolve_with_seed`](Self::get_resolve_with_seed), gated by + /// [`wallet_seed_hash_for`](Self::wallet_seed_hash_for). Never reads a + /// wallet's parked seed. + pub fn get_resolve_local( &self, key: &(PrivateKeyTarget, KeyID), - ) -> Result<Option<(&QualifiedIdentityPublicKey, [u8; 32])>, String> { + ) -> Result<Option<ResolvedPrivateKey>, String> { self.private_keys .get(key) .map( |(qualified_identity_public_key_data, private_key_data)| match private_key_data { - PrivateKeyData::AlwaysClear(clear) | PrivateKeyData::Clear(clear) => { - Ok((qualified_identity_public_key_data, *clear)) - } + PrivateKeyData::AlwaysClear(clear) | PrivateKeyData::Clear(clear) => Ok(( + qualified_identity_public_key_data.clone(), + Zeroizing::new(*clear), + )), PrivateKeyData::Encrypted(_) => { Err("Key is encrypted, please enter password".to_string()) } PrivateKeyData::AtWalletDerivationPath(_) => { - Err("Key is not resolved, please enter password".to_string()) - } - }, - ) - .transpose() - } - - pub fn get_resolve( - &self, - key: &(PrivateKeyTarget, KeyID), - wallets: &[Arc<RwLock<Wallet>>], - network: Network, - ) -> Result<Option<(QualifiedIdentityPublicKey, [u8; 32])>, String> { - self.private_keys - .get(key) - .map( - |(qualified_identity_public_key_data, private_key_data)| match private_key_data { - PrivateKeyData::AlwaysClear(clear) | PrivateKeyData::Clear(clear) => { - Ok((qualified_identity_public_key_data.clone(), *clear)) - } - PrivateKeyData::Encrypted(_) => { - Err("Key is encrypted, please enter password".to_string()) + Err("Key is not resolved, please unlock the wallet".to_string()) } - PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { - wallet_seed_hash, - derivation_path, - }) => { - tracing::debug!( - stored_wallet_seed_hash = %hex::encode(wallet_seed_hash), - derivation_path = %derivation_path, - num_wallets = wallets.len(), - "Looking up wallet for key derivation" - ); - - // Log available wallet seed hashes - for wallet in wallets { - if let Ok(wallet_ref) = wallet.read() { - tracing::debug!( - wallet_seed_hash = %hex::encode(wallet_ref.seed_hash()), - matches = (wallet_ref.seed_hash() == *wallet_seed_hash), - "Available wallet" - ); - } - } - - let derived_key = Wallet::derive_private_key_in_arc_rw_lock_slice( - wallets, - *wallet_seed_hash, - derivation_path, - network, - )? - .ok_or(format!( - "Wallet for key at derivation path {} not present, we have {} wallets", - derivation_path, - wallets.len() - ))?; - // match qualified_identity_public_key_data - // .identity_public_key - // .security_level() - // { - // SecurityLevel::MEDIUM => { - // *private_key_data = PrivateKeyData::AlwaysClear(derived_key) - // } - // _ => *private_key_data = PrivateKeyData::Clear(derived_key), - // } - Ok((qualified_identity_public_key_data.clone(), derived_key)) + PrivateKeyData::InVault => { + Err("Key is stored securely, resolve it through the vault".to_string()) } }, ) .transpose() } - // Allow dead_code: This method provides access to raw private key data, - // useful for inspecting key states and encryption status - #[allow(dead_code)] - pub fn get_private_key_data(&self, key: &(PrivateKeyTarget, KeyID)) -> Option<&PrivateKeyData> { - self.private_keys - .get(key) - .map(|(_, private_key_data)| private_key_data) + /// The wallet seed hash a key would derive from, or `None` for keys that + /// carry their own plaintext ([`PrivateKeyData::Clear`] / + /// [`PrivateKeyData::AlwaysClear`]) or are still encrypted. + /// + /// Pure, secret-free probe: an async caller uses it to decide whether to + /// open a [`with_secret`](crate::wallet_backend::SecretAccess::with_secret) + /// scope at all, so [`get_resolve_with_seed`](Self::get_resolve_with_seed) + /// only prompts for genuinely wallet-derived keys. + pub fn wallet_seed_hash_for(&self, key: &(PrivateKeyTarget, KeyID)) -> Option<WalletSeedHash> { + match self.private_keys.get(key) { + Some((_, PrivateKeyData::AtWalletDerivationPath(wdp))) => Some(wdp.wallet_seed_hash), + _ => None, + } } - // Allow dead_code: This method provides combined access to private key data and wallet info, - // useful for advanced key management and wallet integration scenarios - #[allow(dead_code)] - pub fn get_private_key_data_and_wallet_info( + /// Seed-as-parameter resolver, the JIT counterpart of + /// [`get_resolve_local`](Self::get_resolve_local). + /// + /// For a [`PrivateKeyData::AtWalletDerivationPath`] key, derives from the + /// `seed` borrowed by the caller (resolved once through the JIT chokepoint + /// for the key's wallet seed hash — see + /// [`wallet_seed_hash_for`](Self::wallet_seed_hash_for)) instead of reading + /// the wallet's parked seed. Plaintext-carrying variants + /// ([`PrivateKeyData::Clear`] / [`PrivateKeyData::AlwaysClear`]) defer to + /// `get_resolve_local` and ignore the seed. The derivation path, network, + /// and resulting key are unchanged — only the seed source differs. + pub fn get_resolve_with_seed( &self, key: &(PrivateKeyTarget, KeyID), - ) -> Option<(&PrivateKeyData, &Option<WalletDerivationPath>)> { - self.private_keys - .get(key) - .map(|(qualified_identity_public_key_data, private_key_data)| { - ( - private_key_data, - &qualified_identity_public_key_data.in_wallet_at_derivation_path, + wallets: &[Arc<RwLock<Wallet>>], + seed: &[u8; 64], + network: Network, + ) -> Result<Option<ResolvedPrivateKey>, String> { + match self.private_keys.get(key) { + None => Ok(None), + Some(( + qualified_identity_public_key_data, + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash, + derivation_path, + }), + )) => { + let derived_key = Wallet::derive_private_key_in_arc_rw_lock_slice_with_seed( + wallets, + *wallet_seed_hash, + seed, + derivation_path, + network, ) - }) + .map_err(|e| e.to_string())? + .ok_or(format!( + "Wallet for key at derivation path {} not present, we have {} wallets", + derivation_path, + wallets.len() + ))?; + Ok(Some(( + qualified_identity_public_key_data.clone(), + derived_key, + ))) + } + // Plaintext-carrying / encrypted variants need no seed. + Some(_) => self.get_resolve_local(key), + } } pub fn get_cloned_private_key_data_and_wallet_info( @@ -397,9 +413,7 @@ impl KeyStorage { self.private_keys.contains_key(key) } - // Allow dead_code: This method returns all stored key identifiers, - // useful for key enumeration and management operations - #[allow(dead_code)] + /// Returns all stored key identifiers. pub fn keys_set(&self) -> BTreeSet<(PrivateKeyTarget, KeyID)> { self.private_keys.keys().cloned().collect() } @@ -428,4 +442,317 @@ impl KeyStorage { } } } + + /// Mark `key` as a vault placeholder ([`PrivateKeyData::InVault`]), wiping + /// any resident plaintext bytes. Used after a freshly-added key has been + /// sealed into the secret vault so the at-rest encode path stores + /// no plaintext for it. Returns `true` if the key was present. + pub fn mark_in_vault(&mut self, key: &(PrivateKeyTarget, KeyID)) -> bool { + use zeroize::Zeroize; + match self.private_keys.get_mut(key) { + Some((_pub_key, data)) => { + if let PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) = data { + bytes.zeroize(); + } + *data = PrivateKeyData::InVault; + true + } + None => false, + } + } + + /// Whether the key at `key` is a vault placeholder + /// ([`PrivateKeyData::InVault`]) — its bytes live in the secret vault and + /// are fetched per-use, never resident here. + pub fn is_in_vault(&self, key: &(PrivateKeyTarget, KeyID)) -> bool { + matches!( + self.private_keys.get(key), + Some((_, PrivateKeyData::InVault)) + ) + } + + /// The public-key metadata for `key`, regardless of how its private bytes + /// are stored. Lets a vault-placeholder key still surface its public key + /// for display and signing-key selection without touching the secret. + pub fn public_key_for( + &self, + key: &(PrivateKeyTarget, KeyID), + ) -> Option<&QualifiedIdentityPublicKey> { + self.private_keys.get(key).map(|(pub_key, _)| pub_key) + } + + /// Whether any key still carries resident plaintext bytes + /// ([`PrivateKeyData::Clear`] / [`PrivateKeyData::AlwaysClear`]) that + /// [`Self::take_plaintext_for_vault`] would move into the vault. A cheap, + /// non-mutating probe so callers can skip a full `KeyStorage` clone when + /// there is nothing to migrate (the steady-state, already-`InVault` case). + pub fn has_plaintext_for_vault(&self) -> bool { + self.private_keys.values().any(|(_, data)| { + matches!( + data, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) + ) + }) + } + + /// Whether any key uses the legacy [`PrivateKeyData::Encrypted`] variant. + /// + /// `Encrypted` is **decode-only** — no current producer creates these keys + /// in new installations. They cannot be migrated to the vault without the + /// decryption password, so [`Self::take_plaintext_for_vault`] leaves them + /// untouched. The protect-identity guard calls this to fail-closed: an + /// `Encrypted` key has vault scheme `Absent` and would be silently skipped + /// by the seal step, causing a false-protected report. + // TODO: when a migration path for Encrypted keys is available, + // replace this with a proper re-seal that moves them into the new password + // envelope instead of blocking the protect operation. + pub fn has_encrypted_legacy_keys(&self) -> bool { + self.private_keys + .values() + .any(|(_, data)| matches!(data, PrivateKeyData::Encrypted(_))) + } + + /// Rewrite every plaintext-carrying identity key + /// ([`PrivateKeyData::Clear`] / [`PrivateKeyData::AlwaysClear`]) to an + /// [`PrivateKeyData::InVault`] placeholder, returning the raw bytes that + /// must be stored in the vault under each `(target, key_id)` BEFORE the + /// blob is persisted (migration order: vault first, then blob rewrite). + /// + /// Wallet-derived ([`PrivateKeyData::AtWalletDerivationPath`]) and already + /// vault-backed / encrypted keys are left untouched — they were never + /// plaintext-at-rest. + pub fn take_plaintext_for_vault(&mut self) -> Vec<VaultBoundKey> { + use zeroize::Zeroize; + let mut out = Vec::new(); + for (map_key, (_pub_key, data)) in self.private_keys.iter_mut() { + let raw = match data { + PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => { + let raw = *bytes; + // Wipe the resident array before the `InVault` overwrite + // drops it — de-residenting the key is this fn's whole job. + bytes.zeroize(); + raw + } + _ => continue, + }; + out.push((map_key.clone(), Zeroizing::new(raw))); + *data = PrivateKeyData::InVault; + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + use std::collections::BTreeMap; + + use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + + /// A recognizable 32-byte secret. Delegates to the shared + /// [`distinctive_secret_32`] so the seam / sidecar / QI-blob leak cases + /// share one definition rather than forking it. + fn distinctive_secret() -> [u8; 32] { + distinctive_secret_32() + } + + /// Assert `rendered` exposes the secret in none of the forms a sink could + /// leak it. Thin wrapper over the shared [`assert_no_leak_bytes`] so the + /// existing call sites keep their `&[u8; 32]` ergonomics. + fn assert_no_leak(rendered: &str, secret: &[u8; 32], context: &str) { + assert_no_leak_bytes(rendered, secret, context); + } + + /// The redacting `Debug` (and `Display`) on `PrivateKeyData` must + /// never emit raw plaintext private-key bytes, and that guarantee must hold + /// transitively through the derived-`Debug` chain + /// `QualifiedIdentity -> KeyStorage -> PrivateKeyData`. + #[test] + fn debug_output_never_leaks_plaintext_private_key() { + let secret = distinctive_secret(); + + // 1. The two raw-byte variants directly. + for variant in [ + PrivateKeyData::Clear(secret), + PrivateKeyData::AlwaysClear(secret), + ] { + assert_no_leak(&format!("{variant:?}"), &secret, "PrivateKeyData Debug"); + assert_no_leak(&format!("{variant}"), &secret, "PrivateKeyData Display"); + } + + // 2. Through KeyStorage, which derives Debug and holds the variant. + let platform_version = PlatformVersion::latest(); + let public_key = IdentityPublicKey::random_key(0, Some(42), platform_version); + let mut key_storage = KeyStorage::default(); + key_storage.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, public_key.id()), + ( + QualifiedIdentityPublicKey::from(public_key), + PrivateKeyData::Clear(secret), + ), + ); + assert_no_leak(&format!("{key_storage:?}"), &secret, "KeyStorage Debug"); + + // 3. Through QualifiedIdentity, which derives Debug and holds KeyStorage. + let identity = Identity::create_basic_identity(Identifier::default(), platform_version) + .expect("basic identity"); + let qualified = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: key_storage, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: Network::Testnet, + }; + assert_no_leak( + &format!("{qualified:?}"), + &secret, + "QualifiedIdentity Debug", + ); + } + + /// Helper: a `KeyStorage` carrying one `Clear` (HIGH) and one `AlwaysClear` + /// (MEDIUM) plaintext key plus one `AtWalletDerivationPath` key, used by + /// the migration / residency cases. + fn storage_with_plaintext_and_derived( + secret_high: [u8; 32], + secret_medium: [u8; 32], + ) -> KeyStorage { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + + let high = IdentityPublicKey::random_key(1, Some(1), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), + ( + QualifiedIdentityPublicKey::from(high), + PrivateKeyData::Clear(secret_high), + ), + ); + let medium = IdentityPublicKey::random_key(2, Some(2), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, medium.id()), + ( + QualifiedIdentityPublicKey::from(medium), + PrivateKeyData::AlwaysClear(secret_medium), + ), + ); + let derived = IdentityPublicKey::random_key(3, Some(3), pv); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, derived.id()), + ( + QualifiedIdentityPublicKey::from(derived), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x07; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + ks + } + + /// TS-RESID-02 — a bincode blob written BEFORE `InVault` was appended + /// (discriminants 0–3 only) still decodes into the extended enum, and the + /// new highest-index variant round-trips. Guards the bincode-discriminant + /// trap: appending at index 4 must not shift 0–3. + #[test] + fn ts_resid_02_old_blob_decodes_after_appending_in_vault() { + let cfg = bincode::config::standard(); + // Each of the four pre-existing variants must round-trip unchanged. + for original in [ + PrivateKeyData::AlwaysClear([0x11; 32]), + PrivateKeyData::Clear([0x22; 32]), + PrivateKeyData::Encrypted(vec![0x33; 48]), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x44; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ] { + let bytes = bincode::encode_to_vec(&original, cfg).expect("encode"); + let (decoded, _): (PrivateKeyData, _) = + bincode::decode_from_slice(&bytes, cfg).expect("decode old variant"); + assert!( + decoded == original, + "pre-InVault variant must decode unchanged" + ); + } + // The new variant round-trips too. + let bytes = bincode::encode_to_vec(PrivateKeyData::InVault, cfg).expect("encode"); + let (decoded, _): (PrivateKeyData, _) = + bincode::decode_from_slice(&bytes, cfg).expect("decode InVault"); + assert!(decoded == PrivateKeyData::InVault); + } + + /// TS-RESID-01 / TS-NOLEAK-03 — after `take_plaintext_for_vault`, every + /// plaintext-carrying key is an `InVault` placeholder (zero Clear / + /// AlwaysClear remain), the wallet-derived key is untouched, and the + /// returned raw bytes match the originals. The re-encoded blob leaks + /// neither secret in hex nor decimal-array form. + #[test] + fn ts_resid_01_migration_leaves_only_in_vault_and_blob_has_no_plaintext() { + let high = distinctive_secret_32(); + let mut medium = high; + medium[0] ^= 0xFF; // distinct from `high` + let mut ks = storage_with_plaintext_and_derived(high, medium); + + let taken = ks.take_plaintext_for_vault(); + assert_eq!(taken.len(), 2, "both plaintext keys are extracted"); + let taken_bytes: Vec<[u8; 32]> = taken.iter().map(|(_, b)| **b).collect(); + assert!(taken_bytes.contains(&high) && taken_bytes.contains(&medium)); + + let mut in_vault = 0; + let mut derived = 0; + for (_, data) in ks.private_keys.values() { + match data { + PrivateKeyData::InVault => in_vault += 1, + PrivateKeyData::AtWalletDerivationPath(_) => derived += 1, + PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_) => { + panic!("plaintext key survived migration") + } + PrivateKeyData::Encrypted(_) => {} + } + } + assert_eq!(in_vault, 2, "both plaintext keys became InVault"); + assert_eq!(derived, 1, "wallet-derived key untouched"); + + // The persisted blob carries InVault markers, never plaintext. + let blob = bincode::encode_to_vec(&ks, bincode::config::standard()).expect("encode"); + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &high, "migrated KeyStorage blob (high)"); + assert_no_leak_bytes(&rendered, &medium, "migrated KeyStorage blob (medium)"); + } + + /// `is_in_vault` and `public_key_for` probes: a vault placeholder reports + /// `true` and still surfaces its public key; a plaintext key reports + /// `false`. + #[test] + fn in_vault_and_public_key_probes() { + let mut ks = storage_with_plaintext_and_derived([0x01; 32], [0x02; 32]); + let keys: Vec<_> = ks.private_keys.keys().cloned().collect(); + ks.take_plaintext_for_vault(); + // The two plaintext keys are now InVault; the derived one is not. + let mut in_vault_count = 0; + for k in &keys { + assert!( + ks.public_key_for(k).is_some(), + "public key always available" + ); + if ks.is_in_vault(k) { + in_vault_count += 1; + } + } + assert_eq!(in_vault_count, 2); + } } diff --git a/src/model/qualified_identity/identity_meta.rs b/src/model/qualified_identity/identity_meta.rs new file mode 100644 index 000000000..d00e90512 --- /dev/null +++ b/src/model/qualified_identity/identity_meta.rs @@ -0,0 +1,80 @@ +//! DET-owned identity-metadata sidecar. +//! +//! Carries the cosmetic prompt copy for an identity whose keys are +//! password-protected — currently just the user-set password hint. It is +//! **display-only**: it NEVER decides whether a password is required. The +//! authoritative flag is the at-rest vault scheme (see +//! [`SecretAccess::scope_has_passphrase`](crate::wallet_backend::SecretAccess)), +//! so a sidecar that drifts from the vault can only mis-render a hint, never +//! mis-route a prompt. A missing or corrupt sidecar degrades to "no hint", +//! never an error. +//! +//! Persisted as a single positional-`bincode` blob per `(network, identity_id)` +//! in the cross-network `det-app.sqlite` k/v store behind the `DetKv` +//! schema-version envelope — a deliberate twin of +//! [`WalletMeta`](crate::model::wallet::meta::WalletMeta). The view that reads +//! and writes it is +//! [`IdentityMetaView`](crate::wallet_backend::IdentityMetaView). + +use serde::{Deserialize, Serialize}; + +/// DET-owned per-identity metadata for the password-protection feature. +/// +/// `IdentityMeta` is stored as a positional `bincode::config::standard()` blob +/// behind the `DetKv` schema envelope, so adding, removing, or reordering any +/// field here is a format-breaking change for already-stored blobs. Evolve the +/// shape the same way [`WalletMeta`](crate::model::wallet::meta::WalletMeta) +/// does — a decode-only legacy shape plus a dual-format reader — never by +/// relying on `#[serde(default)]` alone (positional bincode reads past the end +/// of a shorter blob and errors rather than defaulting a trailing field). +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct IdentityMeta { + /// Optional user-set password hint for this identity's protected keys. + /// Shown next to the sign-time prompt. Plain text by design — the user + /// chose it as a memory aid, and it is never the password itself. + #[serde(default)] + pub password_hint: Option<String>, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ID-META-001 — round-trip through bincode so the persisted shape is + /// covered the same way `WalletMeta` is. A field that breaks decoding + /// surfaces here. + #[test] + fn identity_meta_round_trips_through_bincode() { + let original = IdentityMeta { + password_hint: Some("granny's birthday".into()), + }; + let bytes = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (IdentityMeta, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(decoded, original); + } + + /// ID-META-002 — `Default` is the "no hint" shape. + #[test] + fn default_has_no_hint() { + assert!(IdentityMeta::default().password_hint.is_none()); + } + + /// ID-META-003 — the sidecar structurally cannot carry a secret (no key + /// field); canary coverage that a future field never smuggles one in. + #[test] + fn id_meta_003_blob_has_no_secret() { + use crate::wallet_backend::leak_test_support::{ + assert_no_leak_bytes, distinctive_secret_64, + }; + let secret = distinctive_secret_64(); + let meta = IdentityMeta { + password_hint: Some("a hint, not the password".into()), + }; + let blob = + bincode::serde::encode_to_vec(&meta, bincode::config::standard()).expect("encode"); + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &secret, "IdentityMeta sidecar blob"); + } +} diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 931db3707..4e972d719 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1,11 +1,18 @@ pub mod encrypted_key_storage; +pub mod identity_meta; pub mod qualified_identity_public_key; -use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; +// TODO(det): this upward edge is fixed by the `SecretAccess::with_secret` +// contract, whose closures must return `Result<_, TaskError>`. Removing it +// requires making that secret-seam chokepoint generic over the closure error +// type — a wallet_backend change out of scope here. +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, ResolvedPrivateKey}; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::wallet::{Wallet, WalletSeedHash}; use bincode::{Decode, Encode}; use dash_sdk::dashcore_rpc::dashcore::{PubkeyHash, signer}; +use dash_sdk::dpp::async_trait::async_trait; use dash_sdk::dpp::bls_signatures::{Bls12381G2Impl, SignatureSchemes}; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; @@ -25,10 +32,10 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::state_transition::errors::InvalidIdentityPublicKeyTypeError; use dash_sdk::dpp::{ProtocolError, bls_signatures, ed25519_dalek}; use dash_sdk::platform::IdentityPublicKey; -use egui::Color32; use std::collections::{BTreeMap, HashSet}; use std::fmt::{Display, Formatter}; use std::sync::{Arc, RwLock}; +use zeroize::Zeroizing; #[derive(Debug, Encode, Decode, PartialEq, Clone, Copy)] pub enum IdentityType { @@ -38,15 +45,6 @@ pub enum IdentityType { } impl IdentityType { - #[allow(dead_code)] // May be used for voting calculations - pub fn vote_strength(&self) -> u64 { - match self { - IdentityType::User => 1, - IdentityType::Masternode => 1, - IdentityType::Evonode => 4, - } - } - pub fn default_encoding(&self) -> Encoding { match self { IdentityType::User => Encoding::Base58, @@ -54,6 +52,27 @@ impl IdentityType { IdentityType::Evonode => Encoding::Hex, } } + + /// Stable persistence tag, decoupled from the derived `Debug` + /// representation. Stored blobs and their filters share this mapping, so a + /// variant rename can never silently change a discriminator on disk. + pub const fn as_tag(&self) -> &'static str { + match self { + IdentityType::User => "User", + IdentityType::Masternode => "Masternode", + IdentityType::Evonode => "Evonode", + } + } + + /// Inverse of [`IdentityType::as_tag`]. Returns `None` for an unknown tag. + pub fn from_tag(tag: &str) -> Option<Self> { + match tag { + "User" => Some(IdentityType::User), + "Masternode" => Some(IdentityType::Masternode), + "Evonode" => Some(IdentityType::Evonode), + _ => None, + } + } } impl Display for IdentityType { @@ -66,6 +85,25 @@ impl Display for IdentityType { } } +/// Presence of the three masternode/evonode key roles on a loaded node. +/// +/// A node loads read-only without any keys; each role can be present or absent +/// independently. Used by the Masternodes card grid to render the compact +/// `V O P` key-status indicator (present roles emphasised, absent roles dimmed) +/// — never colour-only (NFR-6). +/// +/// Role → purpose mapping (see `verify_*_key_exists_on_identity` in +/// `backend_task/identity/mod.rs`): +/// * Voting → a `PrivateKeyOnVoterIdentity` key / `associated_voter_identity` +/// * Owner → a main-identity key with [`Purpose::OWNER`] +/// * Payout → a main-identity key with [`Purpose::TRANSFER`] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MasternodeKeyPresence { + pub voting: bool, + pub owner: bool, + pub payout: bool, +} + #[derive(Debug, Encode, Decode, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] #[allow(clippy::enum_variant_names)] pub enum PrivateKeyTarget { @@ -140,18 +178,6 @@ impl Display for IdentityStatus { } } -impl From<IdentityStatus> for Color32 { - fn from(value: IdentityStatus) -> Self { - match value { - IdentityStatus::Active => Color32::from_rgb(0, 128, 0), // Green - IdentityStatus::Unknown => Color32::from_rgb(128, 128, 128), // Gray - IdentityStatus::PendingCreation => Color32::from_rgb(255, 165, 0), // Orange - IdentityStatus::NotFound => Color32::from_rgb(255, 0, 0), // Red - IdentityStatus::FailedCreation => Color32::from_rgb(255, 0, 0), // Red - } - } // -} - impl IdentityStatus { /// Returns identity status as a u8 value, for serialization pub fn as_u8(&self) -> u8 { @@ -219,6 +245,12 @@ pub struct QualifiedIdentity { pub private_keys: KeyStorage, pub dpns_names: Vec<DPNSNameInfo>, pub associated_wallets: BTreeMap<WalletSeedHash, Arc<RwLock<Wallet>>>, + /// The JIT secret chokepoint, attached alongside `associated_wallets` when + /// the identity is hydrated. Lets the async `sign` path fetch the HD seed + /// just-in-time (no parked seed read) for the ECDSA_HASH160 recovery scan. + /// Skipped by Encode/Decode and excluded from `PartialEq`, exactly like + /// `associated_wallets` — it is a runtime wiring handle, not identity data. + pub secret_access: Option<crate::wallet_backend::SecretAccess>, /// The index used to register the identity pub wallet_index: Option<u32>, pub top_ups: BTreeMap<u32, u64>, @@ -284,6 +316,7 @@ impl<C> Decode<C> for QualifiedIdentity { private_keys: KeyStorage::decode(decoder)?, dpns_names: Vec::<DPNSNameInfo>::decode(decoder)?, associated_wallets: BTreeMap::new(), // Initialize with an empty vector + secret_access: None, // Runtime wiring, attached at hydration wallet_index: None, top_ups: Default::default(), status: IdentityStatus::Unknown, // Loaded from the database, not encoded @@ -304,8 +337,9 @@ impl Display for QualifiedIdentity { } } +#[async_trait] impl Signer<IdentityPublicKey> for QualifiedIdentity { - fn sign( + async fn sign( &self, identity_public_key: &IdentityPublicKey, data: &[u8], @@ -322,46 +356,38 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { "Attempting to sign with key" ); - // Log available keys - for ((t, id), (pub_key, _)) in self.private_keys.private_keys.iter() { - tracing::debug!( - target = ?t, - key_id = id, - purpose = ?pub_key.identity_public_key.purpose(), - key_type = ?pub_key.identity_public_key.key_type(), - "Available key in identity" + // Resolve the signing key without ever reading a wallet's parked seed + // (see [`Self::resolve_private_key_bytes`]). + let resolved = self + .resolve_private_key_bytes(target.clone(), key_id) + .await + .map_err(|e| ProtocolError::Generic(e.to_string()))?; + + let (_, private_key) = resolved.ok_or_else(|| { + tracing::error!( + key_id = key_id, + purpose = ?identity_public_key.purpose(), + target = ?target, + "Key not found in identity" ); - } - - let (_, private_key) = self - .private_keys - .get_resolve( - &(target.clone(), key_id), - self.associated_wallets - .values() - .cloned() - .collect::<Vec<_>>() - .as_slice(), - self.network, - ) - .map_err(|e| { - tracing::error!(error = %e, "Failed to resolve private key"); - ProtocolError::Generic(e) - })? - .ok_or_else(|| { - tracing::error!( - key_id = key_id, - purpose = ?identity_public_key.purpose(), - target = ?target, - "Key not found in identity" + // Only dump the identity's available keys when resolution failed — + // this is the diagnostic that actually matters, off the hot path. + for ((t, id), (pub_key, _)) in self.private_keys.private_keys.iter() { + tracing::debug!( + target = ?t, + key_id = id, + purpose = ?pub_key.identity_public_key.purpose(), + key_type = ?pub_key.identity_public_key.key_type(), + "Available key in identity" ); - ProtocolError::Generic(format!( - "Key {} ({}) not found in identity {:?}", - identity_public_key.id(), - identity_public_key.purpose(), - self.identity.id().to_string(Encoding::Base58) - )) - })?; + } + ProtocolError::Generic(format!( + "Key {} ({}) not found in identity {:?}", + identity_public_key.id(), + identity_public_key.purpose(), + self.identity.id().to_string(Encoding::Base58) + )) + })?; tracing::debug!("Successfully resolved private key, proceeding to sign"); match identity_public_key.key_type() { @@ -375,7 +401,7 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { let platform_key_data = identity_public_key.data().as_slice(); - if let Ok(secret_key) = SecretKey::from_slice(&private_key) { + if let Ok(secret_key) = SecretKey::from_slice(&private_key[..]) { let secp = Secp256k1::new(); let derived_pubkey = PublicKey::new(secret_key.public_key(&secp)); let pubkey_bytes = derived_pubkey.to_bytes(); @@ -383,50 +409,16 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { let hash160 = ripemd160::Hash::hash(sha256_hash.as_byte_array()); if hash160.as_byte_array() != platform_key_data { - // Mismatch detected - scan identity indices to find the correct derivation path - use dash_sdk::dpp::key_wallet::bip32::{ - DerivationPath as DP, KeyDerivationType, - }; - - if let Some(wallet) = self.associated_wallets.values().next() - && let Ok(wallet_ref) = wallet.read() - && let Ok(seed) = wallet_ref.seed_bytes() + // Mismatch detected — scan identity indices to find + // the correct derivation path. The HD seed is + // fetched just-in-time through the JIT chokepoint + // (no parked-seed read) and the scan runs inside the + // closure, so the seed never enters this layer. + if let Some(found) = self + .sign_via_hash160_path_scan(data, key_id, platform_key_data) + .await? { - // Scan identity indices 0-9 to find matching key - for identity_index in 0..10u32 { - let correct_path = DP::identity_authentication_path( - self.network, - KeyDerivationType::ECDSA, - identity_index, - key_id, - ); - - if let Ok(extended_key) = correct_path - .derive_priv_ecdsa_for_master_seed(seed, self.network) - { - let correct_pubkey = PublicKey::new( - extended_key.private_key.public_key(&secp), - ); - let correct_hash = ripemd160::Hash::hash( - sha256::Hash::hash(&correct_pubkey.to_bytes()) - .as_byte_array(), - ); - - if correct_hash.as_byte_array() == platform_key_data { - tracing::info!( - identity_index = identity_index, - key_id = key_id, - path = %correct_path, - "Using corrected derivation path for signing (found via scan)" - ); - let signature = signer::sign( - data, - &extended_key.private_key.secret_bytes(), - )?; - return Ok(signature.to_vec().into()); - } - } - } + return Ok(found); } tracing::error!( @@ -438,7 +430,7 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { } } - let signature = signer::sign(data, &private_key)?; + let signature = signer::sign(data, &private_key[..])?; Ok(signature.to_vec().into()) } KeyType::BLS12_381 => { @@ -455,14 +447,7 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { .into()) } KeyType::EDDSA_25519_HASH160 => { - #[allow(clippy::useless_conversion)] - let key: [u8; 32] = private_key.try_into().expect("expected 32 bytes"); - #[allow(clippy::unnecessary_fallible_conversions)] - let pk = ed25519_dalek::SigningKey::try_from(&key).map_err(|_e| { - ProtocolError::Generic( - "eddsa 25519 private key from bytes isn't correct".to_string(), - ) - })?; + let pk = ed25519_dalek::SigningKey::from(&*private_key); Ok(pk.sign(data).to_vec().into()) } // the default behavior from @@ -481,7 +466,7 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { )) } - fn sign_create_witness( + async fn sign_create_witness( &self, identity_public_key: &IdentityPublicKey, data: &[u8], @@ -490,7 +475,7 @@ impl Signer<IdentityPublicKey> for QualifiedIdentity { // First, sign the data to get the signature (compact recoverable signature) // The public key will be recovered from the signature during verification - let signature = self.sign(identity_public_key, data)?; + let signature = self.sign(identity_public_key, data).await?; // Create the appropriate AddressWitness based on the key type match identity_public_key.key_type() { @@ -540,20 +525,237 @@ impl QualifiedIdentity { .map_err(|e| format!("Failed to decode QualifiedIdentity: {}", e)) } + /// Which masternode/evonode key roles are loaded for this identity. + /// + /// Voting presence is signalled by a loaded voter identity + /// (`associated_voter_identity`) OR any [`Purpose::VOTING`] key; owner by a + /// [`Purpose::OWNER`] key; payout by a [`Purpose::TRANSFER`] key. Intended + /// for masternode/evonode identities — a `User` identity may carry a + /// `TRANSFER` key for withdrawals, which this method would report as + /// `payout`, so callers must scope it to the Masternodes surface. + pub fn masternode_key_presence(&self) -> MasternodeKeyPresence { + let mut presence = MasternodeKeyPresence { + voting: self.associated_voter_identity.is_some(), + owner: false, + payout: false, + }; + for (public_key, _) in self.private_keys.private_keys.values() { + match public_key.identity_public_key.purpose() { + Purpose::VOTING => presence.voting = true, + Purpose::OWNER => presence.owner = true, + Purpose::TRANSFER => presence.payout = true, + _ => {} + } + } + presence + } + + /// Resolve the 32-byte private key for `(target, key_id)` without ever + /// reading a wallet's parked seed. + /// + /// A wallet-derived key ([`PrivateKeyData::AtWalletDerivationPath`]) pulls + /// its HD seed just-in-time through the [`SecretAccess`] chokepoint and + /// derives inside the scope; a key that carries its own plaintext + /// (`Clear`/`AlwaysClear`) resolves with no seed access and no prompt. The + /// pure [`wallet_seed_hash_for`](KeyStorage::wallet_seed_hash_for) probe + /// decides which path applies, so the prompt fires only for genuinely + /// wallet-derived keys. + /// + /// Returns `Ok(None)` when the key is absent. + /// + /// [`PrivateKeyData::AtWalletDerivationPath`]: encrypted_key_storage::PrivateKeyData::AtWalletDerivationPath + /// [`SecretAccess`]: crate::wallet_backend::SecretAccess + pub async fn resolve_private_key_bytes( + &self, + target: PrivateKeyTarget, + key_id: KeyID, + ) -> Result<Option<ResolvedPrivateKey>, TaskError> { + let resolve_key = (target.clone(), key_id); + + // Vault-backed identity key: fetch the raw bytes per-use through the + // chokepoint (unprotected fast-path, no prompt). Requires the + // chokepoint to be wired; without it the key cannot be resolved (the + // bytes are not resident), so fail closed. + if self.private_keys.is_in_vault(&resolve_key) { + let Some(secret_access) = self.secret_access.as_ref() else { + return Err(TaskError::WalletLocked); + }; + let Some(public_key) = self.private_keys.public_key_for(&resolve_key).cloned() else { + return Ok(None); + }; + let scope = crate::wallet_backend::SecretScope::IdentityKey { + identity_id: self.identity.id().to_buffer(), + target, + key_id, + }; + return secret_access + .with_secret(&scope, move |plaintext| { + let key = plaintext + .expose_identity_key() + .ok_or(TaskError::IdentityKeyMissing)?; + Ok(Some((public_key, Zeroizing::new(*key)))) + }) + .await; + } + + match ( + self.secret_access.as_ref(), + self.private_keys.wallet_seed_hash_for(&resolve_key), + ) { + (Some(secret_access), Some(seed_hash)) => { + let network = self.network; + let wallets = self + .associated_wallets + .values() + .cloned() + .collect::<Vec<_>>(); + secret_access + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + self.private_keys + .get_resolve_with_seed(&resolve_key, &wallets, seed, network) + .map_err(|detail| { + tracing::warn!(error = %detail, "Wallet key lookup failed"); + TaskError::WalletKeyLookupFailed + }) + }, + ) + .await + } + // No chokepoint, or a key that carries its own plaintext: resolve + // seed-free. A wallet-derived key with no chokepoint fails closed + // inside `get_resolve_local`. + _ => self + .private_keys + .get_resolve_local(&resolve_key) + .map_err(|detail| { + tracing::warn!(error = %detail, "Local key resolution failed"); + TaskError::WalletKeyLookupFailed + }), + } + } + + /// The seed hash of the wallet DashPay derives contact keys against. + /// + /// When an identity has more than one associated wallet, both the + /// send-side (contact-request xpub) and the receive-side (incoming + /// address scan) MUST select the *same* wallet, or a contact would pay + /// into addresses the recipient never scans. `associated_wallets` is a + /// `BTreeMap<WalletSeedHash, _>`, so the first key is the lowest seed + /// hash — a stable, content-derived choice that does not depend on + /// insertion order. Both sides call this one helper so the rule lives in + /// exactly one place (SEC-W-001). + pub fn dashpay_wallet_seed_hash(&self) -> Option<WalletSeedHash> { + self.associated_wallets.keys().next().copied() + } + + /// The wallet DashPay derives contact keys against (see + /// [`Self::dashpay_wallet_seed_hash`] for the selection rule). The + /// receive side needs the wallet handle to register scanned addresses; + /// the send side needs only the seed hash. Both resolve to the same + /// wallet by construction. + pub fn dashpay_wallet(&self) -> Option<(WalletSeedHash, &Arc<RwLock<Wallet>>)> { + self.associated_wallets + .iter() + .next() + .map(|(hash, wallet)| (*hash, wallet)) + } + + /// ECDSA_HASH160 recovery scan: when the stored derivation path produces a + /// public-key hash that disagrees with Platform's, scan identity indices + /// 0..10 for the path whose derived key matches `platform_key_data`, and + /// sign `data` with it. + /// + /// The HD seed is fetched just-in-time through the [`SecretAccess`] + /// chokepoint (keyed by the identity's first associated wallet seed hash) + /// and the whole scan runs inside the closure — the seed is borrowed for + /// this one operation and zeroizes when the closure returns; it never + /// enters the model layer by value. + /// + /// Returns `Ok(None)` when the chokepoint is not wired or no associated + /// wallet exists (best-effort recovery — the caller falls back to the + /// originally resolved key). Chokepoint failures (e.g. a cancelled + /// passphrase prompt) surface as a [`ProtocolError`]. + async fn sign_via_hash160_path_scan( + &self, + data: &[u8], + key_id: KeyID, + platform_key_data: &[u8], + ) -> Result<Option<BinaryData>, ProtocolError> { + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::hashes::{Hash, ripemd160, sha256}; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath as DP, KeyDerivationType}; + + let (Some(secret_access), Some(seed_hash)) = + (self.secret_access.as_ref(), self.dashpay_wallet_seed_hash()) + else { + return Ok(None); + }; + + let network = self.network; + // Owned so the closure (`'static`-friendly capture) needs no borrow of + // `data`/`platform_key_data` across the await. + let data = data.to_vec(); + let platform_key_data = platform_key_data.to_vec(); + + secret_access + .with_secret( + &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, + |plaintext| { + let Some(seed) = plaintext.expose_hd_seed() else { + return Ok(None); + }; + let secp = Secp256k1::new(); + for identity_index in 0..10u32 { + let correct_path = DP::identity_authentication_path( + network, + KeyDerivationType::ECDSA, + identity_index, + key_id, + ); + let Ok(extended_key) = + correct_path.derive_priv_ecdsa_for_master_seed(seed, network) + else { + continue; + }; + let correct_pubkey = + PublicKey::new(extended_key.private_key.public_key(&secp)); + let correct_hash = ripemd160::Hash::hash( + sha256::Hash::hash(&correct_pubkey.to_bytes()).as_byte_array(), + ); + if correct_hash.as_byte_array() == platform_key_data.as_slice() { + tracing::info!( + identity_index = identity_index, + key_id = key_id, + path = %correct_path, + "Using corrected derivation path for signing (found via scan)" + ); + let signature = + signer::sign(&data, &extended_key.private_key.secret_bytes()) + .map_err(|_| TaskError::EncryptionError { + detail: + "Failed to sign with the recovered derivation path." + .to_string(), + })?; + return Ok(Some(BinaryData::from(signature.to_vec()))); + } + } + Ok(None) + }, + ) + .await + .map_err(|e| ProtocolError::Generic(format!("HASH160 recovery scan failed: {e}"))) + } + pub fn display_string(&self) -> String { self.alias .clone() .unwrap_or(self.identity.id().to_string(Encoding::Base58)) } - #[allow(dead_code)] // May be used for compact UI displays - pub fn display_short_string(&self) -> String { - self.alias.clone().unwrap_or_else(|| { - let id_str = self.identity.id().to_string(Encoding::Base58); - id_str.chars().take(5).collect() - }) - } - pub fn masternode_payout_address(&self, network: Network) -> Option<Address> { self.identity .get_first_public_key_matching( @@ -636,27 +838,31 @@ impl QualifiedIdentity { keys } - pub fn available_transfer_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { - let mut keys = vec![]; - - // Check the main identity's public keys - for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::TRANSFER { - keys.push(public_key); - } - } - - keys + /// Returns the key to pre-select for signing a withdrawal. + /// + /// Only keys whose private material is held locally are considered (via + /// [`available_withdrawal_keys`](Self::available_withdrawal_keys)). A + /// `TRANSFER` key is preferred, falling back to an `OWNER` key — mirroring + /// Platform's `TransferPreferred` signing-key selection. Returns `None` when + /// no locally-signable withdrawal key exists, so callers never pre-select an + /// on-chain key the signer cannot actually use. + pub fn default_withdrawal_key(&self) -> Option<&QualifiedIdentityPublicKey> { + let keys = self.available_withdrawal_keys(); + keys.iter() + .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) + .or_else(|| { + keys.iter() + .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + }) + .copied() } - pub fn available_authentication_keys_non_master(&self) -> Vec<&QualifiedIdentityPublicKey> { + pub fn available_transfer_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { let mut keys = vec![]; // Check the main identity's public keys for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION - && public_key.identity_public_key.security_level() != SecurityLevel::MASTER - { + if public_key.identity_public_key.purpose() == Purpose::TRANSFER { keys.push(public_key); } } @@ -664,71 +870,34 @@ impl QualifiedIdentity { keys } - #[allow(dead_code)] // May be used for high-security operations - pub fn available_authentication_keys_with_high_security_level( + /// Authentication-purpose keys whose security level satisfies `predicate`. + fn authentication_keys_matching( &self, + predicate: impl Fn(SecurityLevel) -> bool, ) -> Vec<&QualifiedIdentityPublicKey> { - let mut keys = vec![]; - - // Check the main identity's public keys - for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION - && public_key.identity_public_key.security_level() == SecurityLevel::HIGH - { - keys.push(public_key); - } - } - - keys + self.private_keys + .identity_public_keys() + .into_iter() + .map(|(_, public_key)| public_key) + .filter(|public_key| { + public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION + && predicate(public_key.identity_public_key.security_level()) + }) + .collect() } - pub fn available_authentication_keys_with_critical_security_level( - &self, - ) -> Vec<&QualifiedIdentityPublicKey> { - let mut keys = vec![]; - - // Check the main identity's public keys - for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION - && public_key.identity_public_key.security_level() == SecurityLevel::CRITICAL - { - keys.push(public_key); - } - } - - keys + pub fn available_authentication_keys_non_master(&self) -> Vec<&QualifiedIdentityPublicKey> { + self.authentication_keys_matching(|level| level != SecurityLevel::MASTER) } - #[allow(dead_code)] - pub fn available_authentication_keys_with_critical_or_high_security_level( + pub fn available_authentication_keys_with_critical_security_level( &self, ) -> Vec<&QualifiedIdentityPublicKey> { - let mut keys = vec![]; - - // Check the main identity's public keys - for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION - && (public_key.identity_public_key.security_level() == SecurityLevel::CRITICAL - || public_key.identity_public_key.security_level() == SecurityLevel::HIGH) - { - keys.push(public_key); - } - } - - keys + self.authentication_keys_matching(|level| level == SecurityLevel::CRITICAL) } pub fn available_authentication_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { - let mut keys = vec![]; - - // Check the main identity's public keys - for (_, public_key) in self.private_keys.identity_public_keys() { - if public_key.identity_public_key.purpose() == Purpose::AUTHENTICATION { - keys.push(public_key); - } - } - - keys + self.authentication_keys_matching(|_| true) } /// Returns the wallet info for the first public key that is in a wallet. @@ -780,3 +949,223 @@ impl QualifiedIdentity { Ok(wallet_info) } } + +#[cfg(test)] +mod masternode_key_presence_tests { + use super::*; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::platform_value::BinaryData; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + /// Build a main-identity public key with an explicit purpose. Only the + /// purpose is read by [`QualifiedIdentity::masternode_key_presence`]; the + /// key type and data are inert placeholders. + fn key_with_purpose(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0u8; 20]), + disabled_at: None, + }) + } + + /// Assemble a masternode-shaped `QualifiedIdentity`: `voting` attaches a + /// voter identity; each purpose in `main_key_purposes` becomes a + /// main-identity key. + fn qi_with(voting: bool, main_key_purposes: &[Purpose]) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([1u8; 32]), pv).expect("identity"); + + let mut ks = KeyStorage::default(); + for (i, purpose) in main_key_purposes.iter().enumerate() { + let key = key_with_purpose(i as KeyID, *purpose); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + let associated_voter_identity = voting.then(|| { + let voter = Identity::create_basic_identity(Identifier::from([2u8; 32]), pv) + .expect("voter identity"); + let voting_key = key_with_purpose(0, Purpose::VOTING); + (voter, voting_key) + }); + + QualifiedIdentity { + identity, + associated_voter_identity, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// TC-FR3-08 — all eight bit-combinations of {Voting, Owner, Payout} are + /// reported exactly, with all-off and all-on distinct from partial states. + #[test] + fn tc_fr3_08_all_vop_combinations() { + for mask in 0u8..8 { + let voting = mask & 0b100 != 0; + let owner = mask & 0b010 != 0; + let payout = mask & 0b001 != 0; + + let mut purposes = Vec::new(); + if owner { + purposes.push(Purpose::OWNER); + } + if payout { + purposes.push(Purpose::TRANSFER); + } + + let presence = qi_with(voting, &purposes).masternode_key_presence(); + assert_eq!( + presence, + MasternodeKeyPresence { + voting, + owner, + payout, + }, + "mask {mask:03b} (V={voting} O={owner} P={payout}) misreported" + ); + } + } + + /// A `Purpose::VOTING` key on the main identity signals voting readiness + /// even without a separately loaded voter identity. + #[test] + fn voting_purpose_key_counts_as_voting_present() { + let presence = qi_with(false, &[Purpose::VOTING]).masternode_key_presence(); + assert!(presence.voting); + assert!(!presence.owner); + assert!(!presence.payout); + } + + /// A node loaded read-only (no keys, no voter identity) reports every role + /// absent. + #[test] + fn read_only_node_has_no_keys() { + let presence = qi_with(false, &[]).masternode_key_presence(); + assert_eq!(presence, MasternodeKeyPresence::default()); + } +} + +#[cfg(test)] +mod withdrawal_key_tests { + use super::*; + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); + k.set_id(id); + k.set_purpose(purpose); + k.set_security_level(SecurityLevel::CRITICAL); + k + } + + fn build_identity( + identity_type: IdentityType, + on_chain: Vec<IdentityPublicKey>, + with_private: Vec<IdentityPublicKey>, + ) -> QualifiedIdentity { + let public_keys: BTreeMap<KeyID, IdentityPublicKey> = + on_chain.into_iter().map(|k| (k.id(), k)).collect(); + let identity = Identity::new_with_id_and_keys( + Identifier::random(), + public_keys, + PlatformVersion::latest(), + ) + .expect("identity"); + + let mut private_keys = BTreeMap::new(); + for k in with_private { + private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: None, + private_keys: KeyStorage { private_keys }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// Repro for the withdraw key-selection bug: a TRANSFER key that exists + /// on-chain but whose private material is not held locally must never be + /// pre-selected — the signer cannot use it. + #[test] + fn ghost_transfer_key_is_not_selected() { + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![transfer], vec![]); + assert!(qi.default_withdrawal_key().is_none()); + } + + #[test] + fn private_backed_transfer_key_is_selected() { + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![transfer.clone()], vec![transfer]); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.id(), 1); + assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + } + + #[test] + fn owner_key_is_used_as_fallback_when_no_transfer() { + let owner = key(2, Purpose::OWNER); + let qi = build_identity(IdentityType::Masternode, vec![owner.clone()], vec![owner]); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.id(), 2); + assert_eq!(selected.identity_public_key.purpose(), Purpose::OWNER); + } + + #[test] + fn transfer_key_is_preferred_over_owner() { + let owner = key(2, Purpose::OWNER); + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity( + IdentityType::Masternode, + vec![owner.clone(), transfer.clone()], + vec![owner, transfer], + ); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + } +} diff --git a/src/model/qualified_identity/qualified_identity_public_key.rs b/src/model/qualified_identity/qualified_identity_public_key.rs index 5cede03a6..82e05e2df 100644 --- a/src/model/qualified_identity/qualified_identity_public_key.rs +++ b/src/model/qualified_identity/qualified_identity_public_key.rs @@ -36,161 +36,94 @@ impl QualifiedIdentityPublicKey { in_wallet_at_derivation_path, } } + /// Build a qualified key, linking it to a wallet derivation path when one of + /// the key's candidate addresses is known to any of `wallets`. + /// + /// The key data is network-supplied and may be malformed; a key that cannot + /// be parsed into an address is kept unlinked (logged and skipped) rather + /// than panicking. pub fn from_identity_public_key_with_wallets_check( value: IdentityPublicKey, network: Network, wallets: &[&Arc<RwLock<Wallet>>], ) -> Self { - // Initialize `in_wallet_at_derivation_path` as `None` - let mut in_wallet_at_derivation_path = None; - - match value.key_type() { - KeyType::ECDSA_SECP256K1 => { - // Check if data is a full public key (33 bytes) or just a hash (20 bytes) - if value.data().len() == 20 { - // This is actually a hash, treat it as ECDSA_HASH160 - let hash160_data = value.data().as_slice(); - let pubkey_hash = PubkeyHash::from_slice(hash160_data).expect( - "Expected valid 20-byte pubkey hash for ECDSA_SECP256K1 with hash data", - ); - - let address = Address::new(network, Payload::PubkeyHash(pubkey_hash)); - - let testnet_address = if network != Network::Mainnet { - Some(Address::new( - Network::Testnet, - Payload::PubkeyHash(pubkey_hash), - )) - } else { - None - }; - - // Iterate over each wallet to check for matching derivation paths - for locked_wallet in wallets { - let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - - if let Some(testnet_address) = testnet_address.as_ref() { - if let Some(derivation_path) = - wallet.known_addresses.get(testnet_address) - { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - } - } - } else { - // This is a full public key (expected 33 bytes) - let pubkey = PublicKey::from_slice(value.data().as_slice()) - .map_err(|e| format!("Expected valid public key: {}", e)) - .expect("Expected valid public key"); - - let address = Address::p2pkh(&pubkey, network); - - let testnet_address = if network != Network::Mainnet { - Some(Address::p2pkh(&pubkey, Network::Testnet)) - } else { - None - }; - - // Iterate over each wallet to check for matching derivation paths - for locked_wallet in wallets { - let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - - if let Some(testnet_address) = testnet_address.as_ref() { - if let Some(derivation_path) = - wallet.known_addresses.get(testnet_address) - { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - } - } - } + let addresses = candidate_addresses(&value, network); + let in_wallet_at_derivation_path = find_wallet_path(wallets, &addresses); + Self { + identity_public_key: value, + in_wallet_at_derivation_path, + } + } +} - Self { - identity_public_key: value, - in_wallet_at_derivation_path, +/// The addresses a key could resolve to on the active network (plus the Testnet +/// variant on non-mainnet networks). Empty when the key type carries no address +/// or its data is malformed. +fn candidate_addresses(value: &IdentityPublicKey, network: Network) -> Vec<Address> { + let from_pubkey_hash = |pubkey_hash: PubkeyHash| { + let mut addresses = vec![Address::new(network, Payload::PubkeyHash(pubkey_hash))]; + if network != Network::Mainnet { + addresses.push(Address::new( + Network::Testnet, + Payload::PubkeyHash(pubkey_hash), + )); + } + addresses + }; + + match value.key_type() { + // A 20-byte payload is a pubkey hash carried on an ECDSA_SECP256K1 key. + KeyType::ECDSA_SECP256K1 if value.data().len() == 20 => { + match PubkeyHash::from_slice(value.data().as_slice()) { + Ok(pubkey_hash) => from_pubkey_hash(pubkey_hash), + Err(e) => { + tracing::warn!(error = %e, "Skipping identity key with malformed 20-byte hash"); + vec![] } } - KeyType::ECDSA_HASH160 => { - let hash160_data = value.data().as_slice(); - let pubkey_hash = PubkeyHash::from_slice(hash160_data) - .expect("Expected valid 20-byte pubkey hash for ECDSA_HASH160"); - - let address = Address::new(network, Payload::PubkeyHash(pubkey_hash)); - - let testnet_address = if network != Network::Mainnet { - Some(Address::new( - Network::Testnet, - Payload::PubkeyHash(pubkey_hash), - )) - } else { - None - }; - - // Iterate over each wallet to check for matching derivation paths - for locked_wallet in wallets { - let wallet = locked_wallet.read().unwrap(); - if let Some(derivation_path) = wallet.known_addresses.get(&address) { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - - if let Some(testnet_address) = testnet_address.as_ref() { - if let Some(derivation_path) = wallet.known_addresses.get(testnet_address) { - in_wallet_at_derivation_path = Some(WalletDerivationPath { - wallet_seed_hash: wallet.seed_hash(), - derivation_path: derivation_path.clone(), - }); - } - if in_wallet_at_derivation_path.is_some() { - break; - } - } + } + KeyType::ECDSA_SECP256K1 => match PublicKey::from_slice(value.data().as_slice()) { + Ok(pubkey) => { + let mut addresses = vec![Address::p2pkh(&pubkey, network)]; + if network != Network::Mainnet { + addresses.push(Address::p2pkh(&pubkey, Network::Testnet)); } + addresses + } + Err(e) => { + tracing::warn!(error = %e, "Skipping identity key with malformed public key"); + vec![] + } + }, + KeyType::ECDSA_HASH160 => match PubkeyHash::from_slice(value.data().as_slice()) { + Ok(pubkey_hash) => from_pubkey_hash(pubkey_hash), + Err(e) => { + tracing::warn!(error = %e, "Skipping identity key with malformed 20-byte hash"); + vec![] + } + }, + _ => vec![], + } +} - Self { - identity_public_key: value, - in_wallet_at_derivation_path, - } +/// The stored derivation path of the first `addresses` entry known to any of +/// `wallets`, searched wallet-by-wallet then address-by-address. +fn find_wallet_path( + wallets: &[&Arc<RwLock<Wallet>>], + addresses: &[Address], +) -> Option<WalletDerivationPath> { + for locked_wallet in wallets { + let wallet = locked_wallet + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for address in addresses { + if let Some(derivation_path) = wallet.known_addresses.get(address) { + return Some(WalletDerivationPath { + wallet_seed_hash: wallet.seed_hash(), + derivation_path: derivation_path.clone(), + }); } - _ => Self { - identity_public_key: value, - in_wallet_at_derivation_path: None, - }, } } + None } diff --git a/src/model/request_type.rs b/src/model/request_type.rs new file mode 100644 index 000000000..9c49755be --- /dev/null +++ b/src/model/request_type.rs @@ -0,0 +1,36 @@ +/// Platform request kinds, logged with each proof-verification failure. +#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +pub enum RequestType { + BroadcastStateTransition, + GetIdentity, + GetIdentityKeys, + GetIdentitiesContractKeys, + GetIdentityNonce, + GetIdentityContractNonce, + GetIdentityBalance, + GetIdentitiesBalances, + GetIdentityBalanceAndRevision, + GetEvonodesProposedEpochBlocksByIds, + GetEvonodesProposedEpochBlocksByRange, + GetProofs, + GetDataContract, + GetDataContractHistory, + GetDataContracts, + GetDocuments, + GetIdentityByPublicKeyHash, + WaitForStateTransitionResult, + GetConsensusParams, + GetProtocolVersionUpgradeState, + GetProtocolVersionUpgradeVoteStatus, + GetEpochsInfo, + GetContestedResources, + GetContestedResourceVoteState, + GetContestedResourceVotersForIdentity, + GetContestedResourceIdentityVotes, + GetVotePollsByEndDate, + GetPrefundedSpecializedBalance, + GetTotalCreditsInPlatform, + GetPathElements, + GetStatus, + GetCurrentQuorumsInfo, +} diff --git a/src/model/secret.rs b/src/model/secret.rs index 3b3dd2940..ad3815a6a 100644 --- a/src/model/secret.rs +++ b/src/model/secret.rs @@ -3,6 +3,7 @@ use std::fmt; use std::ops::Range; use egui::TextBuffer; +use egui::text::CharIndex; use zeroize::{Zeroize, Zeroizing}; /// Default pre-allocation capacity for `Secret` buffers. @@ -124,6 +125,14 @@ impl Secret { self.inner.is_empty() } + /// Whether the secret's trimmed value is empty. + /// + /// Prefer this over `expose_secret().trim().is_empty()` for presence + /// checks — it avoids exposing the raw bytes unnecessarily. + pub fn is_blank(&self) -> bool { + self.inner.trim().is_empty() + } + /// Returns a new `Secret` containing the trimmed content. /// Keeps the data within the secure wrapper unlike `text().trim()` /// which returns a borrowed `&str`. @@ -175,13 +184,13 @@ impl TextBuffer for Secret { self.inner.as_str() } - fn insert_text(&mut self, text: &str, char_index: usize) -> usize { + fn insert_text(&mut self, text: &str, char_index: CharIndex) -> usize { let n = <String as TextBuffer>::insert_text(&mut *self.inner, text, char_index); self.relock_if_moved(); n } - fn delete_char_range(&mut self, char_range: Range<usize>) { + fn delete_char_range(&mut self, char_range: Range<CharIndex>) { <String as TextBuffer>::delete_char_range(&mut *self.inner, char_range); // Zero deleted bytes in trailing capacity [len..capacity) let ptr = self.inner.as_mut_ptr(); @@ -205,7 +214,7 @@ impl TextBuffer for Secret { } fn take(&mut self) -> String { - // INTENTIONAL(SEC-003): Returns unprotected String — required by egui TextBuffer trait. + // Deliberately returns an unprotected String — required by egui TextBuffer trait. // The undoer is disabled in PasswordInput, limiting the call paths. Accepted as // inherent limitation of the egui framework for the desktop GUI threat model. let copy = self.inner.to_string(); @@ -270,6 +279,44 @@ impl From<&str> for Secret { } } +// -- serde / schemars impls -------------------------------------------------- +// +// `Secret` carries private-key / mnemonic material in MCP tool parameter +// structs. Adding `Deserialize` lets the params struct derive `Deserialize` +// directly — the impl deserializes into a transient `String`, then moves it +// into the zeroizing/mlock'd buffer and drops the transient, so no long-lived +// plain `String` copy persists. The `JsonSchema` impl (gated to +// the features that bring in `rmcp`) exposes `Secret` as a JSON string in the +// MCP tool schema so clients know what format to supply. +// +// `platform_wallet_storage::SecretString` offers the same security guarantees +// but lacks both `Deserialize` and `JsonSchema`, and `IdentityInputToLoad` +// already uses this local `Secret` type — switching would require a lossy +// expose-then-rewrap at the boundary. The local type is therefore preferred +// for MCP parameters. + +impl<'de> serde::Deserialize<'de> for Secret { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let s = <String as serde::Deserialize>::deserialize(deserializer)?; + Ok(Secret::new(s)) + } +} + +/// Expose as a plain JSON string schema — the secure wrapper is invisible to +/// the caller; they just supply a string value. +#[cfg(any(feature = "mcp", feature = "cli"))] +impl rmcp::schemars::JsonSchema for Secret { + fn schema_name() -> std::borrow::Cow<'static, str> { + "Secret".into() + } + fn json_schema(_gen: &mut rmcp::schemars::SchemaGenerator) -> rmcp::schemars::Schema { + rmcp::schemars::json_schema!({ "type": "string" }) + } +} + // -- Tests ------------------------------------------------------------------- #[cfg(test)] @@ -298,12 +345,12 @@ mod tests { let mut secret = Secret::new("hello"); // insert_text appends " world" - let inserted = secret.insert_text(" world", 5); + let inserted = secret.insert_text(" world", CharIndex(5)); assert_eq!(inserted, 6); assert_eq!(secret.expose_secret(), "hello world"); // delete_char_range removes " world" - secret.delete_char_range(5..11); + secret.delete_char_range(CharIndex(5)..CharIndex(11)); assert_eq!(secret.expose_secret(), "hello"); } @@ -404,7 +451,7 @@ mod tests { #[test] fn test_delete_char_range_zeroes_trailing() { let mut secret = Secret::new("abcdef"); - secret.delete_char_range(3..6); + secret.delete_char_range(CharIndex(3)..CharIndex(6)); assert_eq!(secret.expose_secret(), "abc"); // Trailing capacity bytes (after len) should be zeroed let len = secret.inner.len(); diff --git a/src/model/selected_identity.rs b/src/model/selected_identity.rs new file mode 100644 index 000000000..419d1542e --- /dev/null +++ b/src/model/selected_identity.rs @@ -0,0 +1,123 @@ +//! Per-network selected-identity pointer. +//! +//! The selected identity is the app-scoped "who am I operating as" choice. +//! Persisted as a single bincode blob at [`SelectedIdentity::KV_KEY`] in the +//! per-network wallet k/v store (the upstream `SqlitePersister` that +//! [`crate::wallet_backend::WalletBackend`] owns), under the global (`None`) +//! wallet scope — mirroring [`crate::model::selected_wallet::SelectedWallet`]. +//! +//! It is a **separate** blob from `SelectedWallet`: extending the wallet blob +//! with a new bincode field would break decoding of existing `:v1` wallet +//! blobs and silently drop the persisted wallet selection on upgrade. +//! +//! Per-network because identities are per-network: a fresh per-network +//! `AppContext` is built on a network switch, so the blob is per-network for +//! free with no key discriminator. + +use dash_sdk::platform::Identifier; +use serde::{Deserialize, Serialize}; + +/// Persisted "which identity am I operating as" pointer for a single network. +/// A fresh install (or never-touched network) deserialises to `Default` +/// (`None`), and the hub lands on its count-based default. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedIdentity { + /// The selected identity, if any. + pub identity_id: Option<Identifier>, +} + +impl SelectedIdentity { + /// Canonical k/v key under which the blob is stored inside the per-network + /// wallet persister. Global (`None`) wallet scope. + pub const KV_KEY: &'static str = "det:selected_identity:v1"; +} + +/// Keep `selected` only if it is still present in `loaded`. The restore / +/// refresh filter — mirrors the wallet pointer's "keep only if still loaded". +pub fn keep_if_loaded(selected: Option<Identifier>, loaded: &[Identifier]) -> Option<Identifier> { + selected.filter(|id| loaded.contains(id)) +} + +/// Precedence for resolving the active identity: the selected id when it is +/// still loaded, otherwise the first loaded identity, otherwise `None`. +/// +/// Used both for the "who am I" read (over all loaded identities) and for +/// reconciling the active identity to a newly selected wallet (over that +/// wallet's identities) — keep-if-present-else-first-else-none. +pub fn resolve_selected(selected: Option<Identifier>, loaded: &[Identifier]) -> Option<Identifier> { + keep_if_loaded(selected, loaded).or_else(|| loaded.first().copied()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + /// The persisted shape must survive a bincode round-trip, like the + /// sibling `SelectedWallet` blob — a future field change that breaks + /// decoding surfaces here. + #[test] + fn selected_identity_round_trips_through_bincode() { + for original in [ + SelectedIdentity { + identity_id: Some(id(0x11)), + }, + SelectedIdentity { identity_id: None }, + ] { + let encoded = bincode::serde::encode_to_vec(&original, bincode::config::standard()) + .expect("encode"); + let (decoded, _): (SelectedIdentity, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode"); + assert_eq!(decoded, original); + } + } + + #[test] + fn default_is_none() { + assert!(SelectedIdentity::default().identity_id.is_none()); + } + + /// `keep_if_loaded` drops a selection absent from the loaded set and + /// keeps one that is present — the restore/refresh contract. + #[test] + fn keep_if_loaded_drops_unloaded_keeps_loaded() { + let loaded = [id(1), id(2)]; + assert_eq!(keep_if_loaded(Some(id(2)), &loaded), Some(id(2))); + assert_eq!(keep_if_loaded(Some(id(9)), &loaded), None); + assert_eq!(keep_if_loaded(None, &loaded), None); + } + + /// `resolve_selected` precedence: selected-if-loaded → first → none. + #[test] + fn resolve_selected_precedence() { + let loaded = [id(1), id(2), id(3)]; + // Selected and loaded → keep it. + assert_eq!(resolve_selected(Some(id(2)), &loaded), Some(id(2))); + // Selected but not loaded → fall back to the first loaded. + assert_eq!(resolve_selected(Some(id(9)), &loaded), Some(id(1))); + // Nothing selected → first loaded. + assert_eq!(resolve_selected(None, &loaded), Some(id(1))); + // Nothing loaded → none. + assert_eq!(resolve_selected(Some(id(1)), &[]), None); + assert_eq!(resolve_selected(None, &[]), None); + } + + /// Reconciling to a wallet's identity set uses the same precedence: keep + /// the current id if the new wallet owns it, else that wallet's first + /// identity, else none (→ picker). + #[test] + fn resolve_reconciles_to_wallet_identity_set() { + let wallet_a = [id(1), id(2)]; + let wallet_b = [id(7), id(8)]; + // Current id owned by the new wallet → kept. + assert_eq!(resolve_selected(Some(id(2)), &wallet_a), Some(id(2))); + // Current id not owned by the new wallet → first of the new wallet. + assert_eq!(resolve_selected(Some(id(2)), &wallet_b), Some(id(7))); + // New wallet has no identities → none (caller routes to the picker). + assert_eq!(resolve_selected(Some(id(2)), &[]), None); + } +} diff --git a/src/model/selected_wallet.rs b/src/model/selected_wallet.rs new file mode 100644 index 000000000..b6903c782 --- /dev/null +++ b/src/model/selected_wallet.rs @@ -0,0 +1,74 @@ +//! Per-network selected-wallet pointer. +//! +//! Captures which HD wallet (`hd_wallet_hash`) and/or single-key wallet +//! (`single_key_hash`) the user last interacted with on the active +//! network. Persisted as a single bincode blob at [`SelectedWallet::KV_KEY`] +//! in the per-network wallet k/v store (the upstream `SqlitePersister` +//! that [`crate::wallet_backend::WalletBackend`] owns), under the global +//! (`None`) wallet scope. +//! +//! Per-network because wallet selection is meaningful only within a +//! network: the wallet you had selected on testnet is not the wallet +//! you had selected on mainnet. The blob therefore lives inside the +//! network's own persister rather than the cross-network `det-app.sqlite` +//! from C3. +//! +//! On first launch after the C4 upgrade the blob is absent and the +//! [`Default`] (both fields `None`) is returned, matching the +//! "empty start" policy of the data.db unwire. + +use serde::{Deserialize, Serialize}; + +/// SHA-256 seed-hash identifier reused from the wallet model. +pub type SelectedWalletHash = [u8; 32]; + +/// Persisted "which wallet did the user pick last" pointer for a single +/// network. Both fields are optional and independent — a fresh install +/// (or a never-touched network) deserialises to `Default` (both `None`), +/// the wallet picker lands on its empty state, and any later user +/// selection writes the relevant field. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedWallet { + /// Seed hash of the HD wallet the user last selected, if any. + pub hd_wallet_hash: Option<SelectedWalletHash>, + /// Key hash of the single-key wallet the user last selected, if any. + pub single_key_hash: Option<SelectedWalletHash>, +} + +impl SelectedWallet { + /// Canonical k/v key under which the blob is stored inside the + /// per-network wallet persister. Global (`None`) wallet scope. + pub const KV_KEY: &'static str = "det:selected_wallet:v1"; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Round-trip the blob through bincode so the persisted shape is + /// covered the same way C3's `AppSettings` is — a future field + /// addition that breaks decoding will surface here. + #[test] + fn selected_wallet_round_trips_through_bincode() { + let original = SelectedWallet { + hd_wallet_hash: Some([0x11; 32]), + single_key_hash: Some([0x22; 32]), + }; + let encoded = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (SelectedWallet, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode"); + assert_eq!(decoded, original); + } + + /// Default is both-`None` so the empty-start path matches the + /// previous "no selection persisted" behaviour the data.db row + /// produced when fresh. + #[test] + fn default_is_both_none() { + let s = SelectedWallet::default(); + assert!(s.hd_wallet_hash.is_none()); + assert!(s.single_key_hash.is_none()); + } +} diff --git a/src/model/settings.rs b/src/model/settings.rs index 4633afb1f..5a00dc425 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -1,9 +1,22 @@ -use crate::model::password_info::PasswordInfo; -use crate::spv::CoreBackendMode; -use crate::ui::RootScreenType; -use crate::ui::theme::ThemeMode; +//! User-facing application preferences, persisted to the upstream +//! platform-wallet-storage key/value store. +//! +//! These eleven fields were previously columns of DET's `settings` table +//! and have moved to a single bincode-encoded blob under +//! [`AppSettings::KV_KEY`] in the shared application k/v store +//! (`<data_dir>/det-app.sqlite`). The blob is global (`None` wallet +//! scope) and not network-prefixed — it spans every network, since the +//! `network` field itself is the active-network pointer. +//! +//! Selected-wallet hashes (`selected_wallet_hash`, +//! `selected_single_key_hash`) moved out in C4 and live as a +//! [`SelectedWallet`](crate::model::selected_wallet::SelectedWallet) +//! blob in the per-network wallet k/v store. + use dash_sdk::dpp::dashcore::Network; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::str::FromStr; /// User experience mode #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -20,155 +33,373 @@ impl UserMode { UserMode::Advanced => "Advanced", } } + + fn from_str_or_default(s: &str) -> Self { + match s { + "Beginner" => UserMode::Beginner, + _ => UserMode::Advanced, + } + } +} + +/// Theme mode preference persisted in [`AppSettings::theme_mode`]. +/// +/// Pure data enum; theme detection and rendering live in `ui::theme`, which +/// re-exports this type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThemeMode { + Light, + Dark, + #[default] + System, +} + +/// Which root screen the app opens to, persisted in +/// [`AppSettings::root_screen_type`]. +/// +/// The `to_int`/`from_int` mapping is the stable on-disk encoding; the mapping +/// to the UI `ScreenType` lives in `ui` (which re-exports this type). +#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] +#[allow(clippy::enum_variant_names)] +pub enum RootScreenType { + RootScreenIdentities, + RootScreenDPNSActiveContests, + RootScreenDPNSPastContests, + RootScreenDPNSOwnedNames, + RootScreenDPNSScheduledVotes, + RootScreenDocumentQuery, + RootScreenWalletsBalances, + RootScreenToolsTransitionVisualizerScreen, + RootScreenToolsDocumentVisualizerScreen, + RootScreenNetworkChooser, + RootScreenToolsProofVisualizerScreen, + RootScreenMyTokenBalances, + RootScreenTokenSearch, + RootScreenTokenCreator, + RootScreenToolsContractVisualizerScreen, + RootScreenToolsPlatformInfoScreen, + RootScreenDashPayContacts, + RootScreenDashPayProfile, + RootScreenDashPayPayments, + RootScreenDashPayProfileSearch, + RootScreenToolsGroveSTARKScreen, + RootScreenToolsAddressBalanceScreen, + RootScreenDashpay, + /// New unified Identities hub (Home · Contacts · Activity · Settings). + /// Coexists with `RootScreenIdentities` and the DashPay entries while the legacy + /// screens are still wired. Distinct variant so user selection, persistence, and + /// left-nav highlighting stay independent. + RootScreenIdentityHub, + /// Masternodes section (Expert-Mode gated). Node-operator surface for + /// loading masternode/evonode identities, DPNS-contest voting, and + /// owner/voting/payout key management. Distinct variant so its nav gating, + /// selection, and persistence stay independent of the everyday-user tabs. + RootScreenMasternodes, +} + +impl RootScreenType { + /// Convert `RootScreenType` to an integer + pub fn to_int(self) -> u32 { + match self { + RootScreenType::RootScreenIdentities => 0, + RootScreenType::RootScreenDPNSActiveContests => 1, + RootScreenType::RootScreenDPNSPastContests => 2, + RootScreenType::RootScreenDPNSOwnedNames => 3, + RootScreenType::RootScreenDocumentQuery => 4, + RootScreenType::RootScreenWalletsBalances => 5, + RootScreenType::RootScreenToolsTransitionVisualizerScreen => 6, + RootScreenType::RootScreenNetworkChooser => 7, + // 8 used to be the Withdrawals Statuses screen + // 9 used to be the Proof Log screen + RootScreenType::RootScreenDPNSScheduledVotes => 10, + RootScreenType::RootScreenToolsProofVisualizerScreen => 11, + RootScreenType::RootScreenMyTokenBalances => 12, + RootScreenType::RootScreenTokenSearch => 13, + RootScreenType::RootScreenTokenCreator => 14, + RootScreenType::RootScreenToolsDocumentVisualizerScreen => 15, + RootScreenType::RootScreenToolsContractVisualizerScreen => 16, + RootScreenType::RootScreenToolsPlatformInfoScreen => 17, + RootScreenType::RootScreenDashPayContacts => 18, + // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) + RootScreenType::RootScreenDashPayProfile => 20, + RootScreenType::RootScreenDashPayPayments => 21, + RootScreenType::RootScreenDashPayProfileSearch => 22, + RootScreenType::RootScreenDashpay => 24, + RootScreenType::RootScreenToolsGroveSTARKScreen => 25, + RootScreenType::RootScreenToolsAddressBalanceScreen => 26, + RootScreenType::RootScreenIdentityHub => 27, + RootScreenType::RootScreenMasternodes => 28, + } + } + + /// Convert an integer to a `RootScreenType` + pub fn from_int(value: u32) -> Option<Self> { + match value { + 0 => Some(RootScreenType::RootScreenIdentities), + 1 => Some(RootScreenType::RootScreenDPNSActiveContests), + 2 => Some(RootScreenType::RootScreenDPNSPastContests), + 3 => Some(RootScreenType::RootScreenDPNSOwnedNames), + 4 => Some(RootScreenType::RootScreenDocumentQuery), + 5 => Some(RootScreenType::RootScreenWalletsBalances), + 6 => Some(RootScreenType::RootScreenToolsTransitionVisualizerScreen), + 7 => Some(RootScreenType::RootScreenNetworkChooser), + // 8 used to be the Withdrawals Statuses screen + // 9 used to be the Proof Log screen + 10 => Some(RootScreenType::RootScreenDPNSScheduledVotes), + 11 => Some(RootScreenType::RootScreenToolsProofVisualizerScreen), + 12 => Some(RootScreenType::RootScreenMyTokenBalances), + 13 => Some(RootScreenType::RootScreenTokenSearch), + 14 => Some(RootScreenType::RootScreenTokenCreator), + 15 => Some(RootScreenType::RootScreenToolsDocumentVisualizerScreen), + 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), + 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), + 18 => Some(RootScreenType::RootScreenDashPayContacts), + // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) + 20 => Some(RootScreenType::RootScreenDashPayProfile), + 21 => Some(RootScreenType::RootScreenDashPayPayments), + 22 => Some(RootScreenType::RootScreenDashPayProfileSearch), + 24 => Some(RootScreenType::RootScreenDashpay), + 25 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), + 26 => Some(RootScreenType::RootScreenToolsAddressBalanceScreen), + 27 => Some(RootScreenType::RootScreenIdentityHub), + 28 => Some(RootScreenType::RootScreenMasternodes), + _ => None, + } + } } -/// Application settings structure +#[cfg(test)] +mod root_screen_type_tests { + use super::RootScreenType; + + #[test] + fn identity_hub_round_trips() { + let rt = RootScreenType::RootScreenIdentityHub; + let encoded = rt.to_int(); + let decoded = RootScreenType::from_int(encoded) + .expect("new identity hub variant must round-trip through from_int"); + assert_eq!(rt, decoded); + // Value 27 is the canonical on-disk encoding. Keeping it stable means + // existing user settings continue to round-trip correctly as new + // variants are added. + assert_eq!(encoded, 27); + } + + #[test] + fn masternodes_round_trips() { + let rt = RootScreenType::RootScreenMasternodes; + let encoded = rt.to_int(); + let decoded = RootScreenType::from_int(encoded) + .expect("new masternodes variant must round-trip through from_int"); + assert_eq!(rt, decoded); + // Value 28 is the canonical on-disk encoding — keep it stable so + // persisted user settings continue to round-trip as variants are added. + assert_eq!(encoded, 28); + } + + #[test] + fn from_int_returns_none_for_unknown_value() { + assert!(RootScreenType::from_int(9999).is_none()); + } +} + +/// Application-level user preferences. +/// +/// Bincode-serialized as the single blob at [`Self::KV_KEY`] in the +/// shared app k/v store. The 11 fields are exactly the user-preference +/// columns previously held in DET's `settings` table — chain sync, +/// wallet selection, and bootstrap scaffolding are NOT part of this +/// struct. +/// +/// Field rules: +/// * Wire encoding goes through the private [`AppSettingsWire`] sidecar +/// — enum/path domain types are reduced to primitives so existing +/// types do not need `serde` derives. +/// * Default values reproduce the previous database column defaults. #[derive(Debug, Clone)] -pub struct Settings { +pub struct AppSettings { + /// The active network the app is connected to. pub network: Network, + /// Which root screen the app opens to. pub root_screen_type: RootScreenType, - pub password_info: Option<PasswordInfo>, - /// Path to the Dash-Qt binary, if set. None means autodetect. - /// Empty value (`""`) means path deliberately not set, autodetect will not be performed. + /// Path to the Dash-Qt binary, if set. `None` means autodetect. pub dash_qt_path: Option<PathBuf>, + /// Whether DET is allowed to overwrite a user's `dash.conf` file. pub overwrite_dash_conf: bool, + /// User has opted out of the Dash Core ZMQ listener (requires + /// restart to take effect). pub disable_zmq: bool, + /// Light / Dark / System theme preference. pub theme_mode: ThemeMode, - pub core_backend_mode: CoreBackendMode, - /// Whether the user has completed the initial onboarding + /// Whether the user has completed the initial onboarding flow. pub onboarding_completed: bool, - /// Whether to show Evonode-related tools + /// Whether Evonode-related tools are shown in the UI. pub show_evonode_tools: bool, - /// User experience mode (Beginner or Advanced) + /// User experience mode (Beginner or Advanced). pub user_mode: UserMode, - /// Whether to automatically close Dash-Qt when DET exits + /// Whether DET closes Dash-Qt automatically when it exits. pub close_dash_qt_on_exit: bool, + /// SPV sync starts automatically on launch. Default `true` for fresh + /// installs (no saved blob); existing installs keep their stored value — + /// the Network Settings checkbox is the opt-out. + pub auto_start_spv: bool, +} + +impl AppSettings { + /// Canonical k/v key under which the blob is stored. Global scope + /// (no wallet, no network prefix) — see module-level docs. + pub const KV_KEY: &'static str = "det:settings:v1"; } -impl - From<( - Network, - RootScreenType, - Option<PasswordInfo>, - Option<PathBuf>, - bool, - bool, - ThemeMode, - u8, - bool, // onboarding_completed - bool, // show_evonode_tools - UserMode, // user_mode - bool, // close_dash_qt_on_exit - )> for Settings -{ - /// Converts a tuple into a Settings instance - /// - /// Used mainly for database operations where settings are retrieved as a tuple. - fn from( - tuple: ( - Network, - RootScreenType, - Option<PasswordInfo>, - Option<PathBuf>, - bool, - bool, - ThemeMode, - u8, - bool, - bool, - UserMode, - bool, - ), - ) -> Self { - Self::new( - tuple.0, - tuple.1, - tuple.2, - tuple.3, - tuple.4, - tuple.5, - tuple.6, - CoreBackendMode::from(tuple.7), - tuple.8, - tuple.9, - tuple.10, - tuple.11, - ) - } -} - -impl Default for Settings { - /// Default settings for the application +impl Default for AppSettings { fn default() -> Self { - Self::new( - Network::Mainnet, - RootScreenType::RootScreenDashpay, - None, - None, // autodetect - true, - false, - ThemeMode::System, - CoreBackendMode::Spv, // Default to SPV mode - false, // onboarding not completed - false, // don't show evonode tools by default - UserMode::Advanced, // default to advanced mode - true, // close Dash-Qt on exit by default - ) - } -} - -impl Settings { - /// Creates a new Settings instance - #[allow(clippy::too_many_arguments)] - pub fn new( - network: Network, - root_screen_type: RootScreenType, - password_info: Option<PasswordInfo>, - dash_qt_path: Option<PathBuf>, - overwrite_dash_conf: bool, - disable_zmq: bool, - theme_mode: ThemeMode, - core_backend_mode: CoreBackendMode, - onboarding_completed: bool, - show_evonode_tools: bool, - user_mode: UserMode, - close_dash_qt_on_exit: bool, - ) -> Self { + Self { + network: Network::Mainnet, + root_screen_type: RootScreenType::RootScreenDashpay, + dash_qt_path: detect_dash_qt_path(), + overwrite_dash_conf: true, + disable_zmq: false, + theme_mode: ThemeMode::System, + onboarding_completed: false, + show_evonode_tools: false, + user_mode: UserMode::Advanced, + close_dash_qt_on_exit: true, + // Default to on so wallets sync without a manual step on fresh installs. + // Existing users who stored false explicitly (from the old default) keep + // their saved preference — the blob wins over this default. + auto_start_spv: true, + } + } +} + +/// Wire-level mirror used for bincode encoding. Domain enums and paths +/// are flattened to strings / primitives so the existing types do not +/// need `serde` derives. Translation happens here, in one place. +/// +/// `bincode::config::standard()` is positional, so this struct's field +/// order and count *are* the on-disk format for every stored blob. The +/// `_reserved_core_backend_mode` byte is a retired field (the RPC/SPV +/// selector — chain sync is SPV-only now) kept solely to preserve that +/// layout: dropping it would shift every following field and corrupt +/// existing `det:settings:v1` blobs. It is written as a constant and +/// ignored on read. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AppSettingsWire { + network: String, + root_screen_type: u32, + dash_qt_path: Option<String>, + overwrite_dash_conf: bool, + disable_zmq: bool, + theme_mode: String, + _reserved_core_backend_mode: u8, + onboarding_completed: bool, + show_evonode_tools: bool, + user_mode: String, + close_dash_qt_on_exit: bool, + auto_start_spv: bool, +} + +impl From<&AppSettings> for AppSettingsWire { + fn from(s: &AppSettings) -> Self { + Self { + network: s.network.to_string(), + root_screen_type: s.root_screen_type.to_int(), + dash_qt_path: s + .dash_qt_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + overwrite_dash_conf: s.overwrite_dash_conf, + disable_zmq: s.disable_zmq, + theme_mode: theme_mode_to_str(s.theme_mode).to_string(), + // Retired field; constant preserves the wire layout (SPV marker). + _reserved_core_backend_mode: 1, + onboarding_completed: s.onboarding_completed, + show_evonode_tools: s.show_evonode_tools, + user_mode: s.user_mode.as_str().to_string(), + close_dash_qt_on_exit: s.close_dash_qt_on_exit, + auto_start_spv: s.auto_start_spv, + } + } +} + +impl From<AppSettingsWire> 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 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); + let user_mode = UserMode::from_str_or_default(&w.user_mode); Self { network, root_screen_type, - password_info, - dash_qt_path: dash_qt_path.or_else(detect_dash_qt_path), - overwrite_dash_conf, - disable_zmq, + // Verbatim: `None` means "autodetect", but decoding must stay pure + // (no filesystem IO). The autodetect fallback runs once at the + // settings-load call site (`AppContext::get_app_settings`). + dash_qt_path: w.dash_qt_path.map(PathBuf::from), + overwrite_dash_conf: w.overwrite_dash_conf, + disable_zmq: w.disable_zmq, theme_mode, - core_backend_mode, - onboarding_completed, - show_evonode_tools, + onboarding_completed: w.onboarding_completed, + show_evonode_tools: w.show_evonode_tools, user_mode, - close_dash_qt_on_exit, + close_dash_qt_on_exit: w.close_dash_qt_on_exit, + auto_start_spv: w.auto_start_spv, } } } -/// Detects the path to the Dash-Qt binary on the system -fn detect_dash_qt_path() -> Option<PathBuf> { +impl Serialize for AppSettings { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + AppSettingsWire::from(self).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for AppSettings { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + AppSettingsWire::deserialize(deserializer).map(Self::from) + } +} + +fn theme_mode_to_str(mode: ThemeMode) -> &'static str { + match mode { + ThemeMode::Light => "Light", + ThemeMode::Dark => "Dark", + ThemeMode::System => "System", + } +} + +fn theme_mode_from_str(s: &str) -> ThemeMode { + match s { + "Light" => ThemeMode::Light, + "Dark" => ThemeMode::Dark, + _ => ThemeMode::System, + } +} + +/// Detects the path to the Dash-Qt binary on the system. +/// +/// Filesystem IO — never call from a `Deserialize` path. Callers that need an +/// autodetect fallback for a decoded blob run this once at the settings-load +/// call site (`AppContext::get_app_settings`). +pub(crate) fn detect_dash_qt_path() -> Option<PathBuf> { let path = which::which("dash-qt") .map(|path| path.to_string_lossy().to_string()) .inspect_err(|e| tracing::warn!("failed to find dash-qt: {}", e)) .ok() .map(PathBuf::from) .unwrap_or_else(|| { - // Fallback to default paths based on the operating system if cfg!(target_os = "macos") { PathBuf::from("/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt") } else if cfg!(target_os = "windows") { - // Retrieve the PROGRAMFILES environment variable or default to "C:\\Program Files" let program_files = std::env::var("PROGRAMFILES") .unwrap_or_else(|_| "C:\\Program Files".to_string()); PathBuf::from(program_files).join("DashCore\\dash-qt.exe") } else { - PathBuf::from("/usr/local/bin/dash-qt") // Default Linux path + PathBuf::from("/usr/local/bin/dash-qt") } }); @@ -179,3 +410,196 @@ fn detect_dash_qt_path() -> Option<PathBuf> { None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// S1: verify the `AppSettings` defaults for a fresh install (no blob in + /// k/v yet). `auto_start_spv` intentionally differs from the old DB column + /// default (0/false): new installs sync without a manual step; existing + /// users who stored false keep their saved preference (the blob wins). + #[test] + fn default_matches_expected_fresh_install_values() { + let s = AppSettings::default(); + assert_eq!(s.network, Network::Mainnet); + assert!(matches!(s.theme_mode, ThemeMode::System)); + assert!(matches!(s.user_mode, UserMode::Advanced)); + assert!(s.overwrite_dash_conf); + assert!(!s.disable_zmq); + assert!(!s.onboarding_completed); + assert!(!s.show_evonode_tools); + assert!(s.close_dash_qt_on_exit); + assert!(s.auto_start_spv); // on by default for fresh installs + } + + /// S6: `auto_start_spv` default-on semantics — fresh installs auto-connect; + /// an existing blob with `false` keeps `false` (the struct default does NOT + /// override a persisted value). + #[test] + fn auto_start_spv_default_on_and_stored_false_survives_round_trip() { + // Fresh install: no blob → default is true. + assert!( + AppSettings::default().auto_start_spv, + "fresh install must default to auto-connect" + ); + + // Existing user: blob encodes false → decodes to false regardless of + // the current struct default. + let wire = AppSettingsWire { + network: "testnet".to_string(), + root_screen_type: 0, + dash_qt_path: None, + overwrite_dash_conf: true, + disable_zmq: false, + theme_mode: "System".to_string(), + _reserved_core_backend_mode: 1, + onboarding_completed: true, + show_evonode_tools: false, + user_mode: "Advanced".to_string(), + close_dash_qt_on_exit: true, + auto_start_spv: false, // user had auto-connect off + }; + let encoded = + bincode::serde::encode_to_vec(AppSettings::from(wire), bincode::config::standard()) + .expect("encode"); + let (decoded, _): (AppSettings, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode"); + assert!( + !decoded.auto_start_spv, + "a stored false must survive the round-trip — the new default must not override it" + ); + } + + /// S2: a settings blob round-trips through the bincode wire form + /// with every domain field preserved. + #[test] + fn settings_round_trip_through_wire() { + let s = AppSettings { + network: Network::Testnet, + root_screen_type: RootScreenType::RootScreenIdentities, + dash_qt_path: Some(PathBuf::from("/tmp/dash-qt")), + overwrite_dash_conf: false, + disable_zmq: true, + theme_mode: ThemeMode::Dark, + onboarding_completed: true, + show_evonode_tools: true, + user_mode: UserMode::Beginner, + close_dash_qt_on_exit: false, + auto_start_spv: true, + }; + let encoded = + bincode::serde::encode_to_vec(&s, bincode::config::standard()).expect("encode"); + let (decoded, _): (AppSettings, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode"); + assert_eq!(decoded.network, s.network); + assert_eq!(decoded.root_screen_type, s.root_screen_type); + assert_eq!(decoded.dash_qt_path, s.dash_qt_path); + assert_eq!(decoded.overwrite_dash_conf, s.overwrite_dash_conf); + assert_eq!(decoded.disable_zmq, s.disable_zmq); + assert_eq!(decoded.theme_mode, s.theme_mode); + assert_eq!(decoded.onboarding_completed, s.onboarding_completed); + assert_eq!(decoded.show_evonode_tools, s.show_evonode_tools); + assert_eq!(decoded.user_mode, s.user_mode); + assert_eq!(decoded.close_dash_qt_on_exit, s.close_dash_qt_on_exit); + assert_eq!(decoded.auto_start_spv, s.auto_start_spv); + } + + /// S7: the retired `_reserved_core_backend_mode` byte holds the wire + /// layout stable. An existing blob whose reserved byte is `0` (the old + /// "RPC" value) must still decode with every following field intact — + /// proof that removing the live field did not shift the positional + /// bincode format and corrupt already-stored `det:settings:v1` blobs. + #[test] + fn reserved_core_backend_mode_byte_preserves_wire_layout() { + let wire = AppSettingsWire { + network: "testnet".to_string(), + root_screen_type: 3, + dash_qt_path: Some("/opt/dash-qt".to_string()), + overwrite_dash_conf: false, + disable_zmq: true, + theme_mode: "Dark".to_string(), + _reserved_core_backend_mode: 0, // legacy "RPC" value from an old blob + onboarding_completed: true, + show_evonode_tools: true, + user_mode: "Beginner".to_string(), + close_dash_qt_on_exit: false, + auto_start_spv: true, + }; + let encoded = + bincode::serde::encode_to_vec(&wire, bincode::config::standard()).expect("encode"); + let (decoded, _): (AppSettings, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode"); + // Fields after the reserved byte must be read from the correct + // offset — a shifted layout would scramble these. + assert!(decoded.onboarding_completed); + assert!(decoded.show_evonode_tools); + assert_eq!(decoded.user_mode, UserMode::Beginner); + assert!(!decoded.close_dash_qt_on_exit); + assert!(decoded.auto_start_spv); + // Fields before it, for completeness. + assert_eq!(decoded.network, Network::Testnet); + assert!(matches!(decoded.theme_mode, ThemeMode::Dark)); + } + + /// S3: legacy "dash" network value (used by databases predating the + /// `Network::Dash` → `Network::Mainnet` rename) decodes to Mainnet + /// instead of failing or coercing to a different network. + #[test] + fn legacy_dash_network_string_decodes_to_mainnet() { + let wire = AppSettingsWire { + network: "dash".to_string(), + root_screen_type: 0, + dash_qt_path: None, + overwrite_dash_conf: true, + disable_zmq: false, + theme_mode: "System".to_string(), + _reserved_core_backend_mode: 1, + onboarding_completed: false, + show_evonode_tools: false, + user_mode: "Advanced".to_string(), + close_dash_qt_on_exit: true, + auto_start_spv: false, + }; + let s: AppSettings = wire.into(); + assert_eq!(s.network, Network::Mainnet); + } + + fn wire_with_dash_qt_path(dash_qt_path: Option<String>) -> AppSettingsWire { + AppSettingsWire { + network: "testnet".to_string(), + root_screen_type: 0, + dash_qt_path, + overwrite_dash_conf: true, + disable_zmq: false, + theme_mode: "System".to_string(), + _reserved_core_backend_mode: 1, + onboarding_completed: false, + show_evonode_tools: false, + user_mode: "Advanced".to_string(), + close_dash_qt_on_exit: true, + auto_start_spv: false, + } + } + + /// S4: a stored Dash-Qt path is preserved verbatim — autodetect never + /// overrides an explicit user choice on deserialize. + #[test] + fn stored_dash_qt_path_is_preserved() { + let stored = "/custom/path/to/dash-qt"; + let s: AppSettings = wire_with_dash_qt_path(Some(stored.to_string())).into(); + assert_eq!(s.dash_qt_path, Some(PathBuf::from(stored))); + } + + /// S5: an unset (`None`) Dash-Qt path decodes to `None` verbatim — decoding + /// stays pure (no filesystem IO). The autodetect fallback runs at the + /// settings-load call site (`AppContext::get_app_settings`), not here. + #[test] + fn unset_dash_qt_path_decodes_to_none() { + let s: AppSettings = wire_with_dash_qt_path(None).into(); + assert_eq!(s.dash_qt_path, None); + } +} diff --git a/src/model/single_key.rs b/src/model/single_key.rs new file mode 100644 index 000000000..85b21b8b0 --- /dev/null +++ b/src/model/single_key.rs @@ -0,0 +1,151 @@ +//! DET-side metadata for an imported single-key wallet entry (LIVE / current). +//! +//! The actual private-key bytes live in the encrypted secret store; this +//! struct is the public-facing handle that backend tasks and the UI use +//! to list, label, and address-route imported keys. +//! +//! Distinct from the LEGACY Decision-#7 pair — the +//! [`crate::model::wallet::single_key::SingleKeyWallet`] runtime type and its +//! [`crate::database::single_key_wallet`] persistence — which are retained +//! (spending gated) until single-key moves onto the upstream wallet runtime. + +use dash_sdk::dpp::dashcore::key::Error as KeyError; +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; +use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; +use serde::{Deserialize, Serialize}; + +/// Validates a WIF-encoded private key and derives the P2PKH address. +/// +/// Returns the derived address string on success, or the parse error on +/// failure. Pure format validation — no network I/O, no DB access. +/// Used by the import dialog for instant feedback (P8); the backend +/// task is the authoritative enforcement layer. +pub fn validate_wif(wif: &str, network: Network) -> Result<String, KeyError> { + let priv_key = PrivateKey::from_wif(wif)?; + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + let address = Address::p2pkh(&pub_key, network); + Ok(address.to_string()) +} + +/// Display-side metadata for one imported single-key wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportedKey { + /// Base58-encoded P2PKH address derived from the imported private + /// key. Used as the stable identifier in the UI and as the suffix in + /// the secret-store label. + pub address: String, + /// Optional user-supplied nickname. `None` until the user edits it. + pub alias: Option<String>, + /// Network the address is valid on. Must match the active + /// `WalletBackend` network — single-key entries are per-network by + /// the secret store's per-network scoping. + pub network: Network, + /// `true` when the imported key requires a per-key passphrase to use. + /// A protected key is sealed at import time in the upstream Tier-2 envelope + /// (Argon2id + XChaCha20-Poly1305) under that passphrase — one on-disk shape + /// from import onward, with no first-unlock migration. The flag is the + /// prompt-UI signal: `false` means callers can sign without prompting. + #[serde(default)] + pub has_passphrase: bool, + /// Optional user-supplied hint shown next to the passphrase prompt. + /// `None` for legacy entries that pre-date the per-key passphrase. + #[serde(default)] + pub passphrase_hint: Option<String>, + /// Compressed SEC1-encoded **public** key for this imported key. The + /// locked-render cold-boot path needs it to rebuild a passphrase-protected + /// key's display wallet without the secret (moved here from the + /// `SingleKeyEntry` vault blob under the raw-seam migration). Empty for + /// entries written before this field — the caller falls back to deriving + /// from plaintext when the key is unlocked. NON-secret. + #[serde(default)] + pub public_key_bytes: Vec<u8>, +} + +/// The pre-`public_key_bytes` [`ImportedKey`] on-disk shape, decode-only. +/// +/// `ImportedKey` is a positional-bincode `DetKv` value; appending +/// `public_key_bytes` is format-breaking for blobs already written without it +/// (`#[serde(default)]` does not rescue a trailing positional field). The +/// single-key read path tries the current shape first, then falls back to this +/// legacy shape and re-stores in the new shape — the same dual-format treatment +/// `WalletMeta` gets, so the two sibling sidecars share one policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportedKeyV1 { + /// See [`ImportedKey::address`]. + pub address: String, + /// See [`ImportedKey::alias`]. + pub alias: Option<String>, + /// See [`ImportedKey::network`]. + pub network: Network, + /// See [`ImportedKey::has_passphrase`]. + #[serde(default)] + pub has_passphrase: bool, + /// See [`ImportedKey::passphrase_hint`]. + #[serde(default)] + pub passphrase_hint: Option<String>, +} + +impl From<ImportedKeyV1> for ImportedKey { + fn from(v1: ImportedKeyV1) -> Self { + ImportedKey { + address: v1.address, + alias: v1.alias, + network: v1.network, + has_passphrase: v1.has_passphrase, + passphrase_hint: v1.passphrase_hint, + // Pre-this-field blobs have no stored pubkey; locked render falls + // back to deriving from plaintext when the key is unlocked. + public_key_bytes: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::leak_test_support::{assert_no_leak_bytes, distinctive_secret_32}; + + /// TS-NOLEAK-02 (ImportedKey) — the encoded sidecar blob carries NO private + /// key, and the PUBLIC key (needed for locked render) IS present. The + /// sidecar holds only the public key, never the secret; this is the canary. + #[test] + fn ts_noleak_02_imported_key_blob_has_no_private_key_but_has_public() { + let private = distinctive_secret_32(); + // A distinctive PUBLIC-key placeholder we DO expect to find. + let public = vec![0x02u8; 33]; + let imported = ImportedKey { + address: "yTestAddr".into(), + alias: Some("savings".into()), + network: Network::Testnet, + has_passphrase: true, + passphrase_hint: Some("hint".into()), + public_key_bytes: public.clone(), + }; + let blob = + bincode::serde::encode_to_vec(&imported, bincode::config::standard()).expect("encode"); + + // The private key appears in neither hex nor decimal-array form. + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &private, "ImportedKey sidecar blob"); + + // The public key IS present (locked render reads it back). + let decimal = format!( + "[{}]", + public + .iter() + .map(|b| b.to_string()) + .collect::<Vec<_>>() + .join(", ") + ); + // The 33-byte pubkey is embedded as a Vec — its bytes are in the blob. + let blob_contains_pubkey = blob.windows(public.len()).any(|w| w == public.as_slice()); + assert!( + blob_contains_pubkey || rendered.contains(&decimal), + "the public key must be present in the sidecar for locked render" + ); + } +} diff --git a/src/model/spv_status.rs b/src/model/spv_status.rs new file mode 100644 index 000000000..61a5e408b --- /dev/null +++ b/src/model/spv_status.rs @@ -0,0 +1,68 @@ +//! SPV status display value types. +//! +//! These are DET-owned UI/connection display types, decoupled from any chain +//! sync engine. Chain sync is owned by upstream `platform-wallet`'s +//! `SpvRuntime`; P1 introduces an `EventBridge` that feeds these values from +//! upstream sync events. Until then they default to `Idle`/empty. + +use dash_sdk::dash_spv::sync::SyncProgress as SpvSyncProgress; + +/// High-level status of the SPV client runtime, for UI display. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum SpvStatus { + #[default] + Idle = 0, + Starting = 1, + Syncing = 2, + Running = 3, + Stopping = 4, + Stopped = 5, + Error = 6, +} + +impl SpvStatus { + pub fn is_active(self) -> bool { + matches!( + self, + SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Running | SpvStatus::Stopping + ) + } +} + +impl std::fmt::Display for SpvStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpvStatus::Idle => write!(f, "Idle"), + SpvStatus::Starting => write!(f, "Starting"), + SpvStatus::Syncing => write!(f, "Syncing"), + SpvStatus::Running => write!(f, "Running"), + SpvStatus::Stopping => write!(f, "Stopping"), + SpvStatus::Stopped => write!(f, "Stopped"), + SpvStatus::Error => write!(f, "Error"), + } + } +} + +impl From<u8> for SpvStatus { + fn from(value: u8) -> Self { + match value { + 1 => SpvStatus::Starting, + 2 => SpvStatus::Syncing, + 3 => SpvStatus::Running, + 4 => SpvStatus::Stopping, + 5 => SpvStatus::Stopped, + 6 => SpvStatus::Error, + _ => SpvStatus::Idle, + } + } +} + +/// Snapshot of the SPV runtime state for UI consumption. +#[derive(Debug, Clone, Default)] +pub struct SpvStatusSnapshot { + pub status: SpvStatus, + pub sync_progress: Option<SpvSyncProgress>, + pub last_error: Option<String>, + pub connected_peers: usize, +} diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs deleted file mode 100644 index ad9a751ac..000000000 --- a/src/model/wallet/asset_lock_transaction.rs +++ /dev/null @@ -1,757 +0,0 @@ -use crate::context::AppContext; -use crate::model::wallet::Wallet; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dpp::dashcore::secp256k1::Message; -use dash_sdk::dpp::dashcore::sighash::SighashCache; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; -use dash_sdk::dpp::dashcore::{ - Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut, -}; -use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; -use std::collections::BTreeMap; - -/// Result of asset lock fee calculation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AssetLockFeeResult { - /// Transaction fee in duffs. - pub fee: u64, - /// Actual amount for the asset lock output (may differ from requested if - /// `allow_take_fee_from_amount` is true). - pub actual_amount: u64, - /// Change to return, or `None` if no change output is needed. - pub change: Option<u64>, -} - -/// Minimum fee for an asset lock transaction (duffs). -const MIN_ASSET_LOCK_FEE: u64 = 3_000; - -/// Minimum value for a change output (duffs). Outputs below this -/// threshold are considered dust and will be rejected by the network. -/// For P2PKH outputs the Dash Core dust limit is 546 duffs. -const DUST_THRESHOLD: u64 = 546; - -/// Estimate the transaction size in bytes. -/// -/// Assumes P2PKH inputs (~148 B each), standard outputs (~34 B each), -/// a ~10 B header, and a ~60 B asset-lock payload. -fn estimate_tx_size(num_inputs: usize, num_outputs: usize) -> usize { - 10 + (num_inputs * 148) + (num_outputs * 34) + 60 -} - -/// Calculate fee, actual amount, and change for an asset lock transaction. -/// -/// Uses an iterative approach: starts assuming a change output exists, -/// then recomputes if the change disappears under the real fee. -/// -/// # Errors -/// -/// Returns an error string if there are insufficient funds. -pub(crate) fn calculate_asset_lock_fee( - total_input_value: u64, - requested_amount: u64, - num_inputs: usize, - allow_take_fee_from_amount: bool, -) -> Result<AssetLockFeeResult, String> { - // First pass: assume 2 outputs (1 burn + 1 change). - let fee_with_change = std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 2) as u64); - - let required_with_change = requested_amount - .checked_add(fee_with_change) - .ok_or("Overflow computing required amount + fee")?; - let tentative_change = total_input_value.checked_sub(required_with_change); - - // If change exceeds dust threshold, include it as an output. - if let Some(change) = tentative_change - && change >= DUST_THRESHOLD - { - return Ok(AssetLockFeeResult { - fee: fee_with_change, - actual_amount: requested_amount, - change: Some(change), - }); - } - - // Change is zero or negative under the 2-output fee. - // Recompute with 1 output (no change). - let fee_no_change = std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 1) as u64); - - let required_no_change = requested_amount - .checked_add(fee_no_change) - .ok_or("Overflow computing required amount + fee")?; - - if total_input_value >= required_no_change { - // Enough funds without a change output. Any leftover becomes - // additional fee (it is too small for a viable change output). - return Ok(AssetLockFeeResult { - fee: total_input_value - requested_amount, - actual_amount: requested_amount, - change: None, - }); - } - - // Insufficient for the requested amount at the 1-output fee rate. - if allow_take_fee_from_amount { - let adjusted = total_input_value.saturating_sub(fee_no_change); - if adjusted == 0 { - return Err("Insufficient funds for transaction fee".to_string()); - } - Ok(AssetLockFeeResult { - fee: fee_no_change, - actual_amount: adjusted, - change: None, - }) - } else { - Err(format!( - "Insufficient funds: need {} + {} fee, have {}", - requested_amount, fee_no_change, total_input_value - )) - } -} - -impl Wallet { - /// Select UTXOs and compute fee, retrying with the real fee if the initial - /// estimate was too low and additional UTXOs are available. - #[allow(clippy::type_complexity)] - fn select_utxos_with_fee_retry( - &self, - amount: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Result<(BTreeMap<OutPoint, (TxOut, Address)>, AssetLockFeeResult), String> { - let mut fee_estimate = MIN_ASSET_LOCK_FEE; - - for _ in 0..2 { - let (utxos, _) = self - .select_unspent_utxos_for( - amount, - fee_estimate, - allow_take_fee_from_amount, - source_address, - ) - .ok_or_else(|| { - format!( - "Not enough spendable funds to create asset lock transaction: \ - requested amount {} plus estimated fee {}", - amount, fee_estimate - ) - })?; - - let total_input_value: u64 = utxos.iter().map(|(_, (tx_out, _))| tx_out.value).sum(); - let num_inputs = utxos.len(); - - match calculate_asset_lock_fee( - total_input_value, - amount, - num_inputs, - allow_take_fee_from_amount, - ) { - Ok(fee_result) => return Ok((utxos, fee_result)), - Err(_) if fee_estimate == MIN_ASSET_LOCK_FEE => { - // The real fee may exceed our initial estimate. Recompute - // with a 2-output size estimate and retry UTXO selection so - // we can pick up any additional marginal UTXOs. - fee_estimate = - std::cmp::max(MIN_ASSET_LOCK_FEE, estimate_tx_size(num_inputs, 2) as u64); - continue; - } - Err(e) => return Err(e), - } - } - - Err(format!( - "Not enough spendable funds to create asset lock transaction: \ - requested amount {} plus fee {}", - amount, fee_estimate - )) - } - - #[allow(clippy::type_complexity)] - pub fn registration_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - identity_index: u32, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option<Address>, - BTreeMap<OutPoint, (TxOut, Address)>, - ), - String, - > { - let private_key = - self.identity_registration_ecdsa_private_key(app_context, network, identity_index)?; - self.asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - ) - } - - #[allow(clippy::type_complexity, clippy::too_many_arguments)] - pub fn top_up_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - identity_index: u32, - top_up_index: u32, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option<Address>, - BTreeMap<OutPoint, (TxOut, Address)>, - ), - String, - > { - let private_key = self.identity_top_up_ecdsa_private_key( - app_context, - network, - identity_index, - top_up_index, - )?; - self.asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - ) - } - - /// Create an asset lock transaction with a randomly generated one-time key. - /// This is used for generic platform address funding (not identity-specific). - #[allow(clippy::type_complexity)] - pub fn generic_asset_lock_transaction( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Address, - Option<Address>, - BTreeMap<OutPoint, (TxOut, Address)>, - ), - String, - > { - use bip39::rand::rngs::OsRng; - - // Generate a random private key for the asset lock - let secp = Secp256k1::new(); - let (secret_key, _) = secp.generate_keypair(&mut OsRng); - let private_key = PrivateKey::new(secret_key, network); - let public_key = private_key.public_key(&secp); - - // The asset lock address is where the proof will be tied to - let asset_lock_address = Address::p2pkh(&public_key, network); - - let (tx, returned_private_key, change_address, used_utxos) = self - .asset_lock_transaction_from_private_key( - app_context, - network, - amount, - allow_take_fee_from_amount, - private_key, - source_address, - )?; - - Ok(( - tx, - returned_private_key, - asset_lock_address, - change_address, - used_utxos, - )) - } - - #[allow(clippy::type_complexity)] - fn asset_lock_transaction_from_private_key( - &mut self, - app_context: &AppContext, - network: Network, - amount: u64, - allow_take_fee_from_amount: bool, - private_key: PrivateKey, - source_address: Option<&Address>, - ) -> Result< - ( - Transaction, - PrivateKey, - Option<Address>, - BTreeMap<OutPoint, (TxOut, Address)>, - ), - String, - > { - let secp = Secp256k1::new(); - let asset_lock_public_key = private_key.public_key(&secp); - - let one_time_key_hash = asset_lock_public_key.pubkey_hash(); - - // Select UTXOs without committing the removal yet. UTXOs are only removed - // from the wallet after the transaction is fully built and signed, so that a - // failure at any later step (fee shortfall, missing private key, …) cannot - // permanently drop UTXOs from the wallet — especially important in SPV mode - // where there is no Core RPC reload fallback. - // - // Note: `&mut self` ensures exclusive access during the entire select → build - // → sign → remove sequence, preventing concurrent UTXO double-selection. - // - // We use an initial fee estimate for UTXO selection, then recalculate the - // real fee based on input count. If the real fee exceeds the estimate and - // the selected UTXOs are insufficient, we retry once with the computed fee - // so that marginal UTXOs are not missed. - let (utxos, fee_result) = - self.select_utxos_with_fee_retry(amount, allow_take_fee_from_amount, source_address)?; - - let actual_amount = fee_result.actual_amount; - let change_option = fee_result.change; - - let payload_output = TxOut { - value: actual_amount, - script_pubkey: ScriptBuf::new_p2pkh(&one_time_key_hash), - }; - let burn_output = TxOut { - value: actual_amount, - script_pubkey: ScriptBuf::new_op_return(&[]), - }; - - let (change_output, change_address) = if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - ( - Some(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }), - Some(change_address), - ) - } else { - (None, None) - }; - - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![payload_output], - }; - - // Collect inputs from UTXOs - let inputs = utxos - .keys() - .map(|utxo| TxIn { - previous_output: *utxo, - ..Default::default() - }) - .collect(); - - let mut tx = Transaction { - version: 3, - lock_time: 0, - input: inputs, - output: { - let mut outputs = vec![burn_output]; - if let Some(change_output) = change_output { - outputs.push(change_output); - } - outputs - }, - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - - let sighash_u32 = 1u32; - - let cache = SighashCache::new(&tx); - - // Next, collect the sighashes for each input since that's what we need from the - // cache - let sighashes: Result<Vec<_>, String> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!( - "UTXO not found in selected set for input {}", - input.previous_output - ) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_u32) - .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) - }) - .collect(); - let sighashes = sighashes?; - - // Now we can drop the cache to end the immutable borrow - #[allow(clippy::drop_non_drop)] - drop(cache); - - let mut check_utxos = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - // You need to provide the actual script_pubkey of the UTXO being spent - let (_, input_address) = - check_utxos.remove(&input.previous_output).ok_or_else(|| { - format!( - "UTXO not found in selected set for input {}", - input.previous_output - ) - })?; - let message = Message::from_digest(sighash.into()); - - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or(format!( - "Expected address {} to be in wallet", - input_address - ))?; - - // Sign the message with the private key - let sig = secp.sign_ecdsa(&message, &private_key.inner); - - // Serialize the DER-encoded signature and append the sighash type - let mut serialized_sig = sig.serialize_der().to_vec(); - - let mut sig_script = vec![serialized_sig.len() as u8 + 1]; - - sig_script.append(&mut serialized_sig); - - sig_script.push(1); - - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - - sig_script.push(serialized_pub_key.len() as u8); - sig_script.append(&mut serialized_pub_key); - // Create script_sig - input.script_sig = ScriptBuf::from_bytes(sig_script); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed. UTXOs are returned to the caller - // so they can be removed explicitly after successful broadcast. This avoids - // permanently losing UTXO tracking if broadcast fails. - Ok((tx, private_key, change_address, utxos)) - } - - pub fn registration_asset_lock_transaction_for_utxo( - &mut self, - app_context: &AppContext, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - identity_index: u32, - ) -> Result<(Transaction, PrivateKey), String> { - let private_key = - self.identity_registration_ecdsa_private_key(app_context, network, identity_index)?; - self.asset_lock_transaction_for_utxo_from_private_key( - network, - utxo, - previous_tx_output, - input_address, - private_key, - ) - } - - #[allow(clippy::too_many_arguments)] - pub fn top_up_asset_lock_transaction_for_utxo( - &mut self, - app_context: &AppContext, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - identity_index: u32, - top_up_index: u32, - ) -> Result<(Transaction, PrivateKey), String> { - let private_key = self.identity_top_up_ecdsa_private_key( - app_context, - network, - identity_index, - top_up_index, - )?; - self.asset_lock_transaction_for_utxo_from_private_key( - network, - utxo, - previous_tx_output, - input_address, - private_key, - ) - } - - pub fn asset_lock_transaction_for_utxo_from_private_key( - &mut self, - network: Network, - utxo: OutPoint, - previous_tx_output: TxOut, - input_address: Address, - private_key: PrivateKey, - ) -> Result<(Transaction, PrivateKey), String> { - let secp = Secp256k1::new(); - let asset_lock_public_key = private_key.public_key(&secp); - - let one_time_key_hash = asset_lock_public_key.pubkey_hash(); - - // Single-UTXO path: use calculate_asset_lock_fee for consistency with - // the multi-input path, ensuring dust check and overflow safety. - let fee_result = calculate_asset_lock_fee( - previous_tx_output.value, - previous_tx_output.value.saturating_sub(MIN_ASSET_LOCK_FEE), - 1, // single input - true, // take fee from amount since the UTXO *is* the total - )?; - let output_amount = fee_result.actual_amount; - - let payload_output = TxOut { - value: output_amount, - script_pubkey: ScriptBuf::new_p2pkh(&one_time_key_hash), - }; - let burn_output = TxOut { - value: output_amount, - script_pubkey: ScriptBuf::new_op_return(&[]), - }; - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![payload_output], - }; - - // we need to get all inputs from utxos to add them to the transaction - - let mut tx_in = TxIn::default(); - #[allow(clippy::field_reassign_with_default)] - { - tx_in.previous_output = utxo; - } - - let sighash_u32 = 1u32; - - let mut tx: Transaction = Transaction { - version: 3, - lock_time: 0, - input: vec![tx_in], - output: vec![burn_output], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - - let cache = SighashCache::new(&tx); - - // Next, collect the sighashes for each input since that's what we need from the - // cache - let sighashes: Result<Vec<_>, String> = tx - .input - .iter() - .enumerate() - .map(|(i, _)| { - cache - .legacy_signature_hash(i, &previous_tx_output.script_pubkey, sighash_u32) - .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) - }) - .collect(); - let sighashes = sighashes?; - - // Now we can drop the cache to end the immutable borrow - #[allow(clippy::drop_non_drop)] - drop(cache); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - // You need to provide the actual script_pubkey of the UTXO being spent - let message = Message::from_digest(sighash.into()); - - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or(format!( - "Expected address {} to be in wallet for input", - input_address - ))?; - - // Sign the message with the private key - let sig = secp.sign_ecdsa(&message, &private_key.inner); - - // Serialize the DER-encoded signature and append the sighash type - let mut serialized_sig = sig.serialize_der().to_vec(); - - let mut sig_script = vec![serialized_sig.len() as u8 + 1]; - - sig_script.append(&mut serialized_sig); - - sig_script.push(1); - - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - - sig_script.push(serialized_pub_key.len() as u8); - sig_script.append(&mut serialized_pub_key); - // Create script_sig - input.script_sig = ScriptBuf::from_bytes(sig_script); - Ok::<(), String>(()) - })?; - - Ok((tx, private_key)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fee_single_input_minimum() { - // 1 input, amount=10000, total=50000. - // With 2 outputs: size = 10 + 148 + 2*34 + 60 = 286 -> fee = max(3000, 286) = 3000. - // Change = 50000 - 10000 - 3000 = 37000. - let result = calculate_asset_lock_fee(50_000, 10_000, 1, false).expect("should succeed"); - assert_eq!(result.fee, 3_000); - assert_eq!(result.actual_amount, 10_000); - assert_eq!(result.change, Some(37_000)); - } - - #[test] - fn fee_scales_with_inputs() { - // 21 inputs, amount=50000, total=200000. - // With 2 outputs: size = 10 + 21*148 + 2*34 + 60 = 3246, fee = max(3000, 3246) = 3246. - // Change = 200000 - 50000 - 3246 = 146754. - let result = calculate_asset_lock_fee(200_000, 50_000, 21, false).expect("should succeed"); - assert!( - result.fee > MIN_ASSET_LOCK_FEE, - "fee should exceed minimum for many inputs" - ); - assert_eq!(result.fee, 3_246); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, Some(146_754)); - } - - #[test] - fn fee_exact_no_change() { - // 1 input, amount=47000, total=50000. - // With 2 outputs: size = 286, fee = 3000. change = 50000 - 47000 - 3000 = 0 -> None. - // Re-check with 1 output: size = 10 + 148 + 34 + 60 = 252, fee = 3000. - // 50000 >= 47000 + 3000 -> actual fee = 50000 - 47000 = 3000. - let result = calculate_asset_lock_fee(50_000, 47_000, 1, false).expect("should succeed"); - assert_eq!(result.actual_amount, 47_000); - assert_eq!(result.change, None); - } - - #[test] - fn fee_take_from_amount() { - // 1 input, amount=50000, total=50000, allow_take_fee=true. - // With 2 outputs: fee = 3000. change = 50000 - 50000 - 3000 < 0. - // With 1 output: fee = 3000. 50000 < 50000 + 3000. - // Take from amount: actual = 50000 - 3000 = 47000. - let result = calculate_asset_lock_fee(50_000, 50_000, 1, true).expect("should succeed"); - assert_eq!(result.actual_amount, 47_000); - assert_eq!(result.change, None); - } - - #[test] - fn fee_insufficient_funds() { - // 1 input, amount=50000, total=50000, allow_take_fee=false. - let result = calculate_asset_lock_fee(50_000, 50_000, 1, false); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Insufficient funds")); - } - - #[test] - fn fee_insufficient_for_fee_alone() { - // 1 input, amount=2000, total=2000, allow_take_fee=true. - // Fee = 3000 > total -> adjusted = 2000 - 3000 = 0 (saturating) -> error. - let result = calculate_asset_lock_fee(2_000, 2_000, 1, true); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("Insufficient funds"), - "should error when total cannot even cover the fee" - ); - } - - #[test] - fn fee_change_eliminated_by_real_fee_should_succeed() { - // BUG TEST: 21 inputs, amount=50000, total=53220. - // - // Initial UTXO selection uses fee estimate of 3000: - // change = 53220 - 50000 - 3000 = 220 -> has_initial_change = true - // - // Buggy code uses 2 outputs: fee = 10 + 21*148 + 2*34 + 60 = 3246 - // 53220 < 50000 + 3246 = 53246 -> ERROR (false insufficient funds) - // - // Correct code recalculates with 1 output: fee = 10 + 21*148 + 1*34 + 60 = 3212 - // 53220 >= 50000 + 3212 -> succeeds, no change, fee = 3220 - let result = calculate_asset_lock_fee(53_220, 50_000, 21, false); - assert!( - result.is_ok(), - "should NOT fail with insufficient funds; got: {:?}", - result - ); - let result = result.unwrap(); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, None); - // Fee absorbs the leftover: 53220 - 50000 = 3220 - assert_eq!(result.fee, 3_220); - } - - #[test] - fn fee_overestimate_when_change_disappears() { - // BUG TEST: Verify that when initial change exists under 3000 estimate but - // disappears under the real fee, we do not overcharge by counting the - // phantom change output. - // - // 21 inputs, amount=50000, total=53240. - // 2-output fee = 3246 -> 53240 < 53246, would fail with buggy code. - // 1-output fee = 3212 -> 53240 >= 53212, succeeds. fee = 3240, no change. - let result = calculate_asset_lock_fee(53_240, 50_000, 21, false); - assert!( - result.is_ok(), - "should succeed when recalculated without change output; got: {:?}", - result - ); - let result = result.unwrap(); - assert_eq!(result.actual_amount, 50_000); - assert_eq!(result.change, None); - // The fee with 2 outputs (3246) would be wrong here; only 1 output is needed. - // Actual fee = leftover = 53240 - 50000 = 3240, which is >= 1-output minimum (3212). - assert!( - result.fee < 3_246, - "fee should be less than the 2-output estimate of 3246, got {}", - result.fee - ); - assert_eq!(result.fee, 3_240); - } - #[test] - fn fee_dust_change_absorbed_into_fee() { - // 1 input, amount=46500, total=50000. - // With 2 outputs: fee = 3000. change = 50000 - 46500 - 3000 = 500. - // 500 < DUST_THRESHOLD (546) → change is dust, absorbed into fee. - // Falls through to 1-output path: fee = 3000. - // 50000 >= 46500 + 3000 → fee = 50000 - 46500 = 3500 (absorbs dust). - let result = calculate_asset_lock_fee(50_000, 46_500, 1, false).expect("should succeed"); - assert_eq!(result.actual_amount, 46_500); - assert_eq!( - result.change, None, - "dust change should not create an output" - ); - assert_eq!(result.fee, 3_500, "dust should be absorbed into fee"); - } -} diff --git a/src/model/wallet/auth_pubkey_cache.rs b/src/model/wallet/auth_pubkey_cache.rs new file mode 100644 index 000000000..374653d82 --- /dev/null +++ b/src/model/wallet/auth_pubkey_cache.rs @@ -0,0 +1,271 @@ +//! Memoised identity-authentication ECDSA public keys (D4b). +//! +//! The identity-authentication derivation path is hardened at every +//! level, including the leaf `key_index'`, so its public keys cannot be +//! derived from a stored account xpub (BIP-32 forbids public-from-public +//! derivation across a hardened boundary). The seed is mandatory for the +//! first derivation of every key. To keep the *steady-state* read of +//! these keys seed-free, the already-derived public keys are memoised +//! here, keyed by `(network, identity_index, key_index)`. +//! +//! The cache stores **public material only** — a leak of these bytes is +//! not a secret leak (the keys are derivable and end up on-chain). It is +//! persisted in the DET KV sidecar under `DetScope::Wallet(seed_hash)`; +//! an absent key reads as [`AuthPubkeyCache::default`] (cold), and a cold +//! miss self-heals into a warm hit via one just-in-time seed derivation. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::PublicKey; +use serde::{Deserialize, Serialize}; + +/// Serde-stable network tag for cache coordinates. +/// +/// Persisted as part of the bincode key, so the discriminants are pinned +/// — `dashcore::Network` is not serialised directly because its repr is +/// not guaranteed stable across upstream versions. Keying on network +/// keeps mainnet/testnet/devnet/regtest entries distinct under one blob, +/// matching the per-network coin-type baked into the derivation path. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(u8)] +pub enum NetworkTag { + /// Dash main network. + Mainnet = 0, + /// Dash test network. + Testnet = 1, + /// A development network. + Devnet = 2, + /// A local regression-test network. + Regtest = 3, +} + +impl From<Network> for NetworkTag { + fn from(network: Network) -> Self { + match network { + Network::Mainnet => NetworkTag::Mainnet, + Network::Testnet => NetworkTag::Testnet, + Network::Devnet => NetworkTag::Devnet, + Network::Regtest => NetworkTag::Regtest, + } + } +} + +/// Coordinate uniquely identifying one cached identity-auth public key. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AuthKeyCoord { + /// Network the key belongs to — partitions cross-network entries. + pub network: NetworkTag, + /// Zero-based identity index within the wallet. + pub identity_index: u32, + /// Zero-based authentication key index within the identity. + pub key_index: u32, +} + +impl AuthKeyCoord { + /// Build a coordinate from a `dashcore::Network` and the two indices. + pub fn new(network: Network, identity_index: u32, key_index: u32) -> Self { + Self { + network: network.into(), + identity_index, + key_index, + } + } +} + +/// Cached identity-authentication ECDSA public keys for one HD wallet. +/// +/// Public material only. Values are 33-byte compressed `secp256k1` +/// public keys — there is no chain code to keep, because no further +/// public derivation is possible past the hardened leaf. The two readers +/// reconstruct both the serialized form and the hash160 from these +/// bytes. Uses a [`BTreeMap`] for deterministic bincode output, matching +/// the project's persisted-shape discipline (`WalletMeta`, +/// `AppSettings`). +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthPubkeyCache { + /// `(network, identity_index, key_index)` -> 33-byte compressed + /// pubkey. Stored as `Vec<u8>` (always exactly 33 bytes on write) + /// because serde does not derive `Deserialize` for `[u8; 33]`; the + /// length is re-validated on read via `PublicKey::from_slice`. + pub entries: BTreeMap<AuthKeyCoord, Vec<u8>>, +} + +impl AuthPubkeyCache { + /// Look up a cached public key by coordinate. + /// + /// Returns `None` on a miss or when the stored bytes fail to parse as + /// a compressed public key (a corrupt entry degrades to a cold miss, + /// which the caller self-heals by re-deriving). + pub fn get(&self, network: Network, identity_index: u32, key_index: u32) -> Option<PublicKey> { + let coord = AuthKeyCoord::new(network, identity_index, key_index); + let bytes = self.entries.get(&coord)?; + PublicKey::from_slice(bytes).ok() + } + + /// Insert (or overwrite) a derived public key at the given coordinate. + /// + /// Returns `true` when the entry was newly added or changed, `false` + /// when an identical value was already present — lets warm/cold-fill + /// callers skip a redundant persist. + pub fn insert( + &mut self, + network: Network, + identity_index: u32, + key_index: u32, + public_key: &PublicKey, + ) -> bool { + let coord = AuthKeyCoord::new(network, identity_index, key_index); + let bytes = public_key.inner.serialize().to_vec(); + match self.entries.entry(coord) { + Entry::Occupied(e) if *e.get() == bytes => false, + Entry::Occupied(mut e) => { + e.insert(bytes); + true + } + Entry::Vacant(e) => { + e.insert(bytes); + true + } + } + } + + /// Number of cached entries across all networks. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the cache holds no entries (a fresh / cold cache). + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Terse `Debug` — entry count only, never the keys. Matches the +/// `WalletMeta` / `StoredSeedEnvelope` house style (public keys need no +/// redaction, but the blob is not log-useful expanded). +impl std::fmt::Debug for AuthPubkeyCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthPubkeyCache") + .field("entries", &self.entries.len()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::dashcore::secp256k1::{ + PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, + }; + + /// Deterministic test public key from a one-byte seed. + fn pubkey(seed: u8) -> PublicKey { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[seed.max(1); 32]).expect("valid secret key"); + let inner = Secp256k1PublicKey::from_secret_key(&secp, &sk); + PublicKey::new(inner) + } + + /// AUTH-CACHE-001 — a fresh cache is cold: no entries, every lookup + /// misses. This is the "absent key reads as Default" contract. + #[test] + fn default_is_cold() { + let cache = AuthPubkeyCache::default(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + assert!(cache.get(Network::Testnet, 0, 0).is_none()); + } + + /// AUTH-CACHE-002 — an inserted key round-trips through `get` and the + /// returned `PublicKey` is byte-identical to the one inserted. + #[test] + fn insert_then_get_round_trips() { + let mut cache = AuthPubkeyCache::default(); + let pk = pubkey(7); + assert!(cache.insert(Network::Mainnet, 1, 2, &pk)); + let got = cache.get(Network::Mainnet, 1, 2).expect("hit"); + assert_eq!(got, pk); + assert_eq!(got.inner.serialize(), pk.inner.serialize()); + } + + /// AUTH-CACHE-003 — coordinates are network-keyed: the same indices + /// on a different network do not alias (cross-network + /// key reuse is structurally excluded). + #[test] + fn coordinates_are_network_keyed() { + let mut cache = AuthPubkeyCache::default(); + let mainnet_pk = pubkey(11); + let testnet_pk = pubkey(22); + cache.insert(Network::Mainnet, 0, 0, &mainnet_pk); + cache.insert(Network::Testnet, 0, 0, &testnet_pk); + assert_eq!(cache.get(Network::Mainnet, 0, 0), Some(mainnet_pk)); + assert_eq!(cache.get(Network::Testnet, 0, 0), Some(testnet_pk)); + assert_ne!( + cache.get(Network::Mainnet, 0, 0), + cache.get(Network::Testnet, 0, 0) + ); + } + + /// AUTH-CACHE-004 — `insert` reports change: a new coordinate and a + /// value change both return `true`; re-inserting the same value + /// returns `false` so callers can skip a redundant persist. + #[test] + fn insert_reports_change() { + let mut cache = AuthPubkeyCache::default(); + let first = pubkey(3); + let second = pubkey(4); + assert!(cache.insert(Network::Devnet, 5, 6, &first), "new entry"); + assert!( + !cache.insert(Network::Devnet, 5, 6, &first), + "identical re-insert is a no-op" + ); + assert!( + cache.insert(Network::Devnet, 5, 6, &second), + "value change is a write" + ); + assert_eq!(cache.get(Network::Devnet, 5, 6), Some(second)); + } + + /// AUTH-CACHE-005 — the persisted shape round-trips through bincode + /// with `BTreeMap` determinism, the same coverage `WalletMeta` and + /// `AppSettings` carry. Adding a field that breaks decoding surfaces + /// here. + #[test] + fn cache_round_trips_through_bincode() { + let mut original = AuthPubkeyCache::default(); + original.insert(Network::Mainnet, 0, 0, &pubkey(1)); + original.insert(Network::Testnet, 3, 9, &pubkey(2)); + let bytes = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (AuthPubkeyCache, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(decoded, original); + // Determinism: re-encoding the decoded value yields the same bytes. + let bytes2 = + bincode::serde::encode_to_vec(&decoded, bincode::config::standard()).expect("encode"); + assert_eq!(bytes, bytes2); + } + + /// AUTH-CACHE-006 — the network tag discriminants are pinned. A + /// reorder or renumber would silently invalidate every persisted + /// blob, so lock the wire values explicitly. + #[test] + fn network_tag_discriminants_are_pinned() { + assert_eq!(NetworkTag::Mainnet as u8, 0); + assert_eq!(NetworkTag::Testnet as u8, 1); + assert_eq!(NetworkTag::Devnet as u8, 2); + assert_eq!(NetworkTag::Regtest as u8, 3); + } + + /// AUTH-CACHE-007 — `Debug` reveals only the entry count, never the + /// key bytes (house style; keeps logs terse). + #[test] + fn debug_is_terse() { + let mut cache = AuthPubkeyCache::default(); + cache.insert(Network::Mainnet, 0, 0, &pubkey(1)); + let rendered = format!("{cache:?}"); + assert!(rendered.contains("entries: 1"), "got: {rendered}"); + } +} diff --git a/src/model/wallet/birth_height.rs b/src/model/wallet/birth_height.rs new file mode 100644 index 000000000..9a2710b47 --- /dev/null +++ b/src/model/wallet/birth_height.rs @@ -0,0 +1,66 @@ +//! Birth-height policy for upstream wallet registration. +//! +//! When DET registers a wallet with the upstream SPV backend, the persisted +//! `birth_height` decides which compact-filter range the wallet's addresses +//! are matched against. Get it wrong and pre-existing deposits stay invisible +//! at 100% sync (a known funds-visibility trap). +//! +//! The decision is a pure function of how the wallet entered DET, so it lives +//! here as a single source of truth shared by the create/import write path and +//! the cold-boot reconciliation path. + +/// How a wallet first entered DET — the only input the birth-height policy +/// needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletOrigin { + /// A brand-new recovery phrase DET just generated. It has never held funds + /// before this moment, so there is nothing to scan for in the past. + Fresh, + /// A pre-existing recovery phrase brought in from elsewhere — typed at + /// import, recovered, or carried over by migration. It may already hold + /// deposits that predate registration. + Imported, +} + +/// Resolve the `birth_height_override` to pass to the upstream +/// `create_wallet_from_seed_bytes` call for a wallet of the given origin. +/// +/// - [`WalletOrigin::Fresh`] → `None`: the upstream resolves this to the +/// current SPV tip, scanning only from now forward. Correct and cheap — a +/// freshly generated phrase cannot have prior deposits. +/// - [`WalletOrigin::Imported`] → `Some(0)`: full historical scan from +/// genesis. The only setting that guarantees a deposit made before +/// registration (e.g. a balance restored from a recovery phrase) is found. +/// +/// `Some(0)` costs filter-*matching* over the wallet's address set across the +/// whole chain, not a second network download: the SPV client already fetches +/// compact filters from genesis regardless of any wallet's birth height. The +/// match pass populates balances progressively under the normal "syncing" +/// state. A per-network floor below the earliest DET-producible address could +/// trim that cost, but genesis is the safe default and the floor is deferred. +// +// TODO(birth-height-floor): replace genesis with a per-network constant just +// below the earliest height DET could have produced an address, once a +// DET-release-era height is pinned per network. Optimisation only — genesis is +// always correct. +pub fn registration_birth_height(origin: WalletOrigin) -> Option<u32> { + match origin { + WalletOrigin::Fresh => None, + WalletOrigin::Imported => Some(0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_wallet_scans_from_tip() { + assert_eq!(registration_birth_height(WalletOrigin::Fresh), None); + } + + #[test] + fn imported_wallet_scans_from_genesis() { + assert_eq!(registration_birth_height(WalletOrigin::Imported), Some(0)); + } +} diff --git a/src/model/wallet/encryption.rs b/src/model/wallet/encryption.rs index 9e2009ca1..a7a86e02b 100644 --- a/src/model/wallet/encryption.rs +++ b/src/model/wallet/encryption.rs @@ -2,20 +2,65 @@ use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use argon2::{self, Argon2}; use bip39::rand::{RngCore, rngs::OsRng}; +use zeroize::Zeroizing; const SALT_SIZE: usize = 16; // 128-bit salt const NONCE_SIZE: usize = 12; // 96-bit nonce for AES-GCM -pub const DASH_SECRET_MESSAGE: &[u8; 19] = b"dash_secret_message"; - use crate::model::wallet::ClosedKeyItem; use sha2::{Digest, Sha256}; -/// Derive a key from the password and salt using Argon2. -pub fn derive_password_key(password: &str, salt: &[u8]) -> Result<Vec<u8>, String> { +/// An AES-256-GCM envelope: ciphertext plus the random salt and nonce needed +/// to reproduce the key and decrypt it. Produced by [`encrypt_message`] and +/// consumed by [`decrypt_message`]. +pub(crate) struct EncryptedEnvelope { + /// The AES-256-GCM ciphertext (authentication tag included). + pub ciphertext: Vec<u8>, + /// The random 128-bit salt fed to Argon2 for key derivation. + pub salt: Vec<u8>, + /// The random 96-bit AES-GCM nonce. + pub nonce: Vec<u8>, +} + +/// A failure encrypting or decrypting wallet secret material with AES-256-GCM. +/// +/// `Display` is a complete, jargon-free sentence for the Everyday User; the +/// variant preserves which class of failure occurred so callers can branch +/// (e.g. wrong password vs a corrupt at-rest blob) without parsing strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum EncryptionError { + /// The AEAD tag did not verify — wrong password or tampered ciphertext. + #[error("The password is incorrect. Please check it and try again.")] + WrongPassword, + /// The stored data is structurally invalid (bad key/nonce length or a + /// corrupt at-rest blob). + #[error( + "This wallet's saved data appears to be damaged and could not be read. Re-add it from its recovery phrase to restore it." + )] + Malformed, + /// The Argon2 key-derivation step failed. + #[error("The encryption key could not be prepared. Please try again.")] + KeyDerivation, + /// The AES-256-GCM cipher failed to encrypt the data. + #[error("The data could not be encrypted. Please try again.")] + Encryption, +} + +/// Derive a 32-byte AES-256 key from the password and salt using Argon2. +/// +/// The key is returned in a [`Zeroizing`] buffer so the derived secret wipes on +/// drop instead of lingering in a plain `Vec<u8>` after the caller is done. +/// +/// # Errors +/// +/// Returns [`EncryptionError::KeyDerivation`] if Argon2 hashing fails. +pub fn derive_password_key( + password: &str, + salt: &[u8], +) -> Result<Zeroizing<Vec<u8>>, EncryptionError> { let key_length = 32; // For AES-256, we use a 256-bit key (32 bytes) - let mut key = vec![0u8; key_length]; + let mut key = Zeroizing::new(vec![0u8; key_length]); // Using Argon2 with default parameters let argon2 = Argon2::default(); @@ -23,18 +68,18 @@ pub fn derive_password_key(password: &str, salt: &[u8]) -> Result<Vec<u8>, Strin // Deriving the key argon2 .hash_password_into(password.as_bytes(), salt, &mut key) - .map_err(|e| e.to_string())?; + .map_err(|_| EncryptionError::KeyDerivation)?; Ok(key) } -/// Encrypt the seed using AES-256-GCM. -#[allow(clippy::type_complexity)] +/// Encrypt `message` under `password` with AES-256-GCM, returning the +/// [`EncryptedEnvelope`] (ciphertext, salt, nonce). #[allow(deprecated)] -pub fn encrypt_message( +pub(crate) fn encrypt_message( message: &[u8], password: &str, -) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> { +) -> Result<EncryptedEnvelope, EncryptionError> { // Generate a random salt let mut salt = vec![0u8; SALT_SIZE]; OsRng.fill_bytes(&mut salt); @@ -47,15 +92,88 @@ pub fn encrypt_message( OsRng.fill_bytes(&mut nonce); // Create cipher instance - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| EncryptionError::Encryption)?; // Encrypt the seed let nonce_arr = Nonce::from_slice(&nonce); - let encrypted_seed = cipher + let ciphertext = cipher .encrypt(nonce_arr, message) - .map_err(|e| e.to_string())?; + .map_err(|_| EncryptionError::Encryption)?; - Ok((encrypted_seed, salt, nonce)) + Ok(EncryptedEnvelope { + ciphertext, + salt, + nonce, + }) +} + +/// Failure decrypting an AES-256-GCM envelope produced by [`encrypt_message`]. +/// +/// Two outcomes the callers must distinguish: an authentication failure +/// (`WrongPassword` — the supplied password is wrong or the ciphertext was +/// tampered with) versus a structurally invalid envelope (`Malformed` — bad +/// key derivation, cipher init, or nonce length; a corrupt at-rest blob). Each +/// caller maps these to its own typed domain error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DecryptError { + /// The AEAD tag did not verify — wrong password or tampered ciphertext. + WrongPassword, + /// The envelope is structurally invalid (key derivation, cipher init, or + /// nonce length). Diagnostic detail is logged inside [`decrypt_message`]. + Malformed, +} + +/// Decrypt an AES-256-GCM envelope (`ciphertext` + `salt` + `nonce`) under +/// `password` — the inverse of [`encrypt_message`]. Returns the plaintext in a +/// [`Zeroizing`] buffer so it wipes on drop; the caller validates its length. +/// +/// Shared by every AES-GCM legacy-secret reader (the HD-seed migration reader, +/// the imported single-key entry, and the deprecated `ClosedKeyItem` seed +/// store) so the derive-key → init-cipher → checked-nonce → decrypt sequence +/// exists once. Structural failures are logged with `site` for context and +/// returned as [`DecryptError::Malformed`]; an authentication failure is +/// [`DecryptError::WrongPassword`] (no plaintext oracle). +pub(crate) fn decrypt_message( + ciphertext: &[u8], + salt: &[u8], + nonce: &[u8], + password: &str, + site: &'static str, +) -> Result<Zeroizing<Vec<u8>>, DecryptError> { + let key = derive_password_key(password, salt).map_err(|error| { + tracing::warn!( + target = "model::wallet::encryption", + site, + %error, + "Argon2 key derivation failed during decrypt", + ); + DecryptError::Malformed + })?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| { + tracing::warn!( + target = "model::wallet::encryption", + site, + %error, + "AES-GCM init failed during decrypt", + ); + DecryptError::Malformed + })?; + // Checked nonce conversion: an envelope with the wrong nonce length is a + // corrupt at-rest blob, not a panic. `Nonce::from_slice` panics on a length + // mismatch, which would poison the long-lived secret-store mutex. + let nonce_bytes: &[u8; NONCE_SIZE] = nonce.try_into().map_err(|_| { + tracing::warn!( + target = "model::wallet::encryption", + site, + nonce_len = nonce.len(), + "Envelope nonce is not the expected length", + ); + DecryptError::Malformed + })?; + let plaintext = cipher + .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .map_err(|_| DecryptError::WrongPassword)?; + Ok(Zeroizing::new(plaintext)) } impl ClosedKeyItem { @@ -69,37 +187,39 @@ impl ClosedKeyItem { } /// Encrypt the seed using AES-256-GCM. - #[allow(clippy::type_complexity)] pub(crate) fn encrypt_seed( seed: &[u8], password: &str, - ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> { + ) -> Result<EncryptedEnvelope, EncryptionError> { encrypt_message(seed, password) } - /// Decrypt the seed using AES-256-GCM. - #[allow(deprecated)] - pub fn decrypt_seed(&self, password: &str) -> Result<[u8; 64], String> { - // Derive the key - let key = derive_password_key(password, &self.salt)?; - - // Create cipher instance - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; - - // Decrypt the seed - let nonce_arr = Nonce::from_slice(&self.nonce); - let seed = cipher - .decrypt(nonce_arr, self.encrypted_seed.as_slice()) - .map_err(|e| e.to_string())?; - - let sized_seed = seed.try_into().map_err(|e: Vec<u8>| { - format!( - "invalid seed length, expected 64 bytes, got {} bytes", - e.len() - ) + /// Decrypt the seed using AES-256-GCM via the shared [`decrypt_message`] + /// reader. + /// + /// The 64-byte seed is returned in a [`Zeroizing`] buffer so it wipes on + /// drop, matching every other seed reader. The bytes are copied straight + /// into the zeroizing array — no plain `[u8; 64]` stack copy is left behind. + pub fn decrypt_seed(&self, password: &str) -> Result<Zeroizing<[u8; 64]>, EncryptionError> { + let seed = decrypt_message( + &self.encrypted_seed, + &self.salt, + &self.nonce, + password, + "closed_key_item::decrypt_seed", + ) + .map_err(|e| match e { + DecryptError::WrongPassword => EncryptionError::WrongPassword, + DecryptError::Malformed => EncryptionError::Malformed, })?; - Ok(sized_seed) + // A decrypted seed of the wrong length is a corrupt at-rest blob. + if seed.len() != 64 { + return Err(EncryptionError::Malformed); + } + let mut out = Zeroizing::new([0u8; 64]); + out.copy_from_slice(&seed); + Ok(out) } } #[cfg(test)] @@ -112,8 +232,7 @@ mod tests { let password = "securepassword"; // Encrypt the seed using the encrypt_seed method - let (encrypted_seed, salt, nonce) = - ClosedKeyItem::encrypt_seed(&seed, password).expect("Encryption failed"); + let envelope = ClosedKeyItem::encrypt_seed(&seed, password).expect("Encryption failed"); // Compute the seed hash let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); @@ -121,9 +240,9 @@ mod tests { // Create a ClosedWalletSeed instance with the encrypted data let closed_wallet_seed = ClosedKeyItem { seed_hash, - encrypted_seed, - salt, - nonce, + encrypted_seed: envelope.ciphertext, + salt: envelope.salt, + nonce: envelope.nonce, password_hint: None, // Set password hint if needed }; @@ -133,7 +252,7 @@ mod tests { .expect("Decryption failed"); // Verify that the decrypted seed matches the original seed - assert_eq!(seed, decrypted_seed); + assert_eq!(seed, *decrypted_seed); } #[test] @@ -143,8 +262,7 @@ mod tests { let wrong_password = "wrongpassword"; // Encrypt the seed using the encrypt_seed method - let (encrypted_seed, salt, nonce) = - ClosedKeyItem::encrypt_seed(&seed, password).expect("Encryption failed"); + let envelope = ClosedKeyItem::encrypt_seed(&seed, password).expect("Encryption failed"); // Compute the seed hash let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); @@ -152,16 +270,64 @@ mod tests { // Create a ClosedWalletSeed instance with the encrypted data let closed_wallet_seed = ClosedKeyItem { seed_hash, - encrypted_seed, - salt, - nonce, + encrypted_seed: envelope.ciphertext, + salt: envelope.salt, + nonce: envelope.nonce, password_hint: None, }; // Attempt to decrypt with the wrong password let result = closed_wallet_seed.decrypt_seed(wrong_password); - // Verify that decryption fails - assert!(result.is_err()); + // Verify that decryption fails with the typed wrong-password variant. + assert_eq!(result.unwrap_err(), EncryptionError::WrongPassword); + } + + #[test] + fn wrong_password_is_typed_variant() { + let envelope = encrypt_message(b"payload", "right").expect("encrypt"); + let err = decrypt_message( + &envelope.ciphertext, + &envelope.salt, + &envelope.nonce, + "wrong", + "test", + ) + .unwrap_err(); + assert_eq!(err, DecryptError::WrongPassword); + } + + #[test] + fn malformed_envelope_is_typed_variant() { + let envelope = encrypt_message(b"payload", "pw").expect("encrypt"); + // A nonce of the wrong length is a structurally corrupt blob, not a + // wrong password — it must surface as Malformed, never panic. + let err = decrypt_message( + &envelope.ciphertext, + &envelope.salt, + &[0u8; 4], + "pw", + "test", + ) + .unwrap_err(); + assert_eq!(err, DecryptError::Malformed); + } + + #[test] + fn decrypt_seed_wrong_length_blob_is_malformed() { + // Encrypt a 10-byte payload, then decrypt_seed it: the plaintext is not + // 64 bytes, so it is a corrupt seed blob → Malformed (no panic). + let envelope = encrypt_message(&[9u8; 10], "pw").expect("encrypt"); + let item = ClosedKeyItem { + seed_hash: [0u8; 32], + encrypted_seed: envelope.ciphertext, + salt: envelope.salt, + nonce: envelope.nonce, + password_hint: None, + }; + assert_eq!( + item.decrypt_seed("pw").unwrap_err(), + EncryptionError::Malformed + ); } } diff --git a/src/model/wallet/meta.rs b/src/model/wallet/meta.rs new file mode 100644 index 000000000..b7a8ef247 --- /dev/null +++ b/src/model/wallet/meta.rs @@ -0,0 +1,230 @@ +//! DET-owned wallet-metadata sidecar. +//! +//! Upstream `WalletMetadataEntry` only carries `network` and +//! `birth_height`. The fields DET needs in addition — the user-chosen +//! alias, the "main" flag the wallet picker uses to pre-select the +//! default wallet, and the legacy Dash Core wallet name link — live in +//! this DET-side struct. Persisted as a single bincode blob per +//! `(network, seed_hash)` pair in the cross-network `det-app.sqlite` +//! k/v store, behind the `DetKv` schema-version envelope. +//! +//! Two audiences for this struct: +//! +//! - The HD wallet listing path (T-W-01 cuts the legacy `db.get_wallets` +//! readers) reads it to populate the "Alias" and "Main wallet" +//! columns. +//! - The one-shot migration (T-W-00) drains the legacy `wallet` rows +//! into the sidecar so an existing install keeps its names after +//! the upgrade. + +use serde::{Deserialize, Serialize}; + +/// The original (pre-`uses_password`) [`WalletMeta`] on-disk shape, decode-only. +/// +/// `WalletMeta` is a positional-bincode `DetKv` value, so appending the +/// `uses_password` / `password_hint` fields is format-breaking for blobs +/// already written in the 4-field shape — `#[serde(default)]` does NOT rescue +/// them (positional bincode never reports an absent trailing field; it reads +/// past the end and errors). The dual-format reader +/// ([`WalletMetaView::get`](crate::wallet_backend::WalletMetaView)) tries the +/// current shape first, then falls back to decoding this legacy shape, and +/// re-stores in the new shape. No version byte: the two shapes are told apart +/// by which one decodes, leaning on the `DetKv` schema envelope rather than a +/// hand-rolled tag that could collide with a bincode length varint. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WalletMetaV1 { + /// See [`WalletMeta::alias`]. + pub alias: String, + /// See [`WalletMeta::is_main`]. + pub is_main: bool, + /// See [`WalletMeta::core_wallet_name`]. + pub core_wallet_name: Option<String>, + /// See [`WalletMeta::xpub_encoded`]. + #[serde(default)] + pub xpub_encoded: Vec<u8>, +} + +impl From<WalletMetaV1> for WalletMeta { + fn from(v1: WalletMetaV1) -> Self { + WalletMeta { + alias: v1.alias, + is_main: v1.is_main, + core_wallet_name: v1.core_wallet_name, + xpub_encoded: v1.xpub_encoded, + // A v1 blob predates the password sidecar. The unlock/migration + // path reads the authoritative flag from the legacy envelope; this + // default is the safe "ask nothing extra" starting point. + uses_password: false, + password_hint: None, + } + } +} + +/// DET-owned per-wallet metadata. +/// +/// Lives next to the upstream wallet state, not inside it: upstream +/// owns balance / transactions / identities; DET owns these display +/// fields plus a pre-computed master BIP44 ECDSA xpub so the wallet +/// picker can render at cold boot without touching the encrypted seed +/// vault. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WalletMeta { + /// User-visible label. Empty string when the user never named + /// the wallet — matches the legacy "alias is NULL" column shape + /// from the DET data.db. + pub alias: String, + /// Whether this is the "main" wallet on the active network — the + /// wallet picker pre-selects it on launch. At most one wallet + /// per network is expected to carry `true`; the picker does not + /// enforce uniqueness — last-write-wins. + pub is_main: bool, + /// Optional link to a Dash Core wallet by name. Power-user feature + /// for Devnet / Regtest installs that drive a local `dashd` from + /// DET; `None` for the default cloud / SPV install. + pub core_wallet_name: Option<String>, + /// `ExtendedPubKey::encode()` bytes for `m/44'/coin'/0'`. Computed + /// once when the wallet is first persisted; the picker reads it on + /// every boot so locked seeds don't have to be unlocked to render + /// the list. An empty vector means "xpub unknown" — the caller skips + /// the operations that derive from it (currently only the picker). + /// + /// `#[serde(default)]` only supplies a value at the Rust layer; it does + /// NOT make this blob forward-compatible. `WalletMeta` is stored as a + /// positional `bincode::config::standard()` blob behind the `DetKv` + /// schema envelope, so adding, removing, or reordering any field here is + /// a format-breaking change for already-stored blobs. Evolve the shape by + /// adding a decode-only legacy shape (see [`WalletMetaV1`]) and a + /// dual-format reader, never by relying on `#[serde(default)]` alone. + #[serde(default)] + pub xpub_encoded: Vec<u8>, + /// `true` when the wallet's seed was stored under a user password. Moved + /// out of the legacy seed envelope into this non-secret sidecar. Under the + /// Tier-2 keep-protection policy the flag **stays** `true` after migration — + /// the seed is re-wrapped under the same object password (never downgraded + /// to raw), so the persisted flag stays accurate for the prompt UI on every + /// future unlock. + #[serde(default)] + pub uses_password: bool, + /// Optional user-set password hint, moved out of the legacy seed envelope. + /// Shown next to the unlock prompt for protected wallets. + #[serde(default)] + pub password_hint: Option<String>, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// W-META-001 — round-trip through bincode so the persisted shape + /// is covered the same way `AppSettings` / `SelectedWallet` are. + /// Adding a field that breaks decoding surfaces here. + #[test] + fn wallet_meta_round_trips_through_bincode() { + let original = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev-wallet".into()), + xpub_encoded: vec![0xAB; 78], + uses_password: true, + password_hint: Some("granny's birthday".into()), + }; + let bytes = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (WalletMeta, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(decoded, original); + } + + /// W-META-002 — `Default` matches the "fresh install, never named" + /// shape: empty alias, not main, no Dash Core wallet link, no xpub, no + /// password. + #[test] + fn default_is_empty_unnamed_wallet() { + let m = WalletMeta::default(); + assert!(m.alias.is_empty()); + assert!(!m.is_main); + assert!(m.core_wallet_name.is_none()); + assert!(m.xpub_encoded.is_empty()); + assert!(!m.uses_password); + assert!(m.password_hint.is_none()); + } + + /// TS-META-01 (model leg) — the dual-format decode contract the view's + /// `read_meta` relies on: a legacy 4-field blob FAILS to decode as the new + /// 6-field `WalletMeta` (runs out of bytes) but decodes as `WalletMetaV1`; + /// a 6-field blob decodes as `WalletMeta`. This is why "try new, then V1" + /// is correct and order-sensitive. Includes the leading-byte collision case (a + /// 1-char alias, whose bincode length varint is `1`) — the old leading-byte + /// dispatch would have mis-routed it; the try-both reader does not. + #[test] + fn ts_meta_01_dual_format_decode_is_order_sensitive() { + let cfg = bincode::config::standard(); + + for alias in ["paycheque", "a", "ab"] { + let v1 = WalletMetaV1 { + alias: alias.into(), + is_main: true, + core_wallet_name: Some("dev".into()), + xpub_encoded: vec![0x22; 78], + }; + let old_blob = bincode::serde::encode_to_vec(&v1, cfg).expect("encode v1"); + + // The new 6-field struct cannot decode the 4-field blob. + assert!( + bincode::serde::decode_from_slice::<WalletMeta, _>(&old_blob, cfg).is_err(), + "legacy blob (alias {alias:?}) must NOT decode as the new shape", + ); + // The legacy struct does. + let (decoded, _): (WalletMetaV1, _) = + bincode::serde::decode_from_slice(&old_blob, cfg).expect("decode v1"); + assert_eq!(decoded, v1); + + // Migration preserves the v1 fields, defaults the new ones. + let migrated: WalletMeta = decoded.into(); + assert_eq!(migrated.alias, alias); + assert_eq!(migrated.xpub_encoded, vec![0x22; 78]); + assert!(!migrated.uses_password); + assert!(migrated.password_hint.is_none()); + } + + // A new 6-field blob decodes as WalletMeta (and re-stores identically). + let v2 = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev-wallet".into()), + xpub_encoded: vec![0xCD; 78], + uses_password: true, + password_hint: Some("hint".into()), + }; + let new_blob = bincode::serde::encode_to_vec(&v2, cfg).expect("encode v2"); + let (decoded, _): (WalletMeta, _) = + bincode::serde::decode_from_slice(&new_blob, cfg).expect("decode v2"); + assert_eq!(decoded, v2); + } + + /// TS-NOLEAK-02 (WalletMeta) — the encoded sidecar blob carries NO secret. + /// `WalletMeta` structurally cannot hold a key (no secret field); this is + /// canary coverage that a future field never smuggles one in. Asserted in + /// both hex and decimal-array form via the shared helper. + #[test] + fn ts_noleak_02_wallet_meta_blob_has_no_secret() { + use crate::wallet_backend::leak_test_support::{ + assert_no_leak_bytes, distinctive_secret_64, + }; + // A distinctive seed that must NOT appear in the sidecar bytes. + let secret = distinctive_secret_64(); + let meta = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("dev".into()), + // The xpub is PUBLIC material, not the seed — unrelated bytes. + xpub_encoded: vec![0xCD; 78], + uses_password: true, + password_hint: Some("hint".into()), + }; + let blob = + bincode::serde::encode_to_vec(&meta, bincode::config::standard()).expect("encode"); + let rendered = format!("{blob:?}"); + assert_no_leak_bytes(&rendered, &secret, "WalletMeta sidecar blob"); + } +} diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 7897398b5..3de3ba87a 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,36 +1,65 @@ -mod asset_lock_transaction; +pub mod auth_pubkey_cache; +pub mod birth_height; pub mod encryption; -pub mod shielded; +pub mod meta; +pub mod passphrase; +pub mod seed_envelope; pub mod single_key; -mod utxos; -use crate::backend_task::error::TaskError; -use crate::database::{Database, WalletError}; +use crate::database::WalletError; use crate::model::secret::Secret; -use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress}; +use crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache; +use crate::wallet_backend::poison::RwLockRecover; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::async_trait::async_trait; -use dash_sdk::dpp::identity::signer::Signer; use dash_sdk::dpp::key_wallet::account::AccountType; use dash_sdk::dpp::key_wallet::bip32::{ ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, KeyDerivationType, }; -use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; use dash_sdk::dpp::prelude::AddressNonce; -use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressKey, AddressProvider}; +use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressProvider}; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; -use dash_sdk::dpp::dashcore::sighash::SighashCache; +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{ - Address, BlockHash, InstantLock, Network, OutPoint, PrivateKey, PublicKey, ScriptBuf, - Transaction, TxIn, TxOut, Txid, + Address, BlockHash, Network, PrivateKey, PublicKey, Transaction, Txid, }; -use dash_sdk::dpp::platform_value::BinaryData; use std::cmp; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Debug; use std::ops::Range; use std::sync::{Arc, RwLock}; +use thiserror::Error; + +/// Why a set of payment recipients was rejected. +/// +/// Model-local so this pure validator carries no dependency on the +/// backend-task layer; `TaskError` provides a `From` conversion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum PaymentValidationError { + /// The payment named no recipients. + #[error("Add at least one recipient before sending a payment.")] + NoRecipients, + /// A recipient was given a zero amount. + #[error("Enter an amount greater than zero for every recipient, then try again.")] + ZeroAmount, +} + +/// Why constructing a wallet from a seed failed. +/// +/// Model-local so [`Wallet::new_from_seed`] carries no dependency on the +/// backend-task layer; `TaskError` provides a `From` conversion. +#[derive(Debug, Error)] +pub enum WalletCreationError { + /// Encrypting the seed with the supplied password failed. + #[error("Could not process encrypted data. Please check your keys and try again.")] + Encryption { detail: String }, + /// Deriving the master or account keys failed. + #[error("Could not create the wallet. Key derivation failed — please try again.")] + KeyDerivation { + #[source] + source: Box<dyn std::error::Error + Send + Sync>, + }, +} // BIP44 derivation path constants for Dash HD wallets. // Mainnet: m/44'/5'/0' Testnet/Devnet/Regtest: m/44'/1'/0' @@ -44,6 +73,43 @@ pub const DASH_COIN_TYPE: u32 = 5; /// Testnet coin type (shared across all testnet-like networks). pub const DASH_TESTNET_COIN_TYPE: u32 = 1; +/// Resolve the SLIP-0044 coin type for a Dash network. +/// +/// Mainnet uses `5'`; Testnet, Devnet, and Regtest all use `1'`. This mirrors +/// the canonical key-wallet mapping (`DASH_COIN_TYPE` / `DASH_TESTNET_COIN_TYPE` +/// in `key_wallet::dip9`) and the arms upstream `AccountType::derivation_path` +/// applies, so every HD path DET builds agrees with what the wallet derives. +pub const fn coin_type_for_network(network: Network) -> u32 { + match network { + Network::Mainnet => DASH_COIN_TYPE, + Network::Testnet | Network::Devnet | Network::Regtest => DASH_TESTNET_COIN_TYPE, + } +} + +/// Stateless backend-authoritative validation for a wallet payment's +/// recipient amounts. +/// +/// Rejects a payment with no recipients and any recipient whose amount is +/// zero — both would build a degenerate transaction that wastes the network +/// fee without moving the intended funds. Takes raw duff amounts so it carries +/// no dependency on the backend-task recipient type and stays trivially +/// unit-testable; UI screens may call it for instant feedback, but the backend +/// task is the authoritative caller (see CLAUDE.md validation-placement rule). +/// +/// # Errors +/// +/// - [`PaymentValidationError::NoRecipients`] when `amounts_duffs` is empty. +/// - [`PaymentValidationError::ZeroAmount`] when any amount is `0`. +pub fn validate_payment_recipients(amounts_duffs: &[u64]) -> Result<(), PaymentValidationError> { + if amounts_duffs.is_empty() { + return Err(PaymentValidationError::NoRecipients); + } + if amounts_duffs.contains(&0) { + return Err(PaymentValidationError::ZeroAmount); + } + Ok(()) +} + /// BIP44 account 0 path for Dash mainnet: `m/44'/5'/0'`. pub const DASH_BIP44_ACCOUNT_0_PATH_MAINNET: [ChildNumber; 3] = [ ChildNumber::Hardened { @@ -152,10 +218,7 @@ pub trait DerivationPathHelpers { } pub(crate) fn is_bip44_path(path: &DerivationPath, network: Network) -> bool { - let coin_type = match network { - Network::Mainnet => 5, - _ => 1, - }; + let coin_type = coin_type_for_network(network); let components = path.as_ref(); components.len() >= 4 && components[0] == ChildNumber::Hardened { index: 44 } @@ -189,10 +252,7 @@ impl DerivationPathHelpers for DerivationPath { } fn is_asset_lock_funding(&self, network: Network) -> bool { - let coin_type = match network { - Network::Mainnet => 5, - _ => 1, - }; + let coin_type = coin_type_for_network(network); let components = self.as_ref(); components.len() == 5 && components[0] == ChildNumber::Hardened { index: 9 } @@ -218,10 +278,7 @@ impl DerivationPathHelpers for DerivationPath { /// Check if this path is a DIP-17 Platform payment path: m/9'/coin_type'/17'/account'/key_class'/index fn is_platform_payment(&self, network: Network) -> bool { - let coin_type = match network { - Network::Mainnet => 5, - _ => 1, - }; + let coin_type = coin_type_for_network(network); let components = self.as_ref(); // DIP-17: m/9'/coin_type'/17'/account'/key_class'/index components.len() == 6 @@ -237,10 +294,7 @@ impl DerivationPathHelpers for DerivationPath { key_class: u32, index: u32, ) -> DerivationPath { - let coin_type = match network { - Network::Mainnet => 5, - _ => 1, - }; + let coin_type = coin_type_for_network(network); DerivationPath::from(vec![ ChildNumber::Hardened { index: 9 }, ChildNumber::Hardened { index: coin_type }, @@ -254,12 +308,10 @@ impl DerivationPathHelpers for DerivationPath { use crate::context::AppContext; use bitflags::bitflags; -use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::Identity; -use zeroize::Zeroize; +use zeroize::Zeroizing; const BOOTSTRAP_BIP44_EXTERNAL_COUNT: u32 = 32; const BOOTSTRAP_BIP44_CHANGE_COUNT: u32 = 16; @@ -343,30 +395,19 @@ pub struct Wallet { pub wallet_seed: WalletSeed, pub uses_password: bool, pub master_bip44_ecdsa_extended_public_key: ExtendedPubKey, - pub address_balances: BTreeMap<Address, u64>, - /// Historical total received per address (not just current UTXOs) - pub address_total_received: BTreeMap<Address, u64>, + /// Cached DIP-17 platform-payment account-level extended **public** key at + /// `m/9'/coin_type'/17'/0'/0'`. Lets platform-payment addresses be + /// reverse-derived to their index without the HD seed, so a balance the + /// upstream coordinator reports as a raw P2PKH hash can be registered for + /// signing. `None` on wallets created before this cache existed; + /// [`Self::ensure_platform_payment_account_xpub`] backfills it the next + /// time the seed is borrowed. Mirrors `master_bip44_ecdsa_extended_public_key`. + pub platform_payment_account_xpub: Option<ExtendedPubKey>, pub known_addresses: BTreeMap<Address, DerivationPath>, pub watched_addresses: BTreeMap<DerivationPath, AddressInfo>, - #[allow(clippy::type_complexity)] - pub unused_asset_locks: Vec<( - Transaction, - Address, - Credits, - Option<InstantLock>, - Option<AssetLockProof>, - )>, pub alias: Option<String>, pub identities: HashMap<u32, Identity>, - pub utxos: HashMap<Address, HashMap<OutPoint, TxOut>>, - pub transactions: Vec<WalletTransaction>, pub is_main: bool, - pub confirmed_balance: u64, - pub unconfirmed_balance: u64, - pub total_balance: u64, - /// True once SPV has reported balances at least once; distinguishes synced - /// zero-balance from not-yet-synced. - pub spv_balance_known: bool, /// DIP-17: Platform address balances and nonces (keyed by Core Address for lookup) pub platform_address_info: BTreeMap<Address, PlatformAddressInfo>, /// Dash Core wallet name for multi-wallet RPC calls @@ -387,13 +428,20 @@ impl Wallet { network: Network, alias: Option<String>, password: Option<&Secret>, - ) -> Result<Self, TaskError> { + ) -> Result<Self, WalletCreationError> { // Encrypt seed or store plaintext let (encrypted_seed, salt, nonce, uses_password) = match password { Some(pw) if !pw.is_empty() => { - let (enc, s, n) = ClosedKeyItem::encrypt_seed(&seed, pw.expose_secret()) - .map_err(|e| TaskError::EncryptionError { detail: e })?; - (enc, s, n, true) + // `encrypt_seed` is fully typed; `WalletCreationError::Encryption` + // still carries a `String` (pre-existing debt tracked for a later + // type-through), so the typed error is rendered via `Display` here. + let envelope = + ClosedKeyItem::encrypt_seed(&seed, pw.expose_secret()).map_err(|e| { + WalletCreationError::Encryption { + detail: e.to_string(), + } + })?; + (envelope.ciphertext, envelope.salt, envelope.nonce, true) } _ => (seed.to_vec(), vec![], vec![], false), }; @@ -402,28 +450,41 @@ impl Wallet { // Derive master BIP44 extended public key let master_priv = ExtendedPrivKey::new_master(network, &seed).map_err(|e| { - TaskError::WalletKeyDerivationFailed { + WalletCreationError::KeyDerivation { source: Box::new(e), } })?; let bip44_path = Self::bip44_account0_path(network); let secp = Secp256k1::new(); let account_priv = master_priv.derive_priv(&secp, &bip44_path).map_err(|e| { - TaskError::WalletKeyDerivationFailed { + WalletCreationError::KeyDerivation { source: Box::new(e), } })?; let master_bip44_ecdsa_extended_public_key = ExtendedPubKey::from_priv(&secp, &account_priv); + // Cache the DIP-17 platform-payment account xpub up front (like the + // BIP44 one above) so platform addresses stay reverse-derivable — + // index lookup for signing — without ever re-touching the seed. + let platform_payment_account_xpub = derive_platform_payment_account_xpub( + &seed, + network, + PLATFORM_PAYMENT_ACCOUNT, + PLATFORM_PAYMENT_KEY_CLASS, + ) + .map(Some) + .map_err(|e| WalletCreationError::KeyDerivation { + source: Box::new(e), + })?; + // Derive the first receive address (m/44'/coin'/0'/0/0) let (known_addresses, watched_addresses) = Self::derive_first_address(&master_bip44_ecdsa_extended_public_key, network, &secp) - .map_err(|e| TaskError::WalletKeyDerivationFailed { source: e.into() })?; + .map_err(|e| WalletCreationError::KeyDerivation { source: e.into() })?; Ok(Wallet { wallet_seed: WalletSeed::Open(OpenWalletSeed { - seed, wallet_info: ClosedKeyItem { seed_hash, encrypted_seed, @@ -434,20 +495,12 @@ impl Wallet { }), uses_password, master_bip44_ecdsa_extended_public_key, - address_balances: Default::default(), - address_total_received: Default::default(), + platform_payment_account_xpub, known_addresses, watched_addresses, - unused_asset_locks: Default::default(), alias, identities: Default::default(), - utxos: Default::default(), - transactions: Vec::new(), is_main: true, - confirmed_balance: 0, - unconfirmed_balance: 0, - total_balance: 0, - spv_balance_known: false, platform_address_info: Default::default(), core_wallet_name: None, }) @@ -473,7 +526,7 @@ impl Wallet { BTreeMap<Address, DerivationPath>, BTreeMap<DerivationPath, AddressInfo>, ), - String, + WalletError, > { let mut known_addresses = BTreeMap::new(); let mut watched_addresses = BTreeMap::new(); @@ -486,9 +539,7 @@ impl Wallet { .as_slice(), ); - let pk = master_pub - .derive_pub(secp, &address_path) - .map_err(|e| format!("Failed to derive first receive address: {e}"))?; + let pk = master_pub.derive_pub(secp, &address_path)?; let address = Address::p2pkh(&pk.to_pub(), network); let bip44 = match network { Network::Mainnet => &DASH_BIP44_ACCOUNT_0_PATH_MAINNET, @@ -614,30 +665,59 @@ impl WalletTransaction { pub type WalletSeedHash = [u8; 32]; +/// A single per-address entry in a coordinator-push balance update. +/// +/// The raw 20-byte P2PKH `hash` is network-agnostic and must be converted to a +/// `dashcore::Address` by the receiver using the active network before calling +/// [`Wallet::set_platform_address_info`]. `account` / `index` are the DIP-17 +/// coordinates the address was derived at, letting the receiver register its +/// derivation path EXACTLY via [`Wallet::register_platform_payment_address`] +/// instead of reverse-deriving it; `index` is `None` only for legacy data that +/// predates index threading, which falls back to +/// [`Wallet::reconcile_platform_address`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PlatformAddressEntry { + pub hash: [u8; 20], + pub balance: u64, + pub nonce: u32, + /// DIP-17 account index the address belongs to. + pub account: u32, + /// DIP-17 derivation index within the account, when known. + pub index: Option<AddressIndex>, +} + +/// Batch of coordinator-push balance updates, grouped by wallet. +/// +/// Each element is `(seed_hash, entries)` where each `entry` is a +/// [`PlatformAddressEntry`]. +pub type PlatformAddressUpdates = Vec<(WalletSeedHash, Vec<PlatformAddressEntry>)>; + +/// State of a wallet's HD seed. +/// +/// Neither variant ever holds the plaintext seed. `Open` means +/// **unlocked/verified** — the passphrase has been proven correct and the +/// wallet is usable — and `Closed` means locked. Both variants carry only the +/// encrypted [`ClosedKeyItem`] metadata; the plaintext seed lives **only** +/// inside a `with_secret*` frame of the +/// [`SecretAccess`](crate::wallet_backend::SecretAccess) chokepoint. #[derive(Debug, Clone, PartialEq)] pub enum WalletSeed { Open(OpenWalletSeed), Closed(ClosedWalletSeed), } -#[derive(Clone, PartialEq)] -pub struct OpenKeyItem<const N: usize> { - pub seed: [u8; N], - pub wallet_info: ClosedKeyItem, -} -impl<const N: usize> Debug for OpenKeyItem<N> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let hash = ClosedKeyItem::compute_seed_hash(&self.seed); - f.debug_struct("OpenKeyItem") - .field("seed_hash", &hex::encode(hash)) - .finish() - } +/// The "unlocked/verified" half of [`WalletSeed`]. +/// +/// Holds no secret: an open wallet parks no plaintext seed. The retained +/// [`ClosedKeyItem`] carries the encrypted-envelope metadata (`seed_hash`, +/// `salt`, `nonce`, `encrypted_seed`, `password_hint`) the model and UI read +/// without the seed. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenWalletSeed { + pub wallet_info: ClosedKeyItem, } -// Type alias for OpenWalletSeed with a fixed seed size of 64 bytes -pub type OpenWalletSeed = OpenKeyItem<64>; - -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub struct ClosedKeyItem { pub seed_hash: WalletSeedHash, // SHA-256 hash of the seed pub encrypted_seed: Vec<u8>, @@ -646,10 +726,38 @@ pub struct ClosedKeyItem { pub password_hint: Option<String>, } +impl std::fmt::Debug for ClosedKeyItem { + /// Redacting `Debug`: prints only the public seed hash and lengths. + /// + /// For an unprotected wallet `encrypted_seed` is the raw 64-byte + /// plaintext seed, so it must never reach a `Debug` sink (logs, + /// panics). `Wallet`, `WalletSeed`, and `OpenWalletSeed` all derive + /// `Debug` and delegate here, so redacting once protects the whole + /// chain. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClosedKeyItem") + .field("seed_hash", &hex::encode(self.seed_hash)) + .field("encrypted_seed", &"[redacted]") + .field("salt_len", &self.salt.len()) + .field("nonce_len", &self.nonce.len()) + .field("password_hint", &self.password_hint) + .finish() + } +} + pub type ClosedWalletSeed = ClosedKeyItem; impl WalletSeed { - /// Opens the wallet by decrypting the seed using the provided password. + /// Verify the passphrase and mark the wallet unlocked, **without parking + /// the seed**. + /// + /// Decrypts the stored envelope only to prove `password` is correct, then + /// discards the plaintext (it is zeroized at the end of this call). On + /// success the state flips to `Open` carrying no secret. Seed residency is + /// owned entirely by the + /// [`SecretAccess`](crate::wallet_backend::SecretAccess) chokepoint; the + /// caller that wants the session kept unlocked promotes the seed there + /// (see [`AppContext::handle_wallet_unlocked`](crate::context::AppContext::handle_wallet_unlocked)). pub fn open(&mut self, password: &str) -> Result<(), String> { match self { WalletSeed::Open(_) => { @@ -657,10 +765,15 @@ impl WalletSeed { Ok(()) } WalletSeed::Closed(closed_seed) => { - // Try to decrypt the seed - let seed = closed_seed.decrypt_seed(password)?; + // Decrypt to PROVE the password is correct, then drop the + // plaintext (`Zeroizing`) without parking it. `decrypt_seed` is + // fully typed; this method's `String` return is pre-existing + // model/wallet debt tracked for a later type-through, so the + // typed error is rendered through its `Display` here. + let _verified = closed_seed + .decrypt_seed(password) + .map_err(|e| e.to_string())?; let open_wallet_seed = OpenWalletSeed { - seed, wallet_info: closed_seed.clone(), }; *self = WalletSeed::Open(open_wallet_seed); @@ -669,7 +782,11 @@ impl WalletSeed { } } - /// Opens the wallet by decrypting the seed without using a password. + /// Mark a no-password wallet unlocked, **without parking the seed**. + /// + /// The verify-not-park counterpart of [`Self::open`] for unprotected + /// wallets: it validates the stored envelope is a well-formed 64-byte seed, + /// then flips to `Open` carrying no secret. pub fn open_no_password(&mut self) -> Result<(), String> { match self { WalletSeed::Open(_) => { @@ -677,31 +794,29 @@ impl WalletSeed { Ok(()) } WalletSeed::Closed(closed_seed) => { - let open_wallet_seed = - OpenWalletSeed { - seed: closed_seed.encrypted_seed.clone().try_into().map_err( - |e: Vec<u8>| { - format!("incorrect seed size, expected 64 bytes, got {}", e.len()) - }, - )?, - wallet_info: closed_seed.clone(), - }; + // Unprotected envelopes store the raw 64 bytes verbatim; + // validate the length to prove the wallet is openable, then + // drop the plaintext without parking it. + if closed_seed.encrypted_seed.len() != 64 { + return Err(format!( + "incorrect seed size, expected 64 bytes, got {}", + closed_seed.encrypted_seed.len() + )); + } + let open_wallet_seed = OpenWalletSeed { + wallet_info: closed_seed.clone(), + }; *self = WalletSeed::Open(open_wallet_seed); Ok(()) } } } - /// Closes the wallet by securely erasing the seed and transitioning to Closed state. - // Allow dead_code: This method provides explicit wallet closure functionality, - // useful for security-conscious applications requiring manual wallet management - #[allow(dead_code)] + /// Transition the wallet back to the locked (`Closed`) state. + /// Drop the decrypted seed, transitioning the wallet to its closed state. pub fn close(&mut self) { match self { WalletSeed::Open(open_seed) => { - // Zeroize the seed - open_seed.seed.zeroize(); - // Transition back to ClosedWalletSeed let closed_seed = open_seed.wallet_info.clone(); *self = WalletSeed::Closed(closed_seed); } @@ -712,15 +827,6 @@ impl WalletSeed { } } -impl Drop for WalletSeed { - fn drop(&mut self) { - // Securely erase sensitive data - if let WalletSeed::Open(open_seed) = self { - open_seed.seed.zeroize(); - } - } -} - impl Wallet { /// Convert a Platform address to a canonical Core address representation for map keys. /// @@ -735,104 +841,55 @@ impl Wallet { pub fn is_open(&self) -> bool { matches!(self.wallet_seed, WalletSeed::Open(_)) } - pub fn has_balance(&self) -> bool { - self.confirmed_balance_duffs() > 0 || self.unconfirmed_balance > 0 - } - - pub fn has_unused_asset_lock(&self) -> bool { - !self.unused_asset_locks.is_empty() - } - - pub fn max_balance(&self) -> u64 { - self.utxos - .values() - .flat_map(|outpoints_to_tx_out| outpoints_to_tx_out.values().map(|tx_out| tx_out.value)) - .sum::<Duffs>() - } - - pub fn confirmed_balance_duffs(&self) -> u64 { - if self.total_balance > 0 || self.confirmed_balance > 0 || self.unconfirmed_balance > 0 { - self.confirmed_balance - } else { - self.max_balance() - } - } - - /// Returns the SPV-reported confirmed balance, or `None` if SPV hasn't - /// synced balance data yet. Unlike `confirmed_balance_duffs()`, this - /// never falls back to `max_balance()` — callers that need certainty - /// (e.g., test waiters) should use this and retry on `None`. - pub fn spv_confirmed_balance(&self) -> Option<u64> { - if self.spv_balance_known { - Some(self.confirmed_balance) - } else { - None - } - } - - pub fn unconfirmed_balance_duffs(&self) -> u64 { - self.unconfirmed_balance - } - - pub fn total_balance_duffs(&self) -> u64 { - if self.total_balance > 0 { - self.total_balance - } else { - self.max_balance() - } - } - - pub fn update_spv_balances(&mut self, confirmed: u64, unconfirmed: u64, total: u64) { - self.confirmed_balance = confirmed; - self.unconfirmed_balance = unconfirmed; - self.total_balance = total; - self.spv_balance_known = true; - } - - pub fn bootstrap_known_addresses(&mut self, app_context: &AppContext) { - if !self.is_open() { - tracing::debug!("Skipping address bootstrap for locked wallet"); - return; - } - + /// Derive and register the wallet's full bootstrap address set from a + /// borrowed HD seed. + /// + /// The seed is supplied by the async caller that already holds a + /// [`with_secret_session`](crate::wallet_backend::SecretAccess::with_secret_session) + /// scope — one session spans the whole bootstrap run. It is borrowed for + /// the duration of the call and never copied into an owned buffer that + /// outlives the scope; every `bootstrap_*` child derives in place from the + /// same borrow. Derivation paths, the per-network coin-type, and the keys + /// are identical to the prior parked-seed path — only the seed *source* + /// changes (parameter vs `self`). + // TODO(cleanup): seeds the `known_addresses`/`watched_addresses` display + // maps. No funds-safety path reads these — the Receive list reads the + // watched snapshot set. Kept because six readers still iterate the maps and + // need per-address derivation paths the snapshot does not yet carry: the + // address-table, account-summary, and send-autocomplete display views; the + // `system_tab_sections` view; the signing-critical identity-key resolver + // (`qualified_identity_public_key`); and the bootstrap gate + // (`wallet_lifecycle`). Remove the bootstrap + maps + DB rehydrate + // (`database/wallet.rs`) once those readers — especially the identity-key + // resolver — source paths from a path-aware snapshot. + pub fn bootstrap_known_addresses(&mut self, seed: &[u8; 64], app_context: &AppContext) { let network = app_context.network; if let Err(err) = self.bootstrap_bip44_addresses(network, app_context) { tracing::warn!("Failed to bootstrap BIP44 addresses: {}", err); } - if let Err(err) = self.bootstrap_bip32_addresses(network, app_context) { + if let Err(err) = self.bootstrap_bip32_addresses(seed, network, app_context) { tracing::warn!("Failed to bootstrap BIP32 addresses: {}", err); } - if let Err(err) = self.bootstrap_coinjoin_addresses(network, app_context) { + if let Err(err) = self.bootstrap_coinjoin_addresses(seed, network, app_context) { tracing::warn!("Failed to bootstrap CoinJoin addresses: {}", err); } - if let Err(err) = self.bootstrap_identity_addresses(network, app_context) { + if let Err(err) = self.bootstrap_identity_addresses(seed, network, app_context) { tracing::warn!("Failed to bootstrap identity addresses: {}", err); } - if let Err(err) = self.bootstrap_provider_addresses(network, app_context) { + if let Err(err) = self.bootstrap_provider_addresses(seed, network, app_context) { tracing::warn!("Failed to bootstrap provider addresses: {}", err); } - if let Err(err) = self.bootstrap_platform_payment_addresses(network, app_context) { + if let Err(err) = self.bootstrap_platform_payment_addresses(seed, network) { tracing::warn!("Failed to bootstrap Platform payment addresses: {}", err); } } - pub fn set_transactions(&mut self, transactions: Vec<WalletTransaction>) { - self.transactions = transactions; - } - - pub(crate) fn seed_bytes(&self) -> Result<&[u8; 64], String> { - match &self.wallet_seed { - WalletSeed::Open(opened) => Ok(&opened.seed), - WalletSeed::Closed(_) => Err("Wallet is closed, please decrypt it first".to_string()), - } - } - pub fn seed_hash(&self) -> [u8; 32] { match &self.wallet_seed { WalletSeed::Open(opened) => opened.wallet_info.seed_hash, @@ -868,16 +925,14 @@ impl Wallet { } } - // Allow dead_code: This utility method finds wallets by seed hash in collections, - // useful for wallet lookup operations and multi-wallet management - #[allow(dead_code)] + #[cfg(test)] pub fn find_in_arc_rw_lock_slice( slice: &[Arc<RwLock<Wallet>>], wallet_seed_hash: WalletSeedHash, ) -> Option<Arc<RwLock<Wallet>>> { for wallet in slice { // Attempt to read the wallet from the RwLock - let wallet_ref = wallet.read().unwrap(); + let wallet_ref = wallet.read_recover(); // Check if the wallet's seed hash matches the provided wallet_seed_hash if wallet_ref.seed_hash() == wallet_seed_hash { // Return a clone of the Arc<RwLock<Wallet>> that matches @@ -888,239 +943,245 @@ impl Wallet { None } - pub fn derive_private_key_in_arc_rw_lock_slice( + /// Derive the private key for `derivation_path` from a `seed` borrowed by + /// the caller (resolved once through the JIT chokepoint), confirming the + /// matching wallet is present in `slice` by `wallet_seed_hash` without + /// reading any wallet's parked seed. `Ok(None)` when no wallet matches. + pub fn derive_private_key_in_arc_rw_lock_slice_with_seed( slice: &[Arc<RwLock<Wallet>>], wallet_seed_hash: WalletSeedHash, + seed: &[u8; 64], derivation_path: &DerivationPath, network: Network, - ) -> Result<Option<[u8; 32]>, String> { + ) -> Result<Option<Zeroizing<[u8; 32]>>, WalletError> { for wallet in slice { - // Attempt to read the wallet from the RwLock - let wallet_ref = wallet.read().unwrap(); - // Check if this wallet's seed hash matches the target hash + let wallet_ref = wallet.read_recover(); if wallet_ref.seed_hash() == wallet_seed_hash { - // Attempt to derive the private key using the provided derivation path - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(wallet_ref.seed_bytes()?, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - return Ok(Some(extended_private_key.private_key.secret_bytes())); + // SECURITY: `ExtendedPrivKey` is a `Copy` BIP-32 type from + // key_wallet with no `Drop`, so its inner SecretKey + ChainCode + // cannot be wiped by RAII here. Extract the key straight into a + // `Zeroizing` buffer and never bind the intermediate to a named + // local; the transient copy left on the stack is the + // unavoidable residue of a third-party `Copy` type. + let secret = Zeroizing::new( + derivation_path + .derive_priv_ecdsa_for_master_seed(seed, network)? + .private_key + .secret_bytes(), + ); + return Ok(Some(secret)); } } - // Return None if no wallet with the matching seed hash is found Ok(None) } - pub fn private_key_at_derivation_path( + /// Derive the private key for `derivation_path` from a `seed` borrowed by + /// the caller (resolved through the JIT chokepoint). Same path, same + /// per-network derivation, same key as the BIP-32 spec dictates. + pub fn private_key_at_derivation_path_with_seed( &self, + seed: &[u8; 64], derivation_path: &DerivationPath, network: Network, - ) -> Result<PrivateKey, String> { - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + ) -> Result<PrivateKey, WalletError> { + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; Ok(extended_private_key.to_priv()) } - pub fn private_key_for_address( + /// Derive the private key for a known `address` from a `seed` borrowed by + /// the caller (resolved through the JIT chokepoint). + /// + /// Looks up the address's derivation path (pure, no secret) and derives at + /// it. `Ok(None)` when the address is not one of this wallet's known + /// addresses. Same path, same per-network derivation, same key. + pub fn private_key_for_address_with_seed( &self, + seed: &[u8; 64], address: &Address, network: Network, - ) -> Result<Option<PrivateKey>, String> { + ) -> Result<Option<PrivateKey>, WalletError> { self.known_addresses .get(address) .map(|derivation_path| { derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) + .derive_priv_ecdsa_for_master_seed(seed, network) .map(|extended_private_key| extended_private_key.to_priv()) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string()) + .map_err(|e| WalletError::KeyDerivation { source: e }) }) .transpose() } - pub fn unused_bip_44_public_key( - &mut self, - network: Network, - skip_known_addresses_with_no_funds: bool, - change: bool, - register: Option<&AppContext>, - ) -> Result<(PublicKey, DerivationPath), String> { - let mut address_index = 0; - let mut found_unused_derivation_path = None; - let mut known_public_key = None; - while found_unused_derivation_path.is_none() { - let derivation_path_extension = DerivationPath::from( - [ - ChildNumber::Normal { - index: change.into(), - }, - ChildNumber::Normal { - index: address_index, - }, - ] - .as_slice(), - ); - let derivation_path = - DerivationPath::bip_44_payment_path(network, 0, change, address_index); - - if let Some(address_info) = self.watched_addresses.get(&derivation_path) { - // Address is known - let address = &address_info.address; - let balance = self.address_balances.get(address).cloned().unwrap_or(0); - - if balance > 0 { - // Address has funds, skip it - address_index += 1; - continue; - } - - // Address is known and has zero balance - if !skip_known_addresses_with_no_funds { - // We can use this address - found_unused_derivation_path = Some(derivation_path.clone()); - let secp = Secp256k1::new(); - let public_key = self - .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &derivation_path_extension) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())? - .to_pub(); - known_public_key = Some(public_key); - break; - } else { - // Skip known addresses with no funds - address_index += 1; - continue; - } - } else { - let secp = Secp256k1::new(); - let public_key = self - .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &derivation_path_extension) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())? - .to_pub(); - known_public_key = Some(public_key); - if let Some(app_context) = register { - let address = Address::p2pkh(&public_key, network); - app_context.try_import_address( - &address, - self.core_wallet_name.as_deref(), - Some(&format!( - "Managed by Dash Evo Tool {} {}", - self.alias.clone().unwrap_or_default(), - derivation_path - )), - ); - - self.register_address( - address, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::BIP44, - app_context, - )?; - } - found_unused_derivation_path = Some(derivation_path.clone()); - break; - } - } - - let derivation_path = found_unused_derivation_path.unwrap(); - let known_public_key = known_public_key.unwrap(); - Ok((known_public_key, derivation_path)) - } - - pub fn identity_authentication_ecdsa_public_key( + /// Derive one identity-authentication ECDSA public key from a + /// borrowed HD seed. + /// + /// The seed is supplied by the async caller holding a `with_secret` + /// scope; it is never read from `self`. The result is byte-identical + /// to the value the [`AuthPubkeyCache`] serves once the cache is warm — + /// the caller writes it back on a cold miss. + pub fn identity_authentication_ecdsa_public_key_from_seed( &self, + seed: &[u8; 64], network: Network, identity_index: u32, key_index: u32, - ) -> Result<PublicKey, String> { + ) -> Result<PublicKey, WalletError> { let derivation_path = DerivationPath::identity_authentication_path( network, KeyDerivationType::ECDSA, identity_index, key_index, ); - let extended_public_key = derivation_path - .derive_pub_ecdsa_for_master_seed(self.seed_bytes()?, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_public_key = + derivation_path.derive_pub_ecdsa_for_master_seed(seed, network)?; Ok(extended_public_key.to_pub()) } +} - #[allow(clippy::type_complexity)] - pub fn identity_authentication_ecdsa_public_keys_data_map( +/// Identity-auth public-key lookup maps built from cache hits, plus the +/// key indices that missed and must be cold-filled from the seed. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct AuthKeyMaps { + pub by_serialized: BTreeMap<Vec<u8>, u32>, + pub by_hash160: BTreeMap<[u8; 20], u32>, + pub misses: Vec<u32>, +} + +/// Identity-auth public-key lookup maps built from cold seed derivation, +/// plus the derived `(key_index, PublicKey)` pairs for cache write-back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedAuthKeyMaps { + pub by_serialized: BTreeMap<Vec<u8>, u32>, + pub by_hash160: BTreeMap<[u8; 20], u32>, + pub derived: Vec<(u32, PublicKey)>, +} + +impl Wallet { + /// Build the two identity-auth public-key lookup maps for `range` + /// from the cache alone, returning the key indices that missed. + /// + /// Seed-free: every map entry is reconstructed from a cached + /// `PublicKey`, and address registration (when requested) uses that + /// same reconstructed key. `misses` lists the indices the caller must + /// cold-fill — see + /// [`Self::identity_authentication_ecdsa_public_keys_data_map_from_seed`]. + /// Partitioning misses here lets the caller open a *single* + /// `with_secret` scope for the whole request. + pub fn identity_authentication_ecdsa_public_keys_data_map_cached( &mut self, app_context: &AppContext, register_addresses: bool, + cache: &AuthPubkeyCache, network: Network, identity_index: u32, key_index_range: Range<u32>, - ) -> Result<(BTreeMap<Vec<u8>, u32>, BTreeMap<[u8; 20], u32>), String> { - let mut public_key_result_map = BTreeMap::new(); - let mut public_key_hash_result_map = BTreeMap::new(); + ) -> Result<AuthKeyMaps, WalletError> { + let mut by_serialized = BTreeMap::new(); + let mut by_hash160 = BTreeMap::new(); + let mut misses = Vec::new(); for key_index in key_index_range { - let derivation_path = DerivationPath::identity_authentication_path( + let Some(public_key) = cache.get(network, identity_index, key_index) else { + misses.push(key_index); + continue; + }; + self.record_identity_auth_public_key( + &mut by_serialized, + &mut by_hash160, + app_context, + register_addresses, network, - KeyDerivationType::ECDSA, identity_index, key_index, - ); - let extended_public_key = derivation_path - .derive_pub_ecdsa_for_master_seed(self.seed_bytes()?, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + &public_key, + )?; + } + Ok(AuthKeyMaps { + by_serialized, + by_hash160, + misses, + }) + } - let public_key = extended_public_key.to_pub(); - public_key_result_map.insert( - extended_public_key.public_key.serialize().to_vec(), + /// Cold-fill the cache-miss key indices from a borrowed HD seed. + /// + /// Returns the two lookup maps for the *missing* indices plus the + /// freshly derived `(key_index, PublicKey)` pairs for cache write-back; + /// the caller merges the maps into its cache-hit maps. The seed is + /// supplied by the async caller's single `with_secret` scope and never + /// read from `self`. Address registration mirrors the cached path so + /// behaviour is identical regardless of cache warmth. + pub fn identity_authentication_ecdsa_public_keys_data_map_from_seed( + &mut self, + app_context: &AppContext, + register_addresses: bool, + seed: &[u8; 64], + network: Network, + identity_index: u32, + missing_key_indices: &[u32], + ) -> Result<DerivedAuthKeyMaps, WalletError> { + let mut by_serialized = BTreeMap::new(); + let mut by_hash160 = BTreeMap::new(); + let mut derived = Vec::with_capacity(missing_key_indices.len()); + for &key_index in missing_key_indices { + let public_key = self.identity_authentication_ecdsa_public_key_from_seed( + seed, + network, + identity_index, key_index, - ); - public_key_hash_result_map.insert(public_key.pubkey_hash().to_byte_array(), key_index); - if register_addresses { - self.register_address_from_public_key( - &public_key, - &derivation_path, - DerivationPathType::SINGLE_USER_AUTHENTICATION, - DerivationPathReference::BlockchainIdentities, - app_context, - )?; - } + )?; + self.record_identity_auth_public_key( + &mut by_serialized, + &mut by_hash160, + app_context, + register_addresses, + network, + identity_index, + key_index, + &public_key, + )?; + derived.push((key_index, public_key)); } - - Ok((public_key_result_map, public_key_hash_result_map)) + Ok(DerivedAuthKeyMaps { + by_serialized, + by_hash160, + derived, + }) } - pub fn identity_authentication_ecdsa_private_key( + /// Fold one identity-auth public key into the serialized-key and + /// hash160 lookup maps, registering its address when requested. + /// Shared by the cache-hit and cold-fill paths so both produce + /// byte-identical map entries. + #[allow(clippy::too_many_arguments)] + fn record_identity_auth_public_key( &mut self, + public_key_result_map: &mut BTreeMap<Vec<u8>, u32>, + public_key_hash_result_map: &mut BTreeMap<[u8; 20], u32>, app_context: &AppContext, + register_addresses: bool, network: Network, identity_index: u32, key_index: u32, - ) -> Result<(PrivateKey, DerivationPath), String> { - let derivation_path = DerivationPath::identity_authentication_path( - network, - KeyDerivationType::ECDSA, - identity_index, - key_index, - ); - tracing::debug!( - identity_index = identity_index, - key_index = key_index, - path = %derivation_path, - "Generated identity authentication ECDSA derivation path" - ); - let extended_public_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .expect("derivation should not be able to fail"); - - let private_key = extended_public_key.to_priv(); - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::SINGLE_USER_AUTHENTICATION, - DerivationPathReference::BlockchainIdentities, - app_context, - )?; - - Ok((private_key, derivation_path)) + public_key: &PublicKey, + ) -> Result<(), WalletError> { + public_key_result_map.insert(public_key.inner.serialize().to_vec(), key_index); + public_key_hash_result_map.insert(public_key.pubkey_hash().to_byte_array(), key_index); + if register_addresses { + let derivation_path = DerivationPath::identity_authentication_path( + network, + KeyDerivationType::ECDSA, + identity_index, + key_index, + ); + self.register_address_from_public_key( + public_key, + &derivation_path, + DerivationPathType::SINGLE_USER_AUTHENTICATION, + DerivationPathReference::BlockchainIdentities, + app_context, + )?; + } + Ok(()) } fn register_address_from_private_key( @@ -1130,7 +1191,7 @@ impl Wallet { path_type: DerivationPathType, path_reference: DerivationPathReference, app_context: &AppContext, - ) -> Result<(), String> { + ) -> Result<(), WalletError> { let secp = Secp256k1::new(); let address = Address::p2pkh(&private_key.public_key(&secp), app_context.network); self.register_address( @@ -1149,7 +1210,7 @@ impl Wallet { path_type: DerivationPathType, path_reference: DerivationPathReference, app_context: &AppContext, - ) -> Result<(), String> { + ) -> Result<(), WalletError> { let address = Address::p2pkh(public_key, app_context.network); self.register_address( address, @@ -1166,28 +1227,25 @@ impl Wallet { path_type: DerivationPathType, path_reference: DerivationPathReference, app_context: &AppContext, - ) -> Result<(), String> { - if !address.network().eq(&app_context.network) { - return Err(format!( - "address {} network {} does not match wallet network {}", + ) -> Result<(), WalletError> { + // `Address` no longer carries a full `Network` field; use + // `is_valid_for_network` on the unchecked view for the network guard. + if !address + .as_unchecked() + .is_valid_for_network(app_context.network) + { + return Err(WalletError::AddressNetworkMismatch { address, - address.network(), - app_context.network - )); + network: app_context.network, + }); } - app_context - .db - .add_address_if_not_exists( - &self.seed_hash(), - &address, - &app_context.network, - derivation_path, - path_reference, - path_type, - None, - ) - .map_err(|e| e.to_string())?; + // T-W-01: addresses are derived deterministically from the + // master xpub each time the wallet is loaded, so the legacy + // `wallet_addresses` write that used to live here is a dead + // write — no production read path consumes it. The in-memory + // `known_addresses` / `watched_addresses` maps below stay the + // single source of truth at runtime. self.known_addresses .insert(address.clone(), derivation_path.clone()); self.watched_addresses.insert( @@ -1199,13 +1257,9 @@ impl Wallet { }, ); - if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc { - app_context.try_import_address(&address, self.core_wallet_name.as_deref(), None); - } - tracing::trace!( address = ?&address, - network = &address.network().to_string(), + network = &app_context.network.to_string(), "registered new address" ); Ok(()) @@ -1215,8 +1269,8 @@ impl Wallet { &mut self, network: Network, app_context: &AppContext, - ) -> Result<(), String> { - let coin_type = Self::coin_type(network); + ) -> Result<(), WalletError> { + let coin_type = coin_type_for_network(network); let secp = Secp256k1::new(); for (change_flag, max) in [ (false, BOOTSTRAP_BIP44_EXTERNAL_COUNT), @@ -1231,10 +1285,9 @@ impl Wallet { ]; let derived = self .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &child_path) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + .derive_pub(&secp, &child_path)?; let dash_public_key = PublicKey::from_slice(&derived.public_key.serialize()) - .map_err(|e| e.to_string())?; + .map_err(|e| WalletError::PublicKeyParse(Box::new(e)))?; let derivation_path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, ChildNumber::Hardened { index: coin_type }, @@ -1258,19 +1311,18 @@ impl Wallet { fn bootstrap_bip32_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { for account in 0..BOOTSTRAP_BIP32_ACCOUNT_COUNT { for index in 0..BOOTSTRAP_BIP32_ADDRESS_COUNT { let derivation_path = DerivationPath::from(vec![ ChildNumber::Hardened { index: account }, ChildNumber::Normal { index }, ]); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1286,19 +1338,18 @@ impl Wallet { fn bootstrap_coinjoin_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { for account in 0..BOOTSTRAP_COINJOIN_ACCOUNT_COUNT { let base_path = DerivationPath::coinjoin_path(network, account); for index in 0..BOOTSTRAP_COINJOIN_ADDRESS_COUNT { let mut components = base_path.as_ref().to_vec(); components.push(ChildNumber::Normal { index }); let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1314,32 +1365,33 @@ impl Wallet { fn bootstrap_identity_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, - ) -> Result<(), String> { + ) -> Result<(), WalletError> { let registration_indices = self.identity_registration_indices(); self.bootstrap_identity_registration_addresses( + seed, network, app_context, &registration_indices, )?; - self.bootstrap_identity_invitation_addresses(network, app_context)?; - self.bootstrap_identity_topup_addresses(network, app_context, &registration_indices)?; + self.bootstrap_identity_invitation_addresses(seed, network, app_context)?; + self.bootstrap_identity_topup_addresses(seed, network, app_context, &registration_indices)?; Ok(()) } fn bootstrap_identity_registration_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, registration_indices: &BTreeSet<u32>, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { for &index in registration_indices { let derivation_path = DerivationPath::identity_registration_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1354,15 +1406,14 @@ impl Wallet { fn bootstrap_identity_invitation_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { for index in 0..BOOTSTRAP_IDENTITY_INVITATION_COUNT { let derivation_path = DerivationPath::identity_invitation_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1377,18 +1428,17 @@ impl Wallet { fn bootstrap_identity_topup_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, registration_indices: &BTreeSet<u32>, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { for &registration_index in registration_indices { for top_up_index in 0..BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION { let derivation_path = DerivationPath::identity_top_up_path(network, registration_index, top_up_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1399,7 +1449,7 @@ impl Wallet { )?; } } - self.bootstrap_identity_topup_not_bound_addresses(network, app_context, &seed) + self.bootstrap_identity_topup_not_bound_addresses(network, app_context, seed) } fn bootstrap_identity_topup_not_bound_addresses( @@ -1407,17 +1457,16 @@ impl Wallet { network: Network, app_context: &AppContext, seed: &[u8; 64], - ) -> Result<(), String> { + ) -> Result<(), WalletError> { let base_path = AccountType::IdentityTopUpNotBoundToIdentity .derivation_path(network) - .map_err(|e| e.to_string())?; + .map_err(|e| WalletError::AccountDerivationPath(Box::new(e)))?; for index in 0..BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT { let mut components = base_path.as_ref().to_vec(); components.push(ChildNumber::Normal { index }); let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1441,24 +1490,35 @@ impl Wallet { fn bootstrap_provider_addresses( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, - ) -> Result<(), String> { - self.bootstrap_provider_account(network, app_context, AccountType::ProviderVotingKeys)?; - self.bootstrap_provider_account(network, app_context, AccountType::ProviderOwnerKeys)?; + ) -> Result<(), WalletError> { + self.bootstrap_provider_account( + seed, + network, + app_context, + AccountType::ProviderVotingKeys, + )?; + self.bootstrap_provider_account( + seed, + network, + app_context, + AccountType::ProviderOwnerKeys, + )?; Ok(()) } fn bootstrap_provider_account( &mut self, + seed: &[u8; 64], network: Network, app_context: &AppContext, account_type: AccountType, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { let base_path = account_type .derivation_path(network) - .map_err(|e| e.to_string())?; + .map_err(|e| WalletError::AccountDerivationPath(Box::new(e)))?; let key_wallet_reference = account_type.derivation_path_reference(); let path_reference = DerivationPathReference::try_from(key_wallet_reference as u32) .unwrap_or(DerivationPathReference::Unknown); @@ -1468,9 +1528,8 @@ impl Wallet { index: provider_index, }); let derivation_path = DerivationPath::from(components); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, @@ -1487,10 +1546,9 @@ impl Wallet { /// These addresses are for receiving Dash Credits on Platform, independent of identities. fn bootstrap_platform_payment_addresses( &mut self, + seed: &[u8; 64], network: Network, - app_context: &AppContext, - ) -> Result<(), String> { - let seed = *self.seed_bytes()?; + ) -> Result<(), WalletError> { // Default account 0', default key_class 0' (as per DIP-17) let account = 0u32; let key_class = 0u32; @@ -1498,9 +1556,8 @@ impl Wallet { for index in 0..BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT { let derivation_path = DerivationPath::platform_payment_path(network, account, key_class, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); // Create a P2PKH address for platform payment @@ -1508,256 +1565,85 @@ impl Wallet { let public_key = private_key.public_key(&secp); let platform_address = Address::p2pkh(&public_key, network); - // Register the Platform address - self.register_platform_address( - platform_address, - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::PlatformPayment, - app_context, - )?; + let canonical = Wallet::canonical_address(&platform_address, network); + self.register_platform_payment_entry(canonical, derivation_path); } Ok(()) } - /// Register a Platform payment address (DIP-17/18). - /// Platform addresses use different version bytes and are NOT valid on Core chain. - fn register_platform_address( - &mut self, - address: Address, - derivation_path: &DerivationPath, - path_type: DerivationPathType, - path_reference: DerivationPathReference, - app_context: &AppContext, - ) -> Result<(), String> { - let canonical_address = Wallet::canonical_address(&address, app_context.network); - - // Store the address in known_addresses and watched_addresses - // Note: We don't import to Core wallet since Platform addresses are not valid there - app_context - .db - .add_address_if_not_exists( - &self.seed_hash(), - &canonical_address, - &app_context.network, - derivation_path, - path_reference, - path_type, - None, - ) - .map_err(|e| e.to_string())?; - - self.known_addresses - .insert(canonical_address.clone(), derivation_path.clone()); + /// Insert a platform-payment `address` at `path` into the in-memory address + /// maps exactly as the sync provider would (`CLEAR_FUNDS` / + /// `PlatformPayment`). `address` must already be canonical (see + /// [`Wallet::canonical_address`]). Platform payment addresses are not + /// imported to Core — their address format differs. Idempotent. + fn register_platform_payment_entry(&mut self, address: Address, path: DerivationPath) { + self.known_addresses.insert(address.clone(), path.clone()); self.watched_addresses.insert( - derivation_path.clone(), + path, AddressInfo { - address: canonical_address.clone(), - path_type, - path_reference, + address, + path_type: DerivationPathType::CLEAR_FUNDS, + path_reference: DerivationPathReference::PlatformPayment, }, ); - - tracing::trace!( - address = ?&address, - network = &app_context.network.to_string(), - "registered new Platform payment address" - ); - Ok(()) } - fn coin_type(network: Network) -> u32 { - match network { - Network::Mainnet => 5, - _ => 1, - } - } - - pub fn identity_top_up_ecdsa_private_key( - &mut self, - app_context: &AppContext, - network: Network, - identity_index: u32, - top_up_index: u32, - ) -> Result<PrivateKey, String> { - let derivation_path = - DerivationPath::identity_top_up_path(network, identity_index, top_up_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .expect("derivation should not be able to fail"); - let private_key = extended_private_key.to_priv(); - - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, - app_context, - )?; - Ok(private_key) - } - - /// Generate Core key for identity registration - pub fn identity_registration_ecdsa_private_key( - &mut self, - app_context: &AppContext, - network: Network, - index: u32, - ) -> Result<PrivateKey, String> { - let derivation_path = DerivationPath::identity_registration_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .expect("derivation should not be able to fail"); - let private_key = extended_private_key.to_priv(); - - self.register_address_from_private_key( - &private_key, - &derivation_path, - DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, - app_context, - )?; - Ok(private_key) - } - - pub fn receive_address( - &mut self, - network: Network, - skip_known_addresses_with_no_funds: bool, - register: Option<&AppContext>, - ) -> Result<Address, String> { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key( - network, - skip_known_addresses_with_no_funds, - false, - register, - )? - .0, - network, - )) - } - - // Allow dead_code: This method provides receive addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn receive_address_with_derivation_path( - &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, false, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) - } - - pub fn change_address( - &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result<Address, String> { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key(network, false, true, register)? - .0, - network, - )) - } - - // Allow dead_code: This method provides change addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn change_address_with_derivation_path( - &mut self, - network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, true, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) + /// Derive and register a *new* Platform payment address at the next unused + /// index from a `seed` borrowed by the caller (resolved through the JIT + /// chokepoint). + /// + /// Production callers reach this through + /// [`AppContext::generate_platform_receive_address`](crate::context::AppContext) + /// which opens the secret scope. The "return an existing address" shortcut + /// is the caller's responsibility (see `Wallet::platform_addresses`); this + /// is the unlock-required generation step. Same DIP-17 path, same + /// per-network derivation, same address as the retired parked-seed method. + /// The highest registered platform-payment (DIP-17) address index, or + /// `None` if none are registered. Shared by the receive-address generator + /// (`next = this + 1`) and the sync provider (which must cover every + /// registered index) so the handed-out address is always synced. + pub fn highest_platform_payment_index(&self, network: Network) -> Option<u32> { + self.watched_addresses + .keys() + .filter(|path| path.is_platform_payment(network)) + .filter_map(|path| { + path.into_iter().last().and_then(|child| match child { + ChildNumber::Normal { index } | ChildNumber::Hardened { index } => Some(*index), + _ => None, + }) + }) + .max() } - /// Generate a Platform receive address. - /// Either returns an existing Platform address or generates a new one. - pub fn platform_receive_address( + pub fn generate_platform_receive_address_with_seed( &mut self, + seed: &[u8; 64], network: Network, - skip_known_addresses: bool, - register: Option<&AppContext>, - ) -> Result<Address, String> { - // If not skipping known addresses, return first existing one - // This doesn't require the wallet to be unlocked - if !skip_known_addresses { - for (path, info) in &self.watched_addresses { - if path.is_platform_payment(network) { - return Ok(info.address.clone()); - } - } - } - - // Need to generate a new address - this requires the wallet to be unlocked - let seed = *self.seed_bytes()?; + ) -> Result<Address, WalletError> { let secp = Secp256k1::new(); let account = 0u32; let key_class = 0u32; - // Find the highest index in existing Platform payment addresses - let existing_indices: Vec<u32> = self - .watched_addresses - .iter() - .filter(|(path, _)| path.is_platform_payment(network)) - .filter_map(|(path, _)| { - // Extract the index from the path (last component) - path.into_iter().last().and_then(|child| match child { - ChildNumber::Normal { index } | ChildNumber::Hardened { index } => Some(*index), - _ => None, - }) - }) - .collect(); - - // Generate a new Platform address at the next index - let next_index = existing_indices.iter().max().map(|m| m + 1).unwrap_or(0); + // Generate a new Platform address at the next index. The sync provider + // covers every registered index (see `WalletAddressProvider::with_gap_limit`), + // so this handed-out address is always inside the synced window. + let next_index = self + .highest_platform_payment_index(network) + .map(|m| m + 1) + .unwrap_or(0); let derivation_path = DerivationPath::platform_payment_path(network, account, key_class, next_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let extended_private_key = + derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); let public_key = private_key.public_key(&secp); // Create a P2PKH address for platform payment let platform_address = Address::p2pkh(&public_key, network); - // Register the new address - if let Some(app_context) = register { - self.register_platform_address( - platform_address.clone(), - &derivation_path, - DerivationPathType::CLEAR_FUNDS, - DerivationPathReference::PlatformPayment, - app_context, - )?; - } else { - // Just update local state without persisting - self.known_addresses - .insert(platform_address.clone(), derivation_path.clone()); - self.watched_addresses.insert( - derivation_path, - AddressInfo { - address: platform_address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::PlatformPayment, - }, - ); - } + let canonical = Wallet::canonical_address(&platform_address, network); + self.register_platform_payment_entry(canonical, derivation_path); Ok(platform_address) } @@ -1767,7 +1653,7 @@ impl Wallet { network: Network, change: bool, address_index: u32, - ) -> Result<Address, String> { + ) -> Result<Address, WalletError> { let secp = Secp256k1::new(); let path_extension = [ ChildNumber::Normal { @@ -1779,396 +1665,11 @@ impl Wallet { ]; let public_key = self .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &path_extension) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())? + .derive_pub(&secp, &path_extension)? .to_pub(); Ok(Address::p2pkh(&public_key, network)) } - pub fn build_standard_payment_transaction( - &mut self, - app_context: &AppContext, - network: Network, - recipient: &Address, - amount: u64, - fee: u64, - subtract_fee_from_amount: bool, - ) -> Result<Transaction, String> { - if !networks_address_compatible(recipient.network(), &network) { - return Err(format!( - "Recipient address network ({}) does not match wallet network ({})", - recipient.network(), - network - )); - } - - // Select UTXOs without removing them yet — UTXOs are only removed after - // the transaction is fully built and signed, so that a failure at any later - // step cannot permanently drop UTXOs from the wallet. - let (utxos, change_option) = self - .select_unspent_utxos_for(amount, fee, subtract_fee_from_amount, None) - .ok_or_else(|| "Insufficient funds".to_string())?; - - let send_value = if change_option.is_none() && subtract_fee_from_amount { - let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - total_input - .checked_sub(fee) - .ok_or_else(|| "Fee exceeds available amount".to_string())? - } else { - amount - }; - - if send_value == 0 { - return Err("Amount is zero after subtracting fee".to_string()); - } - - let mut outputs = vec![TxOut { - value: send_value, - script_pubkey: recipient.script_pubkey(), - }]; - - if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - outputs.push(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }); - } - - let mut tx = Transaction { - version: 2, - lock_time: 0, - input: utxos - .keys() - .map(|outpoint| TxIn { - previous_output: *outpoint, - ..Default::default() - }) - .collect(), - output: outputs, - special_transaction_payload: None, - }; - - let sighash_flag = 1u32; - let cache = SighashCache::new(&tx); - let sighashes: Vec<_> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!("missing utxo for outpoint {:?}", input.previous_output) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_flag) - .map_err(|source| { - WalletError::Sighash { - input_index: i, - source, - } - .to_string() - }) - }) - .collect::<Result<Vec<_>, String>>()?; - - let secp = Secp256k1::new(); - let mut utxo_lookup = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - let (_, input_address) = - utxo_lookup.remove(&input.previous_output).ok_or_else(|| { - format!("utxo missing for outpoint {:?}", input.previous_output) - })?; - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; - let message = Message::from_digest(sighash.into()); - let sig = secp.sign_ecdsa(&message, &private_key.inner); - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(1); - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - input.script_sig = ScriptBuf::from_bytes(script_sig); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed; commit the UTXO removals now. - self.remove_selected_utxos(&utxos, &app_context.db, network)?; - - Ok(tx) - } - - /// Build a transaction with multiple recipients - pub fn build_multi_recipient_payment_transaction( - &mut self, - app_context: &AppContext, - network: Network, - recipients: &[(Address, u64)], - fee: u64, - subtract_fee_from_amount: bool, - ) -> Result<Transaction, String> { - if recipients.is_empty() { - return Err("No recipients specified".to_string()); - } - - // Validate all recipients are on the correct network - for (recipient, _) in recipients { - if !networks_address_compatible(recipient.network(), &network) { - return Err(format!( - "Recipient address network ({}) does not match wallet network ({})", - recipient.network(), - network - )); - } - } - - // Calculate total amount needed - let total_amount: u64 = recipients.iter().map(|(_, amount)| *amount).sum(); - - // Select UTXOs without removing them yet — UTXOs are only removed after - // the transaction is fully built and signed, so that a failure at any later - // step cannot permanently drop UTXOs from the wallet. - let (utxos, change_option) = self - .select_unspent_utxos_for(total_amount, fee, subtract_fee_from_amount, None) - .ok_or_else(|| "Insufficient funds".to_string())?; - - // Build outputs for each recipient - let mut outputs: Vec<TxOut> = if change_option.is_none() && subtract_fee_from_amount { - // If we're subtracting fee and using all funds, we need to reduce recipient amounts proportionally - let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - let available_after_fee = total_input - .checked_sub(fee) - .ok_or_else(|| "Fee exceeds available amount".to_string())?; - - // Distribute the reduction proportionally across recipients - let reduction_ratio = available_after_fee as f64 / total_amount as f64; - - recipients - .iter() - .map(|(recipient, amount)| { - let adjusted_amount = (*amount as f64 * reduction_ratio) as u64; - TxOut { - value: adjusted_amount, - script_pubkey: recipient.script_pubkey(), - } - }) - .collect() - } else { - recipients - .iter() - .map(|(recipient, amount)| TxOut { - value: *amount, - script_pubkey: recipient.script_pubkey(), - }) - .collect() - }; - - // Check that no output is zero - if outputs.iter().any(|o| o.value == 0) { - return Err("One or more amounts are zero after subtracting fee".to_string()); - } - - // Add change output if needed - if let Some(change) = change_option { - let change_address = self.change_address(network, Some(app_context))?; - outputs.push(TxOut { - value: change, - script_pubkey: change_address.script_pubkey(), - }); - } - - let mut tx = Transaction { - version: 2, - lock_time: 0, - input: utxos - .keys() - .map(|outpoint| TxIn { - previous_output: *outpoint, - ..Default::default() - }) - .collect(), - output: outputs, - special_transaction_payload: None, - }; - - let sighash_flag = 1u32; - let cache = SighashCache::new(&tx); - let sighashes: Vec<_> = tx - .input - .iter() - .enumerate() - .map(|(i, input)| { - let script_pubkey = utxos - .get(&input.previous_output) - .ok_or_else(|| { - format!("missing utxo for outpoint {:?}", input.previous_output) - })? - .0 - .script_pubkey - .clone(); - cache - .legacy_signature_hash(i, &script_pubkey, sighash_flag) - .map_err(|source| { - WalletError::Sighash { - input_index: i, - source, - } - .to_string() - }) - }) - .collect::<Result<Vec<_>, String>>()?; - - let secp = Secp256k1::new(); - let mut utxo_lookup = utxos.clone(); - - tx.input - .iter_mut() - .zip(sighashes.into_iter()) - .try_for_each(|(input, sighash)| { - let (_, input_address) = - utxo_lookup.remove(&input.previous_output).ok_or_else(|| { - format!("utxo missing for outpoint {:?}", input.previous_output) - })?; - let private_key = self - .private_key_for_address(&input_address, network)? - .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; - let message = Message::from_digest(sighash.into()); - let sig = secp.sign_ecdsa(&message, &private_key.inner); - let mut serialized_sig = sig.serialize_der().to_vec(); - let mut script_sig = vec![serialized_sig.len() as u8 + 1]; - script_sig.append(&mut serialized_sig); - script_sig.push(1); - let mut serialized_pub_key = private_key.public_key(&secp).serialize(); - script_sig.push(serialized_pub_key.len() as u8); - script_sig.append(&mut serialized_pub_key); - input.script_sig = ScriptBuf::from_bytes(script_sig); - Ok::<(), String>(()) - })?; - - // Transaction is fully built and signed; commit the UTXO removals now. - self.remove_selected_utxos(&utxos, &app_context.db, network)?; - - Ok(tx) - } - - pub fn update_address_balance( - &mut self, - address: &Address, - new_balance: Duffs, - context: &AppContext, - ) -> Result<(), String> { - // Check if the new balance differs from the current one. - if let Some(current_balance) = self.address_balances.get(address) - && *current_balance == new_balance - { - // If the balance hasn't changed, skip the update. - return Ok(()); - } - - // If there's no current balance or it has changed, update it. - self.address_balances.insert(address.clone(), new_balance); - - // Update the database with the new balance. - context - .db - .update_address_balance(&self.seed_hash(), address, new_balance) - .map_err(|e| e.to_string()) - } - - /// Recalculate and persist balances for all addresses affected by spent UTXOs. - /// - /// Call this after removing entries from `self.utxos` to keep `address_balances` - /// and the database in sync. - pub fn recalculate_affected_address_balances( - &mut self, - used_utxos: &BTreeMap<OutPoint, (TxOut, Address)>, - context: &AppContext, - ) -> Result<(), String> { - self.recalculate_affected_address_balances_with_db(used_utxos, &context.db) - } - - /// Core implementation: recalculate and persist balances for addresses affected - /// by spent UTXOs, using the database directly. - /// - /// Prefer [`Self::recalculate_affected_address_balances`] when an `AppContext` - /// is available. This variant is used by [`Self::remove_selected_utxos`] which - /// already receives `&Database` directly. - fn recalculate_affected_address_balances_with_db( - &mut self, - used_utxos: &BTreeMap<OutPoint, (TxOut, Address)>, - db: &Database, - ) -> Result<(), String> { - let seed_hash = self.seed_hash(); - let affected_addresses: BTreeSet<_> = - used_utxos.values().map(|(_, addr)| addr.clone()).collect(); - for address in affected_addresses { - let new_balance: u64 = self - .utxos - .get(&address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - if let Some(current) = self.address_balances.get(&address) - && *current == new_balance - { - continue; - } - self.address_balances.insert(address.clone(), new_balance); - db.update_address_balance(&seed_hash, &address, new_balance) - .map_err(|e| e.to_string())?; - } - Ok(()) - } - - /// Recalculate and persist the balance for a single address from its remaining UTXOs. - pub fn recalculate_address_balance( - &mut self, - address: &Address, - context: &AppContext, - ) -> Result<(), String> { - let new_balance = self - .utxos - .get(address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - self.update_address_balance(address, new_balance, context) - } - - pub fn update_address_total_received( - &mut self, - address: &Address, - total_received: Duffs, - context: &AppContext, - ) -> Result<(), String> { - // Check if the total received differs from the current value - if let Some(current_total) = self.address_total_received.get(address) - && *current_total == total_received - { - // If the total received hasn't changed, skip the update. - return Ok(()); - } - - // Update in memory - self.address_total_received - .insert(address.clone(), total_received); - - // Update the database - context - .db - .update_address_total_received(&self.seed_hash(), address, total_received) - .map_err(|e| e.to_string()) - } - /// Get all Platform payment addresses from this wallet pub fn platform_addresses(&self, network: Network) -> Vec<(Address, PlatformAddress)> { self.watched_addresses @@ -2254,131 +1755,202 @@ impl Wallet { .insert(address, PlatformAddressInfo { balance, nonce }); } - /// Get the private key for a Platform address - #[allow(clippy::result_large_err)] - pub fn get_platform_address_private_key( - &self, - platform_address: &PlatformAddress, - network: Network, - ) -> Result<PrivateKey, ProtocolError> { - // Find the derivation path by looking through watched_addresses - // and matching the PlatformAddress - let derivation_path = self - .watched_addresses - .iter() - .filter(|(path, _)| path.is_platform_payment(network)) - .find_map(|(path, info)| { - // Try to convert the stored address to a PlatformAddress and compare - PlatformAddress::try_from(info.address.clone()) - .ok() - .filter(|addr| addr == platform_address) - .map(|_| path.clone()) - }) - .ok_or_else(|| { - ProtocolError::Generic(format!( - "Platform address {:?} not found in wallet", - platform_address - )) - })?; - - // Get the seed bytes - let seed = *self.seed_bytes().map_err(ProtocolError::Generic)?; - - // Derive the private key - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&seed, network) - .map_err(|e| ProtocolError::Generic(e.to_string()))?; - - Ok(extended_private_key.to_priv()) + /// Seed Platform address info only when no entry exists for the canonical + /// address — the at-rest warm-start equivalent of `or_insert`, so a live + /// push (which overwrites via [`Self::set_platform_address_info`]) is never + /// clobbered by staler persisted data. + pub fn seed_platform_address_info( + &mut self, + address: Address, + balance: Credits, + nonce: AddressNonce, + ) { + if self.get_platform_address_info(&address).is_none() { + self.set_platform_address_info(address, balance, nonce); + } } -} -/// Signer implementation for Platform addresses -/// Allows the wallet to sign transactions that spend from Platform addresses -impl Signer<PlatformAddress> for Wallet { - fn sign( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result<BinaryData, ProtocolError> { - // Only P2PKH addresses are supported for now - if !platform_address.is_p2pkh() { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are currently supported for signing".to_string(), - )); + /// Backfill the cached DIP-17 platform-payment account xpub if it is + /// missing. A cheap no-op once cached. + /// + /// Wallets persisted before the cache existed load with `None`; the next + /// time their HD seed is borrowed through the JIT chokepoint, this + /// populates the cache so [`Self::reconcile_platform_address`] can + /// reverse-derive addresses without the seed. `seed` must be this wallet's + /// own 64-byte HD seed and `network` its network. Derivation from a valid + /// seed cannot fail in practice; a failure is logged and leaves the cache + /// empty, so reconciliation simply stays deferred. + pub fn ensure_platform_payment_account_xpub(&mut self, seed: &[u8; 64], network: Network) { + if self.platform_payment_account_xpub.is_some() { + return; } + match derive_platform_payment_account_xpub( + seed, + network, + PLATFORM_PAYMENT_ACCOUNT, + PLATFORM_PAYMENT_KEY_CLASS, + ) { + Ok(xpub) => self.platform_payment_account_xpub = Some(xpub), + Err(e) => tracing::warn!( + error = ?e, + "Could not derive the platform-payment account key; \ + platform-address reconciliation stays deferred until the next unlock." + ), + } + } - // The Signer trait doesn't pass network info, so we try each network. - // This is safe because: - // 1. A wallet instance only stores keys for ONE network (set at creation) - // 2. Platform addresses encode their network in the bech32m HRP (dash/tdash per DIP-18) - // 3. get_platform_address_private_key will only succeed for the correct network - // 4. Only one network's derivation will match the wallet's seed - let private_key = self - .get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| { - self.get_platform_address_private_key(platform_address, Network::Regtest) - })?; - - // Sign the data - let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + /// Register the derivation path for a platform-payment `address` so the JIT + /// signer can sign for it, reverse-deriving its DIP-17 index from the + /// cached account xpub — no HD seed required. + /// + /// The upstream coordinator reports discovered balances as raw P2PKH + /// hashes with no derivation index, so they populate `platform_address_info` + /// (the balance/withdraw UI) without ever registering `known_addresses` / + /// `watched_addresses` (the signer's view). That desync is exactly why a + /// withdrawal of a visible balance can fail with "address not found in + /// wallet". This closes the gap: it walks a bounded window of candidate + /// addresses derived from the account xpub and, on a match, registers the + /// address exactly as [`WalletAddressProvider::apply_results_to_wallet`] + /// does (`CLEAR_FUNDS` / `PlatformPayment`). + /// + /// Returns `true` when the address is (now) registered — already known, or + /// found and registered. Returns `false` when the account xpub is not yet + /// cached (an old wallet before its first seed borrow — logged at debug, + /// an expected transient state) or the bounded search did not match a + /// foreign address (logged at warn, a surfaced inconsistency). + pub fn reconcile_platform_address(&mut self, address: &Address, network: Network) -> bool { + let canonical = Wallet::canonical_address(address, network); + if self.known_addresses.contains_key(&canonical) { + return true; + } + + let Some(account_xpub) = self.platform_payment_account_xpub else { + tracing::debug!( + address = %canonical, + "Platform-payment account key not cached yet; deferring address reconciliation until the next unlock." + ); + return false; + }; - Ok(BinaryData::new(signature.to_vec())) - } + // Cover every already-registered index plus a generous margin, floored + // at the default gap limit, so the search always reaches a realistic + // hand-out depth while staying bounded for a would-be-foreign address. + let ceiling = self + .highest_platform_payment_index(network) + .unwrap_or(0) + .max(DEFAULT_GAP_LIMIT) + .saturating_add(PLATFORM_RECONCILE_SCAN_MARGIN); - fn sign_create_witness( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result<AddressWitness, ProtocolError> { - // Only P2PKH addresses are supported for now - if !platform_address.is_p2pkh() { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are currently supported for signing".to_string(), - )); + let secp = Secp256k1::new(); + for index in 0..=ceiling { + let child = match account_xpub.derive_pub(&secp, &[ChildNumber::Normal { index }]) { + Ok(child) => child, + Err(e) => { + tracing::warn!( + index, + error = ?e, + "Could not derive a platform-payment candidate while reconciling an address." + ); + return false; + } + }; + let candidate = Address::p2pkh(&child.to_pub(), network); + if Wallet::canonical_address(&candidate, network) == canonical { + let path = DerivationPath::platform_payment_path( + network, + PLATFORM_PAYMENT_ACCOUNT, + PLATFORM_PAYMENT_KEY_CLASS, + index, + ); + self.register_platform_payment_entry(canonical, path); + return true; + } } - // The Signer trait doesn't pass network info, so we try each network. - // This is safe - see comment in sign() above for explanation. - let private_key = self - .get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| { - self.get_platform_address_private_key(platform_address, Network::Regtest) - })?; - - // Sign the data - produces a compact recoverable signature - // The public key will be recovered from the signature during verification - let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; - - Ok(AddressWitness::P2pkh { - signature: BinaryData::new(signature.to_vec()), - }) + tracing::warn!( + address = %canonical, + ceiling, + "Platform-payment address not found within the reverse-derivation window; it cannot be signed for. It may belong to a different wallet or lie beyond the searched index range." + ); + false } - fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { - // Only P2PKH addresses are supported - if !platform_address.is_p2pkh() { - return false; - } - - // Check if we have the private key for this address - self.get_platform_address_private_key(platform_address, Network::Mainnet) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) - .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Regtest)) - .is_ok() + /// Register a platform-payment `address` for signing at its exact DIP-17 + /// coordinates, with no reverse-derivation scan. + /// + /// The coordinator push now carries the `(account, index)` the address was + /// derived at, so its derivation path is known exactly — unlike + /// [`Self::reconcile_platform_address`], which reverse-derives within a + /// bounded window and fails past its ceiling. Inserts `known_addresses` / + /// `watched_addresses` exactly as the sync provider would + /// (`CLEAR_FUNDS` / `PlatformPayment`). Idempotent. + pub fn register_platform_payment_address( + &mut self, + address: &Address, + account: u32, + index: AddressIndex, + network: Network, + ) { + let canonical = Wallet::canonical_address(address, network); + let path = DerivationPath::platform_payment_path( + network, + account, + PLATFORM_PAYMENT_KEY_CLASS, + index, + ); + self.register_platform_payment_entry(canonical, path); } } /// Default gap limit for HD wallet address scanning const DEFAULT_GAP_LIMIT: AddressIndex = 20; +/// DIP-17 account index for platform-payment addresses. DET uses a single +/// platform-payment account; shared by the sync provider, the eager cache in +/// [`Wallet::new_from_seed`], and reverse-lookup reconciliation. +const PLATFORM_PAYMENT_ACCOUNT: u32 = 0; + +/// DIP-17 key class for platform-payment addresses. +const PLATFORM_PAYMENT_KEY_CLASS: u32 = 0; + +/// How far past the highest registered platform-payment index +/// [`Wallet::reconcile_platform_address`] reverse-derives while searching for +/// an address's index. A coordinator push only ever reports OWNED addresses, +/// so a match normally lands within the first handful of indices; this +/// generous ceiling (25× the default gap limit) covers any realistic hand-out +/// depth while bounding the search for a would-be-foreign address so it can +/// never spin unbounded. +const PLATFORM_RECONCILE_SCAN_MARGIN: AddressIndex = 500; + +/// Derive the DIP-17 platform-payment account-level extended **public** key at +/// `m/9'/coin_type'/17'/account'/key_class'` from a borrowed HD seed. +/// +/// The hardened account / key-class steps require the private key, so the seed +/// is needed here once; the resulting xpub then derives every non-hardened +/// `index` child publicly (used for both balance sync and reverse-lookup). +/// Single source of truth shared by [`Wallet::new_from_seed`], +/// [`Wallet::ensure_platform_payment_account_xpub`], and +/// [`WalletAddressProvider`], so sync and reconciliation always agree on the key. +pub(crate) fn derive_platform_payment_account_xpub( + seed: &[u8; 64], + network: Network, + account: u32, + key_class: u32, +) -> Result<ExtendedPubKey, dash_sdk::dpp::key_wallet::bip32::Error> { + let coin_type = coin_type_for_network(network); + let account_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 17 }, + ChildNumber::Hardened { index: account }, + ChildNumber::Hardened { index: key_class }, + ]); + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, seed)?; + let account_priv = master.derive_priv(&secp, &account_path)?; + Ok(ExtendedPubKey::from_priv(&secp, &account_priv)) +} + /// Provider for wallet Platform addresses that implements AddressProvider for SDK address sync. /// /// This struct tracks the state needed for the SDK's privacy-preserving address balance @@ -2387,7 +1959,8 @@ const DEFAULT_GAP_LIMIT: AddressIndex = 20; /// /// # Usage /// ```ignore -/// let mut provider = WalletAddressProvider::new(&wallet, network)?; +/// // `seed` is borrowed from inside a JIT `with_secret(_session)` scope. +/// let mut provider = WalletAddressProvider::new(&wallet, network, seed)?; /// let result = sdk.sync_address_balances(&mut provider, None, None).await?; /// provider.apply_results_to_wallet(&mut wallet); /// ``` @@ -2396,65 +1969,79 @@ pub struct WalletAddressProvider { network: Network, /// Gap limit for HD wallet scanning gap_limit: AddressIndex, - /// Seed bytes for deriving new addresses (64 bytes) - seed: [u8; 64], + /// DIP-17 account-level extended **public** key at + /// `m/9'/coin_type'/17'/account'/key_class'`. All gap-limit children are + /// the non-hardened final `index`, so addresses derive from this public + /// key alone — the provider never holds the plaintext seed. The seed is + /// borrowed once at construction (through the JIT chokepoint) to derive + /// this xpub, then dropped. + account_xpub: ExtendedPubKey, /// Account index for Platform payment addresses (default 0) account: u32, /// Key class for Platform payment addresses (default 0) key_class: u32, - /// Map of index to (AddressKey, CoreAddress) for pending addresses - pending: BTreeMap<AddressIndex, (AddressKey, Address)>, + /// Map of index to (PlatformAddress, CoreAddress) for pending addresses + pending: BTreeMap<AddressIndex, (PlatformAddress, Address)>, /// Set of indices that have been resolved (found or absent) resolved: BTreeSet<AddressIndex>, /// Highest index found with a non-zero balance highest_found: Option<AddressIndex>, /// Results: address -> balance for addresses found with balance found_balances: BTreeMap<Address, AddressFunds>, - /// Known balances from previous sync for incremental catch-up - stored_balances: Vec<(AddressIndex, AddressKey, AddressFunds)>, - /// Last sync height from previous sync for incremental catch-up - stored_sync_height: u64, } impl WalletAddressProvider { - /// Create a new WalletAddressProvider from a wallet. + /// Create a new WalletAddressProvider from a borrowed HD seed. /// - /// This initializes the provider with Platform payment addresses up to the gap limit. - /// The wallet must be open (unlocked) to access the seed for address derivation. + /// The `seed` is resolved by the async caller through the JIT secret + /// chokepoint and borrowed only for this construction — it is used once to + /// derive the DIP-17 account-level extended public key and is never copied + /// into the provider. All subsequent address derivation is public-key only. /// /// # Errors - /// Returns an error if the wallet is closed/locked. - pub fn new(wallet: &Wallet, network: Network) -> Result<Self, String> { - Self::with_gap_limit(wallet, network, DEFAULT_GAP_LIMIT) + /// Returns an error if the account-level xpub cannot be derived. + pub fn new(wallet: &Wallet, network: Network, seed: &[u8; 64]) -> Result<Self, WalletError> { + Self::with_gap_limit(wallet, network, DEFAULT_GAP_LIMIT, seed) } - /// Create a new WalletAddressProvider with a custom gap limit. + /// Create a new WalletAddressProvider with a custom gap limit from a + /// borrowed HD seed. See [`new`](Self::new) for the seed-borrow contract. /// /// # Errors - /// Returns an error if the wallet is closed/locked. + /// Returns an error if the account-level xpub cannot be derived. pub fn with_gap_limit( wallet: &Wallet, network: Network, gap_limit: AddressIndex, - ) -> Result<Self, String> { - let seed = *wallet.seed_bytes()?; + seed: &[u8; 64], + ) -> Result<Self, WalletError> { + let account = PLATFORM_PAYMENT_ACCOUNT; + let key_class = PLATFORM_PAYMENT_KEY_CLASS; + let account_xpub = derive_platform_payment_account_xpub(seed, network, account, key_class)?; let mut provider = Self { network, gap_limit, - seed, - account: 0, - key_class: 0, + account_xpub, + account, + key_class, pending: BTreeMap::new(), resolved: BTreeSet::new(), highest_found: None, found_balances: BTreeMap::new(), - stored_balances: Vec::new(), - stored_sync_height: 0, }; - // Bootstrap initial addresses (0 to gap_limit - 1) - provider.ensure_addresses_up_to(gap_limit.saturating_sub(1))?; + // Cover at least the bootstrap window (0..gap_limit-1) AND every + // platform-payment index DET has already handed out. The generator + // derives `max(registered)+1` unbounded, so a registered index can + // exceed the gap window; syncing only the window would leave such an + // address unsynced and its credits invisible. The provider is + // the sync window's single source of truth, so it follows derivation. + let highest_registered = wallet.highest_platform_payment_index(network); + let max_index = highest_registered + .map(|i| i.max(gap_limit.saturating_sub(1))) + .unwrap_or(gap_limit.saturating_sub(1)); + provider.ensure_addresses_up_to(max_index)?; Ok(provider) } @@ -2471,29 +2058,6 @@ impl WalletAddressProvider { &self.found_balances } - /// Get the found balances with their indices after sync is complete. - /// - /// Returns an iterator of (index, (&Address, &balance)) for addresses that were found with balance. - /// The index can be used to reconstruct the derivation path. - pub fn found_balances_with_indices( - &self, - ) -> impl Iterator<Item = (AddressIndex, (&Address, &AddressFunds))> { - // Build a reverse lookup from address to index - let address_to_index: BTreeMap<&Address, AddressIndex> = self - .pending - .iter() - .map(|(idx, (_, addr))| (addr, *idx)) - .collect(); - - self.found_balances - .iter() - .filter_map(move |(addr, balance)| { - address_to_index - .get(addr) - .map(|&idx| (idx, (addr, balance))) - }) - } - /// Update a balance for an address (used for terminal balance updates). /// /// This allows applying balance changes discovered after the initial sync. @@ -2506,8 +2070,17 @@ impl WalletAddressProvider { .map(|funds| funds.nonce) .unwrap_or(0); - self.found_balances - .insert(canonical_address, AddressFunds { nonce, balance }); + self.found_balances.insert( + canonical_address, + // No block-height provenance at this manual-update call site; + // `0` marks the balance unknown-provenance (legacy pin), so any + // later height-pinned delta still applies. + AddressFunds { + nonce, + balance, + as_of_height: 0, + }, + ); } /// Apply the sync results to a wallet, updating Platform address info. @@ -2524,8 +2097,10 @@ impl WalletAddressProvider { .map(|(idx, (_, addr))| (Wallet::canonical_address(addr, self.network), *idx)) .collect(); + let mut total_balance: u64 = 0; for (address, funds) in &self.found_balances { let canonical_address = Wallet::canonical_address(address, self.network); + total_balance = total_balance.saturating_add(funds.balance); // Update wallet with synced balances wallet.set_platform_address_info(canonical_address.clone(), funds.balance, funds.nonce); @@ -2540,91 +2115,47 @@ impl WalletAddressProvider { self.key_class, index, ); - - wallet - .known_addresses - .insert(canonical_address.clone(), derivation_path.clone()); - - wallet.watched_addresses.insert( - derivation_path, - AddressInfo { - address: canonical_address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::PlatformPayment, - }, - ); + wallet.register_platform_payment_entry(canonical_address, derivation_path); } } - } - /// Populate stored balances and sync height from a wallet's known state. - /// - /// Call this after construction to enable incremental catch-up. - /// The SDK uses `current_balances()` as the baseline and `last_sync_height()` - /// as the starting block for applying delta operations. - pub fn with_stored_state( - mut self, - wallet: &Wallet, - network: Network, - last_sync_height: u64, - ) -> Self { - self.stored_sync_height = last_sync_height; - - // Populate stored_balances from wallet's known platform addresses - for (core_addr, info) in &wallet.platform_address_info { - // Find the matching pending address to get the index and key - for (index, (key, pending_addr)) in &self.pending { - let canonical = Wallet::canonical_address(pending_addr, network); - if &canonical == core_addr { - self.stored_balances.push(( - *index, - key.clone(), - AddressFunds { - balance: info.balance, - nonce: info.nonce, - }, - )); - break; - } - } - } - - self + tracing::info!( + addresses_with_balance = self.found_balances.len(), + total_balance, + network = %self.network, + "Applied platform-address sync results to wallet" + ); } - /// Derive a Platform address at the given index. + /// Derive a Platform address at the given index from the account-level + /// **public** key — no seed access. + /// + /// The DIP-17 final `index` is a non-hardened child, so deriving it from + /// the account xpub yields the same public key (and therefore the same + /// P2PKH address) the legacy seed-based derivation produced. Parity is + /// asserted by the `xpub_derivation_matches_seed_derivation` test. fn derive_address_at_index( &self, index: AddressIndex, - ) -> Result<(AddressKey, Address), String> { - let derivation_path = DerivationPath::platform_payment_path( - self.network, - self.account, - self.key_class, - index, - ); - - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(&self.seed, self.network) - .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; - + ) -> Result<(PlatformAddress, Address), WalletError> { let secp = Secp256k1::new(); - let private_key = extended_private_key.to_priv(); - let public_key = private_key.public_key(&secp); + let child = self + .account_xpub + .derive_pub(&secp, &[ChildNumber::Normal { index }])?; + let public_key = child.to_pub(); // Create P2PKH address let address = Address::p2pkh(&public_key, self.network); - // Convert to PlatformAddress to get the key + // Convert to PlatformAddress (the SDK address-sync key type) let platform_addr = PlatformAddress::try_from(address.clone()) - .map_err(|e| format!("Failed to convert to PlatformAddress: {}", e))?; - let key = platform_addr.to_bytes(); + .map_err(|e| WalletError::PlatformAddressConversion(Box::new(e)))?; - Ok((key, address)) + Ok((platform_addr, address)) } /// Ensure we have addresses derived up to and including the given index. - fn ensure_addresses_up_to(&mut self, max_index: AddressIndex) -> Result<(), String> { + fn ensure_addresses_up_to(&mut self, max_index: AddressIndex) -> Result<(), WalletError> { let current_max = self.pending.keys().max().copied(); let start = current_max.map(|m| m + 1).unwrap_or(0); @@ -2639,7 +2170,7 @@ impl WalletAddressProvider { } /// Extend pending addresses based on gap limit after finding an address. - fn extend_for_gap_limit(&mut self, found_index: AddressIndex) -> Result<(), String> { + fn extend_for_gap_limit(&mut self, found_index: AddressIndex) -> Result<(), WalletError> { let new_end = found_index.saturating_add(self.gap_limit); self.ensure_addresses_up_to(new_end) } @@ -2647,46 +2178,40 @@ impl WalletAddressProvider { #[async_trait] impl AddressProvider for WalletAddressProvider { + type Tag = AddressIndex; + type Address = PlatformAddress; + fn gap_limit(&self) -> AddressIndex { self.gap_limit } - fn pending_addresses(&self) -> Vec<(AddressIndex, AddressKey)> { + fn pending_addresses(&self) -> impl Iterator<Item = (AddressIndex, PlatformAddress)> + '_ { self.pending .iter() .filter(|(index, _)| !self.resolved.contains(index)) - .map(|(index, (key, _))| (*index, key.clone())) - .collect() + .map(|(index, (platform_addr, _))| (*index, *platform_addr)) } - async fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], funds: AddressFunds) { + async fn on_address_found( + &mut self, + index: AddressIndex, + _address: &PlatformAddress, + funds: AddressFunds, + ) { self.resolved.insert(index); - // Log what the SDK is returning - if let Some((_, core_address)) = self.pending.get(&index) { - // Also show Platform address format for comparison - let platform_addr_str = PlatformAddress::try_from(core_address.clone()) - .map(|p| p.to_bech32m_string(self.network)) - .unwrap_or_else(|_| "conversion failed".to_string()); - tracing::info!( - "on_address_found: index={}, core_address={}, platform_address={}, balance={}, nonce={}", - index, - core_address, - platform_addr_str, - funds.balance, - funds.nonce - ); - } else { - tracing::warn!( - "on_address_found: index={} not in pending! balance={}", - index, - funds.balance - ); - } - - if let Some((_, core_address)) = self.pending.get(&index) { - let canonical_address = Wallet::canonical_address(core_address, self.network); - self.found_balances.insert(canonical_address, funds); + match self.pending.get(&index) { + Some((_, core_address)) => { + let canonical_address = Wallet::canonical_address(core_address, self.network); + self.found_balances.insert(canonical_address, funds); + } + None => { + tracing::warn!( + index, + balance = funds.balance, + "Address sync reported a balance for an index the provider never handed out." + ); + } } if funds.balance > 0 { @@ -2695,12 +2220,12 @@ impl AddressProvider for WalletAddressProvider { // Extend the address range based on gap limit if let Err(e) = self.extend_for_gap_limit(index) { - tracing::warn!("Failed to extend addresses for gap limit: {}", e); + tracing::warn!(index, error = %e, "Could not extend the address-scan window after a balance was found."); } } } - async fn on_address_absent(&mut self, index: AddressIndex, _key: &[u8]) { + async fn on_address_absent(&mut self, index: AddressIndex, _address: &PlatformAddress) { self.resolved.insert(index); } @@ -2710,16 +2235,15 @@ impl AddressProvider for WalletAddressProvider { .any(|index| !self.resolved.contains(index)) } - fn highest_found_index(&self) -> Option<AddressIndex> { - self.highest_found - } - - fn current_balances(&self) -> Vec<(AddressIndex, AddressKey, AddressFunds)> { - self.stored_balances.clone() + fn current_balances( + &self, + ) -> impl Iterator<Item = (AddressIndex, PlatformAddress, AddressFunds)> + '_ { + // DET carries no incremental baseline — every refresh is a full scan. + std::iter::empty() } fn last_sync_height(&self) -> u64 { - self.stored_sync_height + 0 } } @@ -2729,10 +2253,24 @@ mod tests { use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; + /// The deterministic 64-byte seed every [`test_wallet`] is built from. + /// + /// Since R3 the seed is no longer parked in `WalletSeed::Open`, so tests + /// that need the wallet's raw seed take it from here rather than from a + /// (removed) parked-seed accessor — the value is known by construction. + const TEST_SEED: [u8; 64] = [42u8; 64]; + + /// The known seed behind [`test_wallet`]. Test-only replacement for the + /// removed `Wallet::seed_bytes()` — derives nothing, just hands back the + /// constant the wallet was built from. + fn test_seed() -> [u8; 64] { + TEST_SEED + } + /// Helper: create a minimal open wallet for testing. /// Uses a deterministic 64-byte seed and derives the BIP44 master public key. fn test_wallet() -> Wallet { - let seed = [42u8; 64]; + let seed = TEST_SEED; let network = Network::Testnet; let secp = Secp256k1::new(); @@ -2756,7 +2294,6 @@ mod tests { Wallet { wallet_seed: WalletSeed::Open(OpenWalletSeed { - seed, wallet_info: ClosedKeyItem { seed_hash, encrypted_seed: seed.to_vec(), @@ -2767,20 +2304,15 @@ mod tests { }), uses_password: false, master_bip44_ecdsa_extended_public_key, - address_balances: BTreeMap::new(), - address_total_received: BTreeMap::new(), + // `None` here deliberately models a wallet persisted before the + // platform-payment xpub cache existed — the backward-compat state + // reconciliation tests backfill. + platform_payment_account_xpub: None, known_addresses: BTreeMap::new(), watched_addresses: BTreeMap::new(), - unused_asset_locks: Vec::new(), alias: Some("Test Wallet".to_string()), identities: HashMap::new(), - utxos: HashMap::new(), - transactions: Vec::new(), is_main: true, - confirmed_balance: 0, - unconfirmed_balance: 0, - total_balance: 0, - spv_balance_known: false, platform_address_info: BTreeMap::new(), core_wallet_name: None, } @@ -2798,312 +2330,67 @@ mod tests { Address::p2pkh(&pubkey, Network::Testnet) } - /// Helper: create an OutPoint with a deterministic txid - fn test_outpoint(tx_index: u8, vout: u32) -> OutPoint { - let mut txid_bytes = [0u8; 32]; - txid_bytes[0] = tx_index; - OutPoint::new(Txid::from_slice(&txid_bytes).unwrap(), vout) - } - - /// Helper: create a test wallet pre-loaded with a single UTXO of the given value. - fn test_wallet_with_utxo(value: u64) -> Wallet { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, value); - wallet - } - - /// Helper: add a UTXO to a wallet - fn add_utxo(wallet: &mut Wallet, address: &Address, tx_index: u8, vout: u32, value: u64) { - let outpoint = test_outpoint(tx_index, vout); - let tx_out = TxOut { - value, - script_pubkey: address.script_pubkey(), - }; - wallet - .utxos - .entry(address.clone()) - .or_default() - .insert(outpoint, tx_out); - } - - // ======================================================================== - // Balance calculation tests - // ======================================================================== - - #[test] - fn test_max_balance_empty_wallet() { - let wallet = test_wallet(); - assert_eq!(wallet.max_balance(), 0); - } - - #[test] - fn test_max_balance_with_utxos() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - - add_utxo(&mut wallet, &addr1, 1, 0, 50_000); - add_utxo(&mut wallet, &addr1, 2, 0, 30_000); - add_utxo(&mut wallet, &addr2, 3, 0, 20_000); - - assert_eq!(wallet.max_balance(), 100_000); - } - - #[test] - fn test_confirmed_balance_uses_spv_when_set() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - // With SPV balances set, confirmed_balance should return the SPV value - wallet.update_spv_balances(75_000, 5_000, 80_000); - assert_eq!(wallet.confirmed_balance_duffs(), 75_000); - } - - #[test] - fn test_confirmed_balance_falls_back_to_max_balance() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - // Without SPV balances, falls back to max_balance() - assert_eq!(wallet.confirmed_balance_duffs(), 50_000); - } - - #[test] - fn test_unconfirmed_balance() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(100_000, 25_000, 125_000); - assert_eq!(wallet.unconfirmed_balance_duffs(), 25_000); - } - - #[test] - fn test_total_balance_uses_spv_when_set() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - wallet.update_spv_balances(75_000, 5_000, 80_000); - assert_eq!(wallet.total_balance_duffs(), 80_000); - } - - #[test] - fn test_total_balance_falls_back_to_max_balance() { - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - - assert_eq!(wallet.total_balance_duffs(), 50_000); - } - - #[test] - fn test_has_balance() { - let mut wallet = test_wallet(); - assert!(!wallet.has_balance()); - - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 50_000); - // has_balance checks confirmed_balance_duffs() > 0 || unconfirmed > 0 - // Without SPV, confirmed falls back to max_balance = 50_000 - assert!(wallet.has_balance()); - } - - #[test] - fn test_has_balance_with_only_unconfirmed() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(0, 1000, 1000); - assert!(wallet.has_balance()); - } - - #[test] - fn test_update_spv_balances() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(100, 50, 150); - assert_eq!(wallet.confirmed_balance, 100); - assert_eq!(wallet.unconfirmed_balance, 50); - assert_eq!(wallet.total_balance, 150); - } - - #[test] - fn test_spv_confirmed_balance_none_before_sync() { - let wallet = test_wallet(); - // Before any SPV sync, spv_confirmed_balance must return None regardless - // of the UTXO state — callers cannot distinguish synced-zero from unsynced. - assert_eq!(wallet.spv_confirmed_balance(), None); - } - - #[test] - fn test_spv_confirmed_balance_zero_after_sync() { - let mut wallet = test_wallet(); - // After SPV reports zero balance, Some(0) must be returned — not None. - wallet.update_spv_balances(0, 0, 0); - assert_eq!(wallet.spv_confirmed_balance(), Some(0)); - } - - #[test] - fn test_spv_confirmed_balance_nonzero_after_sync() { - let mut wallet = test_wallet(); - wallet.update_spv_balances(75_000, 5_000, 80_000); - assert_eq!(wallet.spv_confirmed_balance(), Some(75_000)); - } - // ======================================================================== - // select_unspent_utxos_for / remove_selected_utxos tests + // WalletSeed::open_no_password guard // ======================================================================== + /// `open_no_password` must REFUSE a password-protected envelope. There is + /// no `uses_password` flag on `ClosedKeyItem`, so the guard keys on the + /// stored-blob length: an unprotected envelope stores the raw 64-byte seed + /// verbatim, whereas a password-protected blob is AES-256-GCM ciphertext + /// (64-byte plaintext + 16-byte tag = 80 bytes). Opening a protected + /// wallet with no passphrase would silently treat the ciphertext as a seed + /// — this pins the rejection. #[test] - fn test_select_utxos_exact_amount() { - let wallet = test_wallet_with_utxo(100_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert_eq!(utxos.len(), 1); - assert!(change.is_none()); // exact amount, no change - // Selection is non-mutating — wallet UTXOs unchanged - assert!(!wallet.utxos.is_empty()); - } - - #[test] - fn test_select_utxos_with_change() { - let wallet = test_wallet_with_utxo(200_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert_eq!(utxos.len(), 1); - assert_eq!(change, Some(100_000)); // 200k - 90k - 10k = 100k change - } - - #[test] - fn test_select_utxos_insufficient_funds() { - let wallet = test_wallet_with_utxo(50_000); - - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); - assert!(result.is_none()); - } - - #[test] - fn test_select_utxos_multiple_utxos_needed() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - add_utxo(&mut wallet, &addr1, 1, 0, 30_000); - add_utxo(&mut wallet, &addr2, 2, 0, 40_000); - add_utxo(&mut wallet, &addr1, 3, 0, 50_000); - - let result = wallet.select_unspent_utxos_for(100_000, 10_000, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - let total_collected: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); - assert!(total_collected >= 110_000); - if let Some(change_amount) = change { - assert_eq!(total_collected, 100_000 + 10_000 + change_amount); - } - } - - #[test] - fn test_select_utxos_allow_take_fee_from_amount() { - let wallet = test_wallet_with_utxo(100_000); - - // Request 100k amount + 10k fee = 110k total, but only 100k available - // With allow_take_fee_from_amount=true, should still succeed since total >= amount - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); - assert!(result.is_some()); - let (_utxos, change) = result.unwrap(); - assert!(change.is_none()); - } - - #[test] - fn test_select_utxos_allow_take_fee_but_not_enough_for_amount() { - let wallet = test_wallet_with_utxo(50_000); - - // Request 100k amount + 10k fee = 110k, only 50k available - // Even with take_fee_from_amount, 50k < 100k amount, so should fail - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); - assert!(result.is_none()); - } - - #[test] - fn test_select_utxos_zero_amount() { - let wallet = test_wallet_with_utxo(50_000); - - let result = wallet.select_unspent_utxos_for(0, 0, false, None); - assert!(result.is_some()); - let (utxos, change) = result.unwrap(); - assert!(utxos.is_empty()); - assert!(change.is_none()); - } - - /// Helper: register a wallet address in the test database so that - /// `update_address_balance` can find the row. - /// Caller must store the wallet first via `db.store_wallet()`. - fn register_test_address(db: &Database, wallet: &Wallet, address: &Address) { - let seed_hash = wallet.seed_hash(); - let path = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - db.add_address_if_not_exists( - &seed_hash, - address, - &Network::Testnet, - &path, - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - Some(0), - ) - .expect("register test address"); - } - - #[test] - fn test_remove_utxos_removes_from_wallet() { - use crate::database::test_helpers::create_test_database; - - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 100_000); - add_utxo(&mut wallet, &addr, 2, 0, 200_000); - assert_eq!(wallet.max_balance(), 300_000); - - let db = create_test_database().expect("test db"); - db.store_wallet(&wallet, &Network::Testnet) - .expect("store test wallet"); - register_test_address(&db, &wallet, &addr); - let (selected, _) = wallet - .select_unspent_utxos_for(90_000, 10_000, false, None) - .unwrap(); - wallet - .remove_selected_utxos(&selected, &db, Network::Testnet) - .unwrap(); + fn open_no_password_rejects_protected_envelope() { + let seed = [0x42u8; 64]; + let envelope = ClosedKeyItem::encrypt_seed(&seed, "a-passphrase").expect("encrypt"); + // Precondition: a protected blob is longer than a bare 64-byte seed. + assert_ne!( + envelope.ciphertext.len(), + 64, + "protected ciphertext must not be exactly 64 bytes" + ); - assert!(wallet.max_balance() < 300_000); + let mut wallet_seed = WalletSeed::Closed(ClosedKeyItem { + seed_hash: ClosedKeyItem::compute_seed_hash(&seed), + encrypted_seed: envelope.ciphertext, + salt: envelope.salt, + nonce: envelope.nonce, + password_hint: None, + }); + + let result = wallet_seed.open_no_password(); + assert!( + result.is_err(), + "open_no_password must reject a password-protected envelope" + ); + assert!( + matches!(wallet_seed, WalletSeed::Closed(_)), + "the wallet must stay Closed when open_no_password is refused" + ); } + /// The matching accept case: an unprotected envelope stores the raw + /// 64-byte seed verbatim, so `open_no_password` flips it to `Open`. #[test] - fn test_remove_utxos_cleans_empty_address_entries() { - use crate::database::test_helpers::create_test_database; - - let mut wallet = test_wallet(); - let addr = test_address(1); - add_utxo(&mut wallet, &addr, 1, 0, 100_000); - - let db = create_test_database().expect("test db"); - db.store_wallet(&wallet, &Network::Testnet) - .expect("store test wallet"); - register_test_address(&db, &wallet, &addr); - let (selected, _) = wallet - .select_unspent_utxos_for(90_000, 10_000, false, None) - .unwrap(); - wallet - .remove_selected_utxos(&selected, &db, Network::Testnet) - .unwrap(); - - assert!(!wallet.utxos.contains_key(&addr)); + fn open_no_password_accepts_unprotected_envelope() { + let seed = [0x09u8; 64]; + let mut wallet_seed = WalletSeed::Closed(ClosedKeyItem { + seed_hash: ClosedKeyItem::compute_seed_hash(&seed), + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + }); + + wallet_seed + .open_no_password() + .expect("unprotected envelope opens without a password"); + assert!( + matches!(wallet_seed, WalletSeed::Open(_)), + "unprotected wallet must flip to Open" + ); } // ======================================================================== @@ -3154,6 +2441,31 @@ mod tests { assert_eq!(info.nonce, 4); } + #[test] + fn test_seed_platform_address_info_is_insert_if_absent() { + let mut wallet = test_wallet(); + let addr = test_address(1); + + // Absent -> seeds the value. + wallet.seed_platform_address_info(addr.clone(), 500_000, 3); + let info = wallet.get_platform_address_info(&addr).unwrap(); + assert_eq!((info.balance, info.nonce), (500_000, 3)); + + // Present -> no-op: a warm-start seed must not clobber a live value. + wallet.seed_platform_address_info(addr.clone(), 999_999, 9); + let info = wallet.get_platform_address_info(&addr).unwrap(); + assert_eq!( + (info.balance, info.nonce), + (500_000, 3), + "seeding an already-present address must leave it untouched" + ); + + // A genuine live update still overwrites. + wallet.set_platform_address_info(addr.clone(), 999_999, 9); + let info = wallet.get_platform_address_info(&addr).unwrap(); + assert_eq!((info.balance, info.nonce), (999_999, 9)); + } + #[test] fn test_get_platform_address_info_direct_lookup() { let mut wallet = test_wallet(); @@ -3274,40 +2586,408 @@ mod tests { } #[test] - fn test_wallet_seed_hash_consistent() { - let wallet = test_wallet(); - let hash1 = wallet.seed_hash(); - let hash2 = wallet.seed_hash(); - assert_eq!(hash1, hash2); + fn test_wallet_seed_hash_consistent() { + let wallet = test_wallet(); + let hash1 = wallet.seed_hash(); + let hash2 = wallet.seed_hash(); + assert_eq!(hash1, hash2); + } + + /// R3 capstone: an open wallet retains NO plaintext seed. + /// + /// `WalletSeed::Open` is the verify-not-park state — it carries only the + /// encrypted-envelope metadata, never the decrypted seed. This test pins + /// the invariant structurally: the `Open` payload exposes only + /// `wallet_info` (a [`ClosedKeyItem`], whose `encrypted_seed` is, for a + /// password wallet, the ciphertext — not the plaintext), and there is no + /// accessor that yields the plaintext seed from an open wallet. + #[test] + fn open_wallet_retains_no_plaintext_seed() { + let password = "correct horse battery staple"; + let secret = Secret::new(password); + let mut wallet = Wallet::new_from_seed( + test_seed(), + Network::Testnet, + Some("verify-not-park".to_string()), + Some(&secret), + ) + .expect("build password wallet"); + + // Lock, then unlock by verifying the passphrase. + wallet.wallet_seed.close(); + assert!(!wallet.is_open()); + wallet + .wallet_seed + .open(password) + .expect("correct passphrase verifies"); + assert!(wallet.is_open(), "verified-correct passphrase opens"); + + // The open payload holds only the ENCRYPTED envelope — never the + // plaintext seed. For a password wallet the stored bytes are the + // ciphertext, which must differ from the plaintext seed. + let WalletSeed::Open(open) = &wallet.wallet_seed else { + panic!("wallet should be open"); + }; + assert_ne!( + open.wallet_info.encrypted_seed, + test_seed().to_vec(), + "open wallet must not store the plaintext seed" + ); + // A wrong passphrase is rejected — proves `open` truly decrypts to + // verify, it does not just flip a flag. + wallet.wallet_seed.close(); + assert!(wallet.wallet_seed.open("wrong passphrase").is_err()); + } + + /// `Debug` of an UNPROTECTED wallet must never leak the plaintext seed. + /// + /// For a no-password wallet `encrypted_seed` holds the raw 64-byte seed + /// verbatim. `ClosedKeyItem` redacts it in `Debug`, and `WalletSeed`, + /// `OpenWalletSeed`, and `Wallet` all delegate to that impl. A known + /// distinctive seed (not all-equal, so byte fragments are unambiguous) + /// must appear in none of their `Debug` renderings. + #[test] + fn debug_output_never_leaks_plaintext_seed() { + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = i as u8; + } + let wallet = Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build unprotected wallet"); + + // The unprotected envelope stores the raw plaintext. + assert_eq!(wallet.encrypted_seed_slice(), seed.as_slice()); + + let needle = hex::encode(seed); + let closed = ClosedKeyItem { + seed_hash: ClosedKeyItem::compute_seed_hash(&seed), + encrypted_seed: seed.to_vec(), + salt: vec![], + nonce: vec![], + password_hint: None, + }; + let open_seed = OpenWalletSeed { + wallet_info: closed.clone(), + }; + let wallet_seed = WalletSeed::Open(open_seed.clone()); + + for (label, rendered) in [ + ("ClosedKeyItem", format!("{closed:?}")), + ("OpenWalletSeed", format!("{open_seed:?}")), + ("WalletSeed", format!("{wallet_seed:?}")), + ("Wallet", format!("{wallet:?}")), + ] { + assert!( + !rendered.contains(&needle), + "{label} Debug leaked hex seed bytes: {rendered}" + ); + // Also catch the raw comma-separated `Vec<u8>` rendering. + assert!( + !rendered.contains("0, 1, 2, 3, 4, 5"), + "{label} Debug leaked raw seed byte sequence: {rendered}" + ); + assert!( + rendered.contains("[redacted]"), + "{label} Debug should mark the seed redacted: {rendered}" + ); + } + } + + // ======================================================================== + // R3 D4b — identity-auth public-key cache byte-equivalence & cold-fill + // ======================================================================== + + /// D4B-EQUIV-001 — the cached public key is byte-identical to the + /// seed-derived one. Warm the cache from the seed-derived key, read it + /// back through the cache path, and assert both the compressed bytes + /// and the hash160 match. This is the load-bearing correctness claim: + /// a cache hit must never serve a different key than the seed would. + #[test] + fn auth_pubkey_cached_equals_seed_derived() { + let wallet = test_wallet(); + let seed = test_seed(); + let network = Network::Testnet; + + for identity_index in 0..2u32 { + for key_index in 0..4u32 { + let from_seed = wallet + .identity_authentication_ecdsa_public_key_from_seed( + &seed, + network, + identity_index, + key_index, + ) + .expect("seed derivation"); + + let mut cache = AuthPubkeyCache::default(); + cache.insert(network, identity_index, key_index, &from_seed); + + let from_cache = cache + .get(network, identity_index, key_index) + .expect("cache hit"); + + assert_eq!(from_cache, from_seed); + assert_eq!(from_cache.inner.serialize(), from_seed.inner.serialize()); + assert_eq!(from_cache.pubkey_hash(), from_seed.pubkey_hash()); + } + } + } + + /// D4B-COLD-001 — a cold (empty) cache misses; the seed-derived path + /// produces the key; warming the cache makes the subsequent read + /// seed-free and identical. Exercises the cold ⇒ JIT-fill ⇒ warm + /// read-back self-heal contract at the model level. + #[test] + fn auth_pubkey_cold_cache_self_heals() { + let wallet = test_wallet(); + let seed = test_seed(); + let network = Network::Testnet; + let (identity_index, key_index) = (1u32, 3u32); + + let mut cache = AuthPubkeyCache::default(); + // Cold: the cache misses entirely. + assert!(cache.get(network, identity_index, key_index).is_none()); + + // JIT cold-fill from the seed, then populate the cache. + let derived = wallet + .identity_authentication_ecdsa_public_key_from_seed( + &seed, + network, + identity_index, + key_index, + ) + .expect("seed derivation"); + assert!(cache.insert(network, identity_index, key_index, &derived)); + + // Warm: the same read now serves from cache, byte-identical. + let warmed = cache + .get(network, identity_index, key_index) + .expect("cache hit after fill"); + assert_eq!(warmed, derived); + + // A different network coordinate stays cold (network-keyed). + assert!( + cache + .get(Network::Mainnet, identity_index, key_index) + .is_none() + ); + } + + // ======================================================================== + // R3 D2 — seed-as-parameter derivation drift tests + // ======================================================================== + + /// One representative derivation path from every `bootstrap_*` family. + /// + /// The bootstrap children differ only in WHICH paths they enumerate; the + /// seed-dependent step is identical (`derive_priv_ecdsa_for_master_seed`), + /// so proving the seed-param derivation matches the self-seed derivation on + /// this representative set proves the whole bootstrap address set is + /// unchanged by the seed-source switch. + fn representative_bootstrap_paths(network: Network) -> Vec<DerivationPath> { + let coin_type = coin_type_for_network(network); + let coinjoin = { + let mut c = DerivationPath::coinjoin_path(network, 0).as_ref().to_vec(); + c.push(ChildNumber::Normal { index: 3 }); + DerivationPath::from(c) + }; + let provider_owner = { + let mut c = AccountType::ProviderOwnerKeys + .derivation_path(network) + .expect("provider path") + .as_ref() + .to_vec(); + c.push(ChildNumber::Hardened { index: 1 }); + DerivationPath::from(c) + }; + let topup_not_bound = { + let mut c = AccountType::IdentityTopUpNotBoundToIdentity + .derivation_path(network) + .expect("not-bound path") + .as_ref() + .to_vec(); + c.push(ChildNumber::Normal { index: 2 }); + DerivationPath::from(c) + }; + vec![ + // BIP-32 + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 5 }, + ]), + // BIP-44 external + change + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 7 }, + ]), + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 1 }, + ChildNumber::Normal { index: 4 }, + ]), + coinjoin, + DerivationPath::identity_registration_path(network, 2), + DerivationPath::identity_invitation_path(network, 3), + DerivationPath::identity_top_up_path(network, 1, 2), + topup_not_bound, + provider_owner, + DerivationPath::platform_payment_path(network, 0, 0, 6), + ] + } + + /// The seed-as-parameter derivation produces byte-identical private keys to + /// a direct BIP-32 reference derivation across every bootstrap family — the + /// derivation math is the spec, not the wrapper. + #[test] + fn seed_param_derivation_matches_reference_derivation() { + for network in [Network::Testnet, Network::Mainnet] { + let wallet = test_wallet(); + // The per-path private key is derived directly from the raw seed + // (BIP-44 master xpub is not involved), so the derivation is + // network-correct as long as the same `network` is passed. + let seed = test_seed(); + for path in representative_bootstrap_paths(network) { + let reference = path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("reference derive") + .to_priv(); + let from_param = wallet + .private_key_at_derivation_path_with_seed(&seed, &path, network) + .expect("seed-param derive"); + assert_eq!( + reference.to_bytes(), + from_param.to_bytes(), + "derivation drift on path {path} for {network:?}" + ); + } + } + } + + /// `private_key_for_address_with_seed` resolves the same key a direct + /// reference derivation at the address's stored path produces. + #[test] + fn private_key_for_address_seed_param_matches_reference() { + let network = Network::Testnet; + let wallet = test_wallet(); + let seed = test_seed(); + + // Derive a known address + path and register it in the wallet. + let path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 1 }, + ]); + let reference = path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("reference derive") + .to_priv(); + let secp = Secp256k1::new(); + let address = Address::p2pkh(&reference.public_key(&secp), network); + + let mut wallet = wallet; + wallet.known_addresses.insert(address.clone(), path); + + let param = wallet + .private_key_for_address_with_seed(&seed, &address, network) + .expect("param") + .expect("known"); + assert_eq!(reference.to_bytes(), param.to_bytes()); + } + + /// `generate_platform_receive_address_with_seed` derives the DIP-17 + /// platform-payment address at the next unused index. On a fresh wallet + /// that is index 0 — assert byte parity against a direct reference + /// derivation at `platform_payment_path(network, 0, 0, 0)` (the legacy + /// parked-seed `platform_receive_address` it replaced is gone). + #[test] + fn platform_receive_address_seed_param_matches() { + for network in [Network::Testnet, Network::Mainnet] { + let mut param_wallet = test_wallet(); + let seed = test_seed(); + + let reference_path = DerivationPath::platform_payment_path(network, 0, 0, 0); + let reference_xprv = reference_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("reference derive"); + let secp = Secp256k1::new(); + let reference = Address::p2pkh(&reference_xprv.to_priv().public_key(&secp), network); + + let param = param_wallet + .generate_platform_receive_address_with_seed(&seed, network) + .expect("seed-param generate"); + assert_eq!( + reference, param, + "platform receive address drift for {network:?}" + ); + } } + /// `derive_private_key_in_arc_rw_lock_slice_with_seed` matches a direct + /// BIP-32 reference derivation at the same path (the legacy parked-seed + /// slice-derive it replaced is gone, so parity is anchored independently). #[test] - fn test_wallet_seed_bytes_available_when_open() { + fn slice_derive_seed_param_matches() { + let network = Network::Testnet; let wallet = test_wallet(); - assert!(wallet.seed_bytes().is_ok()); - assert_eq!(wallet.seed_bytes().unwrap().len(), 64); + let seed_hash = wallet.seed_hash(); + let seed = test_seed(); + let slice = vec![Arc::new(RwLock::new(wallet))]; + + let path = DerivationPath::identity_registration_path(network, 0); + let reference = path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("reference derive") + .private_key + .secret_bytes(); + let param = Wallet::derive_private_key_in_arc_rw_lock_slice_with_seed( + &slice, seed_hash, &seed, &path, network, + ) + .expect("param") + .expect("found"); + assert_eq!(reference, *param); + + // A non-matching seed hash yields None on the seed-param path too. + let other_hash = [0xEE; 32]; + assert!( + Wallet::derive_private_key_in_arc_rw_lock_slice_with_seed( + &slice, other_hash, &seed, &path, network, + ) + .expect("no error") + .is_none() + ); } + /// The borrowed seed never leaks into the error string of a seed-param + /// derivation: a forced derivation failure carries no seed bytes. #[test] - fn test_wallet_has_unused_asset_lock() { - let mut wallet = test_wallet(); - assert!(!wallet.has_unused_asset_lock()); - - // Add a dummy asset lock - wallet.unused_asset_locks.push(( - Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }, - test_address(1), - 100_000, - None, - None, - )); - assert!(wallet.has_unused_asset_lock()); + fn seed_param_derivation_error_does_not_leak_seed() { + const SENTINEL_SEED: [u8; 64] = [0x5A; 64]; + let network = Network::Testnet; + let wallet = test_wallet(); + // An empty path always derives successfully, so force the "wallet not + // present" branch on the slice-derive with a non-matching seed hash and + // confirm the resulting message holds no seed material. + let path = DerivationPath::identity_registration_path(network, 0); + let slice = vec![Arc::new(RwLock::new(wallet))]; + let err = Wallet::derive_private_key_in_arc_rw_lock_slice_with_seed( + &slice, + [0xEE; 32], + &SENTINEL_SEED, + &path, + network, + ); + // Non-matching hash returns Ok(None), not an error — assert the seed + // never surfaces in the Debug of either arm. + let rendered = format!("{err:?}"); + let sentinel_hex = hex::encode(SENTINEL_SEED); + assert!( + !rendered.contains(&sentinel_hex), + "seed leaked into slice-derive result: {rendered}" + ); } // ======================================================================== @@ -3542,93 +3222,6 @@ mod tests { ); } - #[test] - fn test_receive_address_returns_first_unused() { - let mut wallet = test_wallet(); - // With no watched addresses, should derive address at index 0 - let addr = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert!(!addr.to_string().is_empty()); - } - - /// Helper: manually register an address in watched_addresses so the wallet - /// considers it "known" (normally done by register_address with AppContext). - fn register_address_locally( - wallet: &mut Wallet, - address: &Address, - derivation_path: &DerivationPath, - ) { - wallet - .known_addresses - .insert(address.clone(), derivation_path.clone()); - wallet.watched_addresses.insert( - derivation_path.clone(), - AddressInfo { - address: address.clone(), - path_type: DerivationPathType::CLEAR_FUNDS, - path_reference: DerivationPathReference::BIP44, - }, - ); - } - - #[test] - fn test_receive_address_skip_known_with_no_funds() { - let mut wallet = test_wallet(); - - // Derive address at index 0 and register it locally - let addr0 = wallet - .derive_bip44_address(Network::Testnet, false, 0) - .unwrap(); - let path0 = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - register_address_locally(&mut wallet, &addr0, &path0); - - // With skip=false, should return the same known zero-balance address - let addr_same = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert_eq!(addr0, addr_same); - - // With skip=true, should skip the known zero-balance address and get a new one - let addr_next = wallet - .receive_address(Network::Testnet, true, None) - .unwrap(); - assert_ne!(addr0, addr_next); - } - - #[test] - fn test_receive_address_skips_funded_addresses() { - let mut wallet = test_wallet(); - - // Derive and register address at index 0 - let addr0 = wallet - .derive_bip44_address(Network::Testnet, false, 0) - .unwrap(); - let path0 = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ChildNumber::Normal { index: 0 }, - ChildNumber::Normal { index: 0 }, - ]); - register_address_locally(&mut wallet, &addr0, &path0); - - // Fund it - wallet.address_balances.insert(addr0.clone(), 100_000); - - // With skip=false, should skip funded address and derive next index - let addr_next = wallet - .receive_address(Network::Testnet, false, None) - .unwrap(); - assert_ne!(addr0, addr_next, "Should skip funded address"); - } - // ======================================================================== // WalletSeed tests // ======================================================================== @@ -3647,9 +3240,9 @@ mod tests { wallet.wallet_seed.close(); assert!(!wallet.is_open()); - - // After closing, seed_bytes should fail - assert!(wallet.seed_bytes().is_err()); + // The seed_hash survives the lock (it is envelope metadata, not the + // seed) — the closed state still identifies the wallet. + assert_eq!(wallet.seed_hash(), original_hash); // Reopen without password (test wallet has no encryption) wallet.wallet_seed.open_no_password().unwrap(); @@ -3657,44 +3250,6 @@ mod tests { assert_eq!(wallet.seed_hash(), original_hash); } - // ======================================================================== - // utxos_by_address tests - // ======================================================================== - - #[test] - fn test_utxos_by_address_empty() { - let wallet = test_wallet(); - assert!(wallet.utxos_by_address().is_empty()); - } - - #[test] - fn test_utxos_by_address_with_entries() { - let mut wallet = test_wallet(); - let addr1 = test_address(1); - let addr2 = test_address(2); - - add_utxo(&mut wallet, &addr1, 1, 0, 50_000); - add_utxo(&mut wallet, &addr1, 2, 0, 30_000); - add_utxo(&mut wallet, &addr2, 3, 0, 20_000); - - let utxos = wallet.utxos_by_address(); - assert_eq!(utxos.len(), 2); - - let addr1_balance: u64 = utxos - .iter() - .filter(|(a, _)| a == &addr1) - .map(|(_, b)| b) - .sum(); - assert_eq!(addr1_balance, 80_000); - - let addr2_balance: u64 = utxos - .iter() - .filter(|(a, _)| a == &addr2) - .map(|(_, b)| b) - .sum(); - assert_eq!(addr2_balance, 20_000); - } - // ======================================================================== // WalletArcRef tests // ======================================================================== @@ -3744,4 +3299,499 @@ mod tests { let result = Wallet::find_in_arc_rw_lock_slice(&[], [0u8; 32]); assert!(result.is_none()); } + + /// FUND-SAFETY PARITY: the rebuilt `WalletAddressProvider` derives each + /// gap-limit address from the DIP-17 account **xpub** (no owned seed). Its + /// addresses must be byte-identical to the legacy seed-based + /// `derive_priv_ecdsa_for_master_seed(...).to_priv().public_key()` path, on + /// every network — otherwise platform balance sync would query the wrong + /// addresses. + #[test] + fn provider_xpub_matches_seed_derivation() { + for network in [Network::Testnet, Network::Mainnet] { + let seed = [42u8; 64]; + let wallet = Wallet::new_from_seed(seed, network, None, None).expect("wallet"); + let provider = WalletAddressProvider::new(&wallet, network, &seed).expect("provider"); + + let secp = Secp256k1::new(); + for index in 0u32..DEFAULT_GAP_LIMIT { + // Legacy seed-based derivation (what the old provider and the + // platform signer used). + let path = DerivationPath::platform_payment_path(network, 0, 0, index); + let legacy_priv = path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .expect("legacy derive") + .to_priv(); + let legacy_address = Address::p2pkh(&legacy_priv.public_key(&secp), network); + + // Provider's xpub-based derivation. + let (_platform, provider_address) = provider + .derive_address_at_index(index) + .expect("provider derive"); + + assert_eq!( + legacy_address, provider_address, + "provider xpub address diverged from seed derivation at index {index} on {network:?}" + ); + } + } + } + + // ------------------------------------------------------------------- + // F34: backend-authoritative payment-recipient validation. + // ------------------------------------------------------------------- + + #[test] + fn validate_payment_recipients_accepts_positive_amounts() { + assert!(validate_payment_recipients(&[1]).is_ok()); + assert!(validate_payment_recipients(&[100_000, 250_000, 1]).is_ok()); + } + + #[test] + fn validate_payment_recipients_rejects_empty_list() { + assert!(matches!( + validate_payment_recipients(&[]), + Err(PaymentValidationError::NoRecipients) + )); + } + + #[test] + fn validate_payment_recipients_rejects_zero_amount() { + assert!(matches!( + validate_payment_recipients(&[0]), + Err(PaymentValidationError::ZeroAmount) + )); + // A zero anywhere in the list is rejected, not just the first slot. + assert!(matches!( + validate_payment_recipients(&[100_000, 0, 50_000]), + Err(PaymentValidationError::ZeroAmount) + )); + } + + #[test] + fn validate_payment_recipients_empty_takes_precedence_over_zero_check() { + // An empty list reports the no-recipients error, never the zero error. + assert!(matches!( + validate_payment_recipients(&[]), + Err(PaymentValidationError::NoRecipients) + )); + } + + /// FUNDS-SAFETY: every Core receive address handed to a user must live + /// inside the upstream gap-limit pool that SPV actually watches. The + /// upstream BIP-44 external pool watches indices `0..=29` (gap limit 30); + /// anything past index 29 is invisible to SPV, so funds sent there never + /// appear. This pins the property the Receive "New Address" action must + /// satisfy: the address it returns is always in the watched pool. + /// + /// The legacy `Wallet::receive_address(skip = true)` path violated this — + /// it walked the index forward past every known zero-balance address with + /// no gap-limit bound, handing out e.g. index 32 (a real user lost a + /// 1 tDASH deposit this way). The fix routes the action through the + /// upstream `next_unused`, which can only return a watched address. + #[test] + fn receive_address_stays_within_upstream_watched_pool() { + use dash_sdk::dpp::key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use dash_sdk::dpp::key_wallet::managed_account::address_pool::{ + AddressPool, AddressPoolType, KeySource, + }; + + let network = Network::Testnet; + + // The upstream SPV-watched external pool: same account xpub DET uses, + // gap limit 30 ⇒ generates indices 0..=29 and watches exactly those. + let account_xpub = test_wallet().master_bip44_ecdsa_extended_public_key; + let mut watched_pool = AddressPool::new( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + network, + &KeySource::Public(account_xpub), + ) + .expect("upstream external pool"); + + // The upstream next-unused address is, by construction, watched. + let watched_addr = watched_pool + .next_unused(&KeySource::Public(account_xpub), false) + .expect("upstream next_unused"); + assert!( + watched_pool.contains_address(&watched_addr), + "upstream next_unused must return a watched address" + ); + + // An out-of-window index (32, past the gap limit of 30) — what the old + // DET-side derivation could hand out — is NOT in the watched pool, so + // funds sent there would be invisible. The Receive action must never + // produce such an address; it derives via the upstream pool above. + let out_of_window = test_wallet() + .derive_bip44_address(network, false, 32) + .expect("derive index 32"); + assert!( + !watched_pool.contains_address(&out_of_window), + "an index-32 address must escape the watched pool (the hazard being guarded); \ + if it now stays inside, the gap window changed and this guard needs review" + ); + + // The invariant the fixed Receive action satisfies: the handed-out + // address (upstream `next_unused`) is always watched, while an + // out-of-window index is not. + assert!( + watched_pool.contains_address(&watched_addr) + && !watched_pool.contains_address(&out_of_window), + "the watched-pool address is funds-safe; an out-of-window index is not" + ); + } + + /// FUNDS-SAFETY (H2): the change address for a Platform top-up funded from + /// wallet UTXOs must be a watched DIP-17 platform-payment address — the set + /// `WalletAddressProvider` syncs balances for. The legacy `change_address` + /// derives a BIP-44 *internal* (change) Core address and converts it to a + /// `PlatformAddress`; that address is NOT in the platform-payment pool, so + /// its change credits are never synced — invisible and unspendable. + /// + /// Pins: the platform-payment derivation IS in the provider pool, the BIP-44 + /// change derivation is NOT. RED on the legacy change path, GREEN once the + /// change is a platform-payment address. + #[test] + fn platform_change_address_must_be_a_watched_platform_payment_address() { + let seed = TEST_SEED; + let network = Network::Testnet; + + // The watched Platform set: the provider's DIP-17 platform-payment pool. + let provider = WalletAddressProvider::new(&test_wallet(), network, &seed) + .expect("platform address provider"); + let watched: std::collections::BTreeSet<PlatformAddress> = (0..DEFAULT_GAP_LIMIT) + .map(|i| { + provider + .derive_address_at_index(i) + .expect("derive platform address") + .0 + }) + .collect(); + + // The fixed path: a registered platform-payment address is watched. + let mut wallet = test_wallet(); + let platform_core_addr = wallet + .generate_platform_receive_address_with_seed(&seed, network) + .expect("platform receive address"); + let platform_change = + PlatformAddress::try_from(platform_core_addr).expect("platform address conversion"); + assert!( + watched.contains(&platform_change), + "a platform-payment change address must be in the watched provider pool" + ); + + // The legacy path: a BIP-44 change address is NOT a watched platform + // address — this is the bug. The BIP-44 internal (change) branch gives a + // Core address whose PlatformAddress is outside the provider pool. + let bip44_change = test_wallet() + .derive_bip44_address(network, true, 0) + .expect("bip44 change address"); + let bip44_change_platform = + PlatformAddress::try_from(bip44_change).expect("platform address conversion"); + assert!( + !watched.contains(&bip44_change_platform), + "the BIP-44 change address must NOT be a watched platform address (the bug being fixed)" + ); + } + + /// FUNDS-SAFETY: every platform-payment address DET hands out or + /// funds must be inside the provider's synced window. The generator derives + /// `max(registered)+1` unbounded, but the provider only bootstraps + /// `0..=gap_limit-1` and extends past *funded* indices. So a handed-out + /// address at index ≥ gap_limit with nothing funded before it would never be + /// synced — credits invisible/unspendable. + /// + /// Drives the generator past the gap limit (to index 20 with + /// `DEFAULT_GAP_LIMIT == 20`) and asserts that index is in the provider's + /// `pending_addresses()` (the synced set). RED before the provider covers + /// every registered index, GREEN after. + #[test] + fn platform_payment_handout_stays_within_synced_window() { + let seed = TEST_SEED; + let network = Network::Testnet; + + // Hand out platform-payment addresses 0..=DEFAULT_GAP_LIMIT — the last + // one is index DEFAULT_GAP_LIMIT (== 20), one past the bootstrap window. + let mut wallet = test_wallet(); + let mut last_addr = None; + for _ in 0..=DEFAULT_GAP_LIMIT { + last_addr = Some( + wallet + .generate_platform_receive_address_with_seed(&seed, network) + .expect("platform receive address"), + ); + } + let handed_out = PlatformAddress::try_from(last_addr.expect("at least one address")) + .expect("platform address conversion"); + + // The provider must sync the handed-out address: it appears in the + // pending (to-be-synced) set, with nothing funded to trigger an extend. + let provider = WalletAddressProvider::new(&wallet, network, &seed).expect("provider"); + let synced: std::collections::BTreeSet<PlatformAddress> = + provider.pending_addresses().map(|(_, addr)| addr).collect(); + assert!( + synced.contains(&handed_out), + "a handed-out platform-payment address at index {} must be inside the synced window", + DEFAULT_GAP_LIMIT + ); + } + + // ------------------------------------------------------------------- + // Platform-address signer reconciliation: closing the two-map desync + // between `platform_address_info` (balance/UI) and `watched_addresses` + // (signer view) that let a visible balance be unwithdrawable. + // ------------------------------------------------------------------- + + /// A `new_from_seed` wallet on `network` with the platform-payment xpub + /// cache cleared — stands in for a wallet persisted before that cache + /// existed (the affected user's live state). Carries no platform-payment + /// entries in its watched maps, exactly like the pre-reconciliation bug. + fn legacy_wallet(network: Network) -> Wallet { + let mut wallet = Wallet::new_from_seed(TEST_SEED, network, None, None).expect("wallet"); + wallet.platform_payment_account_xpub = None; + wallet + } + + /// The real platform-payment Core address at `index` derived from + /// `TEST_SEED` — the address the upstream coordinator would report a balance + /// for. Independent of the reconciliation path under test. + fn platform_address_at(index: u32, network: Network) -> Address { + let path = DerivationPath::platform_payment_path(network, 0, 0, index); + let secp = Secp256k1::new(); + let xprv = path + .derive_priv_ecdsa_for_master_seed(&TEST_SEED, network) + .expect("derive platform key"); + Address::p2pkh(&xprv.to_priv().public_key(&secp), network) + } + + /// `new_from_seed` caches the platform-payment account xpub eagerly, and the + /// lazy backfill on an old wallet reproduces the identical key — so eager + /// and just-in-time paths never disagree on which addresses are ours. + #[test] + fn new_from_seed_caches_platform_payment_account_xpub() { + for network in [Network::Testnet, Network::Mainnet] { + let wallet = Wallet::new_from_seed(TEST_SEED, network, None, None).expect("wallet"); + let eager = wallet + .platform_payment_account_xpub + .expect("new_from_seed must cache the platform-payment xpub"); + + let mut old = legacy_wallet(network); + assert!(old.platform_payment_account_xpub.is_none()); + old.ensure_platform_payment_account_xpub(&TEST_SEED, network); + assert_eq!( + Some(eager), + old.platform_payment_account_xpub, + "lazy backfill xpub must match the eager one on {network:?}" + ); + } + } + + /// The no-op guard protects an already-cached xpub: a later call (even with + /// the wrong seed) must not overwrite it. + #[test] + fn ensure_platform_payment_account_xpub_is_idempotent() { + let network = Network::Testnet; + let mut wallet = Wallet::new_from_seed(TEST_SEED, network, None, None).expect("wallet"); + let first = wallet.platform_payment_account_xpub; + assert!(first.is_some()); + wallet.ensure_platform_payment_account_xpub(&[0u8; 64], network); + assert_eq!(wallet.platform_payment_account_xpub, first); + } + + /// Reconciliation reverse-derives and registers an address at a NON-trivial + /// index (25, past `DEFAULT_GAP_LIMIT`) purely from the cached account + /// xpub — no seed at reconcile time — under the correct DIP-17 path, and is + /// idempotent. + #[test] + fn reconcile_registers_platform_address_beyond_gap_limit() { + let network = Network::Testnet; + let index = 25u32; + assert!(index > DEFAULT_GAP_LIMIT, "index must exceed the gap limit"); + + let mut wallet = legacy_wallet(network); + wallet.ensure_platform_payment_account_xpub(&TEST_SEED, network); + + let addr = platform_address_at(index, network); + assert!( + !wallet.known_addresses.contains_key(&addr), + "precondition: address not yet registered" + ); + + // Reconcile with NO seed involved — pure public-key reverse-derivation. + assert!(wallet.reconcile_platform_address(&addr, network)); + + let expected_path = DerivationPath::platform_payment_path(network, 0, 0, index); + assert_eq!(wallet.known_addresses.get(&addr), Some(&expected_path)); + let info = wallet + .watched_addresses + .get(&expected_path) + .expect("reconciled path must be watched"); + assert_eq!(info.address, addr); + assert_eq!( + info.path_reference, + DerivationPathReference::PlatformPayment + ); + assert_eq!(info.path_type, DerivationPathType::CLEAR_FUNDS); + + // Idempotent: a second call is a no-op that still reports registered. + assert!(wallet.reconcile_platform_address(&addr, network)); + } + + /// BACKWARD-COMPAT / FUND-SAFETY: the exact scenario that unblocks the + /// affected user. An old wallet knows an index-25 balance (via the + /// coordinator push, so `platform_address_info` is set) but cannot sign for + /// it. After the JIT xpub backfill + reconciliation, the same address is + /// signable through `PlatformPathIndex` + `DetPlatformSigner`. + #[tokio::test] + async fn reconciled_address_becomes_signable_backward_compat() { + use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; + use dash_sdk::dpp::identity::signer::Signer; + + let network = Network::Testnet; + let index = 25u32; + + let mut wallet = legacy_wallet(network); + let addr = platform_address_at(index, network); + let platform_addr = PlatformAddress::try_from(addr.clone()).expect("platform address"); + // Coordinator push landed the balance without a derivation index. + wallet.set_platform_address_info(addr.clone(), 20_000_000_000, 0); + + // Before self-heal: the signer has no path for it. + { + let before = PlatformPathIndex::from_wallet(&wallet, network); + let signer = DetPlatformSigner::from_held(&TEST_SEED, network, &before); + assert!( + !signer.can_sign_with(&platform_addr), + "precondition: the balance is visible but not signable" + ); + } + + // Self-heal exactly as the withdrawal / push paths do. + wallet.ensure_platform_payment_account_xpub(&TEST_SEED, network); + assert!(wallet.reconcile_platform_address(&addr, network)); + + // After self-heal: signable, and a real signature is produced. + let after = PlatformPathIndex::from_wallet(&wallet, network); + let signer = DetPlatformSigner::from_held(&TEST_SEED, network, &after); + assert!( + signer.can_sign_with(&platform_addr), + "reconciled address must be signable" + ); + assert!( + signer.sign(&platform_addr, b"withdraw").await.is_ok(), + "reconciled address must produce a signature" + ); + } + + /// An old wallet whose xpub is not yet cached cannot reconcile — the call is + /// a no-op returning false, registering nothing (the expected transient + /// state before the first seed borrow). + #[test] + fn reconcile_is_noop_without_cached_xpub() { + let network = Network::Testnet; + let mut wallet = legacy_wallet(network); + let addr = platform_address_at(25, network); + assert!(!wallet.reconcile_platform_address(&addr, network)); + assert!(!wallet.known_addresses.contains_key(&addr)); + } + + /// `register_platform_payment_address` inserts the exact DIP-17 entries into + /// `known_addresses` / `watched_addresses` at the derived path — no + /// reverse-derivation, no cached xpub needed. + #[test] + fn register_platform_payment_address_inserts_exact_dip17_entries() { + let network = Network::Testnet; + let index = 7u32; + let mut wallet = legacy_wallet(network); + let addr = platform_address_at(index, network); + assert!( + !wallet.known_addresses.contains_key(&addr), + "precondition: address not yet registered" + ); + + wallet.register_platform_payment_address(&addr, 0, index, network); + + let expected_path = DerivationPath::platform_payment_path(network, 0, 0, index); + assert_eq!(wallet.known_addresses.get(&addr), Some(&expected_path)); + let info = wallet + .watched_addresses + .get(&expected_path) + .expect("registered path must be watched"); + assert_eq!(info.address, addr); + assert_eq!( + info.path_reference, + DerivationPathReference::PlatformPayment + ); + assert_eq!(info.path_type, DerivationPathType::CLEAR_FUNDS); + } + + /// The doc comment's "idempotent" claim: a second identical registration is + /// a no-op — no duplicate entries, no changed state. + #[test] + fn register_platform_payment_address_is_idempotent() { + let network = Network::Testnet; + let index = 7u32; + let mut wallet = legacy_wallet(network); + let addr = platform_address_at(index, network); + + wallet.register_platform_payment_address(&addr, 0, index, network); + let known_after_first = wallet.known_addresses.clone(); + let watched_len = wallet.watched_addresses.len(); + + wallet.register_platform_payment_address(&addr, 0, index, network); + + assert_eq!( + wallet.watched_addresses.len(), + watched_len, + "no duplicate watched entry" + ); + assert_eq!( + wallet.known_addresses, known_after_first, + "second call leaves known_addresses unchanged" + ); + } + + /// The mechanism that actually closes the reported Platform gap: exact + /// registration succeeds precisely where the guess-based reconcile path + /// DEFERS (no cached xpub) — the exact `(account, index)` from the push + /// removes the reverse-derivation dependency entirely. + #[test] + fn exact_registration_registers_where_reconcile_would_defer() { + let network = Network::Testnet; + let index = 7u32; + let mut wallet = legacy_wallet(network); + let addr = platform_address_at(index, network); + + // Guess-based reconcile cannot register without the cached account xpub. + assert!(!wallet.reconcile_platform_address(&addr, network)); + assert!(!wallet.known_addresses.contains_key(&addr)); + + // Exact registration needs no xpub — it registers directly. + wallet.register_platform_payment_address(&addr, 0, index, network); + let expected_path = DerivationPath::platform_payment_path(network, 0, 0, index); + assert_eq!(wallet.known_addresses.get(&addr), Some(&expected_path)); + } + + /// The bounded search terminates and returns false for a foreign address + /// (derived from a different seed) — it never matches this wallet's xpub, so + /// the search must exhaust its ceiling without hanging or registering + /// anything. + #[test] + fn reconcile_bounded_search_rejects_foreign_address() { + let network = Network::Testnet; + let mut wallet = legacy_wallet(network); + wallet.ensure_platform_payment_account_xpub(&TEST_SEED, network); + + let foreign_seed = [0x11u8; 64]; + let path = DerivationPath::platform_payment_path(network, 0, 0, 0); + let secp = Secp256k1::new(); + let xprv = path + .derive_priv_ecdsa_for_master_seed(&foreign_seed, network) + .expect("derive foreign key"); + let foreign = Address::p2pkh(&xprv.to_priv().public_key(&secp), network); + + assert!(!wallet.reconcile_platform_address(&foreign, network)); + assert!(!wallet.known_addresses.contains_key(&foreign)); + } } diff --git a/src/model/wallet/passphrase.rs b/src/model/wallet/passphrase.rs new file mode 100644 index 000000000..3bddd9885 --- /dev/null +++ b/src/model/wallet/passphrase.rs @@ -0,0 +1,117 @@ +//! Stateless validation for per-key (single-key import) passphrases. +//! +//! The single-source-of-truth rules live here so the import dialog, the +//! restore dialog, and the backend all agree on what makes a passphrase +//! acceptable (CLAUDE.md validation-placement rule). UI screens call this +//! for instant feedback; the backend re-checks it as the authoritative +//! enforcement layer. + +use thiserror::Error; + +/// Minimum length (in characters) for a per-key passphrase. Mirrors +/// NIST 800-63B / OWASP ASVS 6.2.1's minimum recommendation. Both the +/// import/restore dialogs and the backend enforce this single value. +pub const MIN_SINGLE_KEY_PASSPHRASE_LEN: usize = 8; + +/// Why a single-key passphrase failed validation. +/// +/// Model-local so this pure validator carries no dependency on the +/// backend-task layer; `TaskError` provides `From` conversions to the +/// user-facing variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum PassphraseError { + /// The passphrase is shorter than [`MIN_SINGLE_KEY_PASSPHRASE_LEN`]. + #[error("Passphrases must be at least {min} characters. Pick a longer one and try again.")] + TooShort { min: u32 }, + /// The passphrase and its confirmation differ. + #[error("The two passphrases do not match. Type them again carefully.")] + Mismatch, +} + +/// Validate a new single-key passphrase and its confirmation. +/// +/// Stateless and dependency-free so it is trivially unit-testable and can +/// run client-side for instant feedback. The backend re-runs the same +/// checks as the authoritative layer, so a callsite that skips this still +/// gets the typed error. +/// +/// # Errors +/// +/// - [`PassphraseError::TooShort`] when `passphrase` has fewer than +/// [`MIN_SINGLE_KEY_PASSPHRASE_LEN`] characters. +/// - [`PassphraseError::Mismatch`] when `passphrase` and `confirm` differ. +pub fn validate_single_key_passphrase( + passphrase: &str, + confirm: &str, +) -> Result<(), PassphraseError> { + if passphrase.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { + return Err(PassphraseError::TooShort { + min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, + }); + } + if passphrase != confirm { + return Err(PassphraseError::Mismatch); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_passphrase_is_too_short() { + let err = validate_single_key_passphrase("", "").expect_err("empty rejected"); + match err { + PassphraseError::TooShort { min } => { + assert_eq!(min, MIN_SINGLE_KEY_PASSPHRASE_LEN as u32); + } + other => panic!("expected TooShort, got {other:?}"), + } + } + + #[test] + fn too_short_passphrase_is_rejected() { + // One character under the limit, matching confirmation — the + // length check must fire before the mismatch check. + let short: String = "a".repeat(MIN_SINGLE_KEY_PASSPHRASE_LEN - 1); + let err = validate_single_key_passphrase(&short, &short).expect_err("short rejected"); + assert!( + matches!(err, PassphraseError::TooShort { .. }), + "expected TooShort, got {err:?}" + ); + } + + #[test] + fn length_counts_characters_not_bytes() { + // Seven multi-byte chars: well over the byte threshold but under + // the character minimum — must still be rejected as too short. + let short = "é".repeat(MIN_SINGLE_KEY_PASSPHRASE_LEN - 1); + let err = validate_single_key_passphrase(&short, &short).expect_err("short rejected"); + assert!( + matches!(err, PassphraseError::TooShort { .. }), + "expected TooShort, got {err:?}" + ); + } + + #[test] + fn mismatched_passphrases_are_rejected() { + let err = validate_single_key_passphrase("longenough1", "longenough2") + .expect_err("mismatch rejected"); + assert!( + matches!(err, PassphraseError::Mismatch), + "expected Mismatch, got {err:?}" + ); + } + + #[test] + fn valid_matching_passphrase_passes() { + assert!(validate_single_key_passphrase("longenough123", "longenough123").is_ok()); + } + + #[test] + fn exactly_minimum_length_passes() { + let exact: String = "a".repeat(MIN_SINGLE_KEY_PASSPHRASE_LEN); + assert!(validate_single_key_passphrase(&exact, &exact).is_ok()); + } +} diff --git a/src/model/wallet/seed_envelope.rs b/src/model/wallet/seed_envelope.rs new file mode 100644 index 000000000..85f6b734f --- /dev/null +++ b/src/model/wallet/seed_envelope.rs @@ -0,0 +1,146 @@ +//! Encrypted seed envelope persisted in the upstream `SecretStore`. +//! +//! Each HD wallet's BIP-39 seed is wrapped twice at rest: DET's own +//! AES-GCM envelope (the legacy `(encrypted_seed, salt, nonce, +//! uses_password)` quartet derived from the user's per-wallet +//! password) is itself stored inside the upstream vault's Argon2id + +//! XChaCha20-Poly1305 file. Double encryption is wasteful CPU but +//! correctness-preserving — the per-wallet password UX stays +//! identical to the legacy behaviour while the storage location moves +//! to the upstream vault. +//! +//! `xpub_encoded` carries the BIP44 ECDSA account-0 extended public key +//! so the wallet picker can render at cold boot without unlocking the +//! vault — the `WalletMeta` sidecar in `det-app.sqlite` keeps the same +//! bytes for the same reason; the two copies are an intentional +//! redundancy. +//! +//! **Status since the Tier-2 adoption: this is a DECODE-ONLY legacy reader.** +//! New protected seeds are sealed directly by the upstream Tier-2 object-password +//! envelope (no DET-side AES-GCM re-wrap on write); a `StoredSeedEnvelope` is only +//! decoded, to migrate not-yet-migrated wallets on first unlock, and the path +//! shrinks as wallets re-wrap to Tier-2. +//! +//! RUSTSEC-2025-0141: bincode 1.x is flagged unmaintained +//! (informational, not an exploitable CVE). This envelope encodes/decodes with +//! bincode **2.x** (`bincode::serde` + `config::standard`); `bincode 1.3.3` in +//! `Cargo.lock` is only a transitive dependency. Exposure is minimal regardless — +//! the payload is read from the LOCAL, owner-only vault, never from untrusted +//! network input. + +use serde::{Deserialize, Serialize}; + +/// Current on-disk version tag for the bincode-encoded +/// [`StoredSeedEnvelope`] payload. The version byte is framed by the +/// storage layer (see [`crate::wallet_backend::wallet_seed_store`]) so +/// future shape changes can bump the tag without touching the struct +/// shape that round-trips here. +pub const STORED_SEED_ENVELOPE_VERSION: u8 = 1; + +/// Full encrypted seed envelope stored under one upstream `SecretStore` +/// entry per HD wallet. Mirrors the legacy `wallet` table columns DET +/// reads to reconstruct a `Wallet`: the AES-GCM ciphertext, the +/// Argon2-derived salt, the GCM nonce, the optional user hint, and the +/// `uses_password` flag the password-prompt UI keys off. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredSeedEnvelope { + /// AES-GCM ciphertext of the BIP-39 seed bytes. When + /// `uses_password` is `false`, contains the raw 64-byte seed + /// verbatim — DET's encryption layer is bypassed for unprotected + /// wallets (the upstream vault still encrypts at rest). + pub encrypted_seed: Vec<u8>, + /// Argon2 salt used to derive the AES-GCM key from the user's + /// password. Empty when `uses_password` is `false`. + pub salt: Vec<u8>, + /// AES-GCM nonce. Empty when `uses_password` is `false`. + pub nonce: Vec<u8>, + /// Optional user-supplied hint shown next to the password prompt. + pub password_hint: Option<String>, + /// `true` when the envelope is encrypted with a user-supplied + /// password. Drives whether the unlock UI prompts for one. + pub uses_password: bool, + /// `ExtendedPubKey::encode()` bytes for the wallet's BIP44 ECDSA + /// account-0 master xpub. Lets the cold-boot wallet picker render + /// addresses without unlocking the vault. + pub xpub_encoded: Vec<u8>, +} + +impl std::fmt::Debug for StoredSeedEnvelope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredSeedEnvelope") + .field("uses_password", &self.uses_password) + .field("encrypted_seed", &"[redacted]") + .field("salt_len", &self.salt.len()) + .field("nonce_len", &self.nonce.len()) + .field("password_hint", &self.password_hint) + .field("xpub_encoded_len", &self.xpub_encoded.len()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_output_does_not_expose_seed_bytes() { + let seed_bytes = [0x42u8; 64]; + let envelope = StoredSeedEnvelope { + encrypted_seed: seed_bytes.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: vec![0xCD; 78], + }; + let dbg = format!("{envelope:?}"); + assert!( + !dbg.contains("42, 42, 42"), + "debug output leaked seed bytes: {dbg}" + ); + assert!( + dbg.contains("[redacted]"), + "expected [redacted] in debug output: {dbg}" + ); + } + + /// Round-trip through bincode — the persisted shape must survive + /// encode/decode without losing any field. Adding a non-defaulted + /// field that breaks decoding surfaces here. + #[test] + fn stored_seed_envelope_round_trips_through_bincode() { + let original = StoredSeedEnvelope { + encrypted_seed: vec![0xAB; 80], + salt: vec![0x01; 16], + nonce: vec![0x02; 12], + password_hint: Some("granny's birthday".into()), + uses_password: true, + xpub_encoded: vec![0xCD; 78], + }; + let bytes = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (StoredSeedEnvelope, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(decoded, original); + } + + /// Non-password envelopes round-trip with empty salt/nonce and + /// `uses_password = false`. The picker still has the xpub bytes + /// so cold-boot rendering works without the vault unlock. + #[test] + fn non_password_envelope_round_trips() { + let original = StoredSeedEnvelope { + encrypted_seed: vec![0x11; 64], + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: vec![0x22; 78], + }; + let bytes = + bincode::serde::encode_to_vec(&original, bincode::config::standard()).expect("encode"); + let (decoded, _): (StoredSeedEnvelope, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).expect("decode"); + assert_eq!(decoded, original); + } +} diff --git a/src/model/wallet/shielded.rs b/src/model/wallet/shielded.rs deleted file mode 100644 index da9ebce13..000000000 --- a/src/model/wallet/shielded.rs +++ /dev/null @@ -1,174 +0,0 @@ -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::grovedb_commitment_tree::RandomSeed; -use dash_sdk::grovedb_commitment_tree::{ - ClientPersistentCommitmentTree, FullViewingKey, IncomingViewingKey, Note, NoteValue, Nullifier, - OutgoingViewingKey, PaymentAddress, Position, Rho, Scope, SpendAuthorizingKey, SpendingKey, -}; -use std::sync::Mutex; -use zip32::AccountId; - -/// Dash coin types per BIP44 -const DASH_COIN_TYPE_MAINNET: u32 = 5; -const DASH_COIN_TYPE_TESTNET: u32 = 1; - -/// Orchard key set derived from a wallet seed via ZIP32. -/// -/// ZIP32 derivation path: `m / 32' / coin_type' / account'` -/// where `purpose = 32` is the ZIP number for Orchard key derivation. -pub struct OrchardKeySet { - pub sk: SpendingKey, - pub fvk: FullViewingKey, - pub ask: SpendAuthorizingKey, - pub ivk: IncomingViewingKey, - pub ovk: OutgoingViewingKey, - pub default_address: PaymentAddress, -} - -/// Derive Orchard keys from an HD wallet seed using ZIP32. -/// -/// The seed can be 32-252 bytes. We pass the wallet's 64-byte BIP39 seed directly. -/// `SpendingKey::from_zip32_seed()` internally derives -/// `ExtendedSpendingKey::from_path(seed, &[32', coin_type', account'])`. -pub fn derive_orchard_keys( - seed_bytes: &[u8], - network: Network, - account: u32, -) -> Result<OrchardKeySet, String> { - let coin_type = match network { - Network::Mainnet => DASH_COIN_TYPE_MAINNET, - _ => DASH_COIN_TYPE_TESTNET, - }; - let account_id = - AccountId::try_from(account).map_err(|_| "Invalid account index".to_string())?; - - let sk = SpendingKey::from_zip32_seed(seed_bytes, coin_type, account_id) - .map_err(|e| format!("ZIP32 derivation failed: {e}"))?; - - let fvk = FullViewingKey::from(&sk); - let ask = SpendAuthorizingKey::from(&sk); - let ivk = fvk.to_ivk(Scope::External); - let ovk = fvk.to_ovk(Scope::External); - let default_address = fvk.address_at(0u32, Scope::External); - - Ok(OrchardKeySet { - sk, - fvk, - ask, - ivk, - ovk, - default_address, - }) -} - -/// A decrypted shielded note owned by the wallet. -pub struct ShieldedNote { - /// The decrypted Orchard note - pub note: Note, - /// Position in the commitment tree (global index) - pub position: Position, - /// Extracted note commitment bytes - pub cmx: [u8; 32], - /// Nullifier for detecting when spent - pub nullifier: Nullifier, - /// Block height where the note appeared - pub block_height: u64, - /// Whether the nullifier was seen on-chain - pub is_spent: bool, - /// Note value in credits (cached from note.value().inner()) - pub value: u64, -} - -/// Per-wallet shielded state, initialized lazily when the shielded tab is first opened. -/// -/// Manual `Debug` impl because `ClientPersistentCommitmentTree` does not implement `Debug`. -pub struct ShieldedWalletState { - /// ZIP32-derived Orchard keys (requires wallet to be unlocked) - pub keys: OrchardKeySet, - /// All tracked shielded notes - pub notes: Vec<ShieldedNote>, - /// Client-side Sinsemilla commitment tree for witness generation (SQLite-backed). - /// - /// Wrapped in `Mutex` because `ClientPersistentCommitmentTree` contains a - /// SQLite `Connection` which is `!Sync`. The mutex makes - /// `ShieldedWalletState` `Sync` so `&ShieldedWalletState` is `Send` across - /// `.await` points in tokio tasks. - pub commitment_tree: Mutex<ClientPersistentCommitmentTree>, - /// Last note global position synced from platform - pub last_synced_index: u64, - /// Block height up to which nullifier sync has been completed - pub last_nullifier_sync_height: u64, - /// Timestamp of last nullifier sync - pub last_nullifier_sync_timestamp: u64, - /// Sum of unspent note values (cached) - pub shielded_balance: u64, - /// When notes were last synced (in-memory only, resets on app restart) - pub last_notes_synced_at: Option<std::time::Instant>, - /// When nullifiers were last checked (in-memory only, resets on app restart) - pub last_nullifiers_synced_at: Option<std::time::Instant>, -} - -impl std::fmt::Debug for ShieldedWalletState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ShieldedWalletState") - .field("notes_count", &self.notes.len()) - .field("last_synced_index", &self.last_synced_index) - .field( - "last_nullifier_sync_height", - &self.last_nullifier_sync_height, - ) - .field("shielded_balance", &self.shielded_balance) - .finish_non_exhaustive() - } -} - -impl ShieldedWalletState { - /// Recalculate the cached shielded balance from unspent notes. - pub fn recalculate_balance(&mut self) { - self.shielded_balance = self - .notes - .iter() - .filter(|n| !n.is_spent) - .map(|n| n.value) - .sum(); - } - - /// Get unspent notes. - pub fn unspent_notes(&self) -> Vec<&ShieldedNote> { - self.notes.iter().filter(|n| !n.is_spent).collect() - } -} - -/// Note serialization format (115 bytes): -/// `recipient(43) || value(8 LE) || rho(32) || rseed(32)` -const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; - -/// Serialize an Orchard Note to 115 bytes for database persistence. -pub fn serialize_note(note: &Note) -> Vec<u8> { - let mut data = Vec::with_capacity(SERIALIZED_NOTE_LEN); - data.extend_from_slice(&note.recipient().to_raw_address_bytes()); - data.extend_from_slice(&note.value().inner().to_le_bytes()); - data.extend_from_slice(&note.rho().to_bytes()); - data.extend_from_slice(note.rseed().as_bytes()); - data -} - -/// Deserialize an Orchard Note from 115 bytes. -pub fn deserialize_note(data: &[u8]) -> Option<Note> { - if data.len() != SERIALIZED_NOTE_LEN { - return None; - } - - let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; - let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; - - let value_bytes: [u8; 8] = data[43..51].try_into().ok()?; - let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); - - let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; - let rho = Rho::from_bytes(&rho_bytes).into_option()?; - - let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; - let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; - - Note::from_parts(recipient, value, rho, rseed).into_option() -} diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index 1a4ad63e0..6a7008823 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -1,7 +1,12 @@ -//! Single Key Wallet - A wallet backed by a single private key (not HD derived) +//! LEGACY — Single-key wallet runtime backed by a single private key (not HD +//! derived). //! -//! This module provides support for importing and using individual private keys -//! as wallets, similar to the functionality in platform-tui. +//! Part of the Decision-#7 single-key carve-out: the `SingleKeyWallet` runtime +//! type is retained (spending is gated) until single-key moves onto the +//! upstream wallet runtime. Its on-disk persistence lives in +//! [`crate::database::single_key_wallet`] (which reads UTXOs via +//! [`crate::database::utxo`]). Not to be confused with the LIVE imported-key +//! metadata sidecar in [`crate::model::single_key`], which is current code. use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; @@ -9,9 +14,9 @@ use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PrivateKey, PublicKey, TxOut}; use sha2::{Digest, Sha256}; use std::collections::HashMap; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; -use super::encryption::derive_password_key; +use super::encryption::{EncryptionError, derive_password_key}; /// Hash of the private key, used as a unique identifier pub type SingleKeyHash = [u8; 32]; @@ -68,7 +73,7 @@ impl std::fmt::Debug for OpenSingleKey { } /// A closed (encrypted) single key -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone, PartialEq)] pub struct ClosedSingleKey { /// SHA-256 hash of the private key pub key_hash: SingleKeyHash, @@ -80,13 +85,36 @@ pub struct ClosedSingleKey { pub nonce: Vec<u8>, } +impl std::fmt::Debug for ClosedSingleKey { + /// Redacting `Debug`: `encrypted_private_key` may hold raw 32 key bytes + /// (the no-password / pre-migration shape), so a derived `Debug` would + /// leak them as a decimal byte array. Mirrors + /// `ClosedKeyItem` / `PrivateKeyData`: prints lengths and the non-secret + /// `key_hash`, never the protected bytes. Parents `SingleKeyData` / + /// `SingleKeyWallet` are safe by delegation. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClosedSingleKey") + .field("key_hash", &hex::encode(self.key_hash)) + .field("encrypted_private_key", &"[redacted]") + .field("salt_len", &self.salt.len()) + .field("nonce_len", &self.nonce.len()) + .finish() + } +} + impl SingleKeyData { /// Opens the key by decrypting it using the provided password pub fn open(&mut self, password: &str) -> Result<(), String> { match self { SingleKeyData::Open(_) => Ok(()), SingleKeyData::Closed(closed) => { - let private_key = closed.decrypt_private_key(password)?; + // `decrypt_private_key` is fully typed; this method's `String` + // return is pre-existing model/wallet debt (crypto + key-parse + // errors mixed) tracked for a later type-through, so the typed + // error is rendered through its `Display` here. + let private_key = closed + .decrypt_private_key(password) + .map_err(|e| e.to_string())?; let open_key = OpenSingleKey { private_key, key_info: closed.clone(), @@ -119,16 +147,6 @@ impl SingleKeyData { } } - /// Closes the key by securely erasing the decrypted data - #[allow(dead_code)] - pub fn close(&mut self) { - if let SingleKeyData::Open(open_key) = self { - let key_info = open_key.key_info.clone(); - open_key.private_key.zeroize(); - *self = SingleKeyData::Closed(key_info); - } - } - /// Returns true if the key is open (decrypted) pub fn is_open(&self) -> bool { matches!(self, SingleKeyData::Open(_)) @@ -162,32 +180,36 @@ impl ClosedSingleKey { key_hash } - /// Encrypt a private key with a password - #[allow(clippy::type_complexity)] - pub fn encrypt_private_key( + /// Encrypt a private key with a password, returning the + /// [`EncryptedEnvelope`](super::encryption::EncryptedEnvelope). + pub(crate) fn encrypt_private_key( private_key: &[u8; 32], password: &str, - ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> { - use super::encryption::encrypt_message; - encrypt_message(private_key, password) + ) -> Result<super::encryption::EncryptedEnvelope, EncryptionError> { + super::encryption::encrypt_message(private_key, password) } /// Decrypt the private key using a password #[allow(deprecated)] - pub fn decrypt_private_key(&self, password: &str) -> Result<[u8; 32], String> { + pub fn decrypt_private_key(&self, password: &str) -> Result<[u8; 32], EncryptionError> { + // Both the derived AES key and the decrypted plaintext are + // secret-bearing; `derive_password_key` already returns a `Zeroizing` + // buffer, and the plaintext below is wrapped too, so both intermediates + // wipe on drop instead of lingering after the bytes are copied out. let key = derive_password_key(password, &self.salt)?; - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| EncryptionError::Malformed)?; let nonce_arr = Nonce::from_slice(&self.nonce); - let decrypted = cipher - .decrypt(nonce_arr, self.encrypted_private_key.as_slice()) - .map_err(|e| e.to_string())?; - - decrypted.try_into().map_err(|e: Vec<u8>| { - format!( - "invalid private key length, expected 32 bytes, got {} bytes", - e.len() - ) - }) + let decrypted = Zeroizing::new( + cipher + .decrypt(nonce_arr, self.encrypted_private_key.as_slice()) + .map_err(|_| EncryptionError::WrongPassword)?, + ); + + let bytes: [u8; 32] = decrypted + .as_slice() + .try_into() + .map_err(|_| EncryptionError::Malformed)?; + Ok(bytes) } } @@ -216,13 +238,17 @@ impl SingleKeyWallet { let key_hash = ClosedSingleKey::compute_key_hash(&private_key_bytes); let (private_key_data, uses_password) = if let Some(pwd) = password { - let (encrypted, salt, nonce) = - ClosedSingleKey::encrypt_private_key(&private_key_bytes, pwd)?; + // `encrypt_private_key` is fully typed; `new`'s `String` return is + // pre-existing model/wallet debt (crypto + key-parse errors mixed) + // tracked for a later type-through, so the typed error is rendered + // through its `Display` here. + let envelope = ClosedSingleKey::encrypt_private_key(&private_key_bytes, pwd) + .map_err(|e| e.to_string())?; let closed = ClosedSingleKey { key_hash, - encrypted_private_key: encrypted, - salt, - nonce, + encrypted_private_key: envelope.ciphertext, + salt: envelope.salt, + nonce: envelope.nonce, }; // Keep it open after creation ( @@ -427,4 +453,56 @@ mod tests { assert!(wallet.is_open()); assert!(!wallet.address.to_string().is_empty()); } + + /// TS-DBG-01 — `ClosedSingleKey`'s `{:?}` exposes no raw 32 bytes, in + /// neither hex nor decimal-array form, and the guarantee holds + /// transitively through `SingleKeyData::Closed` and a `SingleKeyWallet` + /// that holds it. + #[test] + fn ts_dbg_01_closed_single_key_debug_redacts_raw_bytes() { + use crate::wallet_backend::leak_test_support::{ + assert_no_leak_bytes, distinctive_secret_32, + }; + + let secret = distinctive_secret_32(); + // A no-password / pre-migration closed key holds the raw 32 bytes in + // `encrypted_private_key` — exactly the leak the fix guards. + let closed = ClosedSingleKey { + key_hash: ClosedSingleKey::compute_key_hash(&secret), + encrypted_private_key: secret.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + }; + let rendered = format!("{closed:?}"); + assert_no_leak_bytes(&rendered, &secret, "ClosedSingleKey Debug"); + assert!( + rendered.contains("[redacted]"), + "expected a redaction marker: {rendered}" + ); + + // Through SingleKeyData::Closed (derives Debug, holds the variant). + let data = SingleKeyData::Closed(closed.clone()); + assert_no_leak_bytes(&format!("{data:?}"), &secret, "SingleKeyData::Closed Debug"); + + // Through a SingleKeyWallet that holds the closed key. + let priv_key = + PrivateKey::from_byte_array(&secret, Network::Testnet).expect("valid key bytes"); + let secp = Secp256k1::new(); + let public_key = priv_key.public_key(&secp); + let address = Address::p2pkh(&public_key, Network::Testnet); + let wallet = SingleKeyWallet { + private_key_data: data, + uses_password: true, + public_key, + address, + alias: None, + key_hash: closed.key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: HashMap::new(), + core_wallet_name: None, + }; + assert_no_leak_bytes(&format!("{wallet:?}"), &secret, "SingleKeyWallet Debug"); + } } diff --git a/src/model/wallet/utxos.rs b/src/model/wallet/utxos.rs deleted file mode 100644 index c000f25f5..000000000 --- a/src/model/wallet/utxos.rs +++ /dev/null @@ -1,254 +0,0 @@ -use crate::context::AppContext; -use crate::database::{Database, WalletError}; -use crate::model::wallet::{DerivationPathHelpers, Wallet}; -use crate::spv::CoreBackendMode; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dashcore_rpc::json::ListUnspentResultEntry; -use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, TxOut}; -use std::collections::{BTreeMap, HashMap, HashSet}; - -impl Wallet { - /// Selects UTXOs sufficient to cover `amount + fee` without removing them from the wallet. - /// - /// Returns the selected UTXOs and an optional change amount, or `None` if there are - /// insufficient funds. The returned change value is computed from the caller-supplied - /// `fee` parameter; callers that recalculate fees afterward (e.g., based on the actual - /// number of inputs) should ignore the change value and recompute it. - /// - /// Use this when you need to inspect or validate the selection before - /// committing; call [`Self::remove_selected_utxos`] only once the operation - /// is guaranteed to succeed. - /// - /// **Important:** The caller must hold the wallet write lock (`&mut self` on `Wallet`) - /// continuously from this call through the corresponding [`Self::remove_selected_utxos`] - /// call. Dropping the lock between selection and removal would allow a concurrent - /// caller to select the same UTXOs, creating a double-spend. - #[allow(clippy::type_complexity)] - pub fn select_unspent_utxos_for( - &self, - amount: u64, - fee: u64, - allow_take_fee_from_amount: bool, - source_address: Option<&Address>, - ) -> Option<(BTreeMap<OutPoint, (TxOut, Address)>, Option<u64>)> { - let target = amount.checked_add(fee)?; - let mut required: i64 = i64::try_from(target).ok()?; - let mut selected_utxos = BTreeMap::new(); - - let iter: Box<dyn Iterator<Item = (&Address, &HashMap<OutPoint, TxOut>)>> = - match source_address { - Some(addr) => Box::new(self.utxos.get(addr).into_iter().map(move |m| (addr, m))), - None => Box::new(self.utxos.iter()), - }; - for (address, outpoints) in iter { - for (outpoint, tx_out) in outpoints.iter() { - if required <= 0 { - break; - } - selected_utxos.insert(*outpoint, (tx_out.clone(), address.clone())); - required -= tx_out.value as i64; - } - } - - if required > 0 { - if allow_take_fee_from_amount { - let total_collected = target as i64 - required; - if total_collected >= amount as i64 { - let missing_fee = required; // required > 0 - let adjusted_amount = amount as i64 - missing_fee; - if adjusted_amount <= 0 { - return None; - } - Some((selected_utxos, None)) - } else { - None - } - } else { - None - } - } else { - let total_input = target as i64 - required; - let change = total_input as u64 - amount - fee; - let change_option = if change > 0 { Some(change) } else { None }; - Some((selected_utxos, change_option)) - } - } - - /// Removes the given UTXOs from the wallet's in-memory UTXO set, persists - /// the removal to the database, and recalculates affected address balances. - /// - /// Typically called with the result of [`Self::select_unspent_utxos_for`] - /// after the transaction has been fully built and signed. - pub fn remove_selected_utxos( - &mut self, - selected: &BTreeMap<OutPoint, (TxOut, Address)>, - db: &Database, - network: Network, - ) -> Result<(), String> { - // Update in-memory UTXO map - for (outpoint, (_, address)) in selected { - if let Some(outpoints) = self.utxos.get_mut(address) { - outpoints.remove(outpoint); - if outpoints.is_empty() { - self.utxos.remove(address); - } - } else { - tracing::debug!( - ?outpoint, - %address, - "remove_selected_utxos: outpoint not found in wallet, skipping" - ); - } - } - - // Persist UTXO removals to the database - let network_str = network.to_string(); - for outpoint in selected.keys() { - db.drop_utxo(outpoint, &network_str) - .map_err(|e| e.to_string())?; - } - - // Recalculate and persist balances for affected addresses - self.recalculate_affected_address_balances_with_db(selected, db)?; - - Ok(()) - } - - /// Reload UTXOs from Core RPC, updating both in-memory state and database. - /// - /// Returns `true` if the UTXO set changed (something was added or - /// removed), `false` if nothing changed. In SPV mode this is a no-op - /// (`Ok(false)`) because the wallet state is authoritative — UTXOs are - /// synced continuously via compact block filters. - pub fn reload_utxos(&mut self, app_context: &AppContext) -> Result<bool, String> { - let network = app_context.network; - - // SPV wallet state is authoritative — reload is a no-op. - if app_context.core_backend_mode() == CoreBackendMode::Spv { - return Ok(false); - } - - let core_client = app_context - .core_client_for_wallet(self.core_wallet_name.as_deref()) - .map_err(|e| e.to_string())?; - - // Collect Core chain addresses for which we want to load UTXOs. - // Platform addresses are NOT valid on Core chain and must be excluded. - let addresses: Vec<_> = self - .known_addresses - .iter() - .filter(|(_, path)| !path.is_platform_payment(network)) - .map(|(addr, _)| addr) - .collect(); - if tracing::enabled!(tracing::Level::TRACE) { - for addr in addresses.iter() { - let (net, payload) = (*addr).clone().into_parts(); - tracing::trace!(net=net.to_string(),payload=?payload , "Address to load UTXOs for"); - } - } - - // Calling list_unspent with an empty addresses vector will return all UTXOs, - // which is not what we want here. Instead, we handle the empty case explicitly. - let utxos: Vec<ListUnspentResultEntry> = if addresses.is_empty() { - Vec::new() - } else { - core_client - .list_unspent(None, None, Some(&addresses), Some(false), None) - .map_err(|e| e.to_string())? - }; - - // Initialize the HashMap to store the new UTXOs. - let mut new_utxo_map = HashMap::new(); - // Build a set of new OutPoints for easy comparison. - let mut new_outpoints = HashSet::new(); - - // Iterate over the retrieved UTXOs and populate the HashMaps. - for utxo in utxos { - let outpoint = OutPoint::new(utxo.txid, utxo.vout); - let tx_out = TxOut { - value: utxo.amount.to_sat(), - script_pubkey: utxo.script_pub_key.clone(), - }; - new_utxo_map.insert(outpoint, tx_out); - new_outpoints.insert(outpoint); - } - - // Collect current UTXOs into a set for comparison - let mut old_outpoints = HashSet::new(); - for (_address, utxos) in self.utxos.iter() { - for (outpoint, _tx_out) in utxos.iter() { - old_outpoints.insert(*outpoint); - } - } - - // Determine UTXOs to be removed and added - let removed_outpoints: HashSet<_> = - old_outpoints.difference(&new_outpoints).cloned().collect(); - let added_outpoints: HashSet<_> = - new_outpoints.difference(&old_outpoints).cloned().collect(); - - let changed = !removed_outpoints.is_empty() || !added_outpoints.is_empty(); - - // Now update self.utxos by removing UTXOs not present in new_outpoints - let current_utxos = &mut self.utxos; - // Remove UTXOs that are no longer unspent - for utxos in current_utxos.values_mut() { - utxos.retain(|outpoint, _| new_outpoints.contains(outpoint)); - } - // Remove addresses with no UTXOs - current_utxos.retain(|_, utxos| !utxos.is_empty()); - - // Add new UTXOs to self.utxos - let current_utxos = &mut self.utxos; - for (outpoint, tx_out) in &new_utxo_map { - // Get the address from the script_pubkey - let address = Address::from_script(&tx_out.script_pubkey, network) - .map_err(|e| WalletError::AddressError(e).to_string())?; - // Add or update the UTXO in the wallet - current_utxos - .entry(address.clone()) - .or_default() - .insert(*outpoint, tx_out.clone()); - } - - // Persist changes to the database only when something actually changed - if changed { - let db = &app_context.db; - - for outpoint in removed_outpoints { - db.drop_utxo(&outpoint, &network.to_string()) - .map_err(|e| e.to_string())?; - } - - for outpoint in added_outpoints { - let tx_out = &new_utxo_map[&outpoint]; - let address = Address::from_script(&tx_out.script_pubkey, network) - .map_err(|e| WalletError::AddressError(e).to_string())?; - - db.insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - tx_out.script_pubkey.as_bytes(), - network, - ) - .map_err(|e| e.to_string())?; - } - } - - Ok(changed) - } - - /// Get all addresses with their total UTXO balances - pub fn utxos_by_address(&self) -> Vec<(Address, u64)> { - self.utxos - .iter() - .map(|(address, utxos)| { - let total_balance: u64 = utxos.values().map(|tx_out| tx_out.value).sum(); - (address.clone(), total_balance) - }) - .filter(|(_, balance)| *balance > 0) - .collect() - } -} diff --git a/src/spv/error.rs b/src/spv/error.rs deleted file mode 100644 index a4b72a5e0..000000000 --- a/src/spv/error.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Error types for SPV operations. - -use thiserror::Error; - -/// Errors that can occur during SPV operations. -#[derive(Debug, Error, Clone)] -pub enum SpvError { - /// A lock was poisoned (another thread panicked while holding it) - #[error("SPV lock poisoned: {0}")] - LockPoisoned(String), - - /// SPV client is not initialized - #[error("SPV client not initialized")] - ClientNotInitialized, - - /// SPV client is not running - #[error("SPV client not running")] - NotRunning, - - /// Sync operation failed - #[error("SPV sync failed: {0}")] - SyncFailed(String), - - /// Network operation failed - #[error("SPV network error: {0}")] - NetworkError(String), - - /// Wallet operation failed - #[error("SPV wallet error: {0}")] - WalletError(String), - - /// Configuration error - #[error("SPV configuration error: {0}")] - ConfigError(String), - - /// Channel communication error - #[error("SPV channel error: {0}")] - ChannelError(String), - - /// Generic error - #[error("{0}")] - Other(String), -} - -impl From<String> for SpvError { - fn from(s: String) -> Self { - SpvError::Other(s) - } -} - -impl From<&str> for SpvError { - fn from(s: &str) -> Self { - SpvError::Other(s.to_string()) - } -} - -/// Result type for SPV operations. -pub type SpvResult<T> = Result<T, SpvError>; diff --git a/src/spv/manager.rs b/src/spv/manager.rs deleted file mode 100644 index 2ce238e7c..000000000 --- a/src/spv/manager.rs +++ /dev/null @@ -1,1528 +0,0 @@ -use super::error::{SpvError, SpvResult}; -use crate::config::NetworkConfig; -use crate::context::connection_status::ConnectionStatus; -use crate::model::wallet::WalletSeedHash; -use crate::utils::tasks::TaskManager; -use dash_sdk::dash_spv::client::config::MempoolStrategy; -use dash_sdk::dash_spv::client::event_handler::EventHandler; -use dash_sdk::dash_spv::network::NetworkEvent; -use dash_sdk::dash_spv::network::PeerNetworkManager; -use dash_sdk::dash_spv::storage::DiskStorageManager; -use dash_sdk::dash_spv::sync::SyncEvent; -use dash_sdk::dash_spv::sync::SyncProgress as SpvSyncProgress; -use dash_sdk::dash_spv::sync::SyncState; -use dash_sdk::dash_spv::types::ValidationMode; -use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash}; -use dash_sdk::dpp::dashcore::{Address, InstantLock, Network, Transaction, Txid}; -use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ - ManagedWalletInfo, transaction_building::AccountTypePreference, - wallet_info_interface::WalletInfoInterface, -}; -use dash_sdk::dpp::key_wallet_manager::{ - WalletError, WalletEvent, WalletId, WalletInterface, WalletManager, -}; -use std::fmt; -use std::fs; -use std::net::ToSocketAddrs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::SystemTime; -use tokio::sync::RwLock as AsyncRwLock; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use zeroize::Zeroize; - -/// Preferred backend for Core-level operations. -/// -/// SPV is the default for both fresh installs and unknown/invalid persisted -/// values. The enum-level default and the `From<u8>` fallback intentionally -/// match the DB column default and the runtime default in `AppContext` — they -/// must be kept in sync to avoid silent contradictions at startup. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum CoreBackendMode { - Rpc = 0, - #[default] - Spv = 1, -} - -impl CoreBackendMode { - pub fn as_u8(self) -> u8 { - self as u8 - } -} - -impl From<u8> for CoreBackendMode { - fn from(value: u8) -> Self { - match value { - 0 => CoreBackendMode::Rpc, - _ => CoreBackendMode::Spv, - } - } -} - -/// High-level status of the SPV client runtime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum SpvStatus { - #[default] - Idle = 0, - Starting = 1, - Syncing = 2, - Running = 3, - Stopping = 4, - Stopped = 5, - Error = 6, -} - -impl SpvStatus { - pub fn is_active(self) -> bool { - matches!( - self, - SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Running | SpvStatus::Stopping - ) - } -} - -impl std::fmt::Display for SpvStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SpvStatus::Idle => write!(f, "Idle"), - SpvStatus::Starting => write!(f, "Starting"), - SpvStatus::Syncing => write!(f, "Syncing"), - SpvStatus::Running => write!(f, "Running"), - SpvStatus::Stopping => write!(f, "Stopping"), - SpvStatus::Stopped => write!(f, "Stopped"), - SpvStatus::Error => write!(f, "Error"), - } - } -} - -impl From<u8> for SpvStatus { - fn from(value: u8) -> Self { - match value { - 0 => SpvStatus::Idle, - 1 => SpvStatus::Starting, - 2 => SpvStatus::Syncing, - 3 => SpvStatus::Running, - 4 => SpvStatus::Stopping, - 5 => SpvStatus::Stopped, - 6 => SpvStatus::Error, - _ => SpvStatus::Idle, - } - } -} - -/// Snapshot of the SPV runtime state for UI consumption. -/// Uses dash-spv's built-in progress types directly instead of duplicating. -#[derive(Debug, Clone, Default)] -pub struct SpvStatusSnapshot { - pub status: SpvStatus, - pub sync_progress: Option<SpvSyncProgress>, - pub last_error: Option<String>, - pub started_at: Option<SystemTime>, - pub last_updated: Option<SystemTime>, - pub connected_peers: usize, -} - -/// Type alias for the SPV client with our specific configuration -type SpvClient = DashSpvClient< - WalletManager<ManagedWalletInfo>, - PeerNetworkManager, - DiskStorageManager, - SpvEventHandler, ->; - -/// EventHandler implementation that bridges dash-spv push events into -/// SpvManager's shared state (progress, status, peer count, errors). -/// -/// Constructed during `build_client()` and owned by the SPV client. -/// All fields are `Arc`-wrapped so they share state with SpvManager. -pub(crate) struct SpvEventHandler { - sync_progress_state: Arc<RwLock<Option<SpvSyncProgress>>>, - progress_updated_at: Arc<RwLock<Option<SystemTime>>>, - status: Arc<RwLock<SpvStatus>>, - last_error: Arc<RwLock<Option<String>>>, - connected_peers: Arc<RwLock<usize>>, - connection_status: Option<Arc<ConnectionStatus>>, - reconcile_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>, - finality_tx: Arc<Mutex<Option<mpsc::Sender<AssetLockFinalityEvent>>>>, - /// Wallet manager reference for applying InstantSend locks directly. - /// - /// Self-broadcast transactions bypass the MempoolManager and are fed to - /// the WalletManager via `notify_wallet_after_broadcast()`. When the IS - /// lock arrives later, the MempoolManager doesn't know about the tx and - /// cannot apply the lock. We apply it here directly on the WalletManager. - wallet: Arc<AsyncRwLock<WalletManager<ManagedWalletInfo>>>, -} - -impl EventHandler for SpvEventHandler { - fn on_progress(&self, progress: &SpvSyncProgress) { - let is_synced = progress.is_synced(); - let is_error = progress.state() == SyncState::Error; - - // Update sync progress state (read by UI tooltip and progress bars). - if let Ok(mut stored) = self.sync_progress_state.write() { - *stored = Some(progress.clone()); - } - if let Ok(mut updated_at) = self.progress_updated_at.write() { - *updated_at = Some(SystemTime::now()); - } - - // Update SpvStatus based on progress. - let new_status = if let Ok(mut status_guard) = self.status.write() { - if is_synced { - *status_guard = SpvStatus::Running; - Some(SpvStatus::Running) - } else if is_error { - *status_guard = SpvStatus::Error; - Some(SpvStatus::Error) - } else if !matches!( - *status_guard, - SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error - ) { - *status_guard = SpvStatus::Syncing; - Some(SpvStatus::Syncing) - } else { - None - } - } else { - None - }; - - // Write last_error outside status lock (consistent lock ordering). - let mut error_msg = None; - if is_error - && let Ok(mut err_guard) = self.last_error.write() - && err_guard.is_none() - { - let phase = SpvManager::failed_manager_name(progress); - let msg = format!("Sync failed: {phase} (reported by SPV progress channel)"); - *err_guard = Some(msg.clone()); - error_msg = Some(msg); - } - - // Push to ConnectionStatus for the status indicator. - if let Some(cs) = &self.connection_status { - if let Some(s) = new_status { - cs.set_spv_status(s); - } - if let Some(msg) = error_msg { - cs.set_spv_last_error(Some(msg)); - } else if !is_error - && !matches!(self.status.read().ok().as_deref(), Some(&SpvStatus::Error)) - { - // Only clear errors when status is not Error — otherwise - // on_sync_event(ManagerError) sets the error but the next - // on_progress call would immediately clear it. - cs.set_spv_last_error(None); - } - cs.refresh_state(); - } - } - - fn on_sync_event(&self, event: &SyncEvent) { - // Transition to Running on SyncComplete. - if matches!(event, SyncEvent::SyncComplete { .. }) { - if let Ok(mut guard) = self.status.write() { - *guard = SpvStatus::Running; - } - if let Some(cs) = &self.connection_status { - cs.set_spv_status(SpvStatus::Running); - cs.refresh_state(); - } - } - - // Transition to Error on ManagerError. - if let SyncEvent::ManagerError { manager, error } = event { - tracing::error!("SPV manager {manager} reported error: {error}"); - if let Ok(mut guard) = self.status.write() { - *guard = SpvStatus::Error; - } - let limit = error.floor_char_boundary(100); - let msg = format!("Sync manager {manager} failed: {}", &error[..limit]); - if let Ok(mut err_guard) = self.last_error.write() { - if err_guard.is_none() { - *err_guard = Some(msg.clone()); - } else { - tracing::warn!( - %manager, error, "SPV last_error already set, ignoring: {msg}" - ); - } - } - if let Some(cs) = &self.connection_status { - cs.set_spv_status(SpvStatus::Error); - cs.set_spv_last_error(Some(msg)); - cs.refresh_state(); - } - } - - // Signal reconciliation for wallet-relevant events. - let should_signal = matches!( - event, - SyncEvent::BlockProcessed { .. } - | SyncEvent::ChainLockReceived { .. } - | SyncEvent::InstantLockReceived { .. } - | SyncEvent::SyncComplete { .. } - ); - - // TODO(workaround): Remove once dashpay/rust-dashcore#487 is fixed. - // - // Apply InstantSend locks directly on the WalletManager. - // - // Self-broadcast transactions bypass the MempoolManager (they are fed - // directly to WalletManager via notify_wallet_after_broadcast — see - // the other workaround in spawn_request_handler). When the IS lock - // arrives from the network, the MempoolManager doesn't know about - // the tx and stores it as a "pending IS lock" that is never matched. - // Applying the lock here ensures self-broadcast txs transition from - // unconfirmed to spendable. - // - // Once upstream broadcast calls handle_tx() on the MempoolManager, - // both workarounds (notify_wallet_after_broadcast and this) can be - // removed — the normal MempoolManager pipeline will handle everything. - // - // For MempoolManager-tracked txs this is a harmless no-op — the - // WalletManager deduplicates via its instant_send_locks HashSet. - if let SyncEvent::InstantLockReceived { instant_lock, .. } = event { - let wallet = Arc::clone(&self.wallet); - let islock = instant_lock.clone(); - tokio::spawn(async move { - let mut wm = wallet.write().await; - wm.process_instant_send_lock(islock); - }); - } - - // Forward finality-relevant events for asset lock proof construction. - let finality_tx = self.finality_tx.lock().ok().and_then(|g| g.clone()); - if let Some(ref ftx) = finality_tx { - match event { - SyncEvent::InstantLockReceived { instant_lock, .. } => { - if let Err(e) = ftx.try_send(AssetLockFinalityEvent::InstantLock { - txid: instant_lock.txid, - instant_lock: Box::new(instant_lock.clone()), - }) { - tracing::warn!( - "Failed to forward InstantLock for txid {}: {e}", - instant_lock.txid - ); - } - } - SyncEvent::ChainLockReceived { chain_lock, .. } => { - if let Err(e) = ftx.try_send(AssetLockFinalityEvent::ChainLock { - height: chain_lock.block_height, - }) { - tracing::warn!( - "Failed to forward ChainLock for height {}: {e}", - chain_lock.block_height - ); - } - } - _ => {} - } - } - - if should_signal && let Some(tx) = self.reconcile_tx.lock().ok().and_then(|g| g.clone()) { - // Silently discard full-channel errors — reconcile is debounced downstream. - let _ = tx.try_send(()); - } - } - - fn on_network_event(&self, event: &NetworkEvent) { - if let NetworkEvent::PeersUpdated { - connected_count, .. - } = event - { - if let Ok(mut guard) = self.connected_peers.write() { - *guard = *connected_count; - } - if let Some(cs) = &self.connection_status { - cs.set_spv_connected_peers((*connected_count).min(u16::MAX as usize) as u16); - cs.refresh_state(); - } - } - } -} - -/// Events forwarded from SPV to AppContext for asset lock proof construction. -pub(crate) enum AssetLockFinalityEvent { - InstantLock { - txid: Txid, - instant_lock: Box<InstantLock>, - }, - ChainLock { - height: u32, - }, -} - -/// Manages SPV client lifecycle and exposes status updates. -/// Uses dash-spv's built-in state management, running as a spawned task on the main tokio runtime. -/// -/// The client itself is owned by the background task and accessed through -/// its internally-shared components (wallet, storage, etc.) rather than through additional locking. -pub struct SpvManager { - network: Network, - data_dir: PathBuf, - config: Arc<RwLock<NetworkConfig>>, - subtasks: Arc<TaskManager>, - wallet: Arc<AsyncRwLock<WalletManager<ManagedWalletInfo>>>, - // Storage manager for direct access to SPV data (shared component from client) - storage: Arc<Mutex<Option<Arc<tokio::sync::Mutex<DiskStorageManager>>>>>, - // Clone of the running SPV client for direct queries (quorum lookups, etc.) - spv_client: Arc<RwLock<Option<SpvClient>>>, - status: Arc<RwLock<SpvStatus>>, - last_error: Arc<RwLock<Option<String>>>, - started_at: Arc<RwLock<Option<SystemTime>>>, - sync_progress_state: Arc<RwLock<Option<SpvSyncProgress>>>, - progress_updated_at: Arc<RwLock<Option<SystemTime>>>, - // mapping DET wallet seed_hash -> SPV wallet identifier (if created) - det_wallets: Arc<RwLock<std::collections::BTreeMap<[u8; 32], WalletId>>>, - // signal channel to trigger external reconcile on wallet-related events - reconcile_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>, - // signal channel to forward instant lock / chain lock events for asset lock proof construction - finality_tx: Arc<Mutex<Option<mpsc::Sender<AssetLockFinalityEvent>>>>, - // Whether to use local Dash Core node instead of DNS seed discovery - use_local_node: Arc<std::sync::atomic::AtomicBool>, - // Cancellation token for clean shutdown - stop_token: Mutex<Option<CancellationToken>>, - // Channel to send requests to the SPV runtime thread - request_tx: Mutex<Option<mpsc::Sender<SpvRequest>>>, - // Network manager clone for broadcasting transactions (set when client is running) - network_manager: Arc<AsyncRwLock<Option<PeerNetworkManager>>>, - // Number of currently connected SPV peers - connected_peers: Arc<RwLock<usize>>, - // Push SPV status updates to ConnectionStatus (set after construction) - connection_status: Mutex<Option<Arc<ConnectionStatus>>>, -} - -/// Requests that can be sent to the SPV runtime thread -/// -/// Note: These requests are handled in the same async context where the client lives, -/// allowing direct access to client methods without additional locking overhead. -enum SpvRequest { - BroadcastTransaction { - tx: Box<Transaction>, - response_tx: tokio::sync::oneshot::Sender<Result<(), String>>, - }, -} - -#[derive(Debug, Clone)] -pub struct SpvDerivedAddress { - pub address: Address, - pub derivation_path: DerivationPath, -} - -impl SpvManager { - // ==================== Lock Helper Methods ==================== - // These methods provide safe access to locks with proper error handling - // instead of panicking on lock poisoning. - - fn read_status(&self) -> SpvResult<SpvStatus> { - self.status - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("status".into())) - } - - fn write_status(&self, value: SpvStatus) -> SpvResult<()> { - // NOTE: spawn_progress_watcher() bypasses this method because it runs in an - // async move closure that captures Arc<RwLock<SpvStatus>> directly (no &self). - // If you add side-effects here, replicate them in spawn_progress_watcher() - // (around line ~1107). - let mut guard = self - .status - .write() - .map_err(|_| SpvError::LockPoisoned("status".into()))?; - *guard = value; - // Push every status transition to ConnectionStatus so the UI - // reflects changes immediately, without waiting for async watchers. - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_status(value); - cs.refresh_state(); - } - Ok(()) - } - - fn read_last_error(&self) -> SpvResult<Option<String>> { - self.last_error - .read() - .map(|g| g.clone()) - .map_err(|_| SpvError::LockPoisoned("last_error".into())) - } - - fn write_last_error(&self, value: Option<String>) -> SpvResult<()> { - // TODO: Push to ConnectionStatus here (like write_status does) to eliminate - // the scattered `cs.set_spv_last_error()` calls at each callsite. - // See: run_client exit (line ~451), sync manager error (line ~950), - // progress watcher (line ~1153), sync event handler (line ~1246). - let mut guard = self - .last_error - .write() - .map_err(|_| SpvError::LockPoisoned("last_error".into()))?; - *guard = value; - Ok(()) - } - - fn read_started_at(&self) -> SpvResult<Option<SystemTime>> { - self.started_at - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("started_at".into())) - } - - fn write_started_at(&self, value: Option<SystemTime>) -> SpvResult<()> { - let mut guard = self - .started_at - .write() - .map_err(|_| SpvError::LockPoisoned("started_at".into()))?; - *guard = value; - Ok(()) - } - - fn read_sync_progress(&self) -> SpvResult<Option<SpvSyncProgress>> { - self.sync_progress_state - .read() - .map(|g| g.clone()) - .map_err(|_| SpvError::LockPoisoned("sync_progress".into())) - } - - fn write_sync_progress(&self, value: Option<SpvSyncProgress>) -> SpvResult<()> { - let mut guard = self - .sync_progress_state - .write() - .map_err(|_| SpvError::LockPoisoned("sync_progress".into()))?; - *guard = value; - Ok(()) - } - - fn read_progress_updated_at(&self) -> SpvResult<Option<SystemTime>> { - self.progress_updated_at - .read() - .map(|g| *g) - .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into())) - } - - fn write_progress_updated_at(&self, value: Option<SystemTime>) -> SpvResult<()> { - let mut guard = self - .progress_updated_at - .write() - .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into()))?; - *guard = value; - Ok(()) - } - - // ==================== Public API ==================== - - pub fn new( - app_data_dir: &Path, - network: Network, - config: Arc<RwLock<NetworkConfig>>, - subtasks: Arc<TaskManager>, - ) -> Result<Arc<Self>, String> { - let cfg = config.read().map_err(|e| e.to_string())?; - let data_dir = build_spv_data_dir(app_data_dir, network, &cfg)?; - drop(cfg); - fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create SPV data dir: {e}"))?; - - let manager = Arc::new(Self { - network, - data_dir, - config, - subtasks, - wallet: Arc::new(AsyncRwLock::new(WalletManager::<ManagedWalletInfo>::new( - network, - ))), - storage: Arc::new(Mutex::new(None)), - spv_client: Arc::new(RwLock::new(None)), - status: Arc::new(RwLock::new(SpvStatus::Idle)), - last_error: Arc::new(RwLock::new(None)), - started_at: Arc::new(RwLock::new(None)), - sync_progress_state: Arc::new(RwLock::new(None)), - progress_updated_at: Arc::new(RwLock::new(None)), - det_wallets: Arc::new(RwLock::new(std::collections::BTreeMap::new())), - reconcile_tx: Arc::new(Mutex::new(None)), - finality_tx: Arc::new(Mutex::new(None)), - use_local_node: Arc::new(AtomicBool::new(false)), - stop_token: Mutex::new(None), - request_tx: Mutex::new(None), - network_manager: Arc::new(AsyncRwLock::new(None)), - connected_peers: Arc::new(RwLock::new(0)), - connection_status: Mutex::new(None), - }); - - Ok(manager) - } - - /// Set whether to use local Dash Core node for SPV sync instead of DNS seed discovery. - /// Note: This only takes effect when starting a new SPV sync session. - pub fn set_use_local_node(&self, use_local: bool) { - self.use_local_node.store(use_local, Ordering::SeqCst); - } - - /// Get whether to use local Dash Core node for SPV sync. - pub fn use_local_node(&self) -> bool { - self.use_local_node.load(Ordering::SeqCst) - } - - /// Set the ConnectionStatus to receive push-based SPV status updates. - /// Must be called before `start()` so event handlers can push updates. - pub fn set_connection_status(&self, cs: Arc<ConnectionStatus>) { - if let Ok(mut guard) = self.connection_status.lock() { - *guard = Some(cs); - } - } - - fn connection_status_snapshot(&self) -> Option<Arc<ConnectionStatus>> { - self.connection_status.lock().ok().and_then(|g| g.clone()) - } - - /// Async status method for getting full details including progress. - /// Returns default snapshot on lock errors to avoid panics. - pub async fn status_async(&self) -> SpvStatusSnapshot { - let status = self.read_status().unwrap_or(SpvStatus::Idle); - let last_error = self.read_last_error().unwrap_or(None); - let started_at = self.read_started_at().unwrap_or(None); - let sync_progress = self.read_sync_progress().unwrap_or(None); - let last_updated = self - .read_progress_updated_at() - .unwrap_or(None) - .or(Some(SystemTime::now())); - let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); - - SpvStatusSnapshot { - status, - sync_progress, - last_error, - started_at, - last_updated, - connected_peers, - } - } - - /// Sync status method for UI updates (doesn't fetch detailed progress). - /// Returns default snapshot on lock errors to avoid panics. - pub fn status(&self) -> SpvStatusSnapshot { - let status = self.read_status().unwrap_or(SpvStatus::Idle); - let last_error = self.read_last_error().unwrap_or(None); - let started_at = self.read_started_at().unwrap_or(None); - let sync_progress = self.read_sync_progress().unwrap_or(None); - let last_updated = self - .read_progress_updated_at() - .unwrap_or(None) - .or(Some(SystemTime::now())); - let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); - - SpvStatusSnapshot { - status, - sync_progress, - last_error, - started_at, - last_updated, - connected_peers, - } - } - - pub fn start(self: &Arc<Self>, expected_wallet_count: usize) -> Result<(), String> { - // Check if already running - { - let stop_token_guard = self - .stop_token - .lock() - .map_err(|_| "SPV stop_token lock poisoned")?; - if stop_token_guard.is_some() { - return Ok(()); - } - } - - self.write_status(SpvStatus::Starting) - .map_err(|e| e.to_string())?; - self.write_last_error(None).map_err(|e| e.to_string())?; - self.write_started_at(Some(SystemTime::now())) - .map_err(|e| e.to_string())?; - self.write_sync_progress(None).map_err(|e| e.to_string())?; - self.write_progress_updated_at(None) - .map_err(|e| e.to_string())?; - - // Derive stop_token as a child of the global cancellation token so that - // global shutdown automatically cancels SPV without requiring an explicit - // SpvManager::stop() call. SpvManager::stop() can still cancel it early. - let stop_token = self.subtasks.cancellation_token.child_token(); - { - let mut guard = self - .stop_token - .lock() - .map_err(|_| "SPV stop_token lock poisoned")?; - *guard = Some(stop_token.clone()); - } - - let manager = Arc::clone(self); - - // Spawn SPV loop as a task on the main tokio runtime - self.subtasks.spawn_sync("spv_main_loop", async move { - let manager_for_loop = Arc::clone(&manager); - if let Err(err) = manager_for_loop - .run_spv_loop(stop_token, expected_wallet_count) - .await - { - tracing::error!(error = %err, network = ?manager.network, "SPV runtime failed"); - if let Err(e) = manager.write_last_error(Some(err.clone())) { - tracing::error!("Failed to write SPV error: {}", e); - } - if let Err(e) = manager.write_status(SpvStatus::Error) { - tracing::error!("Failed to write SPV status: {}", e); - } - // Push last_error separately — write_status already pushed the status. - if let Some(cs) = manager.connection_status_snapshot() { - cs.set_spv_last_error(Some(err)); - } - } - - // Clean up on exit - if let Ok(mut guard) = manager.stop_token.lock() { - *guard = None; - } - }); - - Ok(()) - } - - pub fn stop(&self) { - tracing::info!("SpvManager::stop() called"); - let maybe_token = self.stop_token.lock().ok().and_then(|g| g.clone()); - - if let Some(token) = maybe_token { - tracing::info!("SpvManager::stop(): cancelling stop_token, setting Stopping"); - let _ = self.write_status(SpvStatus::Stopping); - token.cancel(); - } else { - tracing::debug!("SpvManager::stop(): no stop_token, setting Stopped immediately"); - let _ = self.write_status(SpvStatus::Stopped); - } - } - - pub fn wallet(&self) -> Arc<AsyncRwLock<WalletManager<ManagedWalletInfo>>> { - Arc::clone(&self.wallet) - } - - pub fn det_wallets_snapshot(&self) -> std::collections::BTreeMap<[u8; 32], WalletId> { - self.det_wallets - .read() - .map(|m| m.clone()) - .unwrap_or_default() - } - - pub fn wallet_id_for_seed(&self, seed_hash: WalletSeedHash) -> Option<WalletId> { - self.det_wallets - .read() - .ok() - .and_then(|map| map.get(&seed_hash).copied()) - } - - pub async fn unload_wallet(&self, seed_hash: WalletSeedHash) -> Result<(), String> { - let wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash).copied() - }; - - let Some(wallet_id) = wallet_id else { - return Ok(()); - }; - - let mut wm = self.wallet.write().await; - match wm.remove_wallet(&wallet_id) { - Ok((_wallet, _info)) => { - drop(wm); - let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; - map.remove(&seed_hash); - Ok(()) - } - Err(WalletError::WalletNotFound(_)) => Ok(()), - Err(err) => Err(format!("Failed to unload SPV wallet: {err}")), - } - } - - pub async fn broadcast_transaction(&self, tx: &Transaction) -> Result<(), String> { - let request_tx = self - .request_tx - .lock() - .map_err(|_| "SPV request_tx lock poisoned")? - .clone() - .ok_or_else(|| "SPV client not running".to_string())?; - - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - - request_tx - .send(SpvRequest::BroadcastTransaction { - tx: Box::new(tx.clone()), - response_tx, - }) - .await - .map_err(|_| "SPV runtime channel closed".to_string())?; - - response_rx - .await - .map_err(|_| "SPV request cancelled".to_string())? - } - - /// Create a reconciliation signal channel for external listeners. - /// Returns a receiver that will get a signal when SPV wallet state likely changed. - /// - /// Only one subscriber is supported at a time. Calling this again replaces - /// the previous sender, so the earlier receiver will stop receiving signals. - pub fn register_reconcile_channel(&self) -> mpsc::Receiver<()> { - let (tx, rx) = mpsc::channel(64); - if let Ok(mut guard) = self.reconcile_tx.lock() { - *guard = Some(tx); - } - rx - } - - /// Create a finality event channel for external listeners. - /// Returns a receiver that will get events when SPV detects instant locks or chain locks. - /// - /// Only one subscriber is supported at a time. Calling this again replaces - /// the previous sender, so the earlier receiver will stop receiving events. - pub(crate) fn register_finality_channel(&self) -> mpsc::Receiver<AssetLockFinalityEvent> { - let (tx, rx) = mpsc::channel(64); - if let Ok(mut guard) = self.finality_tx.lock() { - *guard = Some(tx); - } - rx - } - - /// Remove all cached SPV data on disk for the current network. - /// - /// This requires the SPV runtime to be stopped first; otherwise the - /// on-disk files could be re-created immediately by the running client. - pub fn clear_data_dir(&self) -> Result<(), String> { - let status = self.read_status().map_err(|e| e.to_string())?; - if status.is_active() { - return Err("Stop the SPV client before clearing its data".to_string()); - } - - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_guard = None; - } - - if let Ok(mut client_guard) = self.spv_client.write() { - *client_guard = None; - } - - if let Ok(mut request_guard) = self.request_tx.lock() { - *request_guard = None; - } - - if let Ok(mut wallet_map) = self.det_wallets.write() { - wallet_map.clear(); - } - - // Reset the in-memory WalletManager's filter_committed_height so the next - // SPV session scans filters from genesis instead of the stale height from the - // previous run. We reset filter_committed_height (not synced_height) because at - // rust-dashcore 309fac8 these became independent fields — FiltersManager::new() - // reads filter_committed_height() for its "already synced" guard. - // - // This must succeed before we wipe the on-disk data; otherwise the in-memory - // height would stay stale while on-disk filters are gone, re-triggering the - // skipped-rescan bug this clear is meant to prevent. - { - let mut wm = self.wallet.try_write().map_err(|e| { - format!( - "Failed to reset WalletManager filter_committed_height during SPV data clear: {e}" - ) - })?; - wm.update_filter_committed_height(0); - } - - self.write_sync_progress(None).map_err(|e| e.to_string())?; - self.write_progress_updated_at(None) - .map_err(|e| e.to_string())?; - self.write_started_at(None).map_err(|e| e.to_string())?; - self.write_last_error(None).map_err(|e| e.to_string())?; - self.write_status(SpvStatus::Idle) - .map_err(|e| e.to_string())?; - if let Ok(mut guard) = self.connected_peers.write() { - *guard = 0; - } - - if self.data_dir.exists() { - fs::remove_dir_all(&self.data_dir).map_err(|e| { - format!( - "Failed to clear SPV data directory {}: {e}", - self.data_dir.display() - ) - })?; - } - - fs::create_dir_all(&self.data_dir).map_err(|e| { - format!( - "Failed to re-create SPV data directory {}: {e}", - self.data_dir.display() - ) - })?; - - Ok(()) - } - - /// Attempt to resolve a quorum public key via the SPV client's masternode/quorum state. - /// - /// Queries the running SPV client directly. If SPV is not running or the key is not - /// known, an error is returned. - pub fn get_quorum_public_key( - &self, - quorum_type: u32, - quorum_hash: [u8; 32], - core_chain_locked_height: u32, - ) -> Result<[u8; 48], String> { - tracing::debug!( - "get_quorum_public_key called: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - - let client = { - let guard = self - .spv_client - .read() - .map_err(|e| format!("spv_client lock poisoned: {e}"))?; - guard - .clone() - .ok_or_else(|| "SPV client not initialized".to_string())? - }; - - let llmq_type = LLMQType::from(quorum_type as u8); - let qh = QuorumHash::from_byte_array(quorum_hash).reverse(); - - tracing::debug!( - "SPV quorum public key lookup in progress: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - client - .get_quorum_at_height(core_chain_locked_height, llmq_type, qh) - .await - .map(|q| { - tracing::debug!( - "Quorum public key found: type={}, hash={}, height={}", - quorum_type, - hex::encode(quorum_hash), - core_chain_locked_height - ); - *q.quorum_entry.quorum_public_key.as_ref() - }) - .map_err(|e| { - tracing::warn!( - "Quorum lookup failed at height {} for llmq_type={} hash=0x{}: {}", - core_chain_locked_height, - quorum_type, - hex::encode(quorum_hash), - e - ); - e.to_string() - }) - }) - }) - } - - pub async fn load_wallet_from_seed( - &self, - seed_hash: WalletSeedHash, - mut seed_bytes: [u8; 64], - ) -> Result<WalletId, String> { - let existing_wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash).copied() - }; - - let mut wm = self.wallet.write().await; - - if let Some(wallet_id) = existing_wallet_id { - if let Some(wallet) = wm.get_wallet(&wallet_id) - && wallet.can_sign() - { - seed_bytes.zeroize(); - return Ok(wallet_id); - } - - if let Err(err) = wm.remove_wallet(&wallet_id) { - tracing::warn!(wallet = %hex::encode(wallet_id), ?err, "Failed to remove existing SPV wallet before upgrade"); - } else { - tracing::info!(wallet = %hex::encode(wallet_id), "Upgrading SPV wallet from watch-only to full access"); - } - } - - let xprv = ExtendedPrivKey::new_master(self.network, &seed_bytes).map_err(|e| { - seed_bytes.zeroize(); - format!("ExtendedPrivKey::new_master failed: {e}") - })?; - seed_bytes.zeroize(); - let mut xprv_str = xprv.to_string(); - - let account_options = Self::default_account_creation_options(); - - let wallet_id = match wm.import_wallet_from_extended_priv_key(&xprv_str, account_options) { - Ok(id) => { - xprv_str.zeroize(); - id - } - Err(WalletError::WalletExists(id)) => { - xprv_str.zeroize(); - id - } - Err(err) => { - xprv_str.zeroize(); - return Err(format!( - "import_wallet_from_extended_priv_key failed: {err}" - )); - } - }; - - drop(wm); - - let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; - map.insert(seed_hash, wallet_id); - - Ok(wallet_id) - } - - pub async fn next_bip44_receive_address( - &self, - seed_hash: WalletSeedHash, - account_index: u32, - ) -> Result<SpvDerivedAddress, String> { - let wallet_id = { - let map = self.det_wallets.read().map_err(|e| e.to_string())?; - map.get(&seed_hash) - .copied() - .ok_or_else(|| "Wallet seed not loaded into SPV".to_string())? - }; - - let mut wm = self.wallet.write().await; - - let result = wm - .get_receive_address( - &wallet_id, - account_index, - AccountTypePreference::BIP44, - true, - ) - .map_err(|e| format!("get_receive_address failed: {e}"))?; - - let address = result - .address - .ok_or_else(|| "Wallet manager did not return an address".to_string())?; - - let derivation_path = { - let info = wm - .get_wallet_info(&wallet_id) - .ok_or_else(|| "wallet info missing".to_string())?; - let collection = info.accounts(); - let account = collection - .standard_bip44_accounts - .get(&account_index) - .ok_or_else(|| "BIP44 account missing".to_string())?; - let metadata = account - .get_address_info(&address) - .ok_or_else(|| "Address metadata unavailable".to_string())?; - metadata.path - }; - - Ok(SpvDerivedAddress { - address, - derivation_path, - }) - } - - fn default_account_creation_options() -> WalletAccountCreationOptions { - WalletAccountCreationOptions::Default - } - - async fn run_spv_loop( - self: Arc<Self>, - stop_token: CancellationToken, - expected_wallet_count: usize, - ) -> Result<(), String> { - // Wait for all expected wallets to be fully loaded into the WalletManager - // before building the client. Wallet loading happens via queue_spv_wallet_load - // which calls import_wallet_from_extended_priv_key (derives all accounts and - // addresses) under a write lock. Once wallet_count() reaches the expected - // value, all addresses are derived and monitored_addresses() is populated. - if expected_wallet_count > 0 { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - loop { - { - let wm = self.wallet.read().await; - let loaded = wm.wallet_count(); - if loaded >= expected_wallet_count { - let addr_count = wm.monitored_addresses().len(); - tracing::info!( - expected = expected_wallet_count, - loaded, - addresses = addr_count, - "SPV: all wallets loaded with monitored addresses, proceeding" - ); - break; - } - } - if tokio::time::Instant::now() >= deadline { - let wm = self.wallet.read().await; - tracing::warn!( - expected = expected_wallet_count, - loaded = wm.wallet_count(), - "SPV: timed out waiting for all wallets to load, proceeding anyway" - ); - break; - } - if stop_token.is_cancelled() { - return Ok(()); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - } - - // Build the client (run() will call start() internally) - let has_wallets = expected_wallet_count > 0; - let client = self.build_client(has_wallets).await?; - - // Store the shared storage reference for later access - { - let storage = client.storage(); - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_guard = Some(storage); - } - } - - // Subscribe to wallet events (broadcast from WalletManager) - // Note: sync/network/progress events are now dispatched internally by - // DashSpvClient via the EventHandler trait — no manual subscription needed. - { - let wm = self.wallet.read().await; - let wallet_rx = wm.subscribe_events(); - self.spawn_wallet_event_handler(wallet_rx); - } - - // Set up request handler with access to shared components - let (request_tx, request_rx) = mpsc::channel(32); - { - if let Ok(mut guard) = self.request_tx.lock() { - *guard = Some(request_tx); - } - } - - // Spawn request handler in a separate task - self.spawn_request_handler(request_rx, stop_token.clone()); - - // Store a clone of the client for external queries (quorum lookups, etc.) - { - let mut guard = self - .spv_client - .write() - .map_err(|e| format!("spv_client lock poisoned: {e}"))?; - *guard = Some(client.clone()); - } - - let _ = self.write_status(SpvStatus::Syncing); - - // Run sync and monitor with the client owned in this scope - let result = self.clone().run_client(client, stop_token).await; - - // Clear the client reference and network manager since the client is done - { - if let Ok(mut guard) = self.spv_client.write() { - *guard = None; - } - } - { - let mut nm_guard = self.network_manager.write().await; - *nm_guard = None; - } - if let Ok(mut guard) = self.connected_peers.write() { - *guard = 0; - } - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_connected_peers(0); - } - { - // Drop shared storage/request handles so the disk lock is released before restart. - if let Ok(mut storage_guard) = self.storage.lock() { - *storage_guard = None; - } - if let Ok(mut guard) = self.request_tx.lock() { - *guard = None; - } - } - - result - } - - async fn run_client( - self: Arc<Self>, - client: SpvClient, - stop_token: CancellationToken, - ) -> Result<(), String> { - // Run the client continuously - this handles initial sync and ongoing monitoring. - // The client's run() method calls start() internally and monitors until the - // cancellation token is triggered, then calls stop() before returning. - enum Outcome { - RunCompleted(Result<(), dash_sdk::dash_spv::SpvError>), - Cancelled, - } - - let outcome = { - let run_cancel = CancellationToken::new(); - let run_future = client.run(run_cancel.clone()); - tokio::pin!(run_future); - - // stop_token is a child of global_cancel, so it fires on either - // explicit SpvManager::stop() or application-wide shutdown. - tokio::select! { - result = &mut run_future => Outcome::RunCompleted(result), - _ = stop_token.cancelled() => { - run_cancel.cancel(); - Outcome::Cancelled - }, - } - }; // run_future is dropped here, releasing the borrow - - tracing::info!( - "run_client: outcome = {}", - match &outcome { - Outcome::RunCompleted(Ok(())) => "RunCompleted(Ok)", - Outcome::RunCompleted(Err(_)) => "RunCompleted(Err)", - Outcome::Cancelled => "Cancelled", - } - ); - - match outcome { - Outcome::RunCompleted(Ok(())) => { - let _ = self.write_status(SpvStatus::Stopped); - } - Outcome::RunCompleted(Err(err)) => { - let message = format!("client.run() failed: {err}"); - let _ = self.write_last_error(Some(message.clone())); - let _ = self.write_status(SpvStatus::Error); - // Push last_error separately — write_status already pushed the status. - if let Some(cs) = self.connection_status_snapshot() { - cs.set_spv_last_error(Some(message.clone())); - } - } - Outcome::Cancelled => { - let _ = self.write_status(SpvStatus::Stopped); - } - } - Ok(()) - } - - fn spawn_request_handler( - &self, - mut request_rx: mpsc::Receiver<SpvRequest>, - cancel: CancellationToken, - ) { - tracing::info!("SPV request handler started"); - let network_manager = Arc::clone(&self.network_manager); - // TODO(workaround): Remove wallet + reconcile_tx captures once - // dashpay/rust-dashcore#487 is fixed upstream. - let wallet = Arc::clone(&self.wallet); - let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); - self.subtasks.spawn_sync("spv_request_handler", async move { - loop { - tokio::select! { - _ = cancel.cancelled() => { - tracing::info!("SPV request handler cancelled"); - break; - } - request = request_rx.recv() => { - match request { - Some(SpvRequest::BroadcastTransaction { tx, response_tx }) => { - tracing::debug!("Received BroadcastTransaction request"); - let result = { - let nm_guard = network_manager.read().await; - if let Some(ref nm) = *nm_guard { - // Broadcast the transaction to all connected peers - let message = dash_sdk::dpp::dashcore::network::message::NetworkMessage::Tx((*tx).clone()); - let results = nm.broadcast(message).await; - // Check if at least one broadcast succeeded - let mut success = false; - let mut errors = Vec::new(); - for res in results { - match res { - Ok(_) => success = true, - Err(e) => errors.push(e.to_string()), - } - } - if success { - tracing::info!("Transaction {} broadcast successfully", tx.txid()); - Ok(()) - } else if errors.is_empty() { - Err("No peers connected to broadcast transaction".to_string()) - } else { - Err(format!("Broadcast failed: {}", errors.join(", "))) - } - } else { - Err("SPV network manager not available".to_string()) - } - }; - // TODO(workaround): Remove once dashpay/rust-dashcore#487 - // is fixed. Upstream broadcast does not call - // process_mempool_transaction(), so the wallet doesn't - // know about its own outgoing tx until a block is mined. - if result.is_ok() { - notify_wallet_after_broadcast( - &wallet, - &tx, - reconcile_tx.as_ref(), - ) - .await; - } - - let _ = response_tx.send(result); - } - None => { - tracing::warn!("SPV request channel closed"); - break; - } - } - } - } - } - tracing::info!("SPV request handler exiting"); - }); - } - - /// Identify which sync manager phase is in Error state, if any. - /// Checks masternodes first as the most common failure point, - /// rather than pipeline execution order used by `spv_phase_summary()`. - fn failed_manager_name(progress: &SpvSyncProgress) -> &'static str { - if progress - .masternodes() - .is_ok_and(|p| p.state() == SyncState::Error) - { - return "Masternodes"; - } - if progress - .headers() - .is_ok_and(|p| p.state() == SyncState::Error) - { - return "Headers"; - } - if progress - .filter_headers() - .is_ok_and(|p| p.state() == SyncState::Error) - { - return "Filter headers"; - } - if progress - .filters() - .is_ok_and(|p| p.state() == SyncState::Error) - { - return "Filters"; - } - if progress - .blocks() - .is_ok_and(|p| p.state() == SyncState::Error) - { - return "Blocks"; - } - "unknown phase" - } - - fn spawn_wallet_event_handler( - &self, - mut wallet_rx: tokio::sync::broadcast::Receiver<WalletEvent>, - ) { - let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); - let cancel = self.subtasks.cancellation_token.clone(); - - self.subtasks - .spawn_sync("spv_wallet_event_handler", async move { - loop { - tokio::select! { - _ = cancel.cancelled() => break, - result = wallet_rx.recv() => { - match result { - Ok(_event) => { - // All wallet events trigger reconcile - if let Some(ref tx) = reconcile_tx { - let _ = tx.try_send(()); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("Wallet event handler lagged by {} events", n); - // Still trigger reconcile to catch up - if let Some(ref tx) = reconcile_tx { - let _ = tx.try_send(()); - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - } - } - tracing::info!("SPV wallet event handler exiting"); - }); - } - - async fn build_client(&self, has_wallets: bool) -> Result<SpvClient, String> { - // When wallets exist, scan from genesis so historical transactions are found via - // compact block filters. When no wallets are loaded, skip to chain tip (u32::MAX) - // to avoid unnecessary work. We check both the caller hint and the actual wallet - // count (the wait loop in run_spv_loop should have ensured loading completed). - let wallet_count = self.wallet.read().await.wallet_count(); - let start_height = if has_wallets || wallet_count > 0 { - 0 - } else { - u32::MAX - }; - let mut config = ClientConfig::new(self.network) - .with_storage_path(self.data_dir.clone()) - .with_validation_mode(ValidationMode::Full) - .with_start_height(start_height) - .with_mempool_tracking(MempoolStrategy::BloomFilter); - - // Configure peer discovery based on network type and user preference. - // Devnet/Regtest always need explicit peers since they're local networks. - // Mainnet/Testnet can use DNS seed discovery (default) or local node. - if self.network == Network::Devnet || self.network == Network::Regtest { - // Local networks always need explicit peer configuration - if let Some(peer) = self.primary_peer_socket() { - config.add_peer(peer); - } - } else if self.use_local_node() { - // User has chosen to use their local Dash Core node - if let Some(peer) = self.primary_peer_socket() { - config.add_peer(peer); - } - } - // Otherwise, no peers are added and SPV will use DNS seed discovery - - let network_manager = PeerNetworkManager::new(&config) - .await - .map_err(|e| format!("Failed to initialize SPV network manager: {e}"))?; - - // Store a clone of the network manager for broadcasting transactions - { - let mut nm_guard = self.network_manager.write().await; - *nm_guard = Some(network_manager.clone()); - } - - let storage_manager = DiskStorageManager::new(&config) - .await - .map_err(|e| format!("Failed to initialize SPV storage: {e}"))?; - - let event_handler = Arc::new(SpvEventHandler { - sync_progress_state: Arc::clone(&self.sync_progress_state), - progress_updated_at: Arc::clone(&self.progress_updated_at), - status: Arc::clone(&self.status), - last_error: Arc::clone(&self.last_error), - connected_peers: Arc::clone(&self.connected_peers), - connection_status: self.connection_status_snapshot(), - reconcile_tx: Arc::clone(&self.reconcile_tx), - finality_tx: Arc::clone(&self.finality_tx), - wallet: Arc::clone(&self.wallet), - }); - - DashSpvClient::new( - config, - network_manager, - storage_manager, - Arc::clone(&self.wallet), - event_handler, - ) - .await - .map_err(|e| format!("Failed to create SPV client: {e}")) - } - - fn primary_peer_socket(&self) -> Option<std::net::SocketAddr> { - let config = self.config.read().ok()?; - - let host = config.core_host.as_deref()?; - let port = match self.network { - Network::Mainnet => 9999, - Network::Testnet => 19999, - Network::Devnet => 20001, - Network::Regtest => 19899, - }; - - let addr = format!("{}:{}", host, port); - addr.to_socket_addrs().ok()?.next() - } -} - -fn build_spv_data_dir( - app_data_dir: &Path, - network: Network, - config: &NetworkConfig, -) -> Result<PathBuf, String> { - let mut base = app_data_dir.to_path_buf(); - base.push("spv"); - fs::create_dir_all(&base).map_err(|e| format!("Failed to create SPV base dir: {e}"))?; - - let network_dir = match network { - Network::Mainnet => "mainnet".to_string(), - Network::Testnet => "testnet".to_string(), - Network::Devnet => { - let name = config - .devnet_name - .clone() - .unwrap_or_else(|| "devnet".to_string()); - // Sanitize to prevent path traversal (e.g. "../../etc") - let sanitized: String = name - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "devnet".to_string() - } else { - sanitized - } - } - Network::Regtest => "regtest".to_string(), - }; - - Ok(base.join(network_dir)) -} - -/// Workaround for [dashpay/rust-dashcore#487]: upstream `broadcast_transaction()` does not -/// call `process_mempool_transaction()` on the local wallet, so spent UTXOs stay unmarked -/// and the balance is stale until a block is mined. -/// -/// Remove this function once the upstream fix lands. -/// -/// [dashpay/rust-dashcore#487]: https://github.com/dashpay/rust-dashcore/issues/487 -async fn notify_wallet_after_broadcast( - wallet: &Arc<AsyncRwLock<WalletManager<ManagedWalletInfo>>>, - tx: &Transaction, - reconcile_tx: Option<&mpsc::Sender<()>>, -) { - { - let mut wm = wallet.write().await; - let _ = wm.process_mempool_transaction(tx, None).await; - } - if let Some(ch) = reconcile_tx { - let _ = ch.try_send(()); - } - tracing::debug!("Notified wallet about broadcast tx {}", tx.txid()); -} - -impl fmt::Debug for SpvManager { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SpvManager") - .field("network", &self.network) - .field("data_dir", &self.data_dir) - .finish() - } -} diff --git a/src/spv/mod.rs b/src/spv/mod.rs deleted file mode 100644 index 5db1179c7..000000000 --- a/src/spv/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod error; -pub(crate) mod manager; -#[cfg(test)] -mod tests; - -pub use error::{SpvError, SpvResult}; -pub(crate) use manager::AssetLockFinalityEvent; -pub use manager::{CoreBackendMode, SpvDerivedAddress, SpvManager, SpvStatus, SpvStatusSnapshot}; diff --git a/src/spv/tests.rs b/src/spv/tests.rs deleted file mode 100644 index 69f75fc16..000000000 --- a/src/spv/tests.rs +++ /dev/null @@ -1,688 +0,0 @@ -//! Integration tests for SpvManager lifecycle, concurrency, and state transitions. - -use crate::config::NetworkConfig; -use crate::spv::SpvStatus; -use crate::spv::manager::SpvManager; -use crate::utils::tasks::TaskManager; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::key_wallet_manager::WalletInterface; -use std::sync::{Arc, RwLock}; -use tokio::time::{Duration, timeout}; - -/// Deadlock detection timeout: if any operation takes longer than this, -/// the test fails (likely a deadlock). -const DEADLOCK_TIMEOUT: Duration = Duration::from_secs(10); - -/// Create a minimal testnet NetworkConfig for testing. -fn test_network_config() -> NetworkConfig { - NetworkConfig { - dapi_addresses: Some("https://127.0.0.1:1443".to_string()), - core_host: Some("127.0.0.1".to_string()), - core_rpc_port: Some(19998), - core_rpc_user: Some("dashrpc".to_string()), - core_rpc_password: Some("password".to_string()), - core_zmq_endpoint: Some("tcp://127.0.0.1:23709".to_string()), - devnet_name: None, - wallet_private_key: None, - } -} - -/// Create an SpvManager for testing. Uses testnet config and a fresh TaskManager. -/// Returns the `TempDir` so it stays alive for the test duration. -fn create_test_manager() -> (Arc<SpvManager>, Arc<TaskManager>, tempfile::TempDir) { - let config = Arc::new(RwLock::new(test_network_config())); - let task_manager = Arc::new(TaskManager::new()); - let tmp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); - let manager = SpvManager::new( - tmp_dir.path(), - Network::Testnet, - config, - task_manager.clone(), - ) - .expect("SpvManager::new should succeed"); - // Use explicit local peer to avoid DNS seed discovery which hangs in tests - // (upstream bug: dashpay/rust-dashcore#577). - manager.set_use_local_node(true); - (manager, task_manager, tmp_dir) -} - -// ── Construction and initial state ─────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When reading the status snapshot, -/// Then status is Idle, no error, no start time, no progress, and 0 peers. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_new_manager_has_idle_status() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let snapshot = manager.status(); - assert_eq!( - snapshot.status, - SpvStatus::Idle, - "New manager should be Idle" - ); - assert!( - snapshot.last_error.is_none(), - "New manager should have no error" - ); - assert!( - snapshot.started_at.is_none(), - "New manager should have no started_at" - ); - assert!( - snapshot.sync_progress.is_none(), - "New manager should have no sync progress" - ); - assert_eq!( - snapshot.connected_peers, 0, - "New manager should have 0 connected peers" - ); -} - -/// Given a freshly constructed SpvManager, -/// When taking multiple sync and async snapshots in sequence, -/// Then all snapshots are consistent (Idle, no error, 0 peers). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_status_snapshot_consistency() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - for _ in 0..10 { - let sync_snapshot = manager.status(); - let async_snapshot = manager.status_async().await; - - assert_eq!(sync_snapshot.status, SpvStatus::Idle); - assert_eq!(async_snapshot.status, SpvStatus::Idle); - assert!(sync_snapshot.last_error.is_none()); - assert!(async_snapshot.last_error.is_none()); - assert_eq!(sync_snapshot.connected_peers, 0); - assert_eq!(async_snapshot.connected_peers, 0); - } -} - -// ── Stop when idle ─────────────────────────────────────────────── - -/// Given an idle SpvManager that has never been started, -/// When calling stop(), -/// Then it completes without panic or deadlock and sets status to Stopped. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_stop_when_idle_does_not_panic() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - }) - .await; - assert!( - result.is_ok(), - "stop() should complete within timeout (no deadlock)" - ); - - let snapshot = manager.status(); - assert_eq!( - snapshot.status, - SpvStatus::Stopped, - "stop() on idle manager should set status to Stopped" - ); -} - -/// Given an idle SpvManager, -/// When calling stop() twice in succession, -/// Then both calls complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_double_stop_does_not_panic() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - manager.stop(); - }) - .await; - assert!( - result.is_ok(), - "Double stop() should complete within timeout" - ); - - let snapshot = manager.status(); - assert_eq!(snapshot.status, SpvStatus::Stopped); -} - -// ── use_local_node flag ────────────────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When toggling use_local_node on and off, -/// Then the getter reflects each change correctly. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_use_local_node_toggle() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - assert!( - manager.use_local_node(), - "Test helper sets use_local_node to true" - ); - manager.set_use_local_node(false); - assert!(!manager.use_local_node(), "Should be false after clear"); - manager.set_use_local_node(true); - assert!(manager.use_local_node(), "Should be true after set"); -} - -// ── clear_data_dir when idle ───────────────────────────────────── - -/// Given an idle SpvManager, -/// When calling clear_data_dir(), -/// Then it succeeds and the status remains Idle. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_clear_data_dir_when_idle() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { manager.clear_data_dir() }).await; - assert!( - result.is_ok(), - "clear_data_dir() should complete within timeout" - ); - let clear_result = result.unwrap(); - assert!( - clear_result.is_ok(), - "clear_data_dir() should succeed when idle: {:?}", - clear_result.err() - ); - - let snapshot = manager.status(); - assert_eq!(snapshot.status, SpvStatus::Idle); -} - -// ── Concurrent status reads ────────────────────────────────────── - -/// Given an idle SpvManager shared across 20 concurrent tasks, -/// When each task reads the status (sync and async) 100 times, -/// Then all reads complete within the deadlock timeout without panic. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_concurrent_status_reads_no_deadlock() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - let mut handles = Vec::new(); - for _ in 0..20 { - let mgr = Arc::clone(&manager); - handles.push(tokio::spawn(async move { - for _ in 0..100 { - let _snapshot = mgr.status(); - let _async_snapshot = mgr.status_async().await; - tokio::task::yield_now().await; - } - })); - } - for handle in handles { - handle.await.expect("Task should not panic"); - } - }) - .await; - - assert!( - result.is_ok(), - "Concurrent status reads should complete within timeout (no deadlock)" - ); -} - -// ── Start lifecycle (no network) ───────────────────────────────── - -/// Given an idle SpvManager with no wallets, -/// When calling start(0), -/// Then it returns Ok and the status transitions to Starting (or Syncing/Error -/// if the background task progresses before the assertion). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_start_sets_starting_status() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let start_result = manager.start(0); - assert!( - start_result.is_ok(), - "start() should return Ok: {:?}", - start_result.err() - ); - - let snapshot = manager.status(); - assert!( - snapshot.status == SpvStatus::Starting - || snapshot.status == SpvStatus::Syncing - || snapshot.status == SpvStatus::Error, - "After start(), status should be Starting, Syncing, or Error (if network fails fast), got: {:?}", - snapshot.status - ); - - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - let _ = tm.shutdown(); -} - -/// Given an already-started SpvManager, -/// When calling start() a second time, -/// Then it returns Ok without spawning a duplicate loop (idempotent). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_double_start_is_idempotent() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let first = manager.start(0); - assert!(first.is_ok(), "First start() should succeed"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let second = manager.start(0); - assert!(second.is_ok(), "Second start() should succeed (idempotent)"); - - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - let _ = tm.shutdown(); -} - -// ── Start + Stop lifecycle (clean shutdown) ────────────────────── - -/// Given a started SpvManager, -/// When calling stop() and polling for completion, -/// Then the status reaches Stopped (or Error) within the deadlock timeout. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_start_stop_clean_shutdown() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - manager.start(0).expect("start() should succeed"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let result = timeout(DEADLOCK_TIMEOUT, async { - manager.stop(); - for _ in 0..100 { - let snapshot = manager.status(); - if snapshot.status == SpvStatus::Stopped || snapshot.status == SpvStatus::Error { - return snapshot.status; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - manager.status().status - }) - .await; - - assert!( - result.is_ok(), - "Stop should complete within timeout (no deadlock)" - ); - let final_status = result.unwrap(); - assert!( - final_status == SpvStatus::Stopped || final_status == SpvStatus::Error, - "After stop(), status should be Stopped or Error, got: {:?}", - final_status - ); - - let _ = tm.shutdown(); -} - -// ── Rapid start/stop ───────────────────────────────────────────── - -/// Given a SpvManager, -/// When performing 5 rapid start/stop cycles, -/// Then all cycles complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_rapid_start_stop_no_panic() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - for _ in 0..5 { - let _ = manager.start(0); - tokio::time::sleep(Duration::from_millis(50)).await; - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await; - - assert!( - result.is_ok(), - "Rapid start/stop cycles should complete within timeout (no deadlock or panic)" - ); - - let _ = tm.shutdown(); -} - -// ── Concurrent status reads during start/stop ──────────────────── - -/// Given a SpvManager with 10 concurrent reader tasks, -/// When performing a start/stop lifecycle while readers are active, -/// Then all readers complete without panic or deadlock. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_concurrent_reads_during_lifecycle() { - let (manager, tm, _tmp_dir) = create_test_manager(); - - let result = timeout(DEADLOCK_TIMEOUT, async { - let mut readers = Vec::new(); - for _ in 0..10 { - let mgr = Arc::clone(&manager); - readers.push(tokio::spawn(async move { - for _ in 0..50 { - let snapshot = mgr.status(); - let _ = snapshot.status.is_active(); - let _ = snapshot.last_error; - let _ = snapshot.connected_peers; - tokio::task::yield_now().await; - } - })); - } - - let _ = manager.start(0); - tokio::time::sleep(Duration::from_millis(100)).await; - manager.stop(); - tokio::time::sleep(Duration::from_millis(200)).await; - - for r in readers { - r.await.expect("Reader task should not panic"); - } - }) - .await; - - assert!( - result.is_ok(), - "Concurrent reads during lifecycle should complete without deadlock" - ); - - let _ = tm.shutdown(); -} - -// ── SpvStatus helper methods ───────────────────────────────────── - -/// Given all SpvStatus variants, -/// When calling is_active(), -/// Then Starting, Syncing, Running, and Stopping return true; others return false. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_spv_status_is_active() { - assert!(!SpvStatus::Idle.is_active()); - assert!(SpvStatus::Starting.is_active()); - assert!(SpvStatus::Syncing.is_active()); - assert!(SpvStatus::Running.is_active()); - assert!(SpvStatus::Stopping.is_active()); - assert!(!SpvStatus::Stopped.is_active()); - assert!(!SpvStatus::Error.is_active()); -} - -/// Given u8 values 0 through 6 and out-of-range values, -/// When converting via From<u8>, -/// Then each maps to the correct SpvStatus variant (out-of-range defaults to Idle). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_spv_status_from_u8_roundtrip() { - for val in 0u8..=6 { - let status = SpvStatus::from(val); - let display = format!("{}", status); - assert!( - !display.is_empty(), - "Display should not be empty for value {}", - val - ); - } - assert_eq!(SpvStatus::from(255), SpvStatus::Idle); - assert_eq!(SpvStatus::from(7), SpvStatus::Idle); -} - -// ── Wallet operations on idle manager ──────────────────────────── - -/// Given a freshly constructed SpvManager with no wallets loaded, -/// When calling det_wallets_snapshot(), -/// Then the returned map is empty. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_det_wallets_snapshot_empty() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let wallets = manager.det_wallets_snapshot(); - assert!(wallets.is_empty(), "New manager should have no wallets"); -} - -/// Given a freshly constructed SpvManager, -/// When looking up a wallet by an unknown seed hash, -/// Then None is returned. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_wallet_id_for_seed_returns_none() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let seed_hash = [0u8; 32]; - assert!( - manager.wallet_id_for_seed(seed_hash).is_none(), - "Unknown seed hash should return None" - ); -} - -// ── Reconcile and finality channels ────────────────────────────── - -/// Given a freshly constructed SpvManager, -/// When registering a reconcile channel and immediately trying to receive, -/// Then the channel is open but empty (no signals yet). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_register_reconcile_channel() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let mut rx = manager.register_reconcile_channel(); - - let result = rx.try_recv(); - assert!(result.is_err(), "Channel should be empty initially"); -} - -/// Given a freshly constructed SpvManager, -/// When registering a finality channel and immediately trying to receive, -/// Then the channel is open but empty (no events yet). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_register_finality_channel() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - let mut rx = manager.register_finality_channel(); - - let result = rx.try_recv(); - assert!(result.is_err(), "Channel should be empty initially"); -} - -// ── Broadcast transaction on idle manager ──────────────────────── - -/// Given an idle SpvManager that has not been started, -/// When attempting to broadcast a transaction, -/// Then the call fails with an error indicating SPV is not running. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_broadcast_transaction_fails_when_not_running() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - let tx = dash_sdk::dpp::dashcore::Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }; - - let result = manager.broadcast_transaction(&tx).await; - assert!( - result.is_err(), - "Broadcast should fail when SPV is not running" - ); - let err = result.unwrap_err(); - assert!( - err.contains("not running"), - "Error should mention not running, got: {}", - err - ); -} - -// ── CoreBackendMode ────────────────────────────────────────────── - -/// Given CoreBackendMode variants, -/// When converting between u8 and enum via From<u8> and as_u8(), -/// Then roundtrip is correct (0 = Rpc, 1 = Spv, unknown defaults to Spv — the -/// DB column default and the app's runtime default). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_core_backend_mode_roundtrip() { - use crate::spv::CoreBackendMode; - - assert_eq!(CoreBackendMode::from(0), CoreBackendMode::Rpc); - assert_eq!(CoreBackendMode::from(1), CoreBackendMode::Spv); - assert_eq!(CoreBackendMode::from(99), CoreBackendMode::Spv); // default fallback - assert_eq!(CoreBackendMode::default(), CoreBackendMode::Spv); - - assert_eq!(CoreBackendMode::Rpc.as_u8(), 0); - assert_eq!(CoreBackendMode::Spv.as_u8(), 1); -} - -// ── Live testnet sync ──────────────────────────────────────────── - -/// Parse `.env.example` from the project root and extract the TESTNET_ NetworkConfig. -fn load_testnet_config_from_env_example() -> NetworkConfig { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let env_path = std::path::Path::new(manifest_dir).join(".env.example"); - assert!( - env_path.exists(), - ".env.example not found at {}", - env_path.display() - ); - - let contents = std::fs::read_to_string(&env_path).expect("Failed to read .env.example"); - - let mut vars = std::collections::HashMap::new(); - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((key, value)) = line.split_once('=') { - vars.insert(key.to_string(), value.to_string()); - } - } - - envy::prefixed("TESTNET_") - .from_iter(vars) - .expect("Failed to parse TESTNET_ config from .env.example") -} - -/// Given an SpvManager configured with real testnet DAPI addresses from `.env.example`, -/// When starting SPV sync with no wallets, letting it sync for 10 seconds, then stopping, -/// Then at least one peer connects, sync progress is reported, and shutdown completes -/// within 15 seconds without deadlock. -#[ignore] // Requires network access to Dash testnet peers -#[tokio::test(flavor = "multi_thread", worker_threads = 12)] -async fn test_live_testnet_sync_and_shutdown() { - let testnet_config = load_testnet_config_from_env_example(); - let config = Arc::new(RwLock::new(testnet_config)); - let task_manager = Arc::new(TaskManager::new()); - let tmp_dir = tempfile::TempDir::new().expect("Failed to create temp dir"); - let manager = SpvManager::new( - tmp_dir.path(), - Network::Testnet, - config, - task_manager.clone(), - ) - .expect("SpvManager::new should succeed"); - - // Start SPV with no wallets (header-only sync to chain tip) - manager.start(0).expect("start() should succeed"); - - // Wait for peers to connect (up to 30s) - let connect_timeout = Duration::from_secs(30); - let connect_result = timeout(connect_timeout, async { - loop { - let snapshot = manager.status_async().await; - if snapshot.connected_peers > 0 { - return snapshot; - } - if snapshot.status == SpvStatus::Error - && let Some(ref err) = snapshot.last_error - { - eprintln!("SPV reported error during peer discovery: {}", err); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - }) - .await; - - assert!( - connect_result.is_ok(), - "Should connect to at least one testnet peer within 30s" - ); - let snapshot = connect_result.unwrap(); - eprintln!( - "Connected to {} peer(s), status: {:?}", - snapshot.connected_peers, snapshot.status - ); - - // Let the sync run for 10 seconds to exercise the full pipeline - eprintln!("Letting sync run for 10 seconds..."); - tokio::time::sleep(Duration::from_secs(10)).await; - - // Capture state after syncing - let snapshot = manager.status_async().await; - eprintln!( - "After 10s sync: status={:?}, peers={}, progress={:?}", - snapshot.status, snapshot.connected_peers, snapshot.sync_progress - ); - assert!( - snapshot.sync_progress.is_some(), - "Should have received sync progress after 10s of syncing" - ); - - // Shutdown must complete within 15 seconds -- timeout means deadlock - let shutdown_timeout = Duration::from_secs(15); - let shutdown_result = timeout(shutdown_timeout, async { - manager.stop(); - loop { - let snapshot = manager.status_async().await; - if snapshot.status == SpvStatus::Stopped || snapshot.status == SpvStatus::Error { - return snapshot.status; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await; - - assert!( - shutdown_result.is_ok(), - "Shutdown MUST complete within 15s -- timeout indicates a deadlock" - ); - let final_status = shutdown_result.unwrap(); - assert_eq!( - final_status, - SpvStatus::Stopped, - "Final status should be Stopped after clean shutdown, got: {:?}", - final_status - ); - - let _ = task_manager.shutdown(); -} - -/// Regression test: clear_data_dir must reset filter_committed_height (not just synced_height). -/// -/// At rust-dashcore 309fac8, WalletManager gained an independent filter_committed_height -/// field. FiltersManager::new() reads filter_committed_height() for its "already synced" -/// guard — the field is no longer derived from synced_height. Calling update_synced_height(0) -/// alone leaves filter_committed_height stale and causes the next SPV session to declare -/// itself "already synced" and skip the rescan, producing zero balance after a Clear SPV Data. -/// -/// Given a manager with a non-zero filter_committed_height, -/// When clear_data_dir() is called, -/// Then filter_committed_height is reset to 0. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_clear_data_dir_resets_filter_committed_height() { - let (manager, _tm, _tmp_dir) = create_test_manager(); - - // Pre-seed a non-zero filter_committed_height to simulate a previous session. - const PRESEED_HEIGHT: u32 = 5_000; - let wallet_arc = manager.wallet(); - { - let mut wm = wallet_arc.write().await; - wm.update_filter_committed_height(PRESEED_HEIGHT); - } - - // Verify pre-condition. - { - let wm = wallet_arc.read().await; - assert_eq!( - wm.filter_committed_height(), - PRESEED_HEIGHT, - "pre-condition: filter_committed_height should be PRESEED_HEIGHT" - ); - } - - manager - .clear_data_dir() - .expect("clear_data_dir should succeed"); - - // After clearing, filter_committed_height must be 0. - let wm = wallet_arc.read().await; - assert_eq!( - wm.filter_committed_height(), - 0, - "clear_data_dir must reset filter_committed_height to 0" - ); -} diff --git a/src/ui/components/README.md b/src/ui/components/README.md index f93476055..7e51af22c 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -14,10 +14,36 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | DomainType | Description | |-----------|------|------------|-------------| | `AmountInput` | `amount_input.rs` | `Amount` | Decimal amount with validation, min/max, Max button, unit name | -| `AddressInput` | `address_input.rs` | `ValidatedAddress` | Unified address with autocomplete, type detection (Core/Platform/Shielded/Identity), DPNS resolution | +| `AddressInput` | `address_input.rs` | `ValidatedAddress` | Unified address with autocomplete, type detection (Core/Platform/Shielded/Identity), DPNS resolution. GitHub-style tag search (`type:core\|platform\|…`, `wallet:name`; unrecognized tokens are free text). Rows render `[wallet pill] address (name) [type pill] balance`; when no explicit hint is set the placeholder is a live `type:…|… wallet:…|…` legend | | `PasswordInput` | `password_input.rs` | N/A (security) | Masked input with hold-to-reveal, zeroizes on drop. NOT ComponentResponse | | `IdentitySelector` | `identity_selector.rs` | N/A (Widget) | ComboBox dropdown for identity selection | +## Breadcrumb Components + +| 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`. | + +## Display Components + +| Component | File | DomainType | Description | +|-----------|------|------------|-------------| +| `Avatar` | `avatar.rs` | N/A (display) | DashPay contact/profile avatar from a URL. Renders image / spinner / `👤` fallback, decoding + uploading the texture on the UI thread. Backed by `ui/state/avatar_cache.rs` (`AvatarCache`), which fetches off-frame via `DashPayTask::FetchAvatar`. `show(ui, &mut AvatarCache)` returns `AvatarResponse { fetch, clicked }`; the caller dispatches `fetch`. Builders: `corner_radius`, `clickable(tooltip)`. | + +## Placement Rule + +`src/ui/components/` holds **reusable** components only — widgets that plausibly +have a second consumer outside their originating screen (Wallets, Tokens, +Contracts, Tools, Settings, etc.). + +Identity Hub-specific widgets (`IdentityHubTabBar`, `IdentityHeroCard`, +`OnboardingChecklist`, `IdentityPickerCard`, `IdentityPickerAddCard`, +`SocialProfileGateCard`, `RequestCard`, `IdentityPill`) live in +`src/ui/identity/` alongside the tab modules that consume them. If one of +those widgets gains a second consumer outside the hub, promote it into this +directory. + ## Dialog Components | Component | File | DomainType | Description | @@ -25,26 +51,25 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | `ConfirmationDialog` | `confirmation_dialog.rs` | `ConfirmationStatus` | Modal confirm/cancel with danger mode | | `SelectionDialog` | `selection_dialog.rs` | `SelectionStatus` | Modal with ComboBox selection | | `InfoPopup` | `info_popup.rs` | N/A | Info popup with optional markdown | -| `WalletUnlockPopup` | `wallet_unlock_popup.rs` | `WalletUnlockResult` | Password-based wallet unlock | +| `WalletUnlockPopup` | `wallet_unlock_popup.rs` | `WalletUnlockResult` | Password-based wallet unlock (renders via shared `passphrase_modal`) | +| `passphrase_modal()` | `passphrase_modal.rs` | `PassphraseModalOutcome` | Shared passphrase-entry chrome: overlay, centered window, `PasswordInput`, error line, optional extra body (e.g. remember checkbox), Cancel/Esc/X/click-outside → Cancel | +| `EguiSecretPromptHost` | `secret_prompt_host.rs` | N/A (`SecretPrompt`) | egui host for just-in-time secret prompts; enqueues requests for `AppState` to render and answers via one-shot. `ActivePrompt` owns the live modal | ## Feedback Components | Component | File | Description | |-----------|------|-------------| | `MessageBanner` | `message_banner.rs` | Global error/warning/success/info banners. `set_global()`, `with_details()`, auto-dismiss. Extensions: `OptionBannerExt`, `OptionBannerShowExt`, `ResultBannerExt` | +| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_action(label, action_id)`, mirrors `MessageBanner::with_action`), 120s watchdog. A hard block is never keyboard-activatable except via `with_keyboard_escape(action_id)`, which designates one focus-pinned button as a keyboard-reachable escape (Enter/Space) for unbounded blocks (the SPV-sync block). Global path: `set_global()` (raise, mirrors `MessageBanner::set_global`) / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt` (`raise` — the banner's `replace`, renamed to dodge inherent `Option::replace`), `ProgressOverlayResponse` | ## Styled Components (`styled.rs`) | Component | Description | |-----------|-------------| -| `StyledButton` | Primary/Secondary/Danger/Ghost variants, Small/Medium/Large | +| `StyledButton` | Primary button following Dash design guidelines | | `StyledCard` | Card with padding and border | | `StyledCheckbox` | Themed checkbox | -| `GradientButton` | Animated gradient with optional glow | -| `GlassCard` | Glass-morphism card | -| `HeroSection` | Large gradient header | -| `AnimatedIcon` | Configurable animated icon | -| `AnimatedGradientCard` | Card with animated gradient border | +| `GradientButton` | Animated gradient button | ## Layout @@ -52,8 +77,9 @@ Concise catalog of all reusable UI components. Consult before creating new UI el |-----------------|------|-------------| | `island_central_panel()` | `styled.rs` | Responsive central panel, renders global MessageBanners | | `add_location_view()` | `top_panel.rs` | Breadcrumb navigation + connection status | +| `add_top_panel_with_global_nav()` | `top_panel.rs` | Top panel wired to `global_nav_switcher::render()` with subdued pills (`subdued_everyday_spec` / `subdued_wallet_only_spec`); used by Identities, DashPay, DPNS, and Wallets. Masternodes uses the identity-aware capturing variant with interactive pills instead. Every other root screen still calls the plain `add_top_panel()` | | `add_left_panel()` | `left_panel.rs` | Main icon navigation sidebar | -| `add_left_panel()` | `left_wallet_panel.rs` | Wallet/identity 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 | | `ContractChooserState` | `contract_chooser_panel.rs` | Hierarchical contract tree view | @@ -62,7 +88,8 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | Description | |-----------|------|-------------| | `U256EntropyGrid` | `entropy_grid.rs` | 32x8 interactive grid for 256-bit entropy generation | -| `ScreenWithWalletUnlock` | `wallet_unlock.rs` | Trait for screens needing wallet unlock | + +> Non-widget UI state (per-screen view-models and async fetch-state caches that render no egui) lives in `src/ui/state/`, not here. For example `TrackedAssetLockCache` (`src/ui/state/tracked_asset_lock_cache.rs`) caches each wallet's tracked asset locks. See the module placement policy in `CLAUDE.md`. ## Usage Pattern diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 89ba2cb1a..98bb30485 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -3,14 +3,14 @@ use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::components::{Component, ComponentResponse}; -use crate::ui::theme::DashColors; +use crate::ui::theme::{DashColors, Shape}; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use egui::{InnerResponse, Response, Ui, WidgetText}; +use egui::{Color32, InnerResponse, Response, Ui, WidgetText}; use std::ops::Bound; use std::sync::{Arc, RwLock}; @@ -38,25 +38,183 @@ impl DetectedType { /// A single autocomplete entry rendered in the dropdown. /// -/// Pre-computed from wallet/identity data at builder/setter time. +/// Pre-computed from wallet/identity data at builder/setter time. Each field is +/// structured data driving one part of the row: `wallet_name` → wallet pill, +/// `address_string` → address text, `name_label` → `(name)` annotation, +/// `address_kind` → type pill. #[derive(Debug, Clone)] struct AddressEntry { - /// The full address string (populates the text field on selection). + /// The full address string (populates the text field on selection and is + /// shown, truncated unless `full_addresses`, as the row's address text). address_string: String, - /// Classification of this entry. + /// Classification of this entry (drives the type pill). address_kind: AddressKind, - /// Human-readable label (DPNS name, alias, or truncated address). - display_label: String, + /// Human name distinct from the address, shown as `(name)`: DPNS name or + /// alias for identities, `"change"` for change addresses. `None` when the + /// address has no name of its own. + name_label: Option<String>, + /// Owning wallet's display name, shown as a pill. `Some` for Core/Platform + /// entries; `None` for wallet-agnostic kinds (Identity, Shielded). + wallet_name: Option<String>, /// Balance in native units (duffs for Core, credits for Platform/Shielded/Identity). balance: u64, /// Pre-built ValidatedAddress for immediate use on selection. validated: ValidatedAddress, - /// Whether this is a change address (BIP44 m/44'/5'/0'/1/x). - /// Only meaningful for Core addresses; always false for other types. - /// Stored for potential future use in display styling; the "(change)" - /// suffix is already baked into `display_label` at construction time. - #[allow(dead_code)] - is_change: bool, +} + +impl AddressEntry { + /// Alphabetical sort key: the entry's name if it has one, else the address. + fn sort_key(&self) -> &str { + self.name_label + .as_deref() + .unwrap_or(self.address_string.as_str()) + } + + /// Whether `needle` (already lowercased) substring-matches any searchable + /// field: the address, the name, the wallet, or the kind's labels. + fn matches_free_text(&self, needle: &str) -> bool { + self.address_string.to_lowercase().contains(needle) + || self + .name_label + .as_deref() + .is_some_and(|s| s.to_lowercase().contains(needle)) + || self + .wallet_name + .as_deref() + .is_some_and(|s| s.to_lowercase().contains(needle)) + || self + .address_kind + .short_label() + .to_lowercase() + .contains(needle) + || self + .address_kind + .display_name() + .to_lowercase() + .contains(needle) + } +} + +/// A recognized search-tag key. Adding a new tag is a single match arm here plus +/// one in [`ParsedQuery::parse`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TagKey { + Type, + Wallet, +} + +/// Split a whitespace-free token into a recognized `key:value` filter tag. +/// +/// Returns `None` for free-text tokens: any token without a colon, with a +/// non-alphabetic key, or with an unrecognized key (e.g. `foo:bar`) — those are +/// searched literally. A recognized key with an empty value (e.g. bare `type:`) +/// still returns `Some` with an empty value; the caller treats it as no +/// constraint. +fn parse_tag(token: &str) -> Option<(TagKey, &str)> { + let (key, value) = token.split_once(':')?; + if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + let tag = match key.to_ascii_lowercase().as_str() { + "type" => TagKey::Type, + "wallet" => TagKey::Wallet, + _ => return None, + }; + Some((tag, value)) +} + +/// A parsed GitHub-style search query: recognized filter tags plus leftover +/// free-text tokens. +/// +/// - `type:` values prefix-match `AddressKind::short_label()` (restricted to the +/// instance's enabled kinds); multiple `type:` tokens OR together. +/// - `wallet:` values substring-match the entry's wallet name; multiple tokens +/// OR together. +/// - Free-text tokens AND together, each substring-matching any searchable field. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ParsedQuery { + /// Kinds selected by `type:` tokens (union). + type_kinds: Vec<AddressKind>, + /// Whether at least one non-empty `type:` token was present. Distinguishes + /// "no type constraint" from "type constraint that matched no kind". + has_type_constraint: bool, + /// Lowercased values from `wallet:` tokens (union). + wallet_values: Vec<String>, + /// Lowercased free-text tokens (intersection). + free_text: Vec<String>, +} + +impl ParsedQuery { + /// Parse `query` into filter tags and free text. `enabled_kinds` bounds which + /// kinds a `type:` value can select. + fn parse(query: &str, enabled_kinds: &[AddressKind]) -> Self { + let mut parsed = Self::default(); + for token in query.split_whitespace() { + match parse_tag(token) { + Some((TagKey::Type, value)) => { + if value.is_empty() { + continue; + } + parsed.has_type_constraint = true; + let value = value.to_ascii_lowercase(); + for &kind in enabled_kinds { + if kind.short_label().to_ascii_lowercase().starts_with(&value) + && !parsed.type_kinds.contains(&kind) + { + parsed.type_kinds.push(kind); + } + } + } + Some((TagKey::Wallet, value)) => { + if !value.is_empty() { + parsed.wallet_values.push(value.to_ascii_lowercase()); + } + } + None => parsed.free_text.push(token.to_ascii_lowercase()), + } + } + parsed + } + + /// The needle used to float prefix-matching addresses to the top: the joined + /// free text, or `None` when the query is pure tags (no free text). + fn prefix_needle(&self) -> Option<String> { + if self.free_text.is_empty() { + None + } else { + Some(self.free_text.join(" ")) + } + } + + /// Whether `entry` satisfies every tag and free-text constraint. + fn matches(&self, entry: &AddressEntry) -> bool { + if self.has_type_constraint && !self.type_kinds.contains(&entry.address_kind) { + return false; + } + if !self.wallet_values.is_empty() { + match entry.wallet_name.as_deref() { + Some(name) => { + let name = name.to_lowercase(); + if !self.wallet_values.iter().any(|v| name.contains(v)) { + return false; + } + } + None => return false, + } + } + self.free_text.iter().all(|t| entry.matches_free_text(t)) + } +} + +/// One autocomplete row's render descriptor, exposed for UI tests. Each field +/// maps one-to-one to a render decision: `wallet_pill.is_some()` → wallet pill +/// shown, `name.is_some()` → `(name)` shown, `kind` → type pill (always shown). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedRow { + pub address_string: String, + pub wallet_pill: Option<String>, + pub name: Option<String>, + pub kind: AddressKind, } /// Concrete balance range bounds. @@ -125,6 +283,15 @@ impl ComponentResponse for AddressInputResponse { } } +/// A wallet paired with its display-only `WalletBackend`-snapshot per-address +/// balances (`AppContext::snapshot_address_balances`). The component takes +/// balances explicitly so it never reaches into wallet state for funds +/// (A04 — snapshot is display-only). +pub type WalletWithBalances = ( + Arc<RwLock<Wallet>>, + std::collections::BTreeMap<Address, u64>, +); + /// Unified address input with autocomplete, type detection, and validation. /// /// Follows the Component design pattern: lazy-initialize as `Option<AddressInput>` @@ -136,7 +303,7 @@ impl ComponentResponse for AddressInputResponse { /// ```rust,ignore /// let addr_input = self.address_input.get_or_insert_with(|| { /// AddressInput::new(network) -/// .with_wallets(&wallets) +/// .with_wallets(&[(wallet, app_context.snapshot_address_balances(&seed_hash))]) /// .with_label("Destination address") /// .with_hint_text("Enter address or username") /// }); @@ -221,15 +388,18 @@ impl AddressInput { /// - **Identities**: call [`with_identities()`] with `QualifiedIdentity` /// data from `AppContext::load_local_qualified_identities()`. /// - **Shielded**: call [`with_shielded_balance()`] with the address - /// string from `AppContext::shielded_states`. + /// string from the upstream shielded coordinator's default address. /// /// Entries are extracted immediately (read lock acquired once per wallet). - /// Skips gracefully if a wallet lock is poisoned. - /// When more than one wallet is provided, entries are prefixed with the wallet alias. - pub fn with_wallets(mut self, wallets: &[Arc<RwLock<Wallet>>]) -> Self { - let multi = wallets.len() > 1; - for wallet in wallets { - self.extract_wallet_entries(wallet, multi); + /// Skips gracefully if a wallet lock is poisoned. Each Core/Platform entry + /// carries its wallet's name for the wallet pill and `wallet:` search tag. + /// Each entry pairs a wallet with its display-only + /// `WalletBackend`-snapshot per-address balances + /// (`AppContext::snapshot_address_balances`); the component never reaches + /// into wallet state for balances (A04 — snapshot is display-only). + pub fn with_wallets(mut self, wallets: &[WalletWithBalances]) -> Self { + for (wallet, balances) in wallets { + self.extract_wallet_entries(wallet, balances); } self } @@ -331,15 +501,12 @@ impl AddressInput { // --- Mutable setters for runtime reconfiguration --- /// Update wallet data after initialization (e.g., balance refresh). - /// - /// When more than one wallet is provided, entries are prefixed with the wallet alias. - pub fn set_wallets(&mut self, wallets: &[Arc<RwLock<Wallet>>]) { + pub fn set_wallets(&mut self, wallets: &[WalletWithBalances]) { self.all_entries.retain(|e| { e.address_kind != AddressKind::Core && e.address_kind != AddressKind::Platform }); - let multi = wallets.len() > 1; - for wallet in wallets { - self.extract_wallet_entries(wallet, multi); + for (wallet, balances) in wallets { + self.extract_wallet_entries(wallet, balances); } } @@ -364,18 +531,17 @@ impl AddressInput { // --- Entry extraction --- - fn extract_wallet_entries(&mut self, wallet: &Arc<RwLock<Wallet>>, multi_wallet: bool) { + fn extract_wallet_entries( + &mut self, + wallet: &Arc<RwLock<Wallet>>, + address_balances: &std::collections::BTreeMap<Address, u64>, + ) { let guard = match wallet.read().ok() { Some(g) => g, None => return, }; - let prefix = if multi_wallet { - let name = guard.alias.as_deref().unwrap_or("Wallet"); - format!("[{}] ", name) - } else { - String::new() - }; + let wallet_name = Some(guard.alias.as_deref().unwrap_or("Wallet").to_string()); // Whitelist approach: only include addresses whose derivation path // matches the expected type. Unknown or unrecognized paths are excluded @@ -390,21 +556,14 @@ impl AddressInput { if self.exclude_change && is_change { continue; } - let balance = guard.address_balances.get(address).copied().unwrap_or(0); - let addr_str = address.to_string(); - let change_suffix = if is_change { " (change)" } else { "" }; - let display = if self.full_addresses { - format!("{}{}{}", prefix, addr_str, change_suffix) - } else { - format!("{}{}{}", prefix, truncate_address(&addr_str), change_suffix) - }; + let balance = address_balances.get(address).copied().unwrap_or(0); self.all_entries.push(AddressEntry { - address_string: addr_str, + address_string: address.to_string(), address_kind: AddressKind::Core, - display_label: display, + name_label: is_change.then(|| "change".to_string()), + wallet_name: wallet_name.clone(), balance, validated: ValidatedAddress::Core(address.clone()), - is_change, }); } @@ -429,22 +588,17 @@ impl AddressInput { .get(core_addr) .map(|info| info.balance) .unwrap_or(0); - let display = if self.full_addresses { - format!("{}{}", prefix, addr_str) - } else { - format!("{}{}", prefix, truncate_address(&addr_str)) - }; let bech32m = addr_str.clone(); self.all_entries.push(AddressEntry { address_string: addr_str, address_kind: AddressKind::Platform, - display_label: display, + name_label: None, + wallet_name: wallet_name.clone(), balance, validated: ValidatedAddress::Platform { address: platform_addr, bech32m, }, - is_change: false, }); } } @@ -455,42 +609,29 @@ impl AddressInput { let id = qi.identity.id(); let id_str = id.to_string(Encoding::Base58); let dpns_name = qi.dpns_names.first().map(|n| n.name.clone()); - let display = if let Some(ref name) = dpns_name { - name.clone() - } else if let Some(ref alias) = qi.alias { - alias.clone() - } else if self.full_addresses { - id_str.clone() - } else { - truncate_address(&id_str) - }; + let name_label = dpns_name.clone().or_else(|| qi.alias.clone()); self.all_entries.push(AddressEntry { address_string: id_str, address_kind: AddressKind::Identity, - display_label: display, + name_label, + wallet_name: None, balance: qi.identity.balance(), validated: ValidatedAddress::Identity { id, dpns_name: dpns_name.clone(), }, - is_change: false, }); } } fn add_shielded_entry(&mut self, address: String, balance: u64) { - let display = if self.full_addresses { - address.clone() - } else { - truncate_address(&address) - }; self.all_entries.push(AddressEntry { address_string: address.clone(), address_kind: AddressKind::Shielded, - display_label: display, + name_label: None, + wallet_name: None, balance, validated: ValidatedAddress::Shielded(address), - is_change: false, }); } @@ -535,7 +676,9 @@ impl AddressInput { ); } - let detected_kind = detected.to_address_kind().unwrap(); + let detected_kind = detected + .to_address_kind() + .expect("invariant: detected is a known type, Unknown handled above"); // Check enabled kinds if !self.enabled_kinds.contains(&detected_kind) { @@ -588,20 +731,15 @@ impl AddressInput { ); } let canonical = trimmed.to_lowercase(); - let expected_prefix = match self.network { - Network::Mainnet => "dash1", - _ => "tdash1", - }; - if !canonical.starts_with(expected_prefix) - || canonical.starts_with(&format!("{}z", expected_prefix)) + // Network prefix validation is centralized in `model/address.rs` so the + // GUI and the MCP tools share one source of truth. + if let Err(e) = + crate::model::address::validate_platform_address_for_network(&canonical, self.network) { - return ( - Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), - None, - ); + return (Some(e.to_string()), None); } match PlatformAddress::from_bech32m_string(&canonical) { - Ok((pa, _network)) => ( + Ok(pa) => ( None, Some(ValidatedAddress::Platform { address: pa, @@ -618,15 +756,30 @@ impl AddressInput { } fn validate_shielded(&self, trimmed: &str) -> (Option<String>, Option<ValidatedAddress>) { - let expected_prefix = match self.network { - Network::Mainnet => "dash1z", - _ => "tdash1z", - }; - if !trimmed.starts_with(expected_prefix) { - return ( - Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), - None, - ); + // Raw hex form (43 bytes = 86 hex chars) is network-agnostic — accept it + // directly via the shared parser. This preserves the "…or hex" recipient + // entry the standalone private-send screen advertised. + if trimmed.len() == crate::model::address::SHIELDED_ADDRESS_RAW_LEN * 2 + && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) + { + return match crate::model::address::parse_shielded_recipient(trimmed) { + Some(_) => (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))), + None => ( + Some( + "This private address is not valid. Please check it and try again." + .to_string(), + ), + None, + ), + }; + } + + // Network prefix validation is centralized in `model/address.rs` so the + // GUI and the MCP tools share one source of truth. + if let Err(e) = + crate::model::address::validate_orchard_address_for_network(trimmed, self.network) + { + return (Some(e.to_string()), None); } // Orchard shielded addresses are ~70+ chars; reject anything too short. if trimmed.len() < 60 { @@ -640,20 +793,12 @@ impl AddressInput { } use dash_sdk::dpp::address_funds::OrchardAddress; match OrchardAddress::from_bech32m_string(trimmed) { - Ok((_, network)) => { - // Shielded addresses only encode mainnet vs non-mainnet in the HRP. - // Testnet, Devnet, and Local all share "tdash1z" and cannot be - // distinguished at the address level. Enforce mainnet isolation only. - let same_mainnet_class = - (self.network == Network::Mainnet) == (network == Network::Mainnet); - if !same_mainnet_class { - ( - Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), - None, - ) - } else { - (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) - } + Ok(_) => { + // Network is already validated above via the expected_prefix check + // (dash1z for mainnet, tdash1z for non-mainnet). `from_bech32m_string` + // no longer returns the network — the prefix guard is the sole + // network discriminator for shielded addresses. + (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) } Err(_) => ( Some( @@ -699,8 +844,12 @@ impl AddressInput { /// Returns matching entries (truncated to 10) and the total match count /// before truncation. + /// + /// The query is parsed as a GitHub-style tag search ([`ParsedQuery`]): free + /// text still does a substring search across every field, while `type:` and + /// `wallet:` tags narrow by kind and owning wallet. fn filtered_entries(&self) -> (Vec<&AddressEntry>, usize) { - let query = self.input_text.trim().to_lowercase(); + let parsed = ParsedQuery::parse(self.input_text.trim(), &self.enabled_kinds); let mut results: Vec<&AddressEntry> = self .all_entries @@ -722,32 +871,22 @@ impl AddressInput { { return false; } - // When query is empty, show all entries (no substring filter) - if query.is_empty() { - return true; - } - // Substring match against address, label, and type name. - // Typing "platform" or "core" filters to that address type. - e.address_string.to_lowercase().contains(&query) - || e.display_label.to_lowercase().contains(&query) - || e.address_kind.short_label().to_lowercase().contains(&query) - || e.address_kind - .display_name() - .to_lowercase() - .contains(&query) + parsed.matches(e) }) .collect(); - // Sort: exact prefix matches first, then by label - results.sort_by(|a, b| { - if query.is_empty() { - return a.display_label.cmp(&b.display_label); + // Sort: free-text prefix matches on the address float to the top, then + // alphabetical by name (falling back to the address). + let needle = parsed.prefix_needle(); + results.sort_by(|a, b| match &needle { + None => a.sort_key().cmp(b.sort_key()), + Some(needle) => { + let a_prefix = a.address_string.to_lowercase().starts_with(needle); + let b_prefix = b.address_string.to_lowercase().starts_with(needle); + b_prefix + .cmp(&a_prefix) + .then_with(|| a.sort_key().cmp(b.sort_key())) } - let a_prefix = a.address_string.to_lowercase().starts_with(&query); - let b_prefix = b.address_string.to_lowercase().starts_with(&query); - b_prefix - .cmp(&a_prefix) - .then(a.display_label.cmp(&b.display_label)) }); let total = results.len(); @@ -755,6 +894,68 @@ impl AddressInput { (results, total) } + /// The placeholder legend shown when no caller supplied an explicit hint: + /// `type:core|platform|... wallet:abc|def|...`, built from live data. + /// + /// The types segment lists the enabled kinds (in [`AddressKind::ALL`] order); + /// the wallets segment lists up to five distinct wallet names, alphabetically, + /// with a `(+N more)` indicator when more exist. The wallets segment is + /// omitted entirely when no wallets are loaded. + fn dynamic_hint(&self) -> String { + let mut segments = Vec::new(); + + let types: Vec<String> = AddressKind::ALL + .iter() + .copied() + .filter(|k| self.enabled_kinds.contains(k)) + .map(|k| k.short_label().to_lowercase()) + .collect(); + if !types.is_empty() { + segments.push(format!("type:{}", types.join("|"))); + } + + let mut names: Vec<&str> = self + .all_entries + .iter() + .filter_map(|e| e.wallet_name.as_deref()) + .collect(); + names.sort_unstable(); + names.dedup(); + if !names.is_empty() { + let shown = names.iter().take(5).copied().collect::<Vec<_>>().join("|"); + let mut segment = format!("wallet:{shown}"); + if names.len() > 5 { + segment.push_str(&format!(" (+{} more)", names.len() - 5)); + } + segments.push(segment); + } + + segments.join(" ") + } + + /// The hint text actually shown in the field: the caller's explicit hint if + /// set, otherwise the [`dynamic_hint`](Self::dynamic_hint) legend. + pub fn effective_hint_text(&self) -> String { + self.hint_text + .clone() + .unwrap_or_else(|| self.dynamic_hint()) + } + + /// The render descriptors for the currently filtered rows. Exposed for UI + /// tests to assert which pills and name annotations appear per row. + pub fn rendered_rows(&self) -> Vec<RenderedRow> { + self.filtered_entries() + .0 + .into_iter() + .map(|e| RenderedRow { + address_string: e.address_string.clone(), + wallet_pill: e.wallet_name.clone(), + name: e.name_label.clone(), + kind: e.address_kind, + }) + .collect() + } + // --- Balance formatting --- fn format_balance(&self, entry: &AddressEntry) -> String { @@ -796,6 +997,9 @@ impl AddressInput { // --- show() implementation --- fn show_internal(&mut self, ui: &mut Ui) -> InnerResponse<AddressInputResponse> { + // Computed before the `&mut self.input_text` borrow below; falls back to + // the dynamic tag-search legend when no explicit hint was supplied. + let hint_text = self.effective_hint_text(); let resp = ui.vertical(|ui| { // Label if let Some(label) = &self.label { @@ -833,9 +1037,9 @@ impl AddressInput { // Text input let mut text_edit = egui::TextEdit::singleline(&mut self.input_text); - if let Some(hint) = &self.hint_text { + if !hint_text.is_empty() { text_edit = text_edit - .hint_text(egui::RichText::new(hint).color(egui::Color32::GRAY)); + .hint_text(egui::RichText::new(&hint_text).color(egui::Color32::GRAY)); } if let Some(width) = self.desired_width { text_edit = text_edit.desired_width(width); @@ -871,15 +1075,19 @@ impl AddressInput { // Autocomplete popup let mut selected_entry: Option<AddressEntry> = None; if has_focus || self.autocomplete_open { - // Collect filtered entries into an owned snapshot to release the borrow on self + // Collect filtered entries into an owned snapshot to release the + // borrow on self: (balance, address text, entry). let (filtered, total_entries) = self.filtered_entries(); let filtered_len = filtered.len(); let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered .iter() .map(|e| { - let label = - format!("{} ({})", e.display_label, e.address_kind.short_label()); - (label, self.format_balance(e), (*e).clone()) + let address_display = if self.full_addresses { + e.address_string.clone() + } else { + truncate_address(&e.address_string) + }; + (self.format_balance(e), address_display, (*e).clone()) }) .collect(); @@ -896,7 +1104,7 @@ impl AddressInput { egui::ScrollArea::vertical() .max_height(200.0) .show(ui, |ui| { - for (i, (label, balance_str, entry)) in + for (i, (balance_str, address_display, entry)) in entries_snapshot.iter().enumerate() { let highlighted = @@ -913,45 +1121,29 @@ impl AddressInput { ); if ui.is_rect_visible(rect) { - let hovered = response.hovered(); - if highlighted || hovered { - ui.painter().rect_filled( - rect, - egui::CornerRadius::from(2.0), - ui.style().visuals.widgets.hovered.bg_fill, - ); - } - - let text_color = if highlighted || hovered { - ui.style().visuals.widgets.hovered.text_color() - } else { - ui.style().visuals.widgets.inactive.text_color() - }; - - let padding = 4.0; - ui.painter().text( - egui::pos2( - rect.left() + padding, - rect.center().y, - ), - egui::Align2::LEFT_CENTER, - label.as_str(), - egui::TextStyle::Body.resolve(ui.style()), - text_color, - ); - - ui.painter().text( - egui::pos2( - rect.right() - padding, - rect.center().y, - ), - egui::Align2::RIGHT_CENTER, - balance_str.as_str(), - egui::TextStyle::Small.resolve(ui.style()), - DashColors::GRAY, + paint_autocomplete_row( + ui, + rect, + highlighted || response.hovered(), + entry, + address_display, + balance_str, ); } + let label = row_accessible_label( + entry, + address_display, + balance_str, + ); + response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + label.clone(), + ) + }); + if response.clicked() { selected_entry = Some(entry.clone()); } @@ -1004,7 +1196,7 @@ impl AddressInput { self.autocomplete_open = false; } - // Handle autocomplete selection (FIX 7: clear cached_detection) + // Handle autocomplete selection (clear cached_detection). let selected_this_frame = selected_entry.is_some(); if let Some(entry) = selected_entry { self.input_text = entry.address_string.clone(); @@ -1042,10 +1234,10 @@ impl AddressInput { } } - // Build response - // FIX 1: blur validation produces a result => signal changed + // Build response. + // Blur validation producing a result signals changed. let blur_validated = lost_focus && validated_address.is_some(); - // FIX 2: use one-frame local flag for autocomplete selection + // One-frame local flag for autocomplete selection. let changed = text_changed || selected_this_frame || self.changed || blur_validated; if self.changed { self.changed = false; @@ -1111,6 +1303,179 @@ fn truncate_address(addr: &str) -> String { crate::model::address::truncate_address(addr, 8, 6) } +// --- Autocomplete row rendering --- + +/// Horizontal padding inside a pill and the gap after a pill or the address text. +const PILL_PAD_X: f32 = 6.0; +const PILL_GAP: f32 = 6.0; + +/// Paint one autocomplete row: an optional hover highlight, then, left to right, +/// `[wallet pill] address (name)`, with the type pill and balance right-aligned. +fn paint_autocomplete_row( + ui: &Ui, + rect: egui::Rect, + active: bool, + entry: &AddressEntry, + address_display: &str, + balance_str: &str, +) { + let dark_mode = ui.visuals().dark_mode; + + if active { + ui.painter().rect_filled( + rect, + egui::CornerRadius::from(2.0), + ui.style().visuals.widgets.hovered.bg_fill, + ); + } + + let text_color = if active { + ui.style().visuals.widgets.hovered.text_color() + } else { + ui.style().visuals.widgets.inactive.text_color() + }; + let secondary = DashColors::text_secondary(dark_mode); + let center_y = rect.center().y; + let mut x = rect.left() + PILL_GAP; + + // Wallet pill (Core/Platform only). + if let Some(wallet) = &entry.wallet_name { + x += paint_pill( + ui, + x, + center_y, + wallet, + DashColors::text_primary(dark_mode), + wallet_pill_bg(), + ) + PILL_GAP; + } + + // Address text. + let body_font = egui::TextStyle::Body.resolve(ui.style()); + let addr_galley = + ui.painter() + .layout_no_wrap(address_display.to_string(), body_font.clone(), text_color); + let addr_width = addr_galley.size().x; + ui.painter().galley( + egui::pos2(x, center_y - addr_galley.size().y / 2.0), + addr_galley, + text_color, + ); + x += addr_width + PILL_GAP; + + // Name annotation, e.g. "(alice.dash)" or "(change)". + if let Some(name) = &entry.name_label { + let name_galley = ui + .painter() + .layout_no_wrap(format!("({name})"), body_font, secondary); + ui.painter().galley( + egui::pos2(x, center_y - name_galley.size().y / 2.0), + name_galley, + secondary, + ); + } + + // Right cluster: balance rightmost, type pill immediately to its left. + let small_font = egui::TextStyle::Small.resolve(ui.style()); + let bal_galley = + ui.painter() + .layout_no_wrap(balance_str.to_string(), small_font, DashColors::GRAY); + let bal_width = bal_galley.size().x; + let bal_x = rect.right() - PILL_GAP - bal_width; + ui.painter().galley( + egui::pos2(bal_x, center_y - bal_galley.size().y / 2.0), + bal_galley, + DashColors::GRAY, + ); + + let type_text = entry.address_kind.short_label(); + let type_width = measure_pill(ui, type_text); + let type_left = bal_x - PILL_GAP - type_width; + paint_pill( + ui, + type_left, + center_y, + type_text, + secondary, + type_pill_bg(), + ); +} + +/// Font used for pill text. +fn pill_font(ui: &Ui) -> egui::FontId { + egui::TextStyle::Small.resolve(ui.style()) +} + +/// The width a pill would occupy for `text`, without painting it. +fn measure_pill(ui: &Ui, text: &str) -> f32 { + let galley = ui + .painter() + .layout_no_wrap(text.to_string(), pill_font(ui), Color32::PLACEHOLDER); + galley.size().x + PILL_PAD_X * 2.0 +} + +/// Paint a compact rounded pill with its left edge at `left`, vertically centered +/// on `center_y`. Returns the pill's width. +fn paint_pill( + ui: &Ui, + left: f32, + center_y: f32, + text: &str, + text_color: Color32, + bg: Color32, +) -> f32 { + let galley = ui + .painter() + .layout_no_wrap(text.to_string(), pill_font(ui), text_color); + let text_size = galley.size(); + let width = text_size.x + PILL_PAD_X * 2.0; + let height = text_size.y + 2.0; + let rect = egui::Rect::from_min_size( + egui::pos2(left, center_y - height / 2.0), + egui::vec2(width, height), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(Shape::RADIUS_FULL), bg); + ui.painter().galley( + egui::pos2(left + PILL_PAD_X, center_y - text_size.y / 2.0), + galley, + text_color, + ); + width +} + +/// Translucent accent tint for the wallet pill. +fn wallet_pill_bg() -> Color32 { + let c = DashColors::DASH_BLUE; + Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 48) +} + +/// Translucent neutral tint for the type pill, distinct from the wallet pill. +fn type_pill_bg() -> Color32 { + let c = DashColors::GRAY; + Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 48) +} + +/// One-line accessible label mirroring a row's painted content, for screen +/// readers and UI tests (the row is otherwise painted directly and exposes no text). +fn row_accessible_label(entry: &AddressEntry, address_display: &str, balance_str: &str) -> String { + let mut label = String::new(); + if let Some(wallet) = &entry.wallet_name { + label.push_str(wallet); + label.push(' '); + } + label.push_str(address_display); + if let Some(name) = &entry.name_label { + label.push_str(&format!(" ({name})")); + } + label.push_str(&format!( + " {} {}", + entry.address_kind.short_label(), + balance_str + )); + label +} + #[cfg(test)] mod tests { use super::*; @@ -1151,6 +1516,17 @@ mod tests { assert_eq!(result, DetectedType::Shielded); } + #[test] + fn detect_shielded_raw_hex() { + // 43-byte raw hex form (network-agnostic) routes to shielded validation + // so the "…or hex" recipient entry keeps working. + let hex_str = hex::encode(vec![ + 0xABu8; + crate::model::address::SHIELDED_ADDRESS_RAW_LEN + ]); + assert_eq!(detect_address_type(&hex_str, true), DetectedType::Shielded); + } + #[test] fn detect_platform_testnet() { // A plausible platform address prefix @@ -1520,7 +1896,7 @@ mod tests { assert_eq!(va.to_address_string(), "dash1z_test"); } - // --- FIX 1: Blur validation propagation --- + // --- Blur validation propagation --- #[test] fn blur_triggers_validation_for_valid_core_address() { @@ -1551,7 +1927,7 @@ mod tests { assert_eq!(val.unwrap().kind(), AddressKind::Core); } - // --- FIX 4: Mixed-case bech32m rejection --- + // --- Mixed-case bech32m rejection --- #[test] fn platform_mixed_case_rejected() { @@ -1593,4 +1969,403 @@ mod tests { "all-uppercase should pass the case check" ); } + + // --- Search-tag query parser --- + + fn test_entry( + kind: AddressKind, + addr: &str, + name: Option<&str>, + wallet: Option<&str>, + ) -> AddressEntry { + AddressEntry { + address_string: addr.to_string(), + address_kind: kind, + name_label: name.map(String::from), + wallet_name: wallet.map(String::from), + balance: 0, + validated: ValidatedAddress::Shielded(addr.to_string()), + } + } + + #[test] + fn parse_type_tag_selects_kind() { + let q = ParsedQuery::parse("type:core", &AddressKind::ALL); + assert!(q.has_type_constraint); + assert_eq!(q.type_kinds, vec![AddressKind::Core]); + assert!(q.wallet_values.is_empty()); + assert!(q.free_text.is_empty()); + } + + #[test] + fn parse_type_prefix_matches() { + assert_eq!( + ParsedQuery::parse("type:cor", &AddressKind::ALL).type_kinds, + vec![AddressKind::Core] + ); + assert_eq!( + ParsedQuery::parse("type:plat", &AddressKind::ALL).type_kinds, + vec![AddressKind::Platform] + ); + } + + #[test] + fn parse_type_is_case_insensitive() { + assert_eq!( + ParsedQuery::parse("TYPE:Core", &AddressKind::ALL).type_kinds, + vec![AddressKind::Core] + ); + } + + #[test] + fn parse_type_tokens_union() { + let q = ParsedQuery::parse("type:core type:platform", &AddressKind::ALL); + assert_eq!(q.type_kinds, vec![AddressKind::Core, AddressKind::Platform]); + assert!(q.matches(&test_entry(AddressKind::Core, "x", None, None))); + assert!(q.matches(&test_entry(AddressKind::Platform, "x", None, None))); + assert!(!q.matches(&test_entry(AddressKind::Shielded, "x", None, None))); + } + + #[test] + fn parse_wallet_tag_substring_matches() { + let q = ParsedQuery::parse("wallet:abc", &AddressKind::ALL); + assert_eq!(q.wallet_values, vec!["abc".to_string()]); + assert!(q.matches(&test_entry(AddressKind::Core, "x", None, Some("abcdef")))); + assert!(!q.matches(&test_entry(AddressKind::Core, "x", None, Some("xyz")))); + assert!(!q.matches(&test_entry(AddressKind::Core, "x", None, None))); + } + + #[test] + fn parse_wallet_is_case_insensitive() { + let q = ParsedQuery::parse("wallet:ABC", &AddressKind::ALL); + assert!(q.matches(&test_entry( + AddressKind::Core, + "x", + None, + Some("MyAbcWallet") + ))); + } + + #[test] + fn parse_tags_and_together() { + let q = ParsedQuery::parse("type:core wallet:abc", &AddressKind::ALL); + assert!(q.matches(&test_entry( + AddressKind::Core, + "x", + None, + Some("abc wallet") + ))); + // Right wallet, wrong type. + assert!(!q.matches(&test_entry( + AddressKind::Platform, + "x", + None, + Some("abc wallet") + ))); + // Right type, wrong wallet. + assert!(!q.matches(&test_entry(AddressKind::Core, "x", None, Some("zzz")))); + } + + #[test] + fn free_text_tokens_and_together() { + let q = ParsedQuery::parse("foo bar", &AddressKind::ALL); + assert_eq!(q.free_text, vec!["foo".to_string(), "bar".to_string()]); + assert!(q.matches(&test_entry(AddressKind::Core, "foobar", None, None))); + assert!(!q.matches(&test_entry(AddressKind::Core, "foo", None, None))); + } + + #[test] + fn unknown_key_is_treated_as_free_text() { + let q = ParsedQuery::parse("foo:bar", &AddressKind::ALL); + assert!(!q.has_type_constraint); + assert!(q.type_kinds.is_empty()); + assert!(q.wallet_values.is_empty()); + assert_eq!(q.free_text, vec!["foo:bar".to_string()]); + } + + #[test] + fn empty_tag_value_is_ignored() { + let type_only = ParsedQuery::parse("type:", &AddressKind::ALL); + assert!(!type_only.has_type_constraint); + assert!(type_only.free_text.is_empty()); + assert!(type_only.matches(&test_entry(AddressKind::Shielded, "x", None, None))); + + let wallet_only = ParsedQuery::parse("wallet:", &AddressKind::ALL); + assert!(wallet_only.wallet_values.is_empty()); + assert!(wallet_only.matches(&test_entry(AddressKind::Core, "x", None, None))); + } + + #[test] + fn type_restricted_to_enabled_kinds() { + let q = ParsedQuery::parse("type:core", &[AddressKind::Platform]); + assert!(q.has_type_constraint); + assert!(q.type_kinds.is_empty()); + // A kind not enabled for this instance can never match. + assert!(!q.matches(&test_entry(AddressKind::Core, "x", None, None))); + } + + #[test] + fn type_matching_no_kind_excludes_all() { + let q = ParsedQuery::parse("type:zzz", &AddressKind::ALL); + assert!(q.has_type_constraint); + assert!(q.type_kinds.is_empty()); + assert!(!q.matches(&test_entry(AddressKind::Core, "x", None, None))); + } + + #[test] + fn free_text_matches_kind_label() { + // Preserves the pre-tag behavior: typing "core" filters to Core entries. + let q = ParsedQuery::parse("core", &AddressKind::ALL); + assert!(q.matches(&test_entry(AddressKind::Core, "x", None, None))); + assert!(!q.matches(&test_entry(AddressKind::Shielded, "x", None, None))); + } + + #[test] + fn empty_query_matches_everything() { + let q = ParsedQuery::parse("", &AddressKind::ALL); + assert!(q.matches(&test_entry(AddressKind::Identity, "x", None, None))); + assert!(q.prefix_needle().is_none()); + } + + #[test] + fn prefix_needle_is_joined_free_text() { + let q = ParsedQuery::parse("type:core abc", &AddressKind::ALL); + assert_eq!(q.prefix_needle().as_deref(), Some("abc")); + } + + // --- Entry population --- + + fn core_wallet_input(alias: Option<&str>) -> AddressInput { + use std::collections::BTreeMap; + let wallet = + Wallet::new_from_seed([7u8; 64], Network::Testnet, alias.map(String::from), None) + .expect("wallet from seed"); + AddressInput::new(Network::Testnet) + .with_wallets(&[(Arc::new(RwLock::new(wallet)), BTreeMap::new())]) + } + + fn identity_input(alias: Option<&str>, dpns: Option<&str>) -> AddressInput { + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{ + DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, + }; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + let identity = + Identity::create_basic_identity(Identifier::from([9u8; 32]), PlatformVersion::latest()) + .expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: alias.map(String::from), + private_keys: KeyStorage::default(), + dpns_names: dpns + .map(|n| { + vec![DPNSNameInfo { + name: n.to_string(), + acquired_at: 0, + }] + }) + .unwrap_or_default(), + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: Network::Testnet, + }; + AddressInput::new(Network::Testnet).with_identities(&[qi]) + } + + #[test] + fn core_entry_gets_wallet_name_and_no_name_label() { + let input = core_wallet_input(Some("MyWallet")); + let core = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Core) + .expect("core entry present"); + assert_eq!(core.wallet_name.as_deref(), Some("MyWallet")); + assert_eq!(core.name_label, None, "receive address has no name label"); + } + + #[test] + fn core_entry_defaults_wallet_name_when_no_alias() { + let input = core_wallet_input(None); + let core = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Core) + .expect("core entry present"); + assert_eq!(core.wallet_name.as_deref(), Some("Wallet")); + } + + #[test] + fn core_change_entry_gets_change_name_label() { + use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; + use std::collections::BTreeMap; + + let mut wallet = + Wallet::new_from_seed([7u8; 64], Network::Testnet, Some("W".to_string()), None) + .expect("wallet from seed"); + let (_, change_addr) = testnet_core_address(); + // BIP44 change path (m/44'/1'/0'/1/0): components[3] == Normal(1). + let change_path = DerivationPath::from( + [ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 1 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + ); + wallet.known_addresses.insert(change_addr, change_path); + + let input = AddressInput::new(Network::Testnet) + .with_wallets(&[(Arc::new(RwLock::new(wallet)), BTreeMap::new())]); + let change = input + .all_entries + .iter() + .find(|e| e.name_label.as_deref() == Some("change")) + .expect("change entry present"); + assert_eq!(change.address_kind, AddressKind::Core); + assert_eq!(change.wallet_name.as_deref(), Some("W")); + } + + #[test] + fn core_change_entry_excluded_when_requested() { + use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; + use std::collections::BTreeMap; + + let mut wallet = + Wallet::new_from_seed([7u8; 64], Network::Testnet, Some("W".to_string()), None) + .expect("wallet from seed"); + let (_, change_addr) = testnet_core_address(); + let change_path = DerivationPath::from( + [ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 1 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + ); + wallet.known_addresses.insert(change_addr, change_path); + + let input = AddressInput::new(Network::Testnet) + .with_exclude_change(true) + .with_wallets(&[(Arc::new(RwLock::new(wallet)), BTreeMap::new())]); + assert!( + input + .all_entries + .iter() + .all(|e| e.name_label.as_deref() != Some("change")), + "change address must be excluded when with_exclude_change(true)" + ); + } + + #[test] + fn shielded_entry_has_no_name_or_wallet() { + let input = AddressInput::new(Network::Testnet) + .with_shielded_balance("tdash1zexampleaddress".to_string(), 100); + let entry = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Shielded) + .expect("shielded entry present"); + assert_eq!(entry.name_label, None); + assert_eq!(entry.wallet_name, None); + } + + #[test] + fn identity_entry_uses_alias_when_no_dpns() { + let input = identity_input(Some("bob-alias"), None); + let entry = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Identity) + .expect("identity entry present"); + assert_eq!(entry.name_label.as_deref(), Some("bob-alias")); + assert_eq!(entry.wallet_name, None); + } + + #[test] + fn identity_entry_prefers_dpns_over_alias() { + let input = identity_input(Some("bob-alias"), Some("bob.dash")); + let entry = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Identity) + .expect("identity entry present"); + assert_eq!(entry.name_label.as_deref(), Some("bob.dash")); + } + + #[test] + fn identity_entry_has_no_name_when_neither() { + let input = identity_input(None, None); + let entry = input + .all_entries + .iter() + .find(|e| e.address_kind == AddressKind::Identity) + .expect("identity entry present"); + assert_eq!(entry.name_label, None); + } + + // --- Dynamic hint legend --- + + #[test] + fn dynamic_hint_lists_enabled_kinds() { + let input = AddressInput::new(Network::Testnet); + assert_eq!( + input.effective_hint_text(), + "type:core|platform|shielded|identity" + ); + } + + #[test] + fn dynamic_hint_reflects_restricted_kinds() { + let input = AddressInput::new(Network::Testnet) + .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]); + assert_eq!(input.effective_hint_text(), "type:core|platform"); + } + + #[test] + fn explicit_hint_overrides_dynamic_legend() { + let input = AddressInput::new(Network::Testnet).with_hint_text("Paste an address"); + assert_eq!(input.effective_hint_text(), "Paste an address"); + } + + #[test] + fn dynamic_hint_includes_and_trims_wallets() { + use std::collections::BTreeMap; + let wallets: Vec<WalletWithBalances> = (0u8..6) + .map(|i| { + let wallet = Wallet::new_from_seed( + [i + 1; 64], + Network::Testnet, + Some(format!("w{i}")), + None, + ) + .expect("wallet from seed"); + (Arc::new(RwLock::new(wallet)), BTreeMap::new()) + }) + .collect(); + let input = AddressInput::new(Network::Testnet).with_wallets(&wallets); + let hint = input.effective_hint_text(); + assert!( + hint.contains("wallet:w0|w1|w2|w3|w4"), + "hint should list the first five wallets: {hint}" + ); + assert!( + hint.contains("(+1 more)"), + "hint should indicate one trimmed wallet: {hint}" + ); + } } diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 687bb216f..2fa37a311 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -19,18 +19,6 @@ pub struct AmountInputResponse { pub parsed_amount: Option<Amount>, } -impl AmountInputResponse { - /// Returns whether the input is valid (no error message) - pub fn is_valid(&self) -> bool { - self.error_message.is_none() - } - - /// Returns whether the input has changed - pub fn has_changed(&self) -> bool { - self.changed - } -} - impl ComponentResponse for AmountInputResponse { type DomainType = Amount; fn has_changed(&self) -> bool { diff --git a/src/ui/components/avatar.rs b/src/ui/components/avatar.rs new file mode 100644 index 000000000..bce9ab52c --- /dev/null +++ b/src/ui/components/avatar.rs @@ -0,0 +1,199 @@ +//! Avatar — a display-only widget that renders a DashPay contact/profile avatar +//! from a URL, backed by the [`AvatarCache`] fetch cache. +//! +//! The single avatar-rendering path for every DashPay screen. Given a URL and a +//! shared [`AvatarCache`], `show` renders one of: +//! +//! - the uploaded image once the bytes are fetched and decoded, +//! - a spinner while the fetch is in flight (also asking the caller to dispatch +//! the fetch task the first time it is seen), +//! - a `👤` fallback glyph when there is no URL or the fetch/decode failed. +//! +//! Decode (center-crop to square) and GPU-texture upload happen here, on the UI +//! thread; the network fetch runs off-frame through the App Task System. See +//! [`AvatarCache`] for the state machine. +//! +//! [`AvatarCache`]: crate::ui::state::AvatarCache + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::ui::state::AvatarCache; +use crate::ui::theme::{DashColors, ResponseExt}; +use egui::{ColorImage, RichText, TextureHandle, Ui}; + +/// A DashPay avatar widget. Configured per call site (size, corner radius, +/// clickability), rendered against a shared [`AvatarCache`]. +pub struct Avatar<'a> { + url: Option<&'a str>, + size: f32, + corner_radius: f32, + clickable: bool, + tooltip: Option<&'a str>, +} + +/// Outcome of rendering an [`Avatar`]. +#[derive(Default)] +pub struct AvatarResponse { + /// A fetch task the caller must dispatch (the avatar was seen for the first + /// time and is not yet cached). Combine into the screen's [`AppAction`] via + /// [`AvatarResponse::into_action`]. + pub fetch: Option<BackendTask>, + /// Whether a clickable avatar was clicked this frame. + pub clicked: bool, +} + +impl AvatarResponse { + /// The fetch task as an [`AppAction`], or [`AppAction::None`] when nothing + /// needs dispatching. + pub fn into_action(self) -> AppAction { + match self.fetch { + Some(task) => AppAction::BackendTask(task), + None => AppAction::None, + } + } +} + +impl<'a> Avatar<'a> { + /// A square avatar of `size` points for `url`. An empty or `None` URL renders + /// the fallback glyph. Corner radius defaults to a full circle (`size / 2`). + pub fn new(url: Option<&'a str>, size: f32) -> Self { + Self { + url, + size, + corner_radius: size / 2.0, + clickable: false, + tooltip: None, + } + } + + /// Override the corner radius (e.g. a rounded square instead of a circle). + pub fn corner_radius(mut self, corner_radius: f32) -> Self { + self.corner_radius = corner_radius; + self + } + + /// Make the rendered image clickable, showing `tooltip` on hover. The click + /// is reported via [`AvatarResponse::clicked`]. + pub fn clickable(mut self, tooltip: &'a str) -> Self { + self.clickable = true; + self.tooltip = Some(tooltip); + self + } + + /// Render the avatar against `cache`, decoding and uploading its texture on + /// the first frame after the bytes arrive. + pub fn show(self, ui: &mut Ui, cache: &mut AvatarCache) -> AvatarResponse { + let url = match self.url { + Some(url) if !url.is_empty() => url, + _ => { + self.render_fallback(ui); + return AvatarResponse::default(); + } + }; + + // Already decoded and uploaded — the steady state. + if let Some(texture) = cache.ready_texture(url) { + let clicked = self.render_image(ui, texture); + return AvatarResponse { + fetch: None, + clicked, + }; + } + + // Bytes fetched but not yet decoded: decode + upload once, then cache the + // texture so later frames hit the branch above. + match cache.fetched_bytes(url).map(decode_square_avatar) { + Some(Some(image)) => { + let texture = ui.ctx().load_texture( + format!("avatar:{url}"), + image, + egui::TextureOptions::LINEAR, + ); + cache.set_ready(url, texture.clone()); + let clicked = self.render_image(ui, &texture); + return AvatarResponse { + fetch: None, + clicked, + }; + } + Some(None) => { + // Fetched bytes did not decode as an image — fall back. + cache.mark_failed(url); + self.render_fallback(ui); + return AvatarResponse::default(); + } + None => {} + } + + if cache.is_failed(url) { + self.render_fallback(ui); + return AvatarResponse::default(); + } + + // Loading or not yet requested: show a spinner and ask the caller to + // dispatch the fetch (idempotent — `ensure_requested` dedups). + self.render_spinner(ui); + AvatarResponse { + fetch: cache.ensure_requested(url), + clicked: false, + } + } + + /// Render the uploaded avatar image, returning whether it was clicked. + fn render_image(&self, ui: &mut Ui, texture: &TextureHandle) -> bool { + let mut image = egui::Image::new(texture) + .fit_to_exact_size(egui::vec2(self.size, self.size)) + .corner_radius(self.corner_radius); + if self.clickable { + image = image.sense(egui::Sense::click()); + } + let response = ui.add(image); + let response = match self.tooltip { + Some(tooltip) => response.clickable_tooltip(tooltip), + None => response, + }; + self.clickable && response.clicked() + } + + /// Render the `👤` fallback glyph (no URL, or fetch/decode failed). + fn render_fallback(&self, ui: &mut Ui) { + ui.label( + RichText::new("👤") + .size(self.size) + .color(DashColors::DEEP_BLUE), + ); + } + + /// Render the in-flight loading spinner. + fn render_spinner(&self, ui: &mut Ui) { + ui.add( + egui::Spinner::new() + .size(self.size) + .color(DashColors::DASH_BLUE), + ); + } +} + +/// Decode `bytes` into a square [`ColorImage`], center-cropping to the shorter +/// side when the source is not already square. Returns `None` when the bytes are +/// not a decodable image. +fn decode_square_avatar(bytes: &[u8]) -> Option<ColorImage> { + let image = image::load_from_memory(bytes).ok()?; + let rgba = image.to_rgba8(); + let (width, height) = (rgba.width(), rgba.height()); + + let cropped = if width != height { + let side = width.min(height); + let x_offset = (width - side) / 2; + let y_offset = (height - side) / 2; + image::imageops::crop_imm(&rgba, x_offset, y_offset, side, side).to_image() + } else { + rgba + }; + + let size = [cropped.width() as usize, cropped.height() as usize]; + Some(ColorImage::from_rgba_unmultiplied( + size, + &cropped.into_raw(), + )) +} diff --git a/src/ui/components/breadcrumb_pill.rs b/src/ui/components/breadcrumb_pill.rs new file mode 100644 index 000000000..94d5faf18 --- /dev/null +++ b/src/ui/components/breadcrumb_pill.rs @@ -0,0 +1,350 @@ +//! Breadcrumb pill — label + optional icon + optional chevron rendered in the +//! topbar breadcrumb row of the Identities hub. +//! +//! Three visual modes: +//! +//! - `Interactive` — hover border, chevron, clickable. Used for wallet / +//! identity pills that open a dropdown. +//! - `Subdued` — no chevron, no hover treatment, transparent background. +//! Used when the segment is informational only (Alex's one-wallet case). +//! - `Placeholder` — italic, text-secondary, non-interactive. Used when the +//! segment has no value yet: `(no wallet yet)`, `(no identity yet)`, etc. +//! +//! See design-spec §A.3. +//! +//! This component follows `docs/COMPONENT_DESIGN_PATTERN.md`: +//! - private fields + builder methods for configuration, +//! - a response struct carrying interaction state (not the widget), +//! - self-contained theming for light + dark mode. + +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::{DashColors, ResponseExt}; +use eframe::egui::{ + self, Color32, CornerRadius, Frame, Margin, RichText, Sense, Stroke, Ui, WidgetInfo, WidgetType, +}; + +/// The three rendering modes for a breadcrumb pill. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BreadcrumbPillMode { + /// Fully interactive. Hover highlights, chevron visible, clickable. + #[default] + Interactive, + /// Non-interactive, subdued surface, no chevron. For single-option segments. + Subdued, + /// Placeholder text (italic, text-secondary). Non-interactive. + Placeholder, +} + +/// Response struct returned by [`BreadcrumbPill::show`]. +/// +/// Carries whether the user clicked the pill. `has_changed` returns `true` +/// exclusively on click — this is the "state change" callers care about. When +/// clicked, `changed_value` yields the pill's label so a switcher composition +/// can report which segment was activated. +#[derive(Clone, Debug)] +pub struct BreadcrumbPillResponse { + pub clicked: bool, + pub label: String, + pub mode: BreadcrumbPillMode, + /// The pill's **inner** egui `Response` (the `Label` with click `Sense`), + /// for anchoring a dropdown `Popup`. `None` for fabricated responses (tests + /// / compositional callers that did not render). + pub response: Option<egui::Response>, + changed_value: Option<String>, +} + +impl BreadcrumbPillResponse { + /// Construct a response directly. Exposed at crate visibility for + /// compositional callers (e.g. the identity pill wrapper) and for + /// integration tests that fabricate responses without running egui. + pub(crate) fn new(label: String, mode: BreadcrumbPillMode, clicked: bool) -> Self { + let changed_value = if clicked { Some(label.clone()) } else { None }; + Self { + clicked, + label, + mode, + response: None, + changed_value, + } + } +} + +impl ComponentResponse for BreadcrumbPillResponse { + type DomainType = String; + + fn has_changed(&self) -> bool { + self.clicked + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// A breadcrumb pill widget. +#[derive(Clone, Debug)] +pub struct BreadcrumbPill { + label: String, + icon: Option<String>, + mode: BreadcrumbPillMode, + /// Tooltip text. Empty string = no tooltip. + tooltip: String, + /// Accessible name override. Defaults to the visible label. + accessible_name: Option<String>, +} + +impl BreadcrumbPill { + /// Build an interactive pill with the given visible label. + pub fn new(label: impl Into<String>) -> Self { + Self { + label: label.into(), + icon: None, + mode: BreadcrumbPillMode::default(), + tooltip: String::new(), + accessible_name: None, + } + } + + /// Build a placeholder pill (italic, text-secondary, non-interactive). + pub fn placeholder(label: impl Into<String>) -> Self { + let mut pill = Self::new(label); + pill.mode = BreadcrumbPillMode::Placeholder; + pill + } + + /// Flip subdued mode on or off. Subdued pills have no chevron and no hover + /// treatment. + pub fn subdued(mut self, subdued: bool) -> Self { + if subdued { + self.mode = BreadcrumbPillMode::Subdued; + } else if matches!(self.mode, BreadcrumbPillMode::Subdued) { + self.mode = BreadcrumbPillMode::Interactive; + } + self + } + + /// Attach an icon glyph shown before the label. Emoji-safe short strings. + pub fn with_icon(mut self, icon: impl Into<String>) -> Self { + self.icon = Some(icon.into()); + self + } + + /// Attach a tooltip. Empty string disables the tooltip. + pub fn with_tooltip(mut self, text: impl Into<String>) -> Self { + self.tooltip = text.into(); + self + } + + /// Override the accessible name. Defaults to the visible label. + pub fn with_accessible_name(mut self, name: impl Into<String>) -> Self { + self.accessible_name = Some(name.into()); + self + } + + /// Force a specific mode. Useful for tests and compositional callers that + /// need to bypass [`new`] / [`placeholder`]. + pub fn with_mode(mut self, mode: BreadcrumbPillMode) -> Self { + self.mode = mode; + self + } + + /// The pill's visible label. + pub fn label(&self) -> &str { + &self.label + } + + /// The pill's current mode. + pub fn mode(&self) -> BreadcrumbPillMode { + self.mode + } + + /// Whether the pill renders in subdued mode. + pub fn is_subdued(&self) -> bool { + matches!(self.mode, BreadcrumbPillMode::Subdued) + } + + /// Whether the pill is a placeholder. + pub fn is_placeholder(&self) -> bool { + matches!(self.mode, BreadcrumbPillMode::Placeholder) + } + + /// Whether the pill is interactive (handles clicks). + pub fn is_interactive(&self) -> bool { + matches!(self.mode, BreadcrumbPillMode::Interactive) + } + + /// Render the pill and return a response. + /// + /// Only [`BreadcrumbPillMode::Interactive`] pills report `clicked == true`; + /// the other modes are non-interactive by design and their response always + /// has `clicked == false`. + pub fn show(self, ui: &mut Ui) -> BreadcrumbPillResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let accessible_name = self + .accessible_name + .clone() + .unwrap_or_else(|| self.label.clone()); + + let (text_color, bg, stroke) = match self.mode { + BreadcrumbPillMode::Interactive => ( + DashColors::text_primary(dark_mode), + DashColors::surface(dark_mode), + Stroke::new(1.0, DashColors::border_light(dark_mode)), + ), + BreadcrumbPillMode::Subdued => ( + DashColors::text_secondary(dark_mode), + Color32::TRANSPARENT, + Stroke::NONE, + ), + BreadcrumbPillMode::Placeholder => ( + DashColors::text_secondary(dark_mode), + Color32::TRANSPARENT, + Stroke::NONE, + ), + }; + + // Build the label text with optional icon prefix + chevron suffix. + let mut display = String::new(); + if let Some(icon) = &self.icon { + display.push_str(icon); + display.push(' '); + } + display.push_str(&self.label); + if matches!(self.mode, BreadcrumbPillMode::Interactive) { + display.push_str(" ▾"); + } + + let mut rich = RichText::new(display).color(text_color); + if matches!(self.mode, BreadcrumbPillMode::Placeholder) { + rich = rich.italics(); + } + + let frame = Frame::new() + .fill(bg) + .stroke(stroke) + .corner_radius(CornerRadius::same(255)) + .inner_margin(Margin::symmetric(8, 2)); + let sense = if matches!(self.mode, BreadcrumbPillMode::Interactive) { + Sense::click() + } else { + Sense::hover() + }; + + // In egui, `Frame::show(...).response` is the outer allocation, which + // only senses `Sense::hover` by default — child-widget clicks are NOT + // inherited by the outer Response. We must read `.inner` (the value + // returned from the closure) to pick up the Label's click Sense. + // Using the frame's `.response` instead silently breaks click + // detection on interactive pills. See CodeRabbit review on PR #842 for + // the full analysis. + let inner_response = frame + .show(ui, |ui| ui.add(egui::Label::new(rich).sense(sense))) + .inner; + + let response = if !self.tooltip.is_empty() { + if matches!(self.mode, BreadcrumbPillMode::Interactive) { + inner_response.clickable_tooltip(&self.tooltip) + } else { + inner_response.info_tooltip(&self.tooltip) + } + } else { + inner_response + }; + response.widget_info(|| { + WidgetInfo::labeled( + WidgetType::Link, + matches!(self.mode, BreadcrumbPillMode::Interactive), + accessible_name.clone(), + ) + }); + + let clicked = matches!(self.mode, BreadcrumbPillMode::Interactive) && response.clicked(); + let mut out = BreadcrumbPillResponse::new(self.label.clone(), self.mode, clicked); + out.response = Some(response); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_stores_label() { + let pill = BreadcrumbPill::new("Main Wallet"); + assert_eq!(pill.label(), "Main Wallet"); + assert!(pill.is_interactive()); + assert!(!pill.is_subdued()); + assert!(!pill.is_placeholder()); + } + + #[test] + fn subdued_flag_toggles_mode() { + let pill = BreadcrumbPill::new("Main Wallet").subdued(true); + assert!(pill.is_subdued()); + assert!(!pill.is_interactive()); + let pill = pill.subdued(false); + assert!(pill.is_interactive()); + } + + #[test] + fn placeholder_ctor_sets_mode() { + let pill = BreadcrumbPill::placeholder("(no wallet yet)"); + assert_eq!(pill.label(), "(no wallet yet)"); + assert!(pill.is_placeholder()); + assert!(!pill.is_interactive()); + } + + #[test] + fn response_roundtrip_interactive_not_clicked() { + let resp = BreadcrumbPillResponse::new( + "Main Wallet".to_string(), + BreadcrumbPillMode::Interactive, + false, + ); + assert!(!resp.has_changed()); + assert!(resp.is_valid()); + assert!(resp.changed_value().is_none()); + assert_eq!(resp.label, "Main Wallet"); + } + + #[test] + fn response_roundtrip_interactive_clicked() { + let resp = BreadcrumbPillResponse::new( + "Main Wallet".to_string(), + BreadcrumbPillMode::Interactive, + true, + ); + assert!(resp.has_changed()); + assert_eq!(resp.changed_value().as_deref(), Some("Main Wallet")); + } + + #[test] + fn placeholder_response_never_clicked() { + let resp = BreadcrumbPillResponse::new( + "(no wallet yet)".to_string(), + BreadcrumbPillMode::Placeholder, + false, + ); + assert!(!resp.has_changed()); + assert_eq!(resp.mode, BreadcrumbPillMode::Placeholder); + } + + #[test] + fn builder_chain_is_fluent() { + let pill = BreadcrumbPill::new("alex.dash") + .with_icon("👤") + .with_tooltip("Switch between identities") + .with_accessible_name("Identity switcher"); + assert_eq!(pill.label(), "alex.dash"); + assert_eq!(pill.mode(), BreadcrumbPillMode::Interactive); + } +} diff --git a/src/ui/components/confirmation_dialog.rs b/src/ui/components/confirmation_dialog.rs index e7150510d..80808fba6 100644 --- a/src/ui/components/confirmation_dialog.rs +++ b/src/ui/components/confirmation_dialog.rs @@ -1,8 +1,11 @@ use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::modal_chrome::{ModalChromeConfig, modal_chrome}; use crate::ui::helpers::clicked_outside_window; use crate::ui::theme::{ComponentStyles, DashColors}; use egui::{InnerResponse, Ui, WidgetText}; +pub use crate::ui::components::modal_chrome::NOTHING; + /// Response from showing a confirmation dialog #[derive(Debug, Clone, PartialEq)] pub enum ConfirmationStatus { @@ -12,7 +15,6 @@ pub enum ConfirmationStatus { Canceled, } -pub const NOTHING: Option<&str> = None; /// Response struct for the ConfirmationDialog component following the Component trait pattern #[derive(Debug, Clone)] pub struct ConfirmationDialogComponentResponse { @@ -55,7 +57,6 @@ impl ComponentResponse for ConfirmationDialogComponentResponse { pub struct ConfirmationDialog { title: WidgetText, message: WidgetText, - status: Option<ConfirmationStatus>, confirm_text: Option<WidgetText>, cancel_text: Option<WidgetText>, danger_mode: bool, @@ -102,7 +103,6 @@ impl ConfirmationDialog { cancel_text: Some("Cancel".into()), danger_mode: false, is_open: true, - status: None, // No action taken yet } } @@ -134,51 +134,29 @@ impl ConfirmationDialog { impl ConfirmationDialog { /// Show the dialog and return the user's response fn show_dialog(&mut self, ui: &mut Ui) -> InnerResponse<Option<ConfirmationStatus>> { - let mut is_open = self.is_open; - - if !is_open { + if !self.is_open { return InnerResponse::new( None, // no change ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), ); } - // Draw dark overlay behind the dialog for better visibility - let screen_rect = ui.ctx().content_rect(); - let painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("confirmation_dialog_overlay"), - )); - painter.rect_filled( - screen_rect, - 0.0, - DashColors::modal_overlay(), // Semi-transparent black overlay - ); - let mut final_response = None; - let window_response = egui::Window::new(self.title.clone()) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut is_open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(16), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: DashColors::popup_shadow(), - }, - fill: ui.style().visuals.window_fill, - stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), - }) - .show(ui.ctx(), |ui| { + let chrome = modal_chrome( + ui.ctx(), + ModalChromeConfig { + title: self.title.clone(), + overlay_id: egui::Id::new("confirmation_dialog_overlay"), + overlay_order: egui::Order::Background, + window_order: egui::Order::Middle, + resizable: false, + inner_margin: 16, + }, + |ui| { // Set minimum width for the dialog ui.set_min_width(300.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Message content with bold text and proper color ui.add_space(10.0); @@ -221,10 +199,11 @@ impl ConfirmationDialog { } }); }); - }); + }, + ); // Handle window being closed via X button - treat as cancel - if !is_open && final_response.is_none() { + if chrome.closed_via_x && final_response.is_none() { final_response = Some(ConfirmationStatus::Canceled); } @@ -245,23 +224,19 @@ impl ConfirmationDialog { } // Handle click outside window - close for non-danger dialogs - if let Some(ref wr) = window_response + if let Some(ref wr) = chrome.window_response && final_response.is_none() && !self.danger_mode - && clicked_outside_window(ui.ctx(), wr.response.rect) + && clicked_outside_window(ui.ctx(), wr.rect) { final_response = Some(ConfirmationStatus::Canceled); } - // Update the dialog's state - self.is_open = is_open; - // if user actually did something, update the status - if final_response.is_some() { - self.status = final_response.clone(); - } + // The dialog only closes itself via the X button; button clicks leave it to the caller. + self.is_open = !chrome.closed_via_x; - if let Some(window_response) = window_response { - InnerResponse::new(final_response, window_response.response) + if let Some(window_response) = chrome.window_response { + InnerResponse::new(final_response, window_response) } else { InnerResponse::new( final_response, diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index 8661eefbd..3d3699e64 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -15,15 +15,13 @@ use dash_sdk::dpp::data_contract::{ }; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; -use egui::{Color32, Context as EguiContext, Frame, Margin, RichText, SidePanel}; +use egui::{Color32, Frame, Margin, Panel, RichText, Ui}; use std::collections::HashMap; use std::sync::Arc; use tracing::error; +#[derive(Default)] pub struct ContractChooserState { - pub right_click_contract_id: Option<String>, - pub show_context_menu: bool, - pub context_menu_position: egui::Pos2, pub expanded_contracts: std::collections::HashSet<String>, pub expanded_sections: std::collections::HashMap<String, std::collections::HashSet<String>>, pub expanded_doc_types: std::collections::HashMap<String, std::collections::HashSet<String>>, @@ -31,39 +29,37 @@ pub struct ContractChooserState { pub expanded_tokens: std::collections::HashMap<String, std::collections::HashSet<String>>, } -impl Default for ContractChooserState { - fn default() -> Self { - Self { - right_click_contract_id: None, - show_context_menu: false, - context_menu_position: egui::Pos2::ZERO, - expanded_contracts: std::collections::HashSet::new(), - expanded_sections: std::collections::HashMap::new(), - expanded_doc_types: std::collections::HashMap::new(), - expanded_indexes: std::collections::HashMap::new(), - expanded_tokens: std::collections::HashMap::new(), - } - } -} - -// Helper function to render a custom collapsing header with +/- button +/// Renders a custom collapsing header with a +/- toggle and a label. +/// +/// The `InnerResponse::inner` reports whether the toggle or label was clicked; the +/// `InnerResponse::response` covers the whole row and can carry a `context_menu`. fn render_collapsing_header( ui: &mut egui::Ui, text: impl Into<String>, is_expanded: bool, is_selected: bool, indent_level: usize, -) -> bool { +) -> egui::InnerResponse<bool> { let text = text.into(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let indent = indent_level as f32 * 16.0; - let mut clicked = false; + // Font size shrinks with nesting depth: contract > section > doc type > sub-item. + let font_size = match indent_level { + 0 => 16.0, + 1 => 14.0, + 2 => 13.0, + _ => 12.0, + }; + let label_color = if is_selected { + DashColors::DASH_BLUE + } else { + DashColors::text_primary(dark_mode) + }; ui.horizontal(|ui| { ui.add_space(indent); - // +/- button let button_text = if is_expanded { "−" } else { "+" }; let button_response = ui.add( egui::Button::new( @@ -75,77 +71,19 @@ fn render_collapsing_header( .stroke(egui::Stroke::NONE), ); - if button_response.clicked() { - clicked = true; - } - - // Label - make contract names (level 0) larger - let label_text = if indent_level == 0 { - // Contract names - make them the largest with heading font - if is_selected { - RichText::new(text) - .size(16.0) - .heading() - .color(DashColors::DASH_BLUE) - } else { - RichText::new(text) - .size(16.0) - .heading() - .color(DashColors::text_primary(dark_mode)) - } - } else if indent_level == 1 { - // Section headers (Document Types, Tokens, Contract JSON) - medium size - if is_selected { - RichText::new(text) - .size(14.0) - .heading() - .color(DashColors::DASH_BLUE) - } else { - RichText::new(text) - .size(14.0) - .heading() - .color(DashColors::text_primary(dark_mode)) - } - } else if indent_level == 2 { - // Document type names - smaller - if is_selected { - RichText::new(text) - .size(13.0) - .heading() - .color(DashColors::DASH_BLUE) - } else { - RichText::new(text) - .size(13.0) - .heading() - .color(DashColors::text_primary(dark_mode)) - } - } else { - // Indexes and other sub-items - smallest - if is_selected { - RichText::new(text) - .size(12.0) - .heading() - .color(DashColors::DASH_BLUE) - } else { - RichText::new(text) - .size(12.0) - .heading() - .color(DashColors::text_primary(dark_mode)) - } - }; - + let label_text = RichText::new(text) + .size(font_size) + .heading() + .color(label_color); let label_response = ui.add(egui::Label::new(label_text).sense(egui::Sense::click())); - if label_response.clicked() { - clicked = true; - } - }); - clicked + button_response.clicked() || label_response.clicked() + }) } #[allow(clippy::too_many_arguments)] pub fn add_contract_chooser_panel( - ctx: &EguiContext, + ui: &mut Ui, current_search_term: &mut String, app_context: &Arc<AppContext>, selected_data_contract: &mut QualifiedContract, @@ -156,10 +94,12 @@ pub fn add_contract_chooser_panel( pending_fields_selection: &mut HashMap<String, bool>, chooser_state: &mut ContractChooserState, ) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; // Retrieve the list of known contracts - let contracts = app_context.get_contracts(None, None).unwrap_or_else(|e| { + let contracts = app_context.get_contracts().unwrap_or_else(|e| { error!("Error fetching contracts: {}", e); vec![] }); @@ -178,18 +118,18 @@ pub fn add_contract_chooser_panel( }) .collect(); - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; - SidePanel::left("contract_chooser_panel") + Panel::left("contract_chooser_panel") // Let the user resize this panel horizontally .resizable(true) - .default_width(270.0) + .default_size(270.0) .frame( Frame::new() .fill(DashColors::background(dark_mode)) .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect ) - .show(ctx, |ui| { + .show(ui, |ui| { // Fill the entire available height let available_height = ui.available_height(); @@ -235,7 +175,8 @@ pub fn add_contract_chooser_panel( let is_expanded = chooser_state.expanded_contracts.contains(&contract_id); // Render the custom collapsing header for the contract - if render_collapsing_header(ui, &display_name, is_expanded, is_selected_contract, 0) { + let header = render_collapsing_header(ui, &display_name, is_expanded, is_selected_contract, 0); + if header.inner { if is_expanded { chooser_state.expanded_contracts.remove(&contract_id); } else { @@ -243,6 +184,31 @@ pub fn add_contract_chooser_panel( } } + header.response.context_menu(|ui| { + ui.set_min_width(150.0); + if ui.button("Copy (Hex)").clicked() { + if let Ok(bytes) = contract + .contract + .serialize_to_bytes_with_platform_version( + app_context.platform_version(), + ) + { + ui.ctx().copy_text(hex::encode(&bytes)); + } + ui.close(); + } + if ui.button("Copy (JSON)").clicked() { + if let Ok(json_value) = + contract.contract.to_json(app_context.platform_version()) + && let Ok(json_string) = + serde_json::to_string_pretty(&json_value) + { + ui.ctx().copy_text(json_string); + } + ui.close(); + } + }); + // Show contract content if expanded if is_expanded { ui.push_id(&contract_id, |ui| { @@ -258,7 +224,7 @@ pub fn add_contract_chooser_panel( .map(|s| s.contains(&doc_types_key)) .unwrap_or(false); - if render_collapsing_header(ui, "Document Types", doc_types_expanded, false, 1) { + if render_collapsing_header(ui, "Document Types", doc_types_expanded, false, 1).inner { let sections = chooser_state.expanded_sections .entry(contract_id.clone()) .or_default(); @@ -280,7 +246,7 @@ pub fn add_contract_chooser_panel( .map(|s| s.contains(&doc_type_key)) .unwrap_or(false); - if render_collapsing_header(ui, doc_name, doc_expanded, is_selected_doc_type, 2) { + if render_collapsing_header(ui, doc_name, doc_expanded, is_selected_doc_type, 2).inner { let doc_types = chooser_state.expanded_doc_types .entry(contract_id.clone()) .or_default(); @@ -333,7 +299,7 @@ pub fn add_contract_chooser_panel( .unwrap_or(false); let index_label = format!("Index: {}", index_name); - if render_collapsing_header(ui, &index_label, index_expanded, is_selected_index, 3) { + if render_collapsing_header(ui, &index_label, index_expanded, is_selected_index, 3).inner { let indexes = chooser_state.expanded_indexes .entry(contract_id.clone()) .or_default(); @@ -406,7 +372,7 @@ pub fn add_contract_chooser_panel( .map(|s| s.contains(&tokens_key)) .unwrap_or(false); - if render_collapsing_header(ui, "Tokens", tokens_expanded, false, 1) { + if render_collapsing_header(ui, "Tokens", tokens_expanded, false, 1).inner { let sections = chooser_state.expanded_sections .entry(contract_id.clone()) .or_default(); @@ -426,7 +392,7 @@ pub fn add_contract_chooser_panel( .map(|s| s.contains(&token_key)) .unwrap_or(false); - if render_collapsing_header(ui, token_name.to_string(), token_expanded, false, 2) { + if render_collapsing_header(ui, token_name.to_string(), token_expanded, false, 2).inner { let tokens = chooser_state.expanded_tokens .entry(contract_id.clone()) .or_default(); @@ -468,7 +434,7 @@ pub fn add_contract_chooser_panel( .map(|s| s.contains(&json_key)) .unwrap_or(false); - if render_collapsing_header(ui, "Contract JSON", json_expanded, false, 1) { + if render_collapsing_header(ui, "Contract JSON", json_expanded, false, 1).inner { let sections = chooser_state.expanded_sections .entry(contract_id.clone()) .or_default(); @@ -510,9 +476,6 @@ pub fn add_contract_chooser_panel( } }); - // Check for right-click on the contract header - // TODO: Add right-click support to custom header if needed - // Right‐aligned Remove button ui.horizontal(|ui| { ui.add_space(8.0); @@ -541,67 +504,5 @@ pub fn add_contract_chooser_panel( }); // Close the island frame }); - // Show context menu if right-clicked - if chooser_state.show_context_menu - && let Some(ref contract_id_str) = chooser_state.right_click_contract_id - { - // Find the contract that was right-clicked - let contract_opt = contracts - .iter() - .find(|c| c.contract.id().to_string(Encoding::Base58) == *contract_id_str); - - if let Some(contract) = contract_opt { - egui::Window::new("Contract Menu") - .id(egui::Id::new("contract_context_menu")) - .title_bar(false) - .resizable(false) - .collapsible(false) - .fixed_pos(chooser_state.context_menu_position) - .show(ctx, |ui| { - ui.set_min_width(150.0); - - // Copy Hex option - if ui.button("Copy (Hex)").clicked() { - // Serialize contract to bytes - if let Ok(bytes) = - contract.contract.serialize_to_bytes_with_platform_version( - app_context.platform_version(), - ) - { - let hex_string = hex::encode(&bytes); - ui.ctx().copy_text(hex_string); - } - chooser_state.show_context_menu = false; - } - - // Copy JSON option - if ui.button("Copy (JSON)").clicked() { - // Convert contract to JSON - if let Ok(json_value) = - contract.contract.to_json(app_context.platform_version()) - && let Ok(json_string) = serde_json::to_string_pretty(&json_value) - { - ui.ctx().copy_text(json_string); - } - chooser_state.show_context_menu = false; - } - }); - - // Close menu if clicked elsewhere - if ctx.input(|i| i.pointer.any_click()) { - // Check if click was outside the menu - let menu_rect = egui::Rect::from_min_size( - chooser_state.context_menu_position, - egui::vec2(150.0, 70.0), // Approximate size - ); - if let Some(pointer_pos) = ctx.pointer_interact_pos() - && !menu_rect.contains(pointer_pos) - { - chooser_state.show_context_menu = false; - } - } - } - } - action } diff --git a/src/ui/components/dashpay_subscreen_chooser_panel.rs b/src/ui/components/dashpay_subscreen_chooser_panel.rs index 529200da1..b3ed95bf4 100644 --- a/src/ui/components/dashpay_subscreen_chooser_panel.rs +++ b/src/ui/components/dashpay_subscreen_chooser_panel.rs @@ -1,120 +1,51 @@ use crate::app::AppAction; use crate::context::AppContext; -use crate::model::feature_gate::FeatureGate; +use crate::context::feature_gate::FeatureGate; use crate::ui::RootScreenType; +use crate::ui::components::subscreen_chooser_panel::{ + SubscreenNavItem, add_subscreen_chooser_panel, +}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; -use egui::{Context, Frame, Margin, RichText, SidePanel}; +use egui::Ui; use std::sync::Arc; pub fn add_dashpay_subscreen_chooser_panel( - ctx: &Context, + ui: &mut Ui, app_context: &Arc<AppContext>, current_subscreen: DashPaySubscreen, ) -> AppAction { - let mut action = AppAction::None; - let dark_mode = ctx.style().visuals.dark_mode; - - // Build subscreens list - Payment History is experimental (developer mode only) + // Payment History is experimental (developer mode only). let mut subscreens = vec![DashPaySubscreen::Profile, DashPaySubscreen::Contacts]; - if FeatureGate::DeveloperMode.is_available(app_context) { subscreens.push(DashPaySubscreen::Payments); } - subscreens.push(DashPaySubscreen::ProfileSearch); - let active_screen = current_subscreen; - - SidePanel::left("dashpay_subscreen_chooser_panel") - .default_width(270.0) - .frame( - Frame::new() - .fill(DashColors::background(dark_mode)) // Light background instead of transparent - .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect - ) - .show(ctx, |ui| { - // Fill the entire available height - let available_height = ui.available_height(); - - // Create an island panel with rounded edges that fills the height - Frame::new() - .fill(DashColors::surface(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::XL as i8)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - // Account for both outer margin (10px * 2) and inner margin - ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); - // Display subscreen names - ui.vertical(|ui| { - ui.add_space(Spacing::SM); - - for subscreen in subscreens { - let is_active = active_screen == subscreen; - - let display_name = match subscreen { - DashPaySubscreen::Contacts => "Contacts", - DashPaySubscreen::Profile => "My Profile", - DashPaySubscreen::Payments => "Payment History", - DashPaySubscreen::ProfileSearch => "Search Profiles", - }; - - let button = if is_active { - egui::Button::new( - RichText::new(display_name) - .color(DashColors::WHITE) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::DASH_BLUE) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - } else { - egui::Button::new( - RichText::new(display_name) - .color(DashColors::text_primary(dark_mode)) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - }; - - // Show the subscreen name as a clickable option - if ui.add(button).clicked() { - // Handle navigation based on which subscreen is selected - match subscreen { - DashPaySubscreen::Contacts => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDashPayContacts, - ) - } - DashPaySubscreen::Profile => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDashPayProfile, - ) - } - DashPaySubscreen::Payments => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDashPayPayments, - ) - } - DashPaySubscreen::ProfileSearch => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDashPayProfileSearch, - ) - } - } - } - - ui.add_space(Spacing::SM); - } - }); - }); // Close the island frame - }); - - action + let items = subscreens + .into_iter() + .map(|subscreen| { + let (label, target) = match subscreen { + DashPaySubscreen::Contacts => { + ("Contacts", RootScreenType::RootScreenDashPayContacts) + } + DashPaySubscreen::Profile => { + ("My Profile", RootScreenType::RootScreenDashPayProfile) + } + DashPaySubscreen::Payments => { + ("Payment History", RootScreenType::RootScreenDashPayPayments) + } + DashPaySubscreen::ProfileSearch => ( + "Search Profiles", + RootScreenType::RootScreenDashPayProfileSearch, + ), + }; + SubscreenNavItem::new( + label, + subscreen == current_subscreen, + AppAction::SetMainScreen(target), + ) + }) + .collect(); + + add_subscreen_chooser_panel(ui, "dashpay_subscreen_chooser_panel", true, false, items) } diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index be3249308..38fe91295 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -1,111 +1,48 @@ +use crate::app::AppAction; use crate::context::AppContext; use crate::ui::RootScreenType; +use crate::ui::components::subscreen_chooser_panel::{ + SubscreenNavItem, add_subscreen_chooser_panel, +}; use crate::ui::dpns::dpns_contested_names_screen::DPNSSubscreen; -use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; -use crate::{app::AppAction, ui}; -use egui::{Context, Frame, Margin, RichText, SidePanel}; - -pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { - let mut action = AppAction::None; - let dark_mode = ctx.style().visuals.dark_mode; - - let subscreens = vec![ - DPNSSubscreen::Active, - DPNSSubscreen::Past, - DPNSSubscreen::Owned, - DPNSSubscreen::ScheduledVotes, - ]; - - let active_screen = match app_context.get_settings() { - Ok(Some(settings)) => match settings.root_screen_type { - ui::RootScreenType::RootScreenDPNSActiveContests => DPNSSubscreen::Active, - ui::RootScreenType::RootScreenDPNSPastContests => DPNSSubscreen::Past, - ui::RootScreenType::RootScreenDPNSOwnedNames => DPNSSubscreen::Owned, - ui::RootScreenType::RootScreenDPNSScheduledVotes => DPNSSubscreen::ScheduledVotes, - _ => DPNSSubscreen::Active, - }, +use egui::Ui; + +pub fn add_dpns_subscreen_chooser_panel(ui: &mut Ui, app_context: &AppContext) -> AppAction { + let active = match app_context.get_app_settings().root_screen_type { + RootScreenType::RootScreenDPNSActiveContests => DPNSSubscreen::Active, + RootScreenType::RootScreenDPNSPastContests => DPNSSubscreen::Past, + RootScreenType::RootScreenDPNSOwnedNames => DPNSSubscreen::Owned, + RootScreenType::RootScreenDPNSScheduledVotes => DPNSSubscreen::ScheduledVotes, _ => DPNSSubscreen::Active, }; - SidePanel::left("dpns_subscreen_chooser_panel") - .resizable(true) - .default_width(270.0) - .frame( - Frame::new() - .fill(DashColors::background(dark_mode)) - .inner_margin(Margin::symmetric(10, 10)), + let items = [ + ( + DPNSSubscreen::Active, + RootScreenType::RootScreenDPNSActiveContests, + ), + ( + DPNSSubscreen::Past, + RootScreenType::RootScreenDPNSPastContests, + ), + ( + DPNSSubscreen::Owned, + RootScreenType::RootScreenDPNSOwnedNames, + ), + ( + DPNSSubscreen::ScheduledVotes, + RootScreenType::RootScreenDPNSScheduledVotes, + ), + ] + .into_iter() + .map(|(subscreen, target)| { + SubscreenNavItem::new( + subscreen.display_name(), + subscreen == active, + AppAction::SetMainScreen(target), ) - .show(ctx, |ui| { - let available_height = ui.available_height(); - - Frame::new() - .fill(DashColors::surface(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::XL as i8)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); - ui.vertical(|ui| { - ui.add_space(Spacing::SM); - - for subscreen in subscreens { - let is_active = active_screen == subscreen; - - let button = if is_active { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::WHITE) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::DASH_BLUE) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - } else { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::text_primary(dark_mode)) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - }; - - // Show the subscreen name as a clickable option - if ui.add(button).clicked() { - // Handle navigation based on which subscreen is selected - match subscreen { - DPNSSubscreen::Active => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDPNSActiveContests, - ) - } - DPNSSubscreen::Past => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDPNSPastContests, - ) - } - DPNSSubscreen::Owned => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDPNSOwnedNames, - ) - } - DPNSSubscreen::ScheduledVotes => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDPNSScheduledVotes, - ) - } - } - } - - ui.add_space(Spacing::SM); - } - }); - }); // Close the island frame - }); + }) + .collect(); - action + add_subscreen_chooser_panel(ui, "dpns_subscreen_chooser_panel", true, false, items) } diff --git a/src/ui/components/entropy_grid.rs b/src/ui/components/entropy_grid.rs index d60ed442f..7032a7203 100644 --- a/src/ui/components/entropy_grid.rs +++ b/src/ui/components/entropy_grid.rs @@ -62,7 +62,7 @@ impl U256EntropyGrid { // Determine the bit value and colors based on theme let bit_value = (self.random_number[byte_index] >> bit_in_byte) & 1 == 1; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let color = if bit_value { // On squares: Deep Blue in light mode, muted Dash Blue in dark mode if dark_mode { diff --git a/src/ui/components/global_nav_switcher.rs b/src/ui/components/global_nav_switcher.rs new file mode 100644 index 000000000..3ffc270f6 --- /dev/null +++ b/src/ui/components/global_nav_switcher.rs @@ -0,0 +1,684 @@ +//! Page-aware global navigation switcher. +//! +//! Generalizes the Identities-hub breadcrumb into a switcher rendered on every +//! root page. Composes `segment-1 link › 💼 wallet pill › 👤 identity/object +//! pill` per a [`PageNavSpec`], owns the wallet / identity dropdown `Popup`s, +//! and returns a typed [`GlobalNavEffect`] for the shell to apply. +//! +//! It is a pure UI component — it reads the app-scoped selection from +//! `AppContext` and reports an effect; the shell applies it (components render, +//! screens decide). Reuses [`BreadcrumbPill`]/[`IdentityPill`] and the existing +//! [`BreadcrumbPillMode`] verbatim — no new pill widget. +//! +//! Per-state modes follow design-spec §A.3 / §7; tooltips are verbatim from +//! design-spec §D (§7.1). Wallet-scoped identity lists use the *stored* +//! `wallet_hash` filter, never `associated_wallets.keys().next()` (R1). + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; +use crate::ui::RootScreenType; +use crate::ui::components::breadcrumb_pill::{BreadcrumbPill, BreadcrumbPillMode}; +use crate::ui::identity::identity_hero_card::HeroIdentityKind; +use crate::ui::identity::identity_pill::{IdentityPill, display_label}; +use crate::ui::state::global_nav::{ + IdentityPillScope, PageNavSpec, PageObjectItem, PillConsumption, +}; +use crate::ui::state::hub_selection::HubSelection; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, RichText, Sense, Ui}; +use std::sync::Arc; + +/// Inline search appears once a wallet's identity list reaches this size (§A.3). +const SEARCH_THRESHOLD: usize = 7; + +/// A typed switcher outcome the shell applies. Generalizes the hub's +/// `BreadcrumbEffect`: adds [`GlobalNavEffect::SelectPageObject`] for the +/// page-scoped object pill, kept distinct from [`GlobalNavEffect::SelectIdentity`] +/// so a page-scoped selection never writes the app-global identity (FR-6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlobalNavEffect { + /// No interaction this frame. + None, + /// Segment-1 was clicked — navigate to the page's root screen. + NavigateToRoot(RootScreenType), + /// Switch the operating wallet. + SwitchWallet(WalletSeedHash), + /// Select the app-global User identity. + SelectIdentity(Identifier), + /// Select a page-scoped object (the masternode/evonode in view). **Never** + /// the app-global identity — the structural FR-6 boundary. + SelectPageObject(Identifier), + /// "Set up another wallet" — route to the Wallets screen. + AddWallet, + /// "Add another identity" → create a new identity. + AddIdentityCreate, + /// "Add another identity" → load an existing identity. + AddIdentityLoad, + /// Dev-mode: bulk-create test identities. + CreateTestIdentities, +} + +/// Wallet-pill mode by HD-wallet count: 0 → placeholder, 1 → subdued (info +/// only), ≥2 → interactive (opens the wallet dropdown). §A.3 / §7. +fn wallet_pill_mode(wallet_count: usize) -> BreadcrumbPillMode { + match wallet_count { + 0 => BreadcrumbPillMode::Placeholder, + 1 => BreadcrumbPillMode::Subdued, + _ => BreadcrumbPillMode::Interactive, + } +} + +/// tt-2 — interactive wallet pill (≥2 wallets). Verbatim, design-spec §D. +fn tt_wallet_interactive() -> &'static str { + "Switch between your wallets. Each wallet can own several identities." +} + +/// tt-3 — subdued wallet pill (exactly 1 wallet). Verbatim, design-spec §D #3 +/// (the brief's "…to switch between them." is a paraphrase — this is canonical). +fn tt_wallet_subdued(wallet_name: &str) -> String { + format!( + "This identity is funded by {wallet_name}. Set up another wallet on the Wallets screen \ + to unlock switching." + ) +} + +/// tt-4 — interactive identity pill. Verbatim, design-spec §D. +fn tt_identity(wallet_name: &str) -> String { + format!("Switch between identities in {wallet_name} or add a new one.") +} + +/// Short hex of a seed hash, for a wallet with no alias. +fn short_hex(hash: &WalletSeedHash) -> String { + let mut s = String::with_capacity(10); + for b in hash.iter().take(4) { + s.push_str(&format!("{b:02x}")); + } + s.push('…'); + s +} + +/// Loaded HD wallets as `(seed_hash, display_name)`, sorted by hash for a +/// stable order. Name = alias, else a short hex of the seed hash. +fn gather_wallets(app_context: &Arc<AppContext>) -> Vec<(WalletSeedHash, String)> { + let Ok(wallets) = app_context.wallets.read() else { + return Vec::new(); + }; + wallets + .iter() + .map(|(hash, w)| { + let name = w + .read() + .ok() + .and_then(|w| w.alias.clone()) + .filter(|a| !a.trim().is_empty()) + .unwrap_or_else(|| short_hex(hash)); + (*hash, name) + }) + .collect() +} + +/// Identity display label (Local nickname → DPNS → short id). +fn identity_label(qi: &QualifiedIdentity) -> String { + let dpns = qi.dpns_names.first().map(|n| n.name.as_str()); + display_label( + qi.alias.as_deref(), + dpns, + &qi.identity.id().to_string(Encoding::Base58), + ) +} + +/// First uppercase alphanumeric of the label, for the avatar monogram. +fn monogram_initial(label: &str) -> Option<char> { + label + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) +} + +/// 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`). +fn page_object_label( + placeholder: &str, + items: &[PageObjectItem], + selected: Option<Identifier>, +) -> String { + selected + .and_then(|id| items.iter().find(|it| it.id == id)) + .map(|it| it.label.clone()) + .unwrap_or_else(|| placeholder.to_string()) +} + +/// Identity-primary context shared by the wallet + app-global identity pills. +/// Derived once per frame, mirroring the hub's original derivation. +struct AppGlobalContext { + active_id: Option<Identifier>, + pill_identity: Option<QualifiedIdentity>, + active_is_wallet_less: bool, + active_wallet: Option<WalletSeedHash>, + active_wallet_name: String, + scoped: Vec<QualifiedIdentity>, + no_wallet: Vec<QualifiedIdentity>, +} + +/// Derive the app-global (identity-primary) context: the active identity, the +/// wallet derived from it, the wallet-scoped identity list, and the no-wallet +/// group. Identical logic to the original hub switcher. +fn derive_app_global_context( + app_context: &Arc<AppContext>, + wallets: &[(WalletSeedHash, String)], +) -> AppGlobalContext { + // FR-6: the app-global identity pill and its dropdown (including the + // wallet-less "no wallet on this device" group) list User identities only — + // masternode/evonode identities never appear on everyday-user surfaces + // (TC-NAV-17). The wallet-scoped list below is wallet-owned, so it is + // User-only by construction (masternodes are wallet-less). + let all_identities = app_context.load_local_user_identities().unwrap_or_default(); + let all_ids: Vec<Identifier> = all_identities.iter().map(|qi| qi.identity.id()).collect(); + let active_id = app_context.selected_identity_id(); + // The identity pill reflects an *explicitly* chosen identity (or a lone + // auto-selected one). In the ≥2-none-chosen picker state it stays a + // placeholder (§7) — never the first-identity fallback, which would + // duplicate a picker-grid label and disagree with "no identity chosen". + let pill_target_id = crate::model::selected_identity::keep_if_loaded(active_id, &all_ids) + .or_else(|| (all_ids.len() == 1).then(|| all_ids[0])); + let pill_identity = pill_target_id + .and_then(|id| all_identities.iter().find(|qi| qi.identity.id() == id)) + .cloned(); + let active_is_wallet_less = pill_identity + .as_ref() + .is_some_and(|qi| qi.wallet_index.is_none()); + + // The wallet segment is DERIVED from the active identity (identity-primary). + // A wallet-less active identity → no active wallet → empty wallet segment, + // so the pill never shows a wallet belonging to a different identity. + let active_wallet = if active_is_wallet_less { + None + } else { + app_context + .selected_wallet_hash() + .filter(|h| wallets.iter().any(|(wh, _)| wh == h)) + .or_else(|| wallets.first().map(|(h, _)| *h)) + }; + let active_wallet_name = active_wallet + .and_then(|h| wallets.iter().find(|(wh, _)| *wh == h)) + .map(|(_, n)| n.clone()) + .unwrap_or_default(); + + // Identities owned by the active wallet (stored `wallet_hash` filter — R1). + let scoped: Vec<QualifiedIdentity> = active_wallet + .and_then(|h| { + app_context + .load_local_qualified_identities_for_wallet(&h) + .ok() + }) + .unwrap_or_default(); + // Identities with no wallet on this device (imported by id). + let no_wallet: Vec<QualifiedIdentity> = all_identities + .iter() + .filter(|qi| qi.wallet_index.is_none()) + .cloned() + .collect(); + + AppGlobalContext { + active_id, + pill_identity, + active_is_wallet_less, + active_wallet, + active_wallet_name, + scoped, + no_wallet, + } +} + +/// Render the switcher for `spec`. Reads the app-scoped selection; mutates only +/// the `selection` search buffers; returns the user's effect for the shell to +/// apply. +pub fn render( + ui: &mut Ui, + app_context: &Arc<AppContext>, + spec: &PageNavSpec, + selection: &mut HubSelection, +) -> GlobalNavEffect { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut effect = GlobalNavEffect::None; + + let wallets = gather_wallets(app_context); + let wallet_count = wallets.len(); + + // Identity-primary derivation is only needed when the third pill is the + // app-global user identity (avoids an extra DB read on pages whose pill is + // page-scoped, e.g. Masternodes). + let app_global = matches!( + spec.identity_pill(), + Some((IdentityPillScope::AppGlobalUser, _)) + ); + let ctx_data = app_global.then(|| derive_app_global_context(app_context, &wallets)); + + ui.horizontal(|ui| { + // --- Segment 1: page-aware link -------------------------------------- + let link = ui.add( + egui::Label::new(RichText::new(spec.segment1_label()).color(DashColors::DASH_BLUE)) + .sense(Sense::click()), + ); + if link.clicked() { + effect = GlobalNavEffect::NavigateToRoot(spec.segment1_target()); + } + + // --- Segment 2: wallet pill ------------------------------------------ + if let Some(consumption) = spec.wallet_pill() { + ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); + render_wallet_pill( + ui, + consumption, + &wallets, + wallet_count, + app_context, + ctx_data.as_ref(), + dark_mode, + &mut effect, + ); + } + + // --- Segment 3: identity / page-object pill -------------------------- + if let Some((scope, consumption)) = spec.identity_pill() { + ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); + match scope { + IdentityPillScope::AppGlobalUser => { + // `ctx_data` is always `Some` here (derived when app_global). + if let Some(data) = ctx_data.as_ref() { + render_app_global_identity_pill( + ui, + consumption, + app_context, + data, + selection, + wallet_count, + dark_mode, + &mut effect, + ); + } + } + IdentityPillScope::PageScopedObject { + placeholder, + items, + selected, + } => { + render_page_object_pill( + ui, + consumption, + placeholder, + items, + *selected, + dark_mode, + &mut effect, + ); + } + } + } + }); + + effect +} + +/// Render the wallet pill. `Consumed` reproduces the count-based +/// placeholder/subdued/interactive logic (identity-primary when `ctx_data` is +/// present); `Unwired` renders a subdued, non-interactive pill with a +/// how-to-change tooltip and emits no effect. +#[allow(clippy::too_many_arguments)] +fn render_wallet_pill( + ui: &mut Ui, + consumption: &PillConsumption, + wallets: &[(WalletSeedHash, String)], + wallet_count: usize, + app_context: &Arc<AppContext>, + ctx_data: Option<&AppGlobalContext>, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + // Resolve the active wallet: identity-primary when available, else the + // plain app-scoped selection (or the first wallet). + let (active_wallet, active_wallet_name, active_is_wallet_less) = match ctx_data { + Some(d) => ( + d.active_wallet, + d.active_wallet_name.clone(), + d.active_is_wallet_less, + ), + None => { + let active = app_context + .selected_wallet_hash() + .filter(|h| wallets.iter().any(|(wh, _)| wh == h)) + .or_else(|| wallets.first().map(|(h, _)| *h)); + let name = active + .and_then(|h| wallets.iter().find(|(wh, _)| *wh == h)) + .map(|(_, n)| n.clone()) + .unwrap_or_default(); + (active, name, false) + } + }; + + if let PillConsumption::Unwired { tooltip } = consumption { + // Dimmed, no caret, no visible tag — the value stays visible, the + // explanation lives in the tooltip (FR-GLOBAL-NAV-2 rule 3). + if active_wallet.is_some() { + BreadcrumbPill::new(active_wallet_name) + .with_icon("💼") + .subdued(true) + .with_tooltip(tooltip.clone()) + .show(ui); + } else { + BreadcrumbPill::placeholder("(no wallet yet)") + .with_tooltip(tooltip.clone()) + .show(ui); + } + return; + } + + // Consumed: the original count-based rendering. + let wallet_mode = if active_is_wallet_less { + BreadcrumbPillMode::Placeholder + } else { + wallet_pill_mode(wallet_count) + }; + match wallet_mode { + BreadcrumbPillMode::Placeholder => { + let label = if active_is_wallet_less { + "(no wallet)" + } else { + "(no wallet yet)" + }; + BreadcrumbPill::placeholder(label).show(ui); + } + BreadcrumbPillMode::Subdued => { + BreadcrumbPill::new(active_wallet_name.clone()) + .with_icon("💼") + .subdued(true) + .with_tooltip(tt_wallet_subdued(&active_wallet_name)) + .show(ui); + } + BreadcrumbPillMode::Interactive => { + let resp = BreadcrumbPill::new(active_wallet_name.clone()) + .with_icon("💼") + .with_tooltip(tt_wallet_interactive()) + .show(ui); + if let Some(anchor) = resp.response.clone() { + let popup_id = ui.make_persistent_id("global_nav_wallet_switcher"); + egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(220.0); + for (h, name) in wallets { + let is_active = active_wallet == Some(*h); + if ui + .selectable_label(is_active, format!("💼 {name}")) + .clicked() + { + *effect = GlobalNavEffect::SwitchWallet(*h); + ui.close(); + } + } + ui.separator(); + if ui.button("Set up another wallet").clicked() { + *effect = GlobalNavEffect::AddWallet; + ui.close(); + } + }); + } + } + } +} + +/// Render the app-global User identity pill. `Consumed` reproduces the hub's +/// identity dropdown (scoped list + no-wallet group + add flows); `Unwired` +/// renders a subdued, non-interactive pill with a how-to-change tooltip. +#[allow(clippy::too_many_arguments)] +fn render_app_global_identity_pill( + ui: &mut Ui, + consumption: &PillConsumption, + app_context: &Arc<AppContext>, + data: &AppGlobalContext, + selection: &mut HubSelection, + wallet_count: usize, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + let Some(active_qi) = data.pill_identity.as_ref() else { + // No identity in scope: placeholder reflects whether a wallet exists. + let label = if wallet_count == 0 { + "(no identity yet)" + } else { + "(choose an identity)" + }; + let pill = BreadcrumbPill::placeholder(label); + match consumption { + PillConsumption::Unwired { tooltip } => pill.with_tooltip(tooltip.clone()).show(ui), + PillConsumption::Consumed => pill.show(ui), + }; + return; + }; + + let label = identity_label(active_qi); + let kind: HeroIdentityKind = active_qi.identity_type.into(); + let dpns = active_qi.dpns_names.first().map(|n| n.name.clone()); + let id_b58 = active_qi.identity.id().to_string(Encoding::Base58); + + if let PillConsumption::Unwired { tooltip } = consumption { + // Subdued, non-interactive: the value shows dimmed with no caret. + IdentityPill::new(active_qi.alias.as_deref(), dpns.as_deref(), &id_b58) + .with_avatar(kind, monogram_initial(&label)) + .with_mode(BreadcrumbPillMode::Subdued) + .with_tooltip(tooltip.clone()) + .show(ui); + return; + } + + let resp = IdentityPill::new(active_qi.alias.as_deref(), dpns.as_deref(), &id_b58) + .with_avatar(kind, monogram_initial(&label)) + .with_tooltip(tt_identity(&data.active_wallet_name)) + .show(ui); + + if let Some(anchor) = resp.response.clone() { + let popup_id = ui.make_persistent_id("global_nav_identity_switcher"); + egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(240.0); + + // Inline search once the scoped list is long (§A.3). + let filter = if data.scoped.len() >= SEARCH_THRESHOLD { + ui.add( + egui::TextEdit::singleline(selection.identity_search_mut()) + .hint_text("Search identities"), + ); + selection.identity_search().trim().to_lowercase() + } else { + String::new() + }; + + for qi in &data.scoped { + let row = identity_label(qi); + if !filter.is_empty() && !row.to_lowercase().contains(&filter) { + continue; + } + let id = qi.identity.id(); + let is_active = data.active_id == Some(id); + if ui.selectable_label(is_active, row).clicked() { + *effect = GlobalNavEffect::SelectIdentity(id); + ui.close(); + } + } + + if !data.no_wallet.is_empty() { + ui.separator(); + ui.label( + RichText::new("Identities without a wallet on this device") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + for qi in &data.no_wallet { + let id = qi.identity.id(); + let is_active = data.active_id == Some(id); + if ui.selectable_label(is_active, identity_label(qi)).clicked() { + *effect = GlobalNavEffect::SelectIdentity(id); + ui.close(); + } + } + } + + ui.separator(); + if ui.button("Create a new identity").clicked() { + *effect = GlobalNavEffect::AddIdentityCreate; + ui.close(); + } + if ui.button("Load an existing identity").clicked() { + *effect = GlobalNavEffect::AddIdentityLoad; + ui.close(); + } + if app_context.is_developer_mode() + && ui.button("Create multiple test identities").clicked() + { + *effect = GlobalNavEffect::CreateTestIdentities; + ui.close(); + } + }); + } +} + +/// 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. +fn render_page_object_pill( + ui: &mut Ui, + consumption: &PillConsumption, + placeholder: &str, + items: &[PageObjectItem], + selected: Option<Identifier>, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + let label = page_object_label(placeholder, items, selected); + + if let PillConsumption::Unwired { tooltip } = consumption { + BreadcrumbPill::new(label) + .subdued(true) + .with_tooltip(tooltip.clone()) + .show(ui); + return; + } + + let resp = BreadcrumbPill::new(label).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) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + 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() { + *effect = GlobalNavEffect::SelectPageObject(it.id); + ui.close(); + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::new([byte; 32]) + } + + #[test] + fn short_hex_is_stable_prefix() { + let h = [0xABu8; 32]; + assert_eq!(short_hex(&h), "abababab…"); + } + + #[test] + fn monogram_initial_picks_first_alphanumeric_uppercase() { + assert_eq!(monogram_initial("alex.dash"), Some('A')); + assert_eq!(monogram_initial(" 9lives"), Some('9')); + assert_eq!(monogram_initial("…"), None); + } + + /// Wallet-pill mode resolver (moved verbatim from the hub switcher). + #[test] + fn wallet_pill_mode_by_count() { + assert_eq!(wallet_pill_mode(0), BreadcrumbPillMode::Placeholder); + assert_eq!(wallet_pill_mode(1), BreadcrumbPillMode::Subdued); + assert_eq!(wallet_pill_mode(2), BreadcrumbPillMode::Interactive); + assert_eq!(wallet_pill_mode(9), BreadcrumbPillMode::Interactive); + } + + /// Verbatim tooltip strings (regression guard for the design-spec wording). + #[test] + fn tooltips_are_verbatim() { + assert_eq!( + tt_wallet_interactive(), + "Switch between your wallets. Each wallet can own several identities." + ); + assert_eq!( + tt_wallet_subdued("Main Wallet"), + "This identity is funded by Main Wallet. Set up another wallet on the Wallets screen \ + to unlock switching." + ); + assert_eq!( + tt_identity("Main Wallet"), + "Switch between identities in Main Wallet or add a new one." + ); + } + + /// 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![ + PageObjectItem { + id: id(1), + label: "mn-east-01".to_string(), + }, + PageObjectItem { + id: id(2), + label: "evo-west-02".to_string(), + }, + ]; + assert_eq!( + page_object_label("(no masternode yet)", &items, Some(id(2))), + "evo-west-02" + ); + // Nothing selected → placeholder. + assert_eq!( + page_object_label("(no masternode yet)", &items, None), + "(no masternode yet)" + ); + // Stale selection (not in items) → placeholder. + assert_eq!( + page_object_label("(no masternode yet)", &items, Some(id(9))), + "(no masternode yet)" + ); + } + + /// TC-NAV-16 foundation — a page-scoped object selection maps to + /// `SelectPageObject`, never `SelectIdentity`. Guards the FR-6 boundary at + /// the effect level (the switcher's PageScopedObject arm emits only this). + #[test] + fn page_object_effect_is_never_select_identity() { + let picked = GlobalNavEffect::SelectPageObject(id(3)); + assert!(matches!(picked, GlobalNavEffect::SelectPageObject(x) if x == id(3))); + assert!(!matches!(picked, GlobalNavEffect::SelectIdentity(_))); + } +} diff --git a/src/ui/components/icons.rs b/src/ui/components/icons.rs new file mode 100644 index 000000000..3de639557 --- /dev/null +++ b/src/ui/components/icons.rs @@ -0,0 +1,82 @@ +use egui::{Context, TextureHandle}; +use rust_embed::RustEmbed; +use tracing::error; + +#[derive(RustEmbed)] +#[folder = "icons/"] +struct Assets; + +/// Load a raster icon from the embedded `icons/` assets, caching the resulting +/// texture in the egui context so repeated frames reuse it. +pub(crate) fn load_icon(ctx: &Context, path: &str) -> Option<TextureHandle> { + ctx.data_mut(|d| d.get_temp::<TextureHandle>(egui::Id::new(path))) + .or_else(|| { + let content = Assets::get(path).or_else(|| { + error!("Image not found in embedded assets at path: {}", path); + None + })?; + match image::load_from_memory(&content.data) { + Ok(image) => { + let size = [image.width() as usize, image.height() as usize]; + let pixels = image.into_rgba8().into_raw(); + let texture = ctx.load_texture( + path, + egui::ColorImage::from_rgba_unmultiplied(size, &pixels), + egui::TextureOptions::LINEAR, + ); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(path), texture.clone())); + Some(texture) + } + Err(e) => { + error!("Failed to load image from embedded data at {}: {}", path, e); + None + } + } + }) +} + +/// Load an SVG from the embedded `icons/` assets, rasterized to `width`×`height` +/// and cached per dimension in the egui context. +pub fn load_svg_icon(ctx: &Context, path: &str, width: u32, height: u32) -> Option<TextureHandle> { + let cache_key = format!("{}_{}_{}", path, width, height); + ctx.data_mut(|d| d.get_temp::<TextureHandle>(egui::Id::new(&cache_key))) + .or_else(|| { + let content = Assets::get(path).or_else(|| { + error!("SVG not found in embedded assets at path: {}", path); + None + })?; + + let options = resvg::usvg::Options::default(); + let tree = match resvg::usvg::Tree::from_data(&content.data, &options) { + Ok(tree) => tree, + Err(e) => { + error!("Failed to parse SVG at {}: {}", path, e); + return None; + } + }; + + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; + + // Scale the SVG to fit the target box and center it. + let svg_size = tree.size(); + let scale = (width as f32 / svg_size.width()).min(height as f32 / svg_size.height()); + let offset_x = (width as f32 - svg_size.width() * scale) / 2.0; + let offset_y = (height as f32 - svg_size.height() * scale) / 2.0; + let transform = resvg::tiny_skia::Transform::from_scale(scale, scale) + .post_translate(offset_x, offset_y); + + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + let pixels = pixmap.data().to_vec(); + let texture = ctx.load_texture( + &cache_key, + egui::ColorImage::from_rgba_unmultiplied( + [width as usize, height as usize], + &pixels, + ), + egui::TextureOptions::LINEAR, + ); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(&cache_key), texture.clone())); + Some(texture) + }) +} diff --git a/src/ui/components/identity_selector.rs b/src/ui/components/identity_selector.rs index 9b72d0e44..608794d68 100644 --- a/src/ui/components/identity_selector.rs +++ b/src/ui/components/identity_selector.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use std::sync::Arc; +use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::{ identity::accessors::IdentityGettersV0, platform_value::string_encoding::Encoding, @@ -69,6 +71,12 @@ pub struct IdentitySelector<'a> { /// Optional label to display before the selector label: Option<WidgetText>, other_option: bool, + /// When set, seed the (empty) buffer from the app-scoped selected identity. + /// Opt-in — owner/operate-as pickers only; never recipient/target pickers. + app_default: Option<&'a Arc<AppContext>>, + /// When set, write the chosen identity back to the app-scoped selection on + /// a user change. Opt-in — owner/operate-as pickers only. + sync_target: Option<Arc<AppContext>>, } impl<'a> IdentitySelector<'a> { @@ -93,9 +101,27 @@ impl<'a> IdentitySelector<'a> { exclude_identities: &[], label: None, other_option: true, // Default to showing "Other" option + app_default: None, + sync_target: None, } } + /// Seed the initial selection from the app-scoped identity when the buffer + /// is empty. Opt-in; default behaviour (not called) is byte-identical to + /// before. Owner/operate-as pickers only — never recipient/target pickers. + pub fn with_app_default(mut self, app_context: &'a Arc<AppContext>) -> Self { + self.app_default = Some(app_context); + self + } + + /// Write the chosen identity back to the app-scoped selection on a user + /// change. Opt-in; default behaviour (not called) is byte-identical to + /// before. Owner/operate-as pickers only — never recipient/target pickers. + pub fn syncing_global(mut self, app_context: Arc<AppContext>) -> Self { + self.sync_target = Some(app_context); + self + } + /// This method creates a selector that can update a mutable reference to the selected identity /// based on user input. This is useful when you want to allow users to select from existing identities /// or enter a new one, while keeping track of the selected identity in a mutable reference. @@ -160,6 +186,29 @@ impl<'a> IdentitySelector<'a> { }; } } + + /// The app-scoped identity to seed the empty buffer with, if `with_app_default` + /// is set, the buffer is empty, and the selection is one of our options. + fn app_default_seed(&self) -> Option<String> { + let ctx = self.app_default?; + if !self.identity_str.is_empty() { + return None; + } + let id = ctx.selected_identity_id()?; + self.identities + .contains_key(&id) + .then(|| id.to_string(Encoding::Base58)) + } + + /// Write the current selection back to the app-scoped global on a user + /// change, when `syncing_global` is set and a real identity is resolved. + fn sync_to_global(&self) { + if let Some(ctx) = &self.sync_target + && let Some(qi) = self.get_identity(self.identity_str) + { + ctx.set_selected_identity(Some(qi.identity.id())); + } + } } impl<'a> Widget for IdentitySelector<'a> { @@ -181,6 +230,13 @@ impl<'a> Widget for IdentitySelector<'a> { }); } + // Seed from the app-scoped selection (opt-in) before the + // first-identity fallback, so the user's active identity wins. + if let Some(seed) = self.app_default_seed() { + *self.identity_str = seed; + self.on_change(); + } + // If the "Other" option is disabled, we automatically select first identity if !self.other_option && self.identity_str.is_empty() @@ -264,6 +320,8 @@ impl<'a> Widget for IdentitySelector<'a> { let combo_changed = combo_response.inner.unwrap_or(false); if combo_changed || text_response.changed() { self.on_change(); + // Operate-as write-back, only when `syncing_global` opted in. + self.sync_to_global(); } // Return a response that indicates if anything changed @@ -278,3 +336,188 @@ impl<'a> Widget for IdentitySelector<'a> { .inner } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::AppState; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + // ── Isolation helpers ───────────────────────────────────────────────────── + // + // `AppState::new()` resolves its data dir through `DASH_EVO_DATA_DIR`. Tests + // that construct an `AppContext` must serialize on a process-global lock and + // redirect to a throwaway temp dir to avoid opening the real user data dir or + // racing with parallel test threads. + + fn data_dir_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) + } + + /// Runs `f` in a unique temp data dir with a Tokio runtime in context. + /// Serialized by a module-level lock so that parallel test threads don't + /// race on `DASH_EVO_DATA_DIR`. `AppState::new()` internally drives tokio + /// tasks, so a runtime must be entered before calling it. + fn with_isolated_dir<R>(f: impl FnOnce() -> R) -> R { + let lock = data_dir_lock(); + let tmp = tempfile::tempdir().expect("create temp data dir"); + let prior = std::env::var("DASH_EVO_DATA_DIR").ok(); + // Safety: serialized by `lock`; env var restored below before drop. + unsafe { + std::env::set_var("DASH_EVO_DATA_DIR", tmp.path()); + } + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + let result = f(); + drop(_guard); + drop(rt); + unsafe { + match &prior { + Some(v) => std::env::set_var("DASH_EVO_DATA_DIR", v), + None => std::env::remove_var("DASH_EVO_DATA_DIR"), + } + } + drop(lock); + drop(tmp); + result + } + + /// Build a minimal `AppContext` from a bare `AppState`. The wallet backend + /// is NOT wired (no Harness needed). `set_selected_identity` still works: + /// it updates the in-memory mutex and gracefully skips KV persistence + /// (see `persist_selected_identity_kv`: early-return with a debug log when + /// `wallet_backend()` returns `Err`). + fn make_ctx() -> Arc<AppContext> { + let app = AppState::new(egui::Context::default()).expect("AppState builds"); + app.current_app_context().clone() + } + + /// Create a wallet-less `QualifiedIdentity` from a raw id byte. Constructed + /// in-memory and passed directly to the selector's `identities` slice — no + /// DB insertion, no wallet backend needed. + fn make_qi(byte: u8) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(format!("test-{byte:02x}")), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: std::collections::BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: dash_sdk::dpp::dashcore::Network::Testnet, + } + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /// R2 regression-lock: a selector that did NOT opt in via `with_app_default` + /// never reads the app-scoped selection to seed its buffer — so a + /// recipient/target/filter picker behaves exactly as before the feature. + #[test] + fn default_selector_does_not_seed_from_global() { + let mut buf = String::from("some-id"); + let ids: Vec<QualifiedIdentity> = vec![]; + let sel = IdentitySelector::new("recipient", &mut buf, &ids); + assert!(sel.app_default.is_none()); + assert!(sel.app_default_seed().is_none()); + } + + /// R2 regression-lock: a selector that did NOT opt in via `syncing_global` + /// has no write-back target, so a user change can never touch + /// `selected_identity_id` — the 9 no-sync sites stay inert. A regression + /// that defaulted `sync_target` to `Some(..)` would fail here. + #[test] + fn default_selector_has_no_sync_target() { + let mut buf = String::new(); + let ids: Vec<QualifiedIdentity> = vec![]; + let sel = IdentitySelector::new("recipient", &mut buf, &ids); + assert!(sel.sync_target.is_none()); + } + + /// Method-level lock for `sync_to_global`: when the buffer holds a known + /// identity's Base58 and `syncing_global` is set, calling `sync_to_global()` + /// must invoke `ctx.set_selected_identity(Some(id))`. + /// + /// Calls `sync_to_global()` directly (private access inside this module). + /// This tests the *mechanism* in isolation; the *rendering gate* + /// (`combo_changed || text_response.changed()` at line 321) is covered by + /// the kittest at `tests/kittest/identity_selector.rs`. + #[test] + fn syncing_global_writes_selection_to_app_context() { + with_isolated_dir(|| { + let ctx = make_ctx(); + + let first = make_qi(0xAA); + let second = make_qi(0xBB); + let ids = vec![first.clone(), second.clone()]; + + // Global starts pointing at first. + ctx.set_selected_identity(Some(first.identity.id())); + assert_eq!(ctx.selected_identity_id(), Some(first.identity.id())); + + // Build a selector whose buffer holds the second identity's ID. + let mut buf = second.identity.id().to_string(Encoding::Base58); + let sel = IdentitySelector::new("write_back_test", &mut buf, &ids) + .syncing_global(ctx.clone()); + + // Fire the write-back directly (bypasses egui rendering). + sel.sync_to_global(); + + assert_eq!( + ctx.selected_identity_id(), + Some(second.identity.id()), + "syncing_global must write the chosen identity to AppContext" + ); + }); + } + + /// `with_app_default` inert guard: when the global selection names + /// an identity that is NOT in the selector's candidate list, `app_default_seed` + /// must return `None` — the selector must not seed from a foreign identity. + /// + /// This locks the wallet-membership guard used by `CreateAssetLockScreen` + /// (R1): if the app-scoped identity belongs to wallet A but the screen is + /// opened for wallet B, wallet B's identity list won't contain the A-identity, + /// so no seed occurs. + #[test] + fn with_app_default_inert_when_global_id_not_in_candidate_list() { + with_isolated_dir(|| { + let ctx = make_ctx(); + + let global_qi = make_qi(0xCC); + let other_qi = make_qi(0xDD); + + // Point the global selection at global_qi. + ctx.set_selected_identity(Some(global_qi.identity.id())); + + // Candidate list contains ONLY other_qi — global_qi is absent. + let candidate_list = vec![other_qi.clone()]; + let mut buf = String::new(); // empty → with_app_default may attempt a seed + let sel = IdentitySelector::new("inert_test", &mut buf, &candidate_list) + .with_app_default(&ctx); + + assert_eq!( + sel.app_default_seed(), + None, + "with_app_default must not seed when the global id is absent \ + from the selector's candidate list (wallet-membership guard)" + ); + }); + } +} diff --git a/src/ui/components/info_popup.rs b/src/ui/components/info_popup.rs index 8db986afd..0a145299a 100644 --- a/src/ui/components/info_popup.rs +++ b/src/ui/components/info_popup.rs @@ -1,3 +1,4 @@ +use crate::ui::components::modal_chrome::{ModalChromeConfig, modal_chrome}; use crate::ui::helpers::clicked_outside_window; use crate::ui::theme::{ComponentStyles, DashColors}; use egui::{InnerResponse, Ui, WidgetText}; @@ -47,46 +48,29 @@ impl InfoPopup { /// Show the popup and return whether it was closed /// Returns true if the popup was closed (user clicked Close, X button, or Escape) pub fn show(&mut self, ui: &mut Ui) -> InnerResponse<bool> { - let mut is_open = self.is_open; - - if !is_open { + if !self.is_open { return InnerResponse::new( false, ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), ); } - // Draw dark overlay behind the popup for better visibility - let screen_rect = ui.ctx().content_rect(); - let painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("info_popup_overlay"), - )); - painter.rect_filled(screen_rect, 0.0, DashColors::modal_overlay()); - let mut was_closed = false; let is_markdown = self.markdown; let message = self.message.clone(); - - let window_response = egui::Window::new(self.title.clone()) - .collapsible(false) - .resizable(is_markdown) // Allow resizing for markdown content - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut is_open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(16), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: DashColors::popup_shadow(), - }, - fill: ui.style().visuals.window_fill, - stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), - }) - .show(ui.ctx(), |ui| { + let close_text = self.close_text.clone(); + + let chrome = modal_chrome( + ui.ctx(), + ModalChromeConfig { + title: self.title.clone(), + overlay_id: egui::Id::new("info_popup_overlay"), + overlay_order: egui::Order::Background, + window_order: egui::Order::Middle, + resizable: is_markdown, // Allow resizing for markdown content + inner_margin: 16, + }, + |ui| { // Set minimum and maximum width for the popup ui.set_min_width(300.0); if is_markdown { @@ -95,7 +79,7 @@ impl InfoPopup { ui.set_max_width(500.0); } - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Message content ui.add_space(10.0); @@ -110,7 +94,6 @@ impl InfoPopup { }); } else { // Render plain text with tight spacing - // Reduce item spacing for tighter layout ui.spacing_mut().item_spacing.y = 2.0; // Split on double newlines (paragraphs) and render with controlled spacing @@ -121,7 +104,6 @@ impl InfoPopup { ui.label( egui::RichText::new(text).color(DashColors::text_primary(dark_mode)), ); - // Add small space between paragraphs (but not after the last one) if i < paragraphs.len() - 1 { ui.add_space(4.0); } @@ -133,17 +115,15 @@ impl InfoPopup { // Close button ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ComponentStyles::add_primary_button(ui, self.close_text.clone()) - .clicked() - { + if ComponentStyles::add_primary_button(ui, close_text).clicked() { was_closed = true; } }); }); - }); + }, + ); - // Handle window being closed via X button - if !is_open { + if chrome.closed_via_x { was_closed = true; } @@ -158,18 +138,17 @@ impl InfoPopup { } // Handle click outside window - if let Some(ref wr) = window_response + if let Some(ref wr) = chrome.window_response && !was_closed - && clicked_outside_window(ui.ctx(), wr.response.rect) + && clicked_outside_window(ui.ctx(), wr.rect) { was_closed = true; } - // Update the popup's state self.is_open = !was_closed; - if let Some(window_response) = window_response { - InnerResponse::new(was_closed, window_response.response) + if let Some(window_response) = chrome.window_response { + InnerResponse::new(was_closed, window_response) } else { InnerResponse::new( was_closed, diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 098a19415..0c4dd38ff 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -1,125 +1,33 @@ use crate::app::AppAction; use crate::context::AppContext; -use crate::model::feature_gate::FeatureGate; +use crate::context::feature_gate::FeatureGate; use crate::ui::RootScreenType; +use crate::ui::components::icons::{load_icon, load_svg_icon}; use crate::ui::components::styled::GradientButton; use crate::ui::theme::{DashColors, ResponseExt, Shadow, Shape, Spacing}; use dash_sdk::dashcore_rpc::dashcore::Network; use eframe::epaint::Margin; -use egui::{Context, Frame, Image, RichText, SidePanel, TextureHandle}; +use egui::{Frame, Image, Panel, RichText, TextureHandle, Ui}; use egui_extras::{Size, StripBuilder}; -use rust_embed::RustEmbed; use std::sync::Arc; -use tracing::error; - -#[derive(RustEmbed)] -#[folder = "icons/"] // Folder containing embedded assets -struct Assets; - -// Function to load an icon as a texture using embedded assets -fn load_icon(ctx: &Context, path: &str) -> Option<TextureHandle> { - // Use ctx.data_mut to check if texture is already cached - ctx.data_mut(|d| d.get_temp::<TextureHandle>(egui::Id::new(path))) - .or_else(|| { - // Only do expensive operations if texture is not cached - if let Some(content) = Assets::get(path) { - // Load the image from the embedded bytes - if let Ok(image) = image::load_from_memory(&content.data) { - let size = [image.width() as usize, image.height() as usize]; - let rgba_image = image.into_rgba8(); - let pixels = rgba_image.into_raw(); - - let texture = ctx.load_texture( - path, - egui::ColorImage::from_rgba_unmultiplied(size, &pixels), - egui::TextureOptions::LINEAR, // Use linear filtering for smoother scaling - ); - - // Cache the texture - ctx.data_mut(|d| d.insert_temp(egui::Id::new(path), texture.clone())); - - Some(texture) - } else { - error!("Failed to load image from embedded data at path: {}", path); - None - } - } else { - error!("Image not found in embedded assets at path: {}", path); - None - } - }) -} - -// Function to load an SVG as a texture with specified dimensions -pub fn load_svg_icon(ctx: &Context, path: &str, width: u32, height: u32) -> Option<TextureHandle> { - let cache_key = format!("{}_{}_{}", path, width, height); - // Use ctx.data_mut to check if texture is already cached - ctx.data_mut(|d| d.get_temp::<TextureHandle>(egui::Id::new(&cache_key))) - .or_else(|| { - // Only do expensive operations if texture is not cached - if let Some(content) = Assets::get(path) { - // Parse SVG - let options = resvg::usvg::Options::default(); - let tree = match resvg::usvg::Tree::from_data(&content.data, &options) { - Ok(tree) => tree, - Err(e) => { - error!("Failed to parse SVG at {}: {}", path, e); - return None; - } - }; - - // Create a pixmap to render into - let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; - - // Calculate scale to fit the SVG into the desired dimensions - let svg_size = tree.size(); - let scale_x = width as f32 / svg_size.width(); - let scale_y = height as f32 / svg_size.height(); - let scale = scale_x.min(scale_y); - - // Center the SVG - let offset_x = (width as f32 - svg_size.width() * scale) / 2.0; - let offset_y = (height as f32 - svg_size.height() * scale) / 2.0; - - let transform = resvg::tiny_skia::Transform::from_scale(scale, scale) - .post_translate(offset_x, offset_y); - - // Render the SVG - resvg::render(&tree, transform, &mut pixmap.as_mut()); - - // Convert to egui texture - let pixels = pixmap.data().to_vec(); - let texture = ctx.load_texture( - &cache_key, - egui::ColorImage::from_rgba_unmultiplied( - [width as usize, height as usize], - &pixels, - ), - egui::TextureOptions::LINEAR, - ); - - // Cache the texture - ctx.data_mut(|d| d.insert_temp(egui::Id::new(&cache_key), texture.clone())); - - Some(texture) - } else { - error!("SVG not found in embedded assets at path: {}", path); - None - } - }) -} pub fn add_left_panel( - ctx: &Context, + ui: &mut Ui, app_context: &Arc<AppContext>, selected_screen: RootScreenType, ) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; // Define the button details directly in this function. // The optional FeatureGate controls visibility — entries where the gate // evaluates to false are filtered out before rendering. - let buttons: &[(&str, RootScreenType, &str, Option<FeatureGate>)] = &[ + // The button ordering below stays stable for existing users. The + // Identities hub entry (design-spec §A.1) is inserted after the legacy + // `Identities` entry below. Both legacy entries (Dashpay, Identities) + // remain visible during the coexistence period. + let legacy_buttons: &[(&str, RootScreenType, &str, Option<FeatureGate>)] = &[ ( "Dashpay", RootScreenType::RootScreenDashPayProfile, @@ -164,18 +72,47 @@ pub fn add_left_panel( ), ]; - let dark_mode = ctx.style().visuals.dark_mode; + // Build the final button list. The hub and Masternodes entries are inserted + // directly after the legacy `Identities` entry so the identity-related + // entries cluster together while the old ones stay clickable. + let mut buttons: Vec<(&str, RootScreenType, &str, Option<FeatureGate>)> = + Vec::with_capacity(legacy_buttons.len() + 2); + for entry in legacy_buttons.iter() { + buttons.push(*entry); + if entry.1 == RootScreenType::RootScreenIdentities { + buttons.push(( + "Identity Hub", + RootScreenType::RootScreenIdentityHub, + "identity.png", + None, + )); + // Masternodes sits directly below the identity cluster (locked + // decision #3), gated behind Expert Mode — the nav item and route + // are both absent when Expert Mode is off (the gate skip below drops + // the entry). + // TODO: swap `voting.png` for a dedicated node/server glyph when one + // is added to `icons/` (distinct from `identity.png`). + buttons.push(( + "Masternodes", + RootScreenType::RootScreenMasternodes, + "voting.png", + Some(FeatureGate::DeveloperMode), + )); + } + } + + let dark_mode = ctx.global_style().visuals.dark_mode; - SidePanel::left("left_panel") - .min_width(140.0) - .max_width(140.0) + Panel::left("left_panel") + .min_size(140.0) + .max_size(140.0) .resizable(false) .frame( Frame::new() .fill(DashColors::background(dark_mode)) .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect ) - .show(ctx, |ui| { + .show(ui, |ui| { // Create an island panel with rounded edges Frame::new() .fill(DashColors::surface(dark_mode)) @@ -235,11 +172,9 @@ pub fn add_left_panel( RootScreenType::RootScreenToolsPlatformInfoScreen => matches!( selected_screen, RootScreenType::RootScreenToolsPlatformInfoScreen - | RootScreenType::RootScreenToolsProofLogScreen | RootScreenType::RootScreenToolsTransitionVisualizerScreen | RootScreenType::RootScreenToolsDocumentVisualizerScreen | RootScreenType::RootScreenToolsProofVisualizerScreen - | RootScreenType::RootScreenToolsMasternodeListDiffScreen | RootScreenType::RootScreenToolsContractVisualizerScreen | RootScreenType::RootScreenToolsGroveSTARKScreen | RootScreenType::RootScreenToolsAddressBalanceScreen @@ -291,7 +226,6 @@ pub fn add_left_panel( if is_selected { let response = GradientButton::new(*label, app_context) .min_width(60.0) - .glow() .show(ui); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, *label)); if response.clicked() { diff --git a/src/ui/components/left_wallet_panel.rs b/src/ui/components/left_wallet_panel.rs deleted file mode 100644 index aae81dda2..000000000 --- a/src/ui/components/left_wallet_panel.rs +++ /dev/null @@ -1,120 +0,0 @@ -use crate::app::AppAction; -use crate::context::AppContext; -use crate::ui::RootScreenType; -use crate::ui::theme::DashColors; -use eframe::epaint::Margin; -use egui::{Context, Frame, Image, SidePanel, TextureHandle}; -use rust_embed::RustEmbed; -use std::sync::Arc; -use tracing::error; - -#[derive(RustEmbed)] -#[folder = "icons/"] // Adjust the folder path if necessary -struct Assets; - -// Function to load an icon as a texture using embedded assets -#[allow(dead_code)] -fn load_icon(ctx: &Context, path: &str) -> Option<TextureHandle> { - // Attempt to retrieve the embedded file - if let Some(content) = Assets::get(path) { - // Load the image from the embedded bytes - if let Ok(image) = image::load_from_memory(&content.data) { - let size = [image.width() as usize, image.height() as usize]; - let rgba_image = image.into_rgba8(); - let pixels = rgba_image.into_raw(); - - Some(ctx.load_texture( - path, - egui::ColorImage::from_rgba_unmultiplied(size, &pixels), - Default::default(), - )) - } else { - error!("Failed to load image from embedded data at path: {}", path); - None - } - } else { - error!("Image not found in embedded assets at path: {}", path); - None - } -} - -#[allow(dead_code)] -pub fn add_left_panel( - ctx: &Context, - _app_context: &Arc<AppContext>, - selected_screen: RootScreenType, -) -> AppAction { - let mut action = AppAction::None; - - // Define the button details directly in this function - let buttons = [ - ("I", RootScreenType::RootScreenIdentities, "identity.png"), - ( - "C", - RootScreenType::RootScreenDPNSActiveContests, - "voting.png", - ), - ("Q", RootScreenType::RootScreenDocumentQuery, "doc.png"), - ( - "T", - RootScreenType::RootScreenToolsTransitionVisualizerScreen, - "tools.png", - ), - ("N", RootScreenType::RootScreenNetworkChooser, "config.png"), - ]; - - let panel_width = 50.0 + 20.0; // Button width (50) + 10px margin on each side (20 total) - - SidePanel::left("left_panel") - .default_width(panel_width) - .frame( - Frame::new() - .fill(ctx.style().visuals.panel_fill) - .inner_margin(Margin { - left: 10, - right: 10, - top: 10, - bottom: 0, - }), - ) - .show(ctx, |ui| { - ui.vertical_centered(|ui| { - for (label, screen_type, icon_path) in buttons.iter() { - if *screen_type == RootScreenType::RootScreenDocumentQuery { - continue; // Skip rendering the document button for now - } - - let texture: Option<TextureHandle> = load_icon(ctx, icon_path); - let is_selected = selected_screen == *screen_type; - let button_color = if is_selected { - DashColors::ICON_SELECTED_BLUE // Highlighted blue color for selected - } else { - DashColors::ICON_UNSELECTED // Default gray color for unselected - }; - - // Add icon-based button if texture is loaded - if let Some(ref texture) = texture { - let button = egui::Button::image(Image::new(texture).tint(button_color)) - .frame(false); // Remove button frame - - if ui.add(button).clicked() { - action = AppAction::SetMainScreen(*screen_type); - } - } else { - // Fallback to a simple text button if texture loading fails - let button = egui::Button::new(*label) - .fill(button_color) - .min_size(egui::vec2(50.0, 50.0)); - - if ui.add(button).clicked() { - action = AppAction::SetMainScreen(*screen_type); - } - } - - ui.add_space(10.0); // Add some space between buttons - } - }); - }); - - action -} diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 908f1aebe..ed8bcf2e8 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -11,6 +11,12 @@ const DEFAULT_AUTO_DISMISS_SHORT: Duration = Duration::from_secs(5); const DEFAULT_AUTO_DISMISS_LONG: Duration = Duration::from_secs(9); const MAX_BANNERS: usize = 5; const BANNER_STATE_ID: &str = "__global_message_banner"; +/// Egui context-data slot holding the pending action ids that the +/// per-frame app loop drains via [`MessageBanner::take_action`]. A +/// banner with an attached [`BannerHandle::with_action`] pushes its +/// `action_id` here when the user clicks the action button — the app +/// loop consumes it to dispatch the matching backend task. +const BANNER_ACTIONS_ID: &str = "__global_message_banner_actions"; /// Maximum height for the expanded details section before scrolling. const DETAILS_MAX_HEIGHT: f32 = 120.0; @@ -77,6 +83,12 @@ struct BannerState { details: Option<String>, /// Optional recovery suggestion (shown inline below the summary). suggestion: Option<String>, + /// Optional action button (e.g. "Retry now"). When clicked, the + /// `action_id` is pushed into the global [`BANNER_ACTIONS_ID`] + /// queue so the per-frame app loop can dispatch the matching + /// backend task — banners stay UI-only and never call backend + /// code directly. See [`BannerHandle::with_action`]. + action: Option<(String, String)>, /// Whether the details section is currently expanded. details_expanded: bool, /// Whether the banner has been logged (to avoid duplicate log entries on each frame). @@ -95,6 +107,7 @@ impl BannerState { show_elapsed: false, details: None, suggestion: None, + action: None, details_expanded: false, logged: false, } @@ -110,6 +123,7 @@ impl BannerState { self.show_elapsed = false; self.details = None; self.suggestion = None; + self.action = None; self.details_expanded = false; self.logged = false; } @@ -135,7 +149,7 @@ impl BannerState { /// The handle is `'static` and safe to store. Methods that modify the banner /// (`set_message`, `with_auto_dismiss`) take `&self` so the handle can be reused. /// -/// INTENTIONAL(SEC-004): BannerHandle is Send+Sync because egui::Context is +/// BannerHandle is deliberately Send+Sync because egui::Context is /// Send+Sync with internal locking. This is acceptable for a single-threaded /// UI app; egui's own thread-safety guarantees apply. #[derive(Clone)] @@ -197,9 +211,9 @@ impl BannerHandle { /// (nested causes, variant names) that is more useful in a diagnostic /// details pane than the single-line `Display` output. /// - /// INTENTIONAL(RUST-003): When plain strings are passed, `{:?}` wraps them - /// in quotes. This is acceptable since `with_details` is primarily for - /// error types, not user-facing text. + /// When plain strings are passed, `{:?}` wraps them in quotes. This is + /// acceptable since `with_details` is primarily for error types, not + /// user-facing text. /// /// Returns `None` if the banner no longer exists. pub fn with_details(&self, details: impl fmt::Debug) -> Option<&Self> { @@ -223,6 +237,35 @@ impl BannerHandle { Some(self) } + /// Attach a primary action button (label + opaque `action_id`) to + /// this banner. The renderer paints the button next to the + /// dismiss control; clicks push `action_id` into the per-context + /// action queue, which the app loop drains via + /// [`MessageBanner::take_action`] to dispatch the matching backend + /// task. + /// + /// Empty `label` removes any existing action — convenient for + /// idempotent re-renders. + /// + /// Returns `None` if the banner no longer exists. + pub fn with_action( + &self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Option<&Self> { + let label = label.to_string(); + let action_id = action_id.to_string(); + let mut banners = get_banners(&self.ctx); + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.action = if label.is_empty() { + None + } else { + Some((label, action_id)) + }; + set_banners(&self.ctx, banners); + Some(self) + } + /// Attach an optional recovery suggestion to this banner. /// The suggestion is shown inline (visible without expanding). /// Returns `None` if the banner no longer exists. @@ -238,6 +281,21 @@ impl BannerHandle { Some(self) } + /// Disable auto-dismiss for this banner so it stays visible until + /// manually dismissed or cleared. Intended for messages that genuinely + /// affect the user (data loss, a major feature not working) — these + /// must not vanish on a timer before the user has a chance to read + /// them. Mirrors [`MessageBanner::disable_auto_dismiss`] for the + /// per-instance API. + /// Returns `None` if the banner no longer exists. + pub fn disable_auto_dismiss(&self) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.auto_dismiss_after = None; + set_banners(&self.ctx, banners); + Some(self) + } + /// Remove this banner immediately. pub fn clear(self) { let mut banners = get_banners(&self.ctx); @@ -278,6 +336,8 @@ impl MessageBanner { /// Override the auto-dismiss duration for the current message. /// Resets the countdown timer. No-op if no message is set. + // Exercised by the `kittest` integration tests (a separate crate the lib + // build does not see), so it is dead in a plain lib build. #[allow(dead_code)] pub fn set_auto_dismiss(&mut self, duration: Duration) -> &mut Self { if let Some(state) = &mut self.state { @@ -461,11 +521,28 @@ impl MessageBanner { } /// Returns whether any global banner messages exist. + // Exercised by the `kittest` integration tests (a separate crate the lib + // build does not see), so it is dead in a plain lib build. #[allow(dead_code)] pub fn has_global(ctx: &egui::Context) -> bool { !get_banners(ctx).is_empty() } + /// Drain and return the next pending action id queued by an + /// [`BannerHandle::with_action`] button click. Returns `None` when + /// nothing is pending. The app loop polls this each frame and + /// dispatches a matching backend task. Banners themselves never + /// touch backend code — the action id is the seam. + pub fn take_action(ctx: &egui::Context) -> Option<String> { + let mut queue = get_actions(ctx); + if queue.is_empty() { + return None; + } + let id = queue.remove(0); + set_actions(ctx, queue); + Some(id) + } + /// Renders all global banners from egui context data. /// Call inside `island_central_panel` before content. pub fn show_global(ui: &mut egui::Ui) { @@ -572,6 +649,7 @@ fn process_banner(ui: &mut egui::Ui, state: &mut BannerState) -> BannerStatus { annotation.as_deref(), state.suggestion.as_deref(), state.details.as_deref(), + state.action.as_ref(), &mut state.details_expanded, state.key, ) { @@ -593,10 +671,11 @@ fn render_banner( annotation: Option<&str>, suggestion: Option<&str>, details: Option<&str>, + action: Option<&(String, String)>, details_expanded: &mut bool, banner_key: u64, ) -> bool { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let fg_color = DashColors::message_color(message_type, dark_mode); let bg_color = DashColors::message_background_color(message_type, dark_mode); let secondary_color = DashColors::text_secondary(dark_mode); @@ -676,6 +755,22 @@ fn render_banner( ); } + // Primary action button (e.g. "Retry now"). Click pushes the + // action id into the banner-actions queue; the app loop drains + // it and dispatches the matching BackendTask. Keyboard- + // reachable so the Diziet §2.3 a11y rule + // ("Retry reachable in ≤2 Tab stops") holds via standard egui + // focus order. + if let Some((label, action_id)) = action { + ui.add_space(4.0); + let response = ui.add(egui::Button::new( + egui::RichText::new(label).color(fg_color).strong(), + )); + if response.clicked() { + push_action(ui.ctx(), action_id); + } + } + // Technical details (collapsible) if let Some(details) = details { ui.add_space(2.0); @@ -744,6 +839,29 @@ fn set_banners(ctx: &egui::Context, banners: Vec<BannerState>) { } } +/// Reads the pending banner-action queue (FIFO) from egui context data. +fn get_actions(ctx: &egui::Context) -> Vec<String> { + ctx.data(|d| d.get_temp::<Vec<String>>(egui::Id::new(BANNER_ACTIONS_ID))) + .unwrap_or_default() +} + +/// Writes the pending banner-action queue. Removes the slot when empty. +fn set_actions(ctx: &egui::Context, actions: Vec<String>) { + if actions.is_empty() { + ctx.data_mut(|d| d.remove::<Vec<String>>(egui::Id::new(BANNER_ACTIONS_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(BANNER_ACTIONS_ID), actions)); + } +} + +/// Appends an action id to the queue. Called from the renderer when +/// the user clicks an [`BannerHandle::with_action`] button. +fn push_action(ctx: &egui::Context, action_id: &str) { + let mut queue = get_actions(ctx); + queue.push(action_id.to_string()); + set_actions(ctx, queue); +} + fn icon_for_type(message_type: MessageType) -> &'static str { match message_type { MessageType::Error => "\u{26D4}", // no entry (⛔) @@ -768,7 +886,7 @@ pub trait ResultBannerExt<T, E> { /// If `Err`, displays a global error banner with the error's `Display` text. /// Returns `self` unchanged — this is a side-effect-only method. /// - /// INTENTIONAL(SEC-007): Raw `Display` text is shown directly. Callers must + /// Deliberately shows raw `Display` text directly. Callers must /// ensure error types have user-friendly Display implementations. fn or_show_error(self, ctx: &egui::Context) -> Self; } @@ -810,7 +928,7 @@ impl<T> OptionBannerShowExt<T> for Option<T> { /// /// ```ignore /// self.refresh_banner.take_and_clear(); -/// self.refresh_banner.replace(ctx, "Loading...", MessageType::Info); +/// self.refresh_banner.raise(ctx, "Loading...", MessageType::Info); /// self.refresh_banner.replace_with_elapsed(ctx, "Refreshing...", MessageType::Info); /// ``` pub trait OptionBannerExt { @@ -818,9 +936,9 @@ pub trait OptionBannerExt { fn take_and_clear(&mut self); /// Clears any existing banner, sets a new global banner, and stores the handle. - fn replace(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType); + fn raise(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType); - /// Like [`replace`](OptionBannerExt::replace), but also enables elapsed-time display on + /// Like [`raise`](OptionBannerExt::raise), but also enables elapsed-time display on /// the new banner (useful for long-running operations). fn replace_with_elapsed( &mut self, @@ -828,6 +946,16 @@ pub trait OptionBannerExt { msg: impl fmt::Display, msg_type: MessageType, ); + + /// Like [`raise`](OptionBannerExt::raise), but disables auto-dismiss so the banner + /// stays until manually dismissed. Use for messages that genuinely affect the user + /// (data loss, a major feature not working) — these must not disappear on a timer. + fn raise_persistent( + &mut self, + ctx: &egui::Context, + msg: impl fmt::Display, + msg_type: MessageType, + ); } impl OptionBannerExt for Option<BannerHandle> { @@ -837,7 +965,7 @@ impl OptionBannerExt for Option<BannerHandle> { } } - fn replace(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType) { + fn raise(&mut self, ctx: &egui::Context, msg: impl fmt::Display, msg_type: MessageType) { self.take_and_clear(); *self = Some(MessageBanner::set_global(ctx, msg.to_string(), msg_type)); } @@ -853,4 +981,72 @@ impl OptionBannerExt for Option<BannerHandle> { handle.with_elapsed(); *self = Some(handle); } + + fn raise_persistent( + &mut self, + ctx: &egui::Context, + msg: impl fmt::Display, + msg_type: MessageType, + ) { + self.take_and_clear(); + let handle = MessageBanner::set_global(ctx, msg.to_string(), msg_type); + handle.disable_auto_dismiss(); + *self = Some(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TC-MIG-005 (unit) — `with_action` records `(label, id)` on the + /// banner state. `take_action` is initially empty until the user + /// clicks the button (the click path is exercised by the kittest + /// suite); this unit covers the wiring shape. + #[test] + fn with_action_records_label_and_id_on_banner() { + let ctx = egui::Context::default(); + let handle = MessageBanner::set_global(&ctx, "boom", MessageType::Error); + handle.with_action("Retry now", "migration:retry"); + + // Inspect the stored banner state via the get_banners helper. + let banners = get_banners(&ctx); + let b = banners + .iter() + .find(|b| b.text == "boom") + .expect("banner present"); + assert_eq!( + b.action.as_ref().map(|(l, i)| (l.as_str(), i.as_str())), + Some(("Retry now", "migration:retry")), + ); + } + + /// `with_action("")` clears any previously-attached action — keeps + /// per-frame reconciliation idempotent when a banner downgrades + /// from Failed to Running. + #[test] + fn empty_action_label_removes_action() { + let ctx = egui::Context::default(); + let handle = MessageBanner::set_global(&ctx, "boom", MessageType::Error); + handle.with_action("Retry now", "x"); + handle.with_action("", "x"); + + let banners = get_banners(&ctx); + let b = banners.iter().find(|b| b.text == "boom").expect("banner"); + assert!(b.action.is_none(), "empty label must clear the action"); + } + + /// TC-MIG-005 (unit) — push_action enqueues FIFO and take_action + /// drains in the same order, returning None once empty. + #[test] + fn action_queue_drains_fifo() { + let ctx = egui::Context::default(); + assert!(MessageBanner::take_action(&ctx).is_none()); + + push_action(&ctx, "first"); + push_action(&ctx, "second"); + assert_eq!(MessageBanner::take_action(&ctx).as_deref(), Some("first")); + assert_eq!(MessageBanner::take_action(&ctx).as_deref(), Some("second")); + assert!(MessageBanner::take_action(&ctx).is_none()); + } } diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 20e7d880a..fcadc55bf 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,23 +1,30 @@ pub mod address_input; pub mod amount_input; +pub mod avatar; +pub mod breadcrumb_pill; pub mod component_trait; pub mod confirmation_dialog; pub mod contract_chooser_panel; pub mod dashpay_subscreen_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; +pub mod global_nav_switcher; +pub mod icons; pub mod identity_selector; pub mod info_popup; pub mod left_panel; -pub mod left_wallet_panel; pub mod message_banner; +pub mod modal_chrome; +pub mod passphrase_modal; pub mod password_input; +pub mod progress_overlay; +pub mod secret_prompt_host; pub mod selection_dialog; pub mod styled; +pub mod subscreen_chooser_panel; pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; pub mod top_panel; -pub mod wallet_unlock; pub mod wallet_unlock_popup; // Re-export the main traits for easy access @@ -26,3 +33,7 @@ pub use message_banner::{ BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, OptionBannerShowExt, ResultBannerExt, }; +pub use progress_overlay::{ + OptionOverlayExt, OverlayConfig, OverlayHandle, ProgressOverlay, ProgressOverlayResponse, +}; +pub use secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; diff --git a/src/ui/components/modal_chrome.rs b/src/ui/components/modal_chrome.rs new file mode 100644 index 000000000..618dce4dc --- /dev/null +++ b/src/ui/components/modal_chrome.rs @@ -0,0 +1,86 @@ +//! Shared chrome for the app's centered modal dialogs. +//! +//! One place owns the dark backdrop and the bordered, centered `Window` used by the +//! passphrase modal and the confirmation / selection / info dialogs. Callers keep their +//! own dismissal policy (Escape / Enter / click-outside) since it differs per dialog. + +use egui::{Context, Response, Ui, WidgetText}; + +use crate::ui::theme::DashColors; + +/// The "hide this button" sentinel for dialog builders taking `Option<impl Into<WidgetText>>`. +pub const NOTHING: Option<&str> = None; + +/// Layout and ordering knobs for one [`modal_chrome`] render. +pub struct ModalChromeConfig { + /// Window title-bar text. + pub title: WidgetText, + /// Distinct id for the dark backdrop layer, so stacked modals don't share one. + pub overlay_id: egui::Id, + /// Paint order for the dark backdrop. + pub overlay_order: egui::Order, + /// Paint order for the window (above the backdrop). + pub window_order: egui::Order, + /// Whether the user can resize the window. + pub resizable: bool, + /// Inner padding of the window frame, in points. + pub inner_margin: i8, +} + +/// Outcome of a [`modal_chrome`] render. +pub struct ModalChrome<R> { + /// The window's `Response` when it rendered; its `.rect` feeds `clicked_outside_window`. + pub window_response: Option<Response>, + /// True when the title-bar close (X) was clicked this frame. + pub closed_via_x: bool, + /// Value returned by `body` when the window rendered. + pub inner: Option<R>, +} + +/// Draws the dark backdrop and a centered, bordered modal window, running `body` inside. +/// +/// The caller owns dismissal policy: inspect [`ModalChrome::window_response`]'s rect with +/// [`clicked_outside_window`](crate::ui::helpers::clicked_outside_window) and +/// [`ModalChrome::closed_via_x`], plus its own Escape/Enter handling. +pub fn modal_chrome<R>( + ctx: &Context, + config: ModalChromeConfig, + body: impl FnOnce(&mut Ui) -> R, +) -> ModalChrome<R> { + let screen_rect = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new(config.overlay_order, config.overlay_id)); + painter.rect_filled(screen_rect, 0.0, DashColors::modal_overlay()); + + let mut is_open = true; + let window_response = egui::Window::new(config.title) + .collapsible(false) + .resizable(config.resizable) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .order(config.window_order) + .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(config.inner_margin), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: DashColors::popup_shadow(), + }, + fill: ctx.global_style().visuals.window_fill, + stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), + }) + .show(ctx, body); + + let (window_response, inner) = match window_response { + Some(r) => (Some(r.response), r.inner), + None => (None, None), + }; + + ModalChrome { + window_response, + closed_via_x: !is_open, + inner, + } +} diff --git a/src/ui/components/passphrase_modal.rs b/src/ui/components/passphrase_modal.rs new file mode 100644 index 000000000..87a5c3409 --- /dev/null +++ b/src/ui/components/passphrase_modal.rs @@ -0,0 +1,239 @@ +//! Shared modal chrome for passphrase entry. +//! +//! Both the wallet-unlock popup ([`WalletUnlockPopup`](super::wallet_unlock_popup)) +//! and the just-in-time secret prompt +//! ([`EguiSecretPromptHost`](super::secret_prompt_host)) ask the user for a +//! passphrase through the same centered, overlay-dimmed modal built on the +//! shared [`modal_chrome`](super::modal_chrome). This module adds the passphrase +//! specifics: focus-once, the [`PasswordInput`] field, an inline error line, an +//! optional `extra` body (e.g. a "remember" checkbox), and the Cancel / Submit +//! button row. +//! +//! It resolves Cancel / Escape / X / click-outside uniformly to +//! [`PassphraseModalOutcome::Cancel`] so callers never re-implement dismissal. +//! +//! ## State ownership +//! +//! Per-modal mutable state — the [`PasswordInput`] buffer and a focus-once flag +//! — is stored in egui's data cache keyed by `window_title`. Callers carry only +//! domain state (`remember`, `error`). On [`PassphraseModalOutcome::Submit`] the +//! typed text is extracted into a [`Zeroizing`] string and the cache entry is +//! cleared; on [`PassphraseModalOutcome::Cancel`] the cache entry is cleared too. + +use std::fmt; + +use egui::Context; +use zeroize::Zeroizing; + +use crate::ui::components::modal_chrome::{ModalChromeConfig, modal_chrome}; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::helpers::clicked_outside_window; +use crate::ui::theme::{ComponentStyles, DashColors}; + +/// The "keep unlocked" checkbox label, shared by every passphrase modal caller. +/// +/// A complete, translatable sentence (i18n-ready, no placeholders). Defined +/// here so [`WalletUnlockPopup`](super::wallet_unlock_popup) and +/// [`ActivePrompt`](super::secret_prompt_host::ActivePrompt) reference exactly +/// one literal — no silent drift between the two UIs. +pub const KEEP_UNLOCKED_LABEL: &str = "Keep this wallet unlocked until I close the app."; + +/// What the user did with a [`passphrase_modal`] this frame. +#[derive(Clone, PartialEq)] +pub enum PassphraseModalOutcome { + /// The modal is still open; no decision yet. + Pending, + /// The user submitted (Enter or the submit button). The inner value is the + /// typed passphrase; the internal buffer is zeroized immediately after + /// extraction. + Submit(Zeroizing<String>), + /// The user dismissed (Cancel button, Escape, X, or click-outside). + Cancel, +} + +// Manual Debug to prevent the typed passphrase from leaking into logs. +// (`Zeroizing` derives its Debug from the inner type, which would expose the +// plaintext string.) +impl fmt::Debug for PassphraseModalOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending => f.write_str("Pending"), + Self::Submit(_) => f.write_str("Submit(<redacted>)"), + Self::Cancel => f.write_str("Cancel"), + } + } +} + +/// Static copy + layout knobs for one render of [`passphrase_modal`]. +/// +/// Borrowed for the call only; carries no secret. `window_title` and +/// `submit_label` are complete, translatable sentences/labels (i18n-ready) +/// supplied by the caller so the same chrome serves "Unlock Wallet" and the +/// JIT prompt. +pub struct PassphraseModalConfig<'a> { + /// `Window` title (top bar). Stable across re-asks. + pub window_title: &'a str, + /// Body prompt line above the field, e.g. the wallet/key label. + pub body: &'a str, + /// Optional user-set hint shown under the field. + pub hint: Option<&'a str>, + /// Optional inline error (e.g. wrong-passphrase), shown in error color. + pub error: Option<&'a str>, + /// Submit button label, e.g. "Unlock". + pub submit_label: &'a str, + /// Placeholder text shown inside the password field before the user types. + /// Defaults to `"Enter passphrase"` when the callers' existing default is + /// appropriate; use `"Enter password"` for wallet-unlock flows. + pub input_placeholder: &'a str, + /// Caller-specific label for the "keep unlocked" checkbox drawn in `extra`. + /// `None` falls back to [`KEEP_UNLOCKED_LABEL`] (the wallet wording); set + /// `Some(...)` for non-wallet prompts (e.g. an identity key) so the + /// checkbox copy is not wallet-specific (Diziet D-2). + pub remember_label: Option<&'a str>, +} + +/// Per-modal mutable state stored in egui's data cache between frames. +/// +/// Keyed by `window_title` via [`egui::Id::new("passphrase_modal_state").with(title)`]. +/// Created on the first call with `config.input_placeholder`; cleared on +/// Submit or Cancel. +#[derive(Clone)] +struct PassphraseModalState { + password_input: PasswordInput, + focus_requested: bool, +} + +/// Render the shared passphrase modal and return what the user did. +/// +/// All per-modal mutable state (the [`PasswordInput`], focus tracking) is +/// managed internally via egui's data cache so callers only need to carry +/// domain state (`remember`, `error`). The typed passphrase is returned inside +/// [`PassphraseModalOutcome::Submit`] and zeroized in the cache immediately +/// after extraction. +/// +/// `extra` draws any caller-specific body (e.g. the "keep unlocked" checkbox) +/// between the error line and the button row. +pub fn passphrase_modal( + ctx: &Context, + config: &PassphraseModalConfig<'_>, + extra: impl FnOnce(&mut egui::Ui), +) -> PassphraseModalOutcome { + let state_id = egui::Id::new("passphrase_modal_state").with(config.window_title); + + // Load or initialise per-modal state from egui's data cache. `get_temp` + // returns a clone; we mutate the clone during rendering then write it back. + let mut state: PassphraseModalState = ctx + .data(|d| d.get_temp::<PassphraseModalState>(state_id)) + .unwrap_or_else(|| PassphraseModalState { + password_input: PasswordInput::new().with_hint_text(config.input_placeholder), + focus_requested: false, + }); + + let mut should_submit = false; + let mut should_cancel = false; + + // The overlay layer id is salted with the window title so a wallet-unlock modal and a JIT + // secret prompt drawn in the same frame get distinct overlays instead of fighting over one. + // The window renders on Order::Foreground so the prompt stays above the blocking progress + // overlay — that overlay must never cover a secret prompt it triggered. + let chrome = modal_chrome( + ctx, + ModalChromeConfig { + title: config.window_title.into(), + overlay_id: egui::Id::new("passphrase_modal_overlay").with(config.window_title), + overlay_order: egui::Order::Background, + window_order: egui::Order::Foreground, + resizable: false, + inner_margin: 20, + }, + |ui| { + ui.set_min_width(350.0); + ui.set_max_width(400.0); + + let dark_mode = ui.style().visuals.dark_mode; + + ui.label(egui::RichText::new(config.body).color(DashColors::text_primary(dark_mode))); + + ui.add_space(12.0); + + let pw_response = state.password_input.show(ui); + + if !state.focus_requested { + pw_response.response.request_focus(); + state.focus_requested = true; + } + + if pw_response.response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + should_submit = true; + } + + if let Some(hint) = config.hint { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("Hint: {hint}")) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + if let Some(error) = config.error { + ui.add_space(8.0); + ui.colored_label(DashColors::ERROR, error); + } + + ui.add_space(12.0); + + extra(ui); + + ui.add_space(16.0); + + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ComponentStyles::add_primary_button(ui, config.submit_label).clicked() { + should_submit = true; + } + if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { + should_cancel = true; + } + ui.add_space(8.0); + }); + }); + }, + ); + + // X button on the window title bar. + if chrome.closed_via_x { + should_cancel = true; + } + + // Escape key. Consume it so a second passphrase modal in the same frame + // does not also dismiss on the same keypress. + if !should_submit + && !should_cancel + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + should_cancel = true; + } + + // Click outside the window. + if let Some(ref wr) = chrome.window_response + && !should_submit + && !should_cancel + && clicked_outside_window(ctx, wr.rect) + { + should_cancel = true; + } + + // Resolve outcome: Submit takes priority; save or clear the cache entry. + if should_submit { + let text = Zeroizing::new(state.password_input.text().to_string()); + state.password_input.clear(); + ctx.data_mut(|d| d.remove::<PassphraseModalState>(state_id)); + PassphraseModalOutcome::Submit(text) + } else if should_cancel { + ctx.data_mut(|d| d.remove::<PassphraseModalState>(state_id)); + PassphraseModalOutcome::Cancel + } else { + ctx.data_mut(|d| d.insert_temp(state_id, state)); + PassphraseModalOutcome::Pending + } +} diff --git a/src/ui/components/password_input.rs b/src/ui/components/password_input.rs index 96fff94de..b02ecbb82 100644 --- a/src/ui/components/password_input.rs +++ b/src/ui/components/password_input.rs @@ -10,7 +10,6 @@ const PASSWORD_INPUT_REVEAL_ICON_WIDTH: f32 = 28.0; /// /// Intentionally does NOT implement `ComponentResponse` — exposing the `Secret` /// through a generic trait would undermine the security model. -// INTENTIONAL(PROJ-001): Custom response type instead of ComponentResponse. pub struct PasswordInputResponse { /// The underlying `TextEdit` response. pub response: Response, @@ -38,6 +37,12 @@ pub struct PasswordInputResponse { /// let resp = pw.show(ui); /// if resp.changed { /* validate */ } /// ``` +// Intentional: `Clone` is derived so `passphrase_modal` can store the input +// state in egui's data cache (which requires `Clone + Send + Sync + 'static`). +// `Secret::clone` correctly allocates a new mlock-protected buffer so the clone +// is as safe as the original. Callers should not clone `PasswordInput` in +// application code — this impl exists solely for the egui memory subsystem. +#[derive(Clone)] pub struct PasswordInput { secret: Secret, hint_text: String, @@ -135,10 +140,10 @@ impl PasswordInput { /// Render the password input. Returns a [`PasswordInputResponse`]. pub fn show(&mut self, ui: &mut Ui) -> PasswordInputResponse { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // -- TextEdit -------------------------------------------------------- - // INTENTIONAL(SEC-005): Egui TextEdit may cache plaintext in layout galleys and + // Egui TextEdit may cache plaintext in layout galleys and // accessibility state. Accepted as inherent framework limitation for desktop GUI threat model. let mut text_edit = egui::TextEdit::singleline(&mut self.secret) .password(!self.revealing) @@ -178,7 +183,7 @@ impl PasswordInput { let text_response = ui.add(text_edit); - // SEC-001: Disable egui's Undoer to prevent plaintext undo history. + // Disable egui's Undoer to prevent plaintext undo history. if let Some(mut state) = egui::widgets::text_edit::TextEditState::load(ui.ctx(), text_response.id) { diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs new file mode 100644 index 000000000..c3731b282 --- /dev/null +++ b/src/ui/components/progress_overlay.rs @@ -0,0 +1,1910 @@ +//! Full-window blocking progress overlay. +//! +//! A sibling to [`MessageBanner`](super::message_banner) for operations that are +//! unsafe or meaningless to interact *around* — broadcasting a state transition, +//! signing, key import, a multi-step registration, a migration. It draws a +//! dimming plane over the whole window, blocks all interaction beneath it, and +//! shows an indeterminate [`egui::Spinner`] (no ETA), an optional discrete step +//! counter, an optional description, and optional generic action buttons. +//! +//! Like the banner, it is a **visual + input** block only — it never waits in +//! the frame loop. The owning operation runs on a tokio `BackendTask`; the +//! overlay is raised at dispatch and lowered when the `TaskResult` is polled. +//! +//! ## Buttons are a generic facility (no built-in Cancel) +//! +//! The overlay has **no** Cancel concept. A caller attaches a generic button +//! with [`OverlayConfig::with_action`] / [`OverlayHandle::with_action`] (or the +//! `*_secondary_action` variants), picking its own opaque action id and label. +//! Clicking the button enqueues that action id, keyed by the owning entry; the +//! overlay does **not** auto-lower. The owning screen drains **its own** ids via +//! [`OverlayHandle::take_actions`] (FIFO) at the top of its `ui()` and decides +//! what to do — including running its own cancellation logic if it labelled a +//! button "Cancel". The app loop only sweeps orphaned ids via +//! [`ProgressOverlay::sweep_orphan_actions`]. +//! +//! ## Two render paths (mirrors `MessageBanner`) +//! +//! Like `MessageBanner`, this type has both an instance [`Component`] path and a +//! global path, sharing one layout helper ([`render_card`]): +//! +//! - **Global** — state lives in egui `ctx.data`; [`ProgressOverlay::set_global`] +//! raises it, [`ProgressOverlay::render_global`] paints the full-window dim + +//! input sink + centered card once per frame from `AppState::update`. This is +//! the app-level blocking path the application depends on. +//! - **Instance** — `ProgressOverlay { state }` configured via builder methods, +//! rendered inline by [`Component::show`]. It paints only the card (no dim/sink) +//! and surfaces a clicked button's action id through [`ProgressOverlayResponse`]. +//! +//! ## Concurrency (global path) +//! +//! Global state is a **stack** of active requests. The overlay is visible while +//! the stack is non-empty; the topmost (last-pushed) entry is rendered; each +//! handle dismisses only its own entry; the overlay clears when the stack empties. +//! A human cannot launch a second blocker, so concurrency only arises from +//! programmatic tasks — the stack guarantees the UI never unblocks while an +//! operation is still running. + +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use egui::InnerResponse; +use tracing::{debug, warn}; + +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{ComponentStyles, DashColors, Shadow, Shape, Spacing}; + +const OVERLAY_STATE_ID: &str = "__global_progress_overlay"; +const OVERLAY_ACTIONS_ID: &str = "__global_progress_overlay_actions"; +const OVERLAY_DIM_SINK_ID: &str = "__global_progress_overlay_sink"; +const OVERLAY_CARD_ID: &str = "__global_progress_overlay_card"; + +/// After this long on the topmost request the renderer auto-reveals the honest +/// elapsed readout and a reassurance line. Visual only — never auto-aborts. +const STUCK_OVERLAY_THRESHOLD: Duration = Duration::from_secs(30); + +/// After this long *without progress* on the topmost request, escalate the +/// reassurance copy and fire the one-shot developer watchdog (A-1). A leaked +/// handle (C1) or an un-bounded op (C2) is the usual cause — both are bugs. +const STUCK_OVERLAY_WATCHDOG_THRESHOLD: Duration = Duration::from_secs(120); + +/// Diameter of the indeterminate spinner inside the card. +const SPINNER_SIZE: f32 = 32.0; +/// Card minimum width so short content still reads as a deliberate dialog. +const CARD_MIN_WIDTH: f32 = 240.0; +/// Card maximum width so long descriptions wrap instead of stretching. +const CARD_MAX_WIDTH: f32 = 420.0; +/// Description scrolls inside the card past this height (FR-6: never off-screen). +const DESCRIPTION_MAX_HEIGHT: f32 = 160.0; + +/// Reassurance line revealed once the soft 30 s stuck threshold passes. +/// Deliberately neutral — SPV initial sync is expected to take several minutes, +/// so copy that implies a fault ("longer than usual") would be misleading. +const STUCK_REASSURANCE: &str = "Still in progress — please keep the app open."; + +/// Escalated reassurance shown once the 120 s no-progress watchdog trips, +/// replacing (not stacking with) [`STUCK_REASSURANCE`]. +const STUCK_WATCHDOG_REASSURANCE: &str = "This is taking much longer than expected. The operation is still running — please keep the app open."; + +/// Monotonic counter for generating unique overlay keys. +static OVERLAY_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_overlay_key() -> u64 { + // Relaxed is sufficient: we only need uniqueness, not ordering. The counter + // runs in a single-threaded UI context. + OVERLAY_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// Visual tier of an overlay button (F-3/F-4/F-7). Styling and placement only — +/// both tiers are generic and carry no built-in semantics (there is no Cancel). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ButtonStyle { + /// Accent fill; hugs the right edge — the affirmative / continue action. + Primary, + /// Muted fill; sits to the left of the primary — e.g. a caller's "cancel". + Secondary, +} + +/// One generic action button on the overlay card. The caller owns both the +/// `label` (an i18n unit, user-visible and logged) and the opaque `action_id` +/// enqueued on click. `style` controls appearance and placement only. +#[derive(Clone)] +struct OverlayButton { + label: String, + action_id: String, + style: ButtonStyle, +} + +impl OverlayButton { + fn new(id: impl fmt::Display, label: impl fmt::Display, style: ButtonStyle) -> Self { + Self { + label: label.to_string(), + action_id: id.to_string(), + style, + } + } +} + +/// Snapshot of the content fields logged for change-detection: the description +/// and the step pair. Compared to log a content update exactly once (NFR-5). +type LoggedContent = (Option<String>, Option<(u32, u32)>); + +/// One active blocking request on the overlay stack (and the per-instance state +/// for the [`Component`] path). +#[derive(Clone)] +struct OverlayState { + key: u64, + description: Option<String>, + /// Raw, unvalidated; the renderer gates on [`step_is_renderable`]. + step: Option<(u32, u32)>, + buttons: Vec<OverlayButton>, + /// Explicit opt-in; also forced once `created_at` passes the threshold. + show_elapsed: bool, + created_at: Instant, + /// Set once the overlay has been logged as shown (NFR-5). + logged: bool, + /// Last content logged, so a description/step update logs exactly once. + logged_content: Option<LoggedContent>, + /// Hidden, monotonic liveness token (A-1). Never rendered. An owner that drives + /// a long phase whose shown `(description, step)` is constant (e.g. SPV headers) + /// advances this from the underlying progress (a climbing height) so the + /// watchdog can tell a slow-but-advancing phase from a genuine stall. + progress_token: Option<u64>, + /// The `progress_token` value at the last watchdog reset, for change detection. + last_progress_token: Option<u64>, + /// Last time real progress was seen — either the shown `(description, step)` + /// changed OR the hidden `progress_token` advanced. The no-progress watchdog + /// (A-1) measures from here, so a legitimately advancing flow (multi-step or a + /// single slow-but-advancing phase) never trips it while a genuinely wedged + /// operation does. + last_progress_at: Instant, + /// Set once the no-progress watchdog has fired its one-shot dev-error (A-1), + /// so the error logs exactly once, never per frame (NFR-5). + watchdog_logged: bool, + /// Set once focus has been placed on the first button (focus trap). + focus_requested: bool, + /// Opt-in action id designated as the single keyboard-reachable escape. + /// When set, `claim_input` activates it at frame start: a press of + /// Enter/Space enqueues this action directly (the same queue a click feeds) and + /// is stripped like every other key, so the escape needs no focus and the key + /// never reaches a widget beneath. `None` by default — a block is fully + /// keyboard-blocked unless it opts in. + keyboard_escape_action: Option<String>, +} + +impl OverlayState { + fn new(key: u64, description: Option<String>, config: &OverlayConfig) -> Self { + let now = Instant::now(); + Self { + key, + description, + step: config.step, + buttons: config.buttons.clone(), + show_elapsed: config.show_elapsed, + created_at: now, + logged: false, + logged_content: None, + progress_token: config.progress_token, + last_progress_token: config.progress_token, + last_progress_at: now, + watchdog_logged: false, + focus_requested: false, + keyboard_escape_action: config.keyboard_escape_action.clone(), + } + } +} + +/// Builder/config for [`ProgressOverlay::set_global`]. `OverlayConfig::default()` +/// is a spinner-only block: no counter, no buttons, elapsed off. +#[derive(Clone, Default)] +pub struct OverlayConfig { + description: Option<String>, + step: Option<(u32, u32)>, + show_elapsed: bool, + buttons: Vec<OverlayButton>, + /// Hidden liveness token (A-1). Never rendered; see [`with_progress_token`]. + /// + /// [`with_progress_token`]: Self::with_progress_token + progress_token: Option<u64>, + /// Opt-in keyboard escape; see [`with_keyboard_escape`]. + /// + /// [`with_keyboard_escape`]: Self::with_keyboard_escape + keyboard_escape_action: Option<String>, +} + +impl OverlayConfig { + pub fn new() -> Self { + Self::default() + } + + /// Set the description. The `description` argument of `set_global` wins when + /// this is unset, so most callers pass the text there instead. + pub fn with_description(mut self, text: impl fmt::Display) -> Self { + let text = text.to_string(); + self.description = (!text.is_empty()).then_some(text); + self + } + + pub fn with_step(mut self, current: u32, total: u32) -> Self { + self.step = Some((current, total)); + self + } + + /// Show the honest count-up elapsed readout from the start. + pub fn with_elapsed(mut self) -> Self { + self.show_elapsed = true; + self + } + + /// Seed the **hidden** liveness token (A-1). NOT rendered to the user — it only + /// feeds the no-progress watchdog: a change in the token between frames counts + /// as progress and resets the watchdog clock, so a slow-but-still-advancing + /// operation (e.g. SPV headers on a slow link, where the shown "Step N of 5" + /// stays constant for minutes) never trips the false-stall escalation. Most + /// callers update it each frame via [`OverlayHandle::set_progress_token`]. + pub fn with_progress_token(mut self, token: u64) -> Self { + self.progress_token = Some(token); + self + } + + /// Add a **primary** action button (accent fill, hugs the right edge). Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// the displayed `label` comes first, the opaque `action_id` enqueued on click + /// second. Clicking does not lower the overlay — the owning screen drains the + /// id and decides what to do. + /// + /// Buttons render right-to-left in the order added: primaries hug the right + /// edge, secondaries sit to their left. `label` and `action_id` are + /// user-visible and logged — never pass secrets or PII. + pub fn with_action(mut self, label: impl fmt::Display, action_id: impl fmt::Display) -> Self { + self.buttons + .push(OverlayButton::new(action_id, label, ButtonStyle::Primary)); + self + } + + /// Add a **secondary** action button (muted fill, sits left of the primary). + /// Same generic semantics as [`with_action`](Self::with_action) — only the + /// styling and placement differ; there is no built-in Cancel. `label` + /// and `action_id` are user-visible and logged — never pass secrets or PII. + pub fn with_secondary_action( + mut self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Self { + self.buttons + .push(OverlayButton::new(action_id, label, ButtonStyle::Secondary)); + self + } + + /// Designate one already-added action as the single **keyboard-reachable + /// escape**. Pass the same opaque `action_id` you gave to + /// [`with_action`](Self::with_action) / [`with_secondary_action`](Self::with_secondary_action). + /// + /// A hard block is otherwise never keyboard-activatable: `claim_input` strips + /// every navigation/confirm key every frame so a focused button cannot be + /// triggered by keyboard. This opt-in carves out exactly one exception — the + /// designated button stays activatable with Enter or Space — for blocks that are + /// **unbounded** and would otherwise strand a keyboard-only / assistive-tech user + /// (the reference adopter is the SPV-sync block, whose sync can wait + /// indefinitely for peers). The overlay focus-pins the escape button so the + /// passthrough can never reach a widget beneath; every OTHER hard block, and + /// everything beneath, stays fully keyboard-blocked. Designating an action id + /// that no button carries is a no-op. + pub fn with_keyboard_escape(mut self, action_id: impl fmt::Display) -> Self { + self.keyboard_escape_action = Some(action_id.to_string()); + self + } +} + +/// Lifecycle handle for a raised overlay, returned by +/// [`ProgressOverlay::set_global`]. Identifies its entry by an internal key, so +/// content can be updated without losing the reference. Methods are no-ops +/// returning `None` once the entry is gone. +/// +/// `OverlayHandle` is deliberately `Send + Sync` only because it holds +/// an `egui::Context` (itself `Send + Sync` via internal locking). That does NOT +/// make handle operations thread-safe to interleave: every method reads-modifies- +/// writes the global `ctx.data` overlay slot non-atomically, so the real +/// invariant is that all handle operations run on the single egui UI/update +/// thread (where the overlay is shown, mutated, and cleared). The `Send + Sync` +/// derivation is incidental to `egui::Context`'s bounds, not a claim of +/// cross-thread correctness. +#[derive(Clone)] +pub struct OverlayHandle { + ctx: egui::Context, + key: u64, +} + +impl OverlayHandle { + /// Whether this handle's entry is still on the stack. + pub fn is_active(&self) -> bool { + get_overlay_state(&self.ctx) + .iter() + .any(|s| s.key == self.key) + } + + /// How long ago this entry was raised, or `None` if it is gone. + pub fn elapsed(&self) -> Option<Duration> { + get_overlay_state(&self.ctx) + .iter() + .find(|s| s.key == self.key) + .map(|s| s.created_at.elapsed()) + } + + /// Update the description in place. Returns `None` if the entry is gone. + pub fn set_description(&self, text: impl fmt::Display) -> Option<&Self> { + self.mutate(|s| { + let text = text.to_string(); + s.description = (!text.is_empty()).then_some(text); + }) + } + + /// Update the step counter in place. Returns `None` if the entry is gone. + pub fn set_step(&self, current: u32, total: u32) -> Option<&Self> { + self.mutate(|s| s.step = Some((current, total))) + } + + /// Remove the step counter. Returns `None` if the entry is gone. + pub fn clear_step(&self) -> Option<&Self> { + self.mutate(|s| s.step = None) + } + + /// Attach a **primary** action button (accent fill, right edge). Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// `label` shown verbatim first, opaque `action_id` enqueued on click second. + /// Buttons render right-to-left in the order added. `label`/`action_id` + /// are user-visible and logged — never pass secrets or PII. Returns `None` if + /// the entry is gone. + pub fn with_action( + &self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Option<&Self> { + let button = OverlayButton::new(action_id, label, ButtonStyle::Primary); + self.mutate(|s| s.buttons.push(button)) + } + + /// Attach a **secondary** action button (muted fill, left of the primary). + /// Same generic semantics as [`with_action`](Self::with_action). + /// `label`/`action_id` are user-visible and logged. Returns `None` if the entry + /// is gone. + pub fn with_secondary_action( + &self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Option<&Self> { + let button = OverlayButton::new(action_id, label, ButtonStyle::Secondary); + self.mutate(|s| s.buttons.push(button)) + } + + /// Update the **hidden** liveness token in place (A-1). NOT rendered — it only + /// feeds the no-progress watchdog so an advancing underlying operation (e.g. an + /// SPV phase whose height climbs while the shown "Step N of 5" stays constant) + /// resets the watchdog clock. Returns `None` if the entry is gone. + pub fn set_progress_token(&self, token: u64) -> Option<&Self> { + self.mutate(|s| s.progress_token = Some(token)) + } + + /// Designate one already-attached action as the single keyboard-reachable + /// escape, mirroring [`OverlayConfig::with_keyboard_escape`]. Pass the same + /// opaque `action_id` you gave to a `with_action` / `with_secondary_action` + /// call. Returns `None` if the entry is gone. + pub fn with_keyboard_escape(&self, action_id: impl fmt::Display) -> Option<&Self> { + let action_id = action_id.to_string(); + self.mutate(|s| s.keyboard_escape_action = Some(action_id)) + } + + /// Drain (FIFO) and remove the action ids enqueued by **this handle's** + /// button clicks, leaving other overlay entries' actions untouched (A-3). + /// + /// The owning screen calls this at the top of its own `ui()` each frame and + /// matches its own colon-namespaced ids (e.g. `shielded:build:cancel`), + /// running whatever logic it owns — including its own cancellation. Returns + /// an empty `Vec` when this handle has no pending clicks (or is already gone). + pub fn take_actions(&self) -> Vec<String> { + let mut queue = get_overlay_actions(&self.ctx); + let mut mine = Vec::new(); + queue.retain(|a| { + if a.key == self.key { + mine.push(a.action_id.clone()); + false + } else { + true + } + }); + if !mine.is_empty() { + set_overlay_actions(&self.ctx, queue); + } + mine + } + + /// Test clock seam (RQ-2): shift this entry's `created_at` **and** + /// `last_progress_at` into the past by `by`, so a kittest can render past the + /// 30 s soft-reveal and 120 s no-progress watchdog thresholds without waiting. + /// Returns `None` if the entry is gone. Compiled only under the `testing` + /// feature — never part of the production surface. + #[cfg(feature = "testing")] + pub fn backdate(&self, by: Duration) -> Option<&Self> { + self.mutate(|s| { + if let Some(t) = s.created_at.checked_sub(by) { + s.created_at = t; + } + if let Some(t) = s.last_progress_at.checked_sub(by) { + s.last_progress_at = t; + } + }) + } + + /// Dismiss only this handle's entry, and purge any of its still-pending action + /// ids so a normal dismiss leaves nothing for the orphan-sweeper (A-3). The + /// overlay lowers when the stack empties. + pub fn clear(self) { + let mut stack = get_overlay_state(&self.ctx); + let before = stack.len(); + stack.retain(|s| s.key != self.key); + if stack.len() != before { + debug!(key = self.key, "Blocking progress overlay dismissed"); + } + set_overlay_state(&self.ctx, stack); + + let mut queue = get_overlay_actions(&self.ctx); + let kept = queue.len(); + queue.retain(|a| a.key != self.key); + if queue.len() != kept { + set_overlay_actions(&self.ctx, queue); + } + } + + /// Find this handle's entry, apply `f`, and write the stack back. The next + /// `log_overlay_state` detects the content change (it compares + /// `(description, step)`), logs the update once, and bumps `last_progress_at` + /// for the no-progress watchdog — so this method intentionally does not touch + /// `logged_content` itself. + fn mutate(&self, f: impl FnOnce(&mut OverlayState)) -> Option<&Self> { + let mut stack = get_overlay_state(&self.ctx); + let entry = stack.iter_mut().find(|s| s.key == self.key)?; + f(entry); + set_overlay_state(&self.ctx, stack); + Some(self) + } +} + +/// Response returned by [`ProgressOverlay::show`] (the [`Component`] path). +/// +/// The only thing a user can change about an overlay is clicking one of its +/// generic buttons, so the domain value is the clicked button's **action id**. +/// `changed_value` is `Some(action_id)` for the single frame a button is clicked +/// and `None` otherwise; the overlay is never in an "invalid" state. +#[derive(Clone)] +pub struct ProgressOverlayResponse { + action: Option<String>, + changed: bool, +} + +impl ComponentResponse for ProgressOverlayResponse { + type DomainType = String; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.action + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// The blocking progress overlay. +/// +/// Two paths share the [`render_card`] layout helper (mirrors `MessageBanner`): +/// the global `ctx.data` path ([`set_global`](Self::set_global) / +/// [`render_global`](Self::render_global)), driven once per frame from +/// `AppState::update`, and the instance [`Component`] path configured by the +/// builder methods and rendered by [`Component::show`]. +pub struct ProgressOverlay { + state: Option<OverlayState>, + /// The action id of the most recently clicked button on this instance, + /// surfaced by [`Component::current_value`]. `None` until a click occurs. + last_action: Option<String>, +} + +impl Default for ProgressOverlay { + fn default() -> Self { + Self::new() + } +} + +impl ProgressOverlay { + // ── Instance (Component) path ─────────────────────────────────────────── + + /// Create a spinner-only instance overlay. Configure it with the builder + /// methods, then render it inline via [`Component::show`]. + pub fn new() -> Self { + let key = next_overlay_key(); + Self { + state: Some(OverlayState::new(key, None, &OverlayConfig::default())), + last_action: None, + } + } + + /// Set the description shown beneath the spinner. An empty string clears it. + pub fn with_description(mut self, text: impl fmt::Display) -> Self { + if let Some(state) = &mut self.state { + let text = text.to_string(); + state.description = (!text.is_empty()).then_some(text); + } + self + } + + /// Show a discrete step counter ("Step {current} of {total}"). + pub fn with_step(mut self, current: u32, total: u32) -> Self { + if let Some(state) = &mut self.state { + state.step = Some((current, total)); + } + self + } + + /// Show the honest count-up elapsed readout from the start. + pub fn with_elapsed(mut self) -> Self { + if let Some(state) = &mut self.state { + state.show_elapsed = true; + } + self + } + + /// Add a **primary** action button. Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// the `label` shown on the button comes first, the opaque `action_id` surfaced + /// through [`ProgressOverlayResponse`] on click second. + pub fn with_action(mut self, label: impl fmt::Display, action_id: impl fmt::Display) -> Self { + if let Some(state) = &mut self.state { + state + .buttons + .push(OverlayButton::new(action_id, label, ButtonStyle::Primary)); + } + self + } + + /// Add a **secondary** action button (left of the primary). Same generic + /// semantics as [`with_action`](Self::with_action). + pub fn with_secondary_action( + mut self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Self { + if let Some(state) = &mut self.state { + state + .buttons + .push(OverlayButton::new(action_id, label, ButtonStyle::Secondary)); + } + self + } + + /// Clear this instance so [`Component::show`] renders nothing and returns the + /// empty response — makes the `state == None` path reachable via the + /// public API. Idempotent. + pub fn clear(&mut self) { + self.state = None; + } + + // ── Global (ctx.data) path ────────────────────────────────────────────── + + /// Raise the overlay and return its handle. Non-blocking: only writes + /// `ctx.data`. The `description` argument is used unless `config` already + /// carries one. + /// + /// **Button-less lifecycle rule:** a button-less app-level block has no + /// automatic teardown — the no-progress watchdog only *logs*, it never + /// lowers the block. A button-less block MUST therefore either be driven by + /// a frame-driven reconcile owner that lowers it when the work ends (the + /// reference pattern is the SPV adopter, `AppState::update_spv_overlay`), or + /// carry an escape button; a leaked or forgotten handle strands the UI with + /// no way out. + /// + /// The `description` (and any button `label`/`id`) is user-visible + /// and written to logs on show — never pass secrets, passphrases, or PII. + pub fn set_global( + ctx: &egui::Context, + description: impl fmt::Display, + config: OverlayConfig, + ) -> OverlayHandle { + let description = config.description.clone().or_else(|| { + let text = description.to_string(); + (!text.is_empty()).then_some(text) + }); + let key = next_overlay_key(); + let mut stack = get_overlay_state(ctx); + if !stack.is_empty() { + // Logged here (once per show), never per frame — a blocking overlay + // cannot be stacked by a human, so this signals a programmatic smell. + warn!( + key, + depth = stack.len(), + "A blocking overlay was requested while another is active" + ); + } + stack.push(OverlayState::new(key, description, &config)); + set_overlay_state(ctx, stack); + OverlayHandle { + ctx: ctx.clone(), + key, + } + } + + /// Convenience: a spinner-only block with no text, counter, or buttons. + /// + /// As a button-less block it has no escape, so the button-less lifecycle rule from + /// [`set_global`](Self::set_global) applies in full: drive it from a + /// frame-driven reconcile owner (e.g. `AppState::update_spv_overlay`) that + /// lowers it when the work ends — a leaked handle has no automatic teardown. + pub fn set_global_spinner_only(ctx: &egui::Context) -> OverlayHandle { + Self::set_global(ctx, "", OverlayConfig::default()) + } + + /// Whether any overlay is active. Cheap one-slot read (NFR-6). + pub fn has_global(ctx: &egui::Context) -> bool { + !get_overlay_state(ctx).is_empty() + } + + /// Orphan-sweeper (A-3): drain and return only action ids whose owning + /// overlay entry is **no longer on the stack** — i.e. the owner cleared or + /// dropped its handle without draining. Live owners' actions are left + /// untouched, so this can never race or pre-empt a screen that owns an active + /// overlay, regardless of call order. The app loop calls this and logs each + /// truly-orphaned id; screens drain their own via [`OverlayHandle::take_actions`]. + pub fn sweep_orphan_actions(ctx: &egui::Context) -> Vec<String> { + let live: std::collections::HashSet<u64> = + get_overlay_state(ctx).iter().map(|s| s.key).collect(); + let mut queue = get_overlay_actions(ctx); + let mut orphans = Vec::new(); + queue.retain(|a| { + if live.contains(&a.key) { + true + } else { + orphans.push(a.action_id.clone()); + false + } + }); + if !orphans.is_empty() { + set_overlay_actions(ctx, queue); + } + orphans + } + + /// Clear every entry — used on network switch alongside the banner reset. + /// + /// Also clears the pending action queue, so a click queued just + /// before a network switch cannot survive into the new context and be + /// mis-dispatched there. + pub fn clear_all_global(ctx: &egui::Context) { + set_overlay_state(ctx, Vec::new()); + set_overlay_actions(ctx, Vec::new()); + } + + /// Claim all keyboard and text input for the active block, at frame start. + /// + /// Must be called near the top of `AppState::update` — **before** the panels + /// and the visible screen run — and the caller MUST skip it while a secret + /// prompt is active above the overlay (that modal needs the keyboard). + /// Early-outs when no overlay is active. + /// + /// Why a separate frame-start pass: `render_global`'s own key filter runs at + /// the *end* of the frame, one frame too late for a button-less block raised + /// over an already-focused field — the field beneath has already consumed the + /// keystroke. `claim_input` closes that leak by, while a block is up: + /// - releasing text-edit focus from any field beneath (so it stops drawing a + /// caret and consuming text — affects only text widgets, never an overlay + /// button), and + /// - stripping `Event::Text`, the clipboard events (Copy/Cut/Paste), and the + /// navigation/confirm/edit keys (Tab, Enter, Escape, Space, arrows, + /// Backspace, Delete, Home, End, PageUp, PageDown) from `i.events` so + /// nothing beneath observes them. + /// + /// A hard block is never keyboard-dismissable or keyboard-activatable, with one + /// opt-in exception: a block that designates a single keyboard escape via + /// [`OverlayConfig::with_keyboard_escape`]. For such a block, a frame-start press + /// of **Enter or Space** enqueues the designated action directly — the same queue + /// a click feeds — and the key is then stripped along with every other one. The + /// activation happens here, before the beneath `ui()` runs, so it needs no focus + /// and the key never survives to a widget beneath. Every + /// other key, and every non-opted block, stays fully blocked. + pub fn claim_input(ctx: &egui::Context) { + let stack = get_overlay_state(ctx); + let Some(top) = stack.last() else { + return; + }; + // Release beneath focus ONLY for a button-less block — it has no widget of + // its own to hold focus, so a focused field beneath would keep its caret. + // A buttoned block keeps its button focused (`render_buttons` manages the + // focus + lock), so do NOT clear focus here — `stop_text_input` clears the + // *currently focused* widget regardless of type, which would steal the + // button's focus every frame. + if top.buttons.is_empty() { + ctx.memory_mut(|m| m.stop_text_input()); + } + // A designated keyboard escape is activated HERE, at frame start: a press of + // Enter/Space enqueues its action directly (the same queue a click feeds) and + // is then stripped like every other key. Doing it before the beneath `ui()` + // runs means the activation needs no focus and the key never + // survives to a focus-independent handler beneath. A non-opted block + // enqueues nothing and strips Enter/Space exactly the same. + let escape_action = top.keyboard_escape_action.clone(); + let key = top.key; + let mut activate_escape = false; + ctx.input_mut(|i| { + i.events.retain(|e| { + if matches!( + e, + egui::Event::Text(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) + ) { + return false; + } + if let egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, + pressed: true, + repeat, + .. + } = e + { + // Enqueue once per real press (ignore key-repeat); always strip. + if escape_action.is_some() && !*repeat { + activate_escape = true; + } + return false; + } + !matches!( + e, + egui::Event::Key { + key: egui::Key::Tab + | egui::Key::Escape + | egui::Key::ArrowUp + | egui::Key::ArrowDown + | egui::Key::ArrowLeft + | egui::Key::ArrowRight + | egui::Key::Backspace + | egui::Key::Delete + | egui::Key::Home + | egui::Key::End + | egui::Key::PageUp + | egui::Key::PageDown, + pressed: true, + .. + } + ) + }); + }); + // Enqueue after releasing the input lock — `push_overlay_action` takes the + // `ctx.data` lock, which must not nest inside `ctx.input_mut`. + if activate_escape && let Some(action_id) = escape_action { + push_overlay_action(ctx, key, &action_id); + } + // TODO: claim pointer press/click/drag at frame start + // (analogue of the keyboard frame-start claim above) to close the + // one-frame click-through on the raising frame. + } + + /// Render the topmost entry. Call once per frame from `AppState::update`, + /// after the panels and before the secret prompt. Early-outs to a single + /// `ctx.data` read when no overlay is active (NFR-6). + /// + /// `secret_prompt_active` mirrors the [`claim_input`](Self::claim_input) + /// secret-prompt gate: when `true` the block suppresses its own focus management + /// so the passphrase modal rendered above it keeps the keyboard. + /// + /// Unlike [`MessageBanner`](super::message_banner::MessageBanner), whose global + /// path pairs `set_global` with [`show_global`](super::message_banner::MessageBanner::show_global) + /// (rendered lazily inside `island_central_panel`), the overlay pairs + /// [`set_global`](Self::set_global) with `render_global`: it owns a full-window + /// dim, input sink, and focus trap that must be painted every frame from the app + /// loop on `Order::Foreground`, not lazily from within a panel. + pub fn render_global(ctx: &egui::Context, secret_prompt_active: bool) { + let mut stack = get_overlay_state(ctx); + let Some(top) = stack.last_mut() else { + return; + }; + + // NB: render_global does NO keyboard stripping. All key/text claiming + // happens in `claim_input` at frame start, which the app loop gates on no + // active secret prompt — a passphrase modal rendered above + // the overlay must keep Enter/Esc/Tab. Stripping here would be both too + // late (end-of-frame) and ungated (would re-break the prompt). The buttoned + // case additionally relies on the focus-lock filter set in `render_buttons`. + let elapsed = top.created_at.elapsed(); + let stuck = stuck_reveal(elapsed); + let show_elapsed = top.show_elapsed || stuck; + let key = top.key; + // Logs once on show / once per shown content change, and resets the + // no-progress watchdog clock on real progress — a shown (description, step) + // change OR a hidden `progress_token` advance (see `log_overlay_state`). + log_overlay_state(top); + + // No-progress watchdog (A-1): once the topmost request has shown no + // progress for over two minutes, escalate the reassurance copy and fire a + // one-shot dev-error — almost always a leaked handle (C1) or an un-bounded + // operation (C2), i.e. a bug. No panic: a time-based assert would be flaky. + let watchdog = watchdog_tripped(top.last_progress_at); + if watchdog && !top.watchdog_logged { + top.watchdog_logged = true; + tracing::error!( + key, + "Blocking overlay has shown no progress for over 2 minutes — \ + likely a leaked handle or an un-bounded operation" + ); + // TODO: make the no-progress watchdog actionable (auto-attach + // an escape or enforce a frame-driven reconcile owner for button-less + // blocks) — pending product decision; conflicts with the no-built-in- + // cancel directive. + } + + let dark_mode = ctx.global_style().visuals.dark_mode; + let rect = ctx.content_rect(); + + // The dim + pointer sink + card render on Order::Foreground so + // they sit above Foreground popups (egui ComboBox, address autocomplete, + // SelectionDialog) that would otherwise float over a Middle-order block and + // stay clickable. The secret prompt is raised to match and rendered later + // (focus-raised), so it still wins above the overlay (R-1, TC-OVL-048). + let sink_layer = + egui::LayerId::new(egui::Order::Foreground, egui::Id::new(OVERLAY_DIM_SINK_ID)); + ctx.layer_painter(sink_layer) + .rect_filled(rect, 0.0, DashColors::modal_overlay()); + egui::Area::new(egui::Id::new(OVERLAY_DIM_SINK_ID)) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .show(ctx, |ui| { + ui.allocate_response(rect.size(), egui::Sense::click_and_drag()); + }); + + let card_layer = + egui::LayerId::new(egui::Order::Foreground, egui::Id::new(OVERLAY_CARD_ID)); + let mut clicked = None; + egui::Area::new(egui::Id::new(OVERLAY_CARD_ID)) + .order(egui::Order::Foreground) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + clicked = render_card( + ui, + top, + dark_mode, + elapsed, + show_elapsed, + stuck, + watchdog, + true, + secret_prompt_active, + ); + }); + + // Pin the card directly above the sink. egui auto-raises any interactable + // Area to the top of its Order on a pointer press (`area.rs` bring-to-front), + // so a backdrop press over the sink would otherwise float it above the card + // and bury the buttons beneath the click-absorbing sink — trapping the SPV + // escape. A sublayer is placed above its parent after that sort each frame, + // making the card-above-sink z-order hold by construction. + ctx.set_sublayer(sink_layer, card_layer); + + // The click does not lower the overlay — the owning screen drains its own + // ids via `OverlayHandle::take_actions`; the app loop only sweeps orphans. + if let Some(action_id) = clicked { + push_overlay_action(ctx, key, &action_id); + } + + if show_elapsed || watchdog { + ctx.request_repaint_after(Duration::from_secs(1)); + } + + set_overlay_state(ctx, stack); + } +} + +impl Component for ProgressOverlay { + type DomainType = String; + type Response = ProgressOverlayResponse; + + /// Render this instance's overlay card inline (no dim/sink — the full-window + /// block is the global [`render_global`](ProgressOverlay::render_global) + /// concern). Shares the [`render_card`] layout helper with the global path. + fn show(&mut self, ui: &mut egui::Ui) -> InnerResponse<Self::Response> { + let Some(state) = &mut self.state else { + return empty_overlay_response(ui); + }; + let dark_mode = ui.style().visuals.dark_mode; + let elapsed = state.created_at.elapsed(); + let stuck = stuck_reveal(elapsed); + let show_elapsed = state.show_elapsed || stuck; + let watchdog = watchdog_tripped(state.last_progress_at); + + // The instance path renders the card WITHOUT seizing global focus + // or installing the focus-lock filter (`trap_focus = false`). That trap + // belongs to the full-window global block; an inline, non-blocking widget + // must leave the host screen's Tab/arrow/Esc navigation intact. + let clicked = render_card( + ui, + state, + dark_mode, + elapsed, + show_elapsed, + stuck, + watchdog, + false, + false, + ); + if let Some(action_id) = &clicked { + self.last_action = Some(action_id.clone()); + } + let changed = clicked.is_some(); + + InnerResponse::new( + ProgressOverlayResponse { + action: clicked, + changed, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + + fn current_value(&self) -> Option<String> { + self.last_action.clone() + } +} + +/// Helper for the empty-state return in [`Component::show`]. +fn empty_overlay_response(ui: &mut egui::Ui) -> InnerResponse<ProgressOverlayResponse> { + InnerResponse::new( + ProgressOverlayResponse { + action: None, + changed: false, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) +} + +/// Whether the topmost request has been stuck long enough to reveal the honest +/// elapsed readout and the soft reassurance line (D-4). Takes `elapsed` as a +/// parameter (the clock seam) so the threshold logic is unit-testable without a +/// real wall-clock wait. +fn stuck_reveal(elapsed: Duration) -> bool { + elapsed >= STUCK_OVERLAY_THRESHOLD +} + +/// Whether the no-progress watchdog has tripped: over [`STUCK_OVERLAY_WATCHDOG_THRESHOLD`] +/// has elapsed since the last content change (A-1). Like [`stuck_reveal`], takes +/// the measured instant as a parameter so it is unit-testable. +fn watchdog_tripped(last_progress: Instant) -> bool { + last_progress.elapsed() >= STUCK_OVERLAY_WATCHDOG_THRESHOLD +} + +/// Whether a `(current, total)` step pair is meaningful enough to render. Hides +/// nonsense pairs (`0 of 0`, `4 of 3`, `0 of 5`) rather than painting them. +fn step_is_renderable(current: u32, total: u32) -> bool { + current >= 1 && total >= 1 && current <= total +} + +/// Log the overlay once on show and once per *visible* content change (NFR-5), +/// and reset the no-progress watchdog clock on any real progress — a change in the +/// shown `(description, step)` OR an advance of the hidden `progress_token` (A-1). +/// +/// The two signals are deliberately separated: the debug log fires only on a shown +/// content change (so a per-frame token advance never spams the log), while the +/// watchdog reset also honours the token (so a slow-but-advancing phase whose shown +/// copy is constant — e.g. SPV headers on a slow link — never trips a false stall). +fn log_overlay_state(state: &mut OverlayState) { + let content = (state.description.clone(), state.step); + if !state.logged { + state.logged = true; + state.logged_content = Some(content); + state.last_progress_token = state.progress_token; + debug!( + description = ?state.description, + step = ?state.step, + "Blocking progress overlay shown" + ); + return; + } + + let content_changed = state.logged_content.as_ref() != Some(&content); + let token_advanced = state.progress_token != state.last_progress_token; + + if content_changed || token_advanced { + // Real progress (shown copy changed, or the hidden token advanced): reset + // the no-progress watchdog clock (A-1) so a legitimately advancing flow + // never trips it. + state.last_progress_at = Instant::now(); + state.last_progress_token = state.progress_token; + } + + if content_changed { + // Only a *shown* change is logged, exactly once (NFR-5) — a per-frame token + // advance is a hidden liveness signal, not a user-visible update. + state.logged_content = Some(content); + debug!( + description = ?state.description, + step = ?state.step, + "Blocking progress overlay updated" + ); + } +} + +/// Render the centered card contents: spinner, optional step, optional +/// description, optional elapsed/reassurance, optional button row. Returns the +/// action id of a button clicked this frame, if any. Shared by the instance +/// [`Component::show`] and the global [`ProgressOverlay::render_global`] paths. +#[allow(clippy::too_many_arguments)] +fn render_card( + ui: &mut egui::Ui, + state: &mut OverlayState, + dark_mode: bool, + elapsed: Duration, + show_elapsed: bool, + stuck: bool, + watchdog: bool, + trap_focus: bool, + secret_prompt_active: bool, +) -> Option<String> { + let mut clicked = None; + egui::Frame::new() + .fill(ui.style().visuals.window_fill) + .inner_margin(egui::Margin::same(Spacing::MD as i8)) + .corner_radius(Shape::RADIUS_LG as f32) + .shadow(Shadow::elevated()) + .stroke(egui::Stroke::new( + Shape::BORDER_WIDTH, + DashColors::popup_border_glow(), + )) + .show(ui, |ui| { + // Clamp the card to the window so it — and its wrapped description — + // never run off-screen in a very narrow window (FR-6 AC-6.2). + let window_width = ui.ctx().content_rect().width(); + let max_width = CARD_MAX_WIDTH.min(window_width - 2.0 * Spacing::MD); + ui.set_min_width(CARD_MIN_WIDTH.min(max_width)); + ui.set_max_width(max_width.max(0.0)); + ui.vertical_centered(|ui| { + ui.add( + egui::Spinner::new() + .size(SPINNER_SIZE) + .color(DashColors::DASH_BLUE), + ); + + if let Some((current, total)) = state.step + && step_is_renderable(current, total) + { + ui.add_space(Spacing::SM); + ui.label( + egui::RichText::new(format!("Step {current} of {total}")) + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + } + + if let Some(description) = &state.description { + ui.add_space(Spacing::SM); + egui::ScrollArea::vertical() + .id_salt(state.key) + .max_height(DESCRIPTION_MAX_HEIGHT) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(description) + .color(DashColors::text_primary(dark_mode)), + ) + .wrap(), + ); + }); + } + + if show_elapsed { + ui.add_space(Spacing::XS); + let seconds = elapsed.as_secs(); + ui.label( + egui::RichText::new(format!("Elapsed: {seconds}s")) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // The 120 s watchdog escalation replaces (never stacks with) the + // soft 30 s reassurance line (A-1). + if watchdog { + ui.add_space(Spacing::XS); + ui.label( + egui::RichText::new(STUCK_WATCHDOG_REASSURANCE) + .color(DashColors::text_secondary(dark_mode)), + ); + } else if stuck { + ui.add_space(Spacing::XS); + ui.label( + egui::RichText::new(STUCK_REASSURANCE) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + if !state.buttons.is_empty() { + ui.add_space(Spacing::MD); + clicked = + render_buttons(ui, state, dark_mode, trap_focus, secret_prompt_active); + } + }); + }); + clicked +} + +/// Render the action button row. Layout mirrors `ConfirmationDialog`: a +/// `right_to_left` row so the **primary** action hugs the RIGHT edge and any +/// **secondary** buttons sit to its LEFT; a single button hugs the right edge. +/// Within each tier, buttons render in the order they were added. Returns the +/// clicked button's action id, if any. Clicks never lower the overlay. +/// +/// `trap_focus` is `true` only for the global full-window block: a button is +/// focused on raise and a focus-lock filter traps Tab/arrows/Esc on it so keyboard +/// navigation cannot escape to a widget beneath the block. The focused button is the +/// **designated keyboard escape** when the block opts into one, +/// otherwise the first button — but the focus is purely visual: keyboard activation +/// of the escape happens at frame start in [`ProgressOverlay::claim_input`], not via +/// this focused button. `secret_prompt_active` suppresses all focus management so a +/// passphrase modal rendered above the block keeps the keyboard. The +/// instance [`Component`] path passes `false` for both so an inline, +/// non-blocking widget never seizes the host screen's focus. +fn render_buttons( + ui: &mut egui::Ui, + state: &mut OverlayState, + dark_mode: bool, + trap_focus: bool, + secret_prompt_active: bool, +) -> Option<String> { + let escape_action = state.keyboard_escape_action.as_deref(); + // Re-request focus every frame for an opt-in escape so a click/Tab can never + // leave it un-focused; a non-escape block requests once and relies on the lock. + let want_focus = trap_focus && (!state.focus_requested || escape_action.is_some()); + let mut clicked = None; + let mut first_id = None; + let mut escape_id = None; + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Primaries first → rightmost (accent); secondaries after → to their left. + let ordered = state + .buttons + .iter() + .filter(|b| b.style == ButtonStyle::Primary) + .chain( + state + .buttons + .iter() + .filter(|b| b.style == ButtonStyle::Secondary), + ); + for button in ordered { + let response = match button.style { + ButtonStyle::Primary => ComponentStyles::add_primary_button(ui, &button.label), + ButtonStyle::Secondary => { + ComponentStyles::add_secondary_button(ui, &button.label, dark_mode) + } + }; + if first_id.is_none() { + first_id = Some(response.id); + } + if escape_action == Some(button.action_id.as_str()) { + escape_id = Some(response.id); + } + if response.clicked() { + clicked = Some(button.action_id.clone()); + } + } + }); + + // Pin focus to the designated escape if present, else the first button — but + // never while a secret prompt is up: the prompt is rendered above the overlay and + // owns the keyboard, and keyboard activation of the escape no longer + // needs focus (it fires at frame start in `claim_input`). + let focus_target = escape_id.or(first_id); + if trap_focus + && !secret_prompt_active + && let Some(id) = focus_target + { + if want_focus { + ui.memory_mut(|m| m.request_focus(id)); + } + // Trap keyboard focus on the block. egui resolves Tab/arrow navigation in + // `begin_pass` (before this code runs), so filtering those key events here + // is too late — only a focus lock filter on the focused widget keeps + // navigation from escaping to a widget beneath. No-op until the button has + // held focus for a frame, which the focus request above arranges. + ui.memory_mut(|m| { + m.set_focus_lock_filter( + id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ) + }); + state.focus_requested = true; + } + clicked +} + +/// Reads the overlay stack from egui context data. +fn get_overlay_state(ctx: &egui::Context) -> Vec<OverlayState> { + ctx.data(|d| d.get_temp::<Vec<OverlayState>>(egui::Id::new(OVERLAY_STATE_ID))) + .unwrap_or_default() +} + +/// Writes the overlay stack to egui context data. Removes the slot when empty. +fn set_overlay_state(ctx: &egui::Context, stack: Vec<OverlayState>) { + if stack.is_empty() { + ctx.data_mut(|d| d.remove::<Vec<OverlayState>>(egui::Id::new(OVERLAY_STATE_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(OVERLAY_STATE_ID), stack)); + } +} + +/// A pending button click, scoped to the overlay entry that owns it (A-3). The +/// `key` lets the owning [`OverlayHandle`] drain only its own ids while the +/// orphan-sweeper reclaims ids whose owner is gone. +#[derive(Clone)] +struct OverlayAction { + key: u64, + action_id: String, +} + +/// Reads the pending overlay-action queue (FIFO) from egui context data. +fn get_overlay_actions(ctx: &egui::Context) -> Vec<OverlayAction> { + ctx.data(|d| d.get_temp::<Vec<OverlayAction>>(egui::Id::new(OVERLAY_ACTIONS_ID))) + .unwrap_or_default() +} + +/// Writes the pending overlay-action queue. Removes the slot when empty. +fn set_overlay_actions(ctx: &egui::Context, actions: Vec<OverlayAction>) { + if actions.is_empty() { + ctx.data_mut(|d| d.remove::<Vec<OverlayAction>>(egui::Id::new(OVERLAY_ACTIONS_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(OVERLAY_ACTIONS_ID), actions)); + } +} + +/// Appends an action id (scoped to its owning entry's `key`) to the queue. +/// Called from `render_global` on a button click. +fn push_overlay_action(ctx: &egui::Context, key: u64, action_id: &str) { + let mut queue = get_overlay_actions(ctx); + queue.push(OverlayAction { + key, + action_id: action_id.to_string(), + }); + set_overlay_actions(ctx, queue); +} + +/// Lifecycle helpers for an `Option<OverlayHandle>` screen field, mirroring +/// [`OptionBannerExt`](super::message_banner::OptionBannerExt). A dispatching +/// screen stores `op_overlay: Option<OverlayHandle>`, raises it when returning +/// the `BackendTask`, and lowers it in `display_task_result` via +/// `take_and_clear()` before AppState shows the result banner. +pub trait OptionOverlayExt { + /// Take the handle (leaving `None`) and dismiss its overlay entry. + fn take_and_clear(&mut self); + + /// Clear any existing overlay, raise a new one, and store the handle. Named + /// to match [`OptionBannerExt::raise`](super::message_banner::OptionBannerExt::raise): + /// an inherent `Option::replace(value)` already exists and wins method + /// resolution, so naming this (or the banner analogue) `replace` would shadow + /// it and make every `slot.replace(ctx, desc, config)` call fail to compile + /// (arity mismatch against the inherent one-arg method). `OptionBannerExt` + /// keeps a `replace` method too, but only as a backward-compatible alias that + /// must be called fully qualified — new code should use `raise`, same as here. + fn raise(&mut self, ctx: &egui::Context, description: impl fmt::Display, config: OverlayConfig); +} + +impl OptionOverlayExt for Option<OverlayHandle> { + fn take_and_clear(&mut self) { + if let Some(handle) = self.take() { + handle.clear(); + } + } + + fn raise( + &mut self, + ctx: &egui::Context, + description: impl fmt::Display, + config: OverlayConfig, + ) { + self.take_and_clear(); + *self = Some(ProgressOverlay::set_global(ctx, description, config)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives one render pass over a bare context so `ctx.data`-level effects + /// (log-once, focus) can be inspected without a kittest harness. + fn render_once(ctx: &egui::Context) { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx(); + ProgressOverlay::render_global(ctx, false); + }); + } + + #[test] + fn step_is_renderable_accepts_valid_and_rejects_nonsense() { + assert!(step_is_renderable(1, 1)); + assert!(step_is_renderable(3, 5)); + assert!(step_is_renderable(5, 5)); + assert!(!step_is_renderable(0, 0)); + assert!(!step_is_renderable(4, 3)); + assert!(!step_is_renderable(0, 5)); + } + + #[test] + fn stuck_reveal_triggers_only_past_threshold() { + assert!(!stuck_reveal(Duration::from_secs(0))); + assert!(!stuck_reveal( + STUCK_OVERLAY_THRESHOLD - Duration::from_millis(1) + )); + assert!(stuck_reveal(STUCK_OVERLAY_THRESHOLD)); + assert!(stuck_reveal(Duration::from_secs(60))); + } + + #[test] + fn show_pushes_entry_and_has_global_reports_it() { + let ctx = egui::Context::default(); + assert!(!ProgressOverlay::has_global(&ctx)); + let handle = ProgressOverlay::set_global(&ctx, "Loading.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + assert!(handle.is_active()); + assert!(handle.elapsed().is_some()); + } + + #[test] + fn config_with_description_wins_over_argument() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global( + &ctx, + "", + OverlayConfig::new().with_description("From config."), + ); + let stack = get_overlay_state(&ctx); + let entry = stack.iter().find(|s| s.key == handle.key).unwrap(); + assert_eq!(entry.description.as_deref(), Some("From config.")); + } + + #[test] + fn spinner_only_has_no_text() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); + let stack = get_overlay_state(&ctx); + let entry = stack.iter().find(|s| s.key == handle.key).unwrap(); + assert!(entry.description.is_none()); + assert!(entry.step.is_none()); + assert!(entry.buttons.is_empty()); + } + + #[test] + fn stale_handle_updates_are_none_and_do_not_panic() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global(&ctx, "Gone soon.", OverlayConfig::default()); + handle.clone().clear(); + assert!(handle.set_description("After clear").is_none()); + assert!(handle.set_step(1, 3).is_none()); + assert!(handle.clear_step().is_none()); + assert!( + handle + .with_action("Run in background", "overlay.bg") + .is_none() + ); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn double_clear_is_a_noop() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global(&ctx, "Once.", OverlayConfig::default()); + handle.clone().clear(); + handle.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn stack_renders_topmost_and_each_handle_clears_only_itself() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::set_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "Operation B.", OverlayConfig::default()); + assert!(a.is_active()); + assert!(b.is_active()); + + let stack = get_overlay_state(&ctx); + assert_eq!( + stack.last().unwrap().description.as_deref(), + Some("Operation B.") + ); + + b.clear(); + assert!(a.is_active()); + assert!(ProgressOverlay::has_global(&ctx)); + let stack = get_overlay_state(&ctx); + assert_eq!( + stack.last().unwrap().description.as_deref(), + Some("Operation A.") + ); + + a.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + /// A-3 — a handle drains its **own** clicks FIFO then empties, and the + /// orphan-sweeper sees nothing while the owner is still live. + #[test] + fn handle_take_actions_drains_own_fifo_then_empties() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); + assert!(handle.take_actions().is_empty()); + + push_overlay_action(&ctx, handle.key, "first"); + push_overlay_action(&ctx, handle.key, "second"); + + // The owner is live, so the orphan-sweeper must not touch its ids. + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); + + assert_eq!(handle.take_actions(), vec!["first", "second"]); + assert!(handle.take_actions().is_empty()); + } + + /// A-3 — two stacked overlays: a click keyed to B is drained only by B; A + /// never steals it (no cross-owner theft). + #[test] + fn keyed_actions_isolate_owners() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::set_global(&ctx, "A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "B.", OverlayConfig::default()); + push_overlay_action(&ctx, b.key, "b:action"); + + assert!(a.take_actions().is_empty(), "A must not see B's click"); + assert_eq!(b.take_actions(), vec!["b:action"]); + assert!(b.take_actions().is_empty()); + } + + /// A-3 — a handle dropped without draining leaves its id reachable only via + /// `sweep_orphan_actions`; `clear()` instead leaves nothing for the sweeper. + #[test] + fn orphan_sweeper_reclaims_only_dead_owner_ids() { + let ctx = egui::Context::default(); + + // Owner clears normally → its pending id is purged, sweeper finds nothing. + let cleared = ProgressOverlay::set_global_spinner_only(&ctx); + push_overlay_action(&ctx, cleared.key, "cleared:id"); + cleared.clear(); + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); + + // Owner dropped without draining → its id is orphaned and swept once. + let dropped = ProgressOverlay::set_global_spinner_only(&ctx); + let dropped_key = dropped.key; + push_overlay_action(&ctx, dropped_key, "dropped:id"); + ProgressOverlay::clear_all_global(&ctx); // entry gone, id keyed to a dead owner + // Re-enqueue against the now-dead key to model the drop-without-drain race. + push_overlay_action(&ctx, dropped_key, "dropped:id"); + assert_eq!( + ProgressOverlay::sweep_orphan_actions(&ctx), + vec!["dropped:id"] + ); + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); + } + + /// `clear_all_global` (network switch) drains the action queue too, + /// so a click queued just before the switch cannot survive into the new + /// context and be mis-dispatched. + #[test] + fn clear_all_global_clears_action_queue() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); + push_overlay_action(&ctx, handle.key, "shielded:build:cancel"); + + ProgressOverlay::clear_all_global(&ctx); + + assert!(!ProgressOverlay::has_global(&ctx), "state stack is cleared"); + assert!( + ProgressOverlay::sweep_orphan_actions(&ctx).is_empty(), + "the action queue must be cleared on a network switch" + ); + } + + /// A pressed key-down `Event::Key` with no modifiers, for input tests. + fn key_down(key: egui::Key) -> egui::Event { + egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + /// While a block is up, `claim_input` strips typed text and the + /// navigation/confirm keys (Tab/Enter/Escape/Space/arrows) so nothing beneath + /// the block observes them. + #[test] + fn claim_input_strips_text_and_nav_keys_when_block_active() { + let ctx = egui::Context::default(); + ProgressOverlay::set_global_spinner_only(&ctx); + + let leaked = std::cell::Cell::new(true); + let raw = egui::RawInput { + events: vec![ + egui::Event::Text("hello".to_string()), + key_down(egui::Key::Tab), + key_down(egui::Key::Enter), + key_down(egui::Key::Escape), + key_down(egui::Key::Space), + key_down(egui::Key::ArrowDown), + ], + ..Default::default() + }; + let _ = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx(); + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + leaked.set(i.events.iter().any(|e| { + matches!( + e, + egui::Event::Text(_) + | egui::Event::Key { + key: egui::Key::Tab + | egui::Key::Enter + | egui::Key::Escape + | egui::Key::Space + | egui::Key::ArrowDown, + pressed: true, + .. + } + ) + })); + }); + }); + assert!( + !leaked.get(), + "claim_input must strip all text + nav/confirm key-down events while a block is up" + ); + } + + /// `with_keyboard_escape` records the designated escape + /// action id on both the config (via `set_global`) and a live handle. + #[test] + fn with_keyboard_escape_records_action_via_config_and_handle() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global( + &ctx, + "Syncing.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + let read = |key: u64| { + get_overlay_state(&ctx) + .into_iter() + .find(|s| s.key == key) + .and_then(|s| s.keyboard_escape_action) + }; + assert_eq!(read(handle.key).as_deref(), Some("spv:escape")); + + // The handle-side mutator designates the escape on a block raised without it. + let plain = ProgressOverlay::set_global( + &ctx, + "Working.", + OverlayConfig::new().with_secondary_action("Continue", "later"), + ); + assert!(read(plain.key).is_none()); + assert!(plain.with_keyboard_escape("later").is_some()); + assert_eq!(read(plain.key).as_deref(), Some("later")); + } + + /// A designated keyboard escape is activated at FRAME START: + /// `claim_input` enqueues its action (focus-independent — no render has run, so + /// no button is focused) and STRIPS Enter/Space so the key can never reach the + /// focused button or a focus-independent handler beneath. The activation does not + /// depend on, or wait for, the escape button holding focus. + #[test] + fn claim_input_escape_block_enqueues_action_and_strips_keys() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global( + &ctx, + "Syncing.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + let leaked = std::cell::Cell::new(true); + let raw = egui::RawInput { + events: vec![key_down(egui::Key::Enter), key_down(egui::Key::Space)], + ..Default::default() + }; + let _ = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx(); + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + leaked.set(i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, + pressed: true, + .. + } + ) + })); + }); + }); + assert!( + !leaked.get(), + "Enter/Space are stripped at frame start — never reach the button or a widget beneath" + ); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "the escape action is enqueued directly at frame start, focus-independent" + ); + } + + /// `claim_input` is a no-op when no overlay is active — it must not eat input + /// from the rest of the app. + #[test] + fn claim_input_is_noop_when_idle() { + let ctx = egui::Context::default(); + let kept = std::cell::Cell::new(false); + let raw = egui::RawInput { + events: vec![egui::Event::Text("hi".to_string())], + ..Default::default() + }; + let _ = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx(); + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + kept.set(i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))); + }); + }); + assert!( + kept.get(), + "claim_input must not strip input when no block is active" + ); + } + + #[test] + fn render_logs_once_then_marks_logged() { + let ctx = egui::Context::default(); + ProgressOverlay::set_global(&ctx, "Working.", OverlayConfig::default()); + render_once(&ctx); + let stack = get_overlay_state(&ctx); + let entry = stack.last().unwrap(); + assert!(entry.logged); + assert_eq!( + entry.logged_content, + Some((Some("Working.".to_string()), None)) + ); + // A second render with no content change keeps the marker stable. + render_once(&ctx); + let stack = get_overlay_state(&ctx); + assert!(stack.last().unwrap().logged); + } + + #[test] + fn elapsed_counts_up_monotonically() { + let ctx = egui::Context::default(); + let handle = + ProgressOverlay::set_global(&ctx, "Slow.", OverlayConfig::new().with_elapsed()); + let first = handle.elapsed().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + let second = handle.elapsed().unwrap(); + assert!(second >= first, "Instant-based elapsed never counts down"); + } + + #[test] + fn option_overlay_ext_raise_swaps_entry() { + let ctx = egui::Context::default(); + let mut slot: Option<OverlayHandle> = None; + slot.raise(&ctx, "First.", OverlayConfig::default()); + let first_key = slot.as_ref().unwrap().key; + slot.raise(&ctx, "Second.", OverlayConfig::default()); + let second_key = slot.as_ref().unwrap().key; + assert_ne!(first_key, second_key); + // Only the latest entry survives the swap. + let stack = get_overlay_state(&ctx); + assert_eq!(stack.len(), 1); + assert_eq!(stack.last().unwrap().key, second_key); + slot.take_and_clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + // ── Component (instance) path ─────────────────────────────────────────── + + #[test] + fn component_response_accessors_are_honest() { + // No click: not changed, valid, no error, no value. + let idle = ProgressOverlayResponse { + action: None, + changed: false, + }; + assert!(!idle.has_changed()); + assert!(idle.is_valid()); + assert!(idle.error_message().is_none()); + assert!(idle.changed_value().is_none()); + + // A click surfaces the button's action id as the changed value. + let clicked = ProgressOverlayResponse { + action: Some("overlay.bg".to_string()), + changed: true, + }; + assert!(clicked.has_changed()); + assert!(clicked.is_valid()); + assert_eq!(clicked.changed_value().as_deref(), Some("overlay.bg")); + } + + #[test] + fn component_show_renders_instance_and_reports_no_click() { + let ctx = egui::Context::default(); + let mut overlay = ProgressOverlay::new() + .with_description("Instance overlay.") + .with_step(2, 5) + .with_action("Run in background", "overlay.bg"); + // No interaction has happened yet. + assert!(overlay.current_value().is_none()); + + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::CentralPanel::default().show(ui, |ui| { + let response = overlay.show(ui).inner; + // A frame with no click is unchanged, valid, and value-free. + assert!(!response.has_changed()); + assert!(response.is_valid()); + assert!(response.changed_value().is_none()); + }); + }); + + // current_value still None — clicks are surfaced via the response and + // recorded on the instance only when they occur. + assert!(overlay.current_value().is_none()); + } + + /// A-1 — the no-progress watchdog trips only past its threshold (clock seam). + #[test] + fn watchdog_tripped_only_past_threshold() { + let now = Instant::now(); + assert!(!watchdog_tripped(now)); + if let Some(old) = + now.checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(1)) + { + assert!(watchdog_tripped(old)); + } + if let Some(recent) = + now.checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD - Duration::from_secs(1)) + { + assert!(!watchdog_tripped(recent)); + } + } + + /// A-1 — a real content change resets the no-progress clock (so a progressing + /// flow never trips the watchdog); no change leaves it untouched. + #[test] + fn log_overlay_state_bumps_progress_clock_on_content_change() { + let mut state = OverlayState::new(1, Some("a".to_string()), &OverlayConfig::default()); + state.logged = true; + state.logged_content = Some((Some("a".to_string()), None)); + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + assert!(watchdog_tripped(state.last_progress_at)); + + // Content changes → log_overlay_state bumps the clock. + state.description = Some("b".to_string()); + log_overlay_state(&mut state); + assert!( + !watchdog_tripped(state.last_progress_at), + "a content change must reset the no-progress clock" + ); + + // No change → the clock is left untouched. + let before = state.last_progress_at; + log_overlay_state(&mut state); + assert_eq!( + state.last_progress_at, before, + "no content change must not touch the clock" + ); + } + + /// A-1 (Item B) — an advancing hidden `progress_token` resets the no-progress + /// clock even when the shown `(description, step)` is unchanged (a slow-but- + /// advancing phase), but it must NOT emit a content-update log; an unchanged + /// token (a genuine stall) leaves the clock alone so the watchdog still trips. + #[test] + fn log_overlay_state_token_advance_resets_clock_without_content_change() { + let mut state = OverlayState::new( + 1, + Some("Syncing with the Dash network.".to_string()), + &OverlayConfig::new().with_progress_token(10), + ); + // Prime as already-shown at token 10 (the `!logged` path records the token). + log_overlay_state(&mut state); + assert_eq!(state.last_progress_token, Some(10)); + + // Age the clock past the watchdog with NO visible content change. + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + assert!(watchdog_tripped(state.last_progress_at)); + + // Token advances (height climbed) → clock resets, watchdog cleared — with + // the shown copy untouched. + let logged_before = state.logged_content.clone(); + state.progress_token = Some(20); + log_overlay_state(&mut state); + assert!( + !watchdog_tripped(state.last_progress_at), + "an advancing hidden token must reset the no-progress clock" + ); + assert_eq!( + state.logged_content, logged_before, + "a hidden token advance is NOT a shown content change (NFR-5)" + ); + + // Same token again (a true stall) → clock NOT reset, watchdog stays tripped. + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + let before = state.last_progress_at; + log_overlay_state(&mut state); + assert_eq!( + state.last_progress_at, before, + "an unchanged token must not reset the clock" + ); + assert!(watchdog_tripped(state.last_progress_at)); + } + + /// A-1 — the watchdog dev-error flag flips once on render and stays set, so the + /// error logs exactly once rather than every frame (NFR-5). + #[test] + fn watchdog_flag_flips_once_via_render() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); + { + let mut stack = get_overlay_state(&ctx); + let top = stack.iter_mut().find(|s| s.key == handle.key).unwrap(); + top.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + set_overlay_state(&ctx, stack); + } + let logged = |ctx: &egui::Context| { + get_overlay_state(ctx) + .iter() + .find(|s| s.key == handle.key) + .map(|s| s.watchdog_logged) + .unwrap() + }; + render_once(&ctx); + assert!(logged(&ctx), "the watchdog flag flips on render"); + render_once(&ctx); + assert!(logged(&ctx), "and stays set across frames"); + } + + /// The instance `clear()` makes the empty-response path reachable via + /// the public API: after clear, `show()` renders nothing and reports no value. + #[test] + fn instance_clear_reaches_empty_response() { + let ctx = egui::Context::default(); + let mut overlay = ProgressOverlay::new().with_description("Working."); + overlay.clear(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::CentralPanel::default().show(ui, |ui| { + let response = overlay.show(ui).inner; + assert!(!response.has_changed()); + assert!(response.changed_value().is_none()); + }); + }); + assert!(overlay.current_value().is_none()); + } +} diff --git a/src/ui/components/secret_prompt_host.rs b/src/ui/components/secret_prompt_host.rs new file mode 100644 index 000000000..000eb977e --- /dev/null +++ b/src/ui/components/secret_prompt_host.rs @@ -0,0 +1,311 @@ +//! The egui implementation of the just-in-time secret prompt seam. +//! +//! [`EguiSecretPromptHost`] is the GUI's [`SecretPrompt`]. A backend operation +//! that needs a passphrase calls `request()` from a tokio task; the host +//! enqueues the request onto an mpsc channel, repaints the egui context (so +//! the modal surfaces even when the UI was idle), and awaits a one-shot reply. +//! +//! [`AppState`](crate::app::AppState) owns the receiving end: each frame it +//! drains one request into an [`ActivePrompt`], renders the shared +//! [`passphrase_modal`], and answers the one-shot on submit or cancel. Exactly +//! one prompt is active at a time; further requests wait in the channel (FIFO), +//! so two concurrent operations never stack modals. +//! +//! M-DONT-LEAK-TYPES: this host lives in the UI layer but speaks only the +//! `secret_prompt` seam — no `key_wallet`/`WalletId`/`SecretAccess` internals +//! cross into egui. The lone secret type on the wire is `SecretString`, the +//! `platform_wallet_storage` zeroizing wrapper the project already depends on. + +use async_trait::async_trait; +use platform_wallet_storage::secrets::SecretString; +use tokio::sync::{mpsc, oneshot}; + +use crate::ui::components::passphrase_modal::{ + KEEP_UNLOCKED_LABEL, PassphraseModalConfig, PassphraseModalOutcome, passphrase_modal, +}; +use crate::wallet_backend::secret_prompt::{ + RememberPolicy, SecretPrompt, SecretPromptCancelled, SecretPromptReply, SecretPromptRequest, + SecretPromptRetry, SecretScope, +}; + +/// A request plus its reply channel, carried from the host to `AppState`. +type ReplySender = oneshot::Sender<Result<SecretPromptReply, SecretPromptCancelled>>; + +/// One queued prompt: the request and the one-shot the host is awaiting. +pub struct QueuedPrompt { + request: SecretPromptRequest, + reply: ReplySender, +} + +/// The GUI [`SecretPrompt`]. Clone is cheap (channel sender + egui context +/// handle are both `Arc`-backed), so it can be handed to every per-network +/// `SecretAccess`. +#[derive(Clone)] +pub struct EguiSecretPromptHost { + queue: mpsc::UnboundedSender<QueuedPrompt>, + egui_ctx: egui::Context, +} + +impl EguiSecretPromptHost { + /// Build a host paired with the receiver `AppState` drains each frame. + pub fn new(egui_ctx: egui::Context) -> (Self, mpsc::UnboundedReceiver<QueuedPrompt>) { + let (queue, receiver) = mpsc::unbounded_channel(); + (Self { queue, egui_ctx }, receiver) + } +} + +#[async_trait] +impl SecretPrompt for EguiSecretPromptHost { + async fn request( + &self, + request: SecretPromptRequest, + ) -> Result<SecretPromptReply, SecretPromptCancelled> { + let (reply, reply_rx) = oneshot::channel(); + // If the UI side is gone (app shutting down), there is no one to ask: + // treat it as a cancel rather than hanging. + if self.queue.send(QueuedPrompt { request, reply }).is_err() { + return Err(SecretPromptCancelled); + } + // Surface the modal immediately even if egui was idle. + self.egui_ctx.request_repaint(); + // A dropped reply sender (modal dismissed, or AppState torn down) + // resolves as a clean cancel. + match reply_rx.await { + Ok(result) => result, + Err(_) => Err(SecretPromptCancelled), + } + } +} + +/// The prompt `AppState` is currently rendering, holding the field state. +/// +/// Drains one [`QueuedPrompt`] off the host channel and owns only the domain +/// state — the remember checkbox and the reply channel — until the user submits +/// or cancels. The [`PasswordInput`] and focus-tracking state are managed +/// internally by [`passphrase_modal`] in egui's data cache. On Submit or +/// Cancel the prompt answers the one-shot and is dropped. +pub struct ActivePrompt { + request: SecretPromptRequest, + reply: Option<ReplySender>, + remember: bool, +} + +impl ActivePrompt { + /// Adopt a freshly-drained request as the active prompt. + pub fn new(queued: QueuedPrompt) -> Self { + Self { + request: queued.request, + reply: Some(queued.reply), + remember: false, + } + } + + /// Build a stub active prompt for tests (RQ-1): no real secret, a reply + /// channel whose receiver is dropped immediately. Lets a test put + /// `AppState::active_secret_prompt` into the `Some` state to exercise the + /// overlay input-claim gate. Compiled only under the `testing` feature. + #[cfg(feature = "testing")] + pub fn test_stub() -> Self { + let (reply, _rx) = oneshot::channel(); + Self { + request: SecretPromptRequest::new( + crate::wallet_backend::secret_prompt::SecretScope::SingleKey { + address: "test".to_string(), + }, + "Test prompt", + ), + reply: Some(reply), + remember: false, + } + } + + /// Render the modal for this frame. Returns `true` when the prompt has + /// resolved (the caller should drop it and drain the next request). + pub fn show(&mut self, ctx: &egui::Context) -> bool { + let retry_error = self + .request + .retry_reason + .map(|SecretPromptRetry::WrongPassphrase| "That passphrase is not correct. Try again."); + + // Identity-key prompts say "key", not "wallet" (Diziet D-2). + let remember_label = match self.request.scope { + SecretScope::IdentityKey { .. } => "Keep this key unlocked until I close the app.", + _ => KEEP_UNLOCKED_LABEL, + }; + + let config = PassphraseModalConfig { + window_title: "Unlock to continue", + body: &self.request.display_label, + hint: self.request.hint.as_deref(), + error: retry_error, + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + remember_label: Some(remember_label), + }; + + let mut remember = self.remember; + let outcome = passphrase_modal(ctx, &config, |ui| { + ui.checkbox( + &mut remember, + config.remember_label.unwrap_or(KEEP_UNLOCKED_LABEL), + ); + }); + self.remember = remember; + + match outcome { + PassphraseModalOutcome::Pending => false, + PassphraseModalOutcome::Submit(text) => { + self.submit(&text); + true + } + PassphraseModalOutcome::Cancel => { + self.cancel(); + true + } + } + } + + /// Send the typed reply. + fn submit(&mut self, text: &str) { + let passphrase = SecretString::new(text); + let remember = if self.remember { + RememberPolicy::UntilAppClose + } else { + RememberPolicy::None + }; + if let Some(reply) = self.reply.take() { + let _ = reply.send(Ok(SecretPromptReply::new(passphrase, remember))); + } + } + + /// Answer the one-shot with a cancel. + fn cancel(&mut self) { + if let Some(reply) = self.reply.take() { + let _ = reply.send(Err(SecretPromptCancelled)); + } + } +} + +impl Drop for ActivePrompt { + fn drop(&mut self) { + // If the prompt is dropped without an explicit decision (e.g. app + // teardown), answer the awaiting `with_secret` with a clean cancel + // rather than leaving it parked forever. Dropping the sender alone + // would also resolve as cancel, but sending is explicit. + if let Some(reply) = self.reply.take() { + let _ = reply.send(Err(SecretPromptCancelled)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::secret_prompt::{SecretPrompt, SecretPromptRequest, SecretScope}; + + fn hd_scope() -> SecretScope { + SecretScope::HdSeed { + seed_hash: [0x33; 32], + } + } + + /// The host enqueues a request and resolves with the reply the drain + /// delivers — the UI↔async round-trip without a real egui frame. + #[tokio::test] + async fn request_round_trips_through_the_channel() { + let (host, mut rx) = EguiSecretPromptHost::new(egui::Context::default()); + + // Backend side: await the prompt. + let handle = tokio::spawn(async move { + host.request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + }); + + // UI side: drain the request and answer the one-shot as the modal + // would on submit. + let queued = rx.recv().await.expect("request enqueued"); + assert_eq!(queued.request.display_label, "My Wallet"); + queued + .reply + .send(Ok(SecretPromptReply::new( + SecretString::new("hunter2"), + RememberPolicy::UntilAppClose, + ))) + .expect("send reply"); + + let reply = handle.await.expect("join").expect("not cancelled"); + assert_eq!(reply.passphrase.expose_secret(), "hunter2"); + assert_eq!(reply.remember, RememberPolicy::UntilAppClose); + } + + /// Dropping the drained reply sender (modal dismissed / host torn down) + /// resolves `request()` as a clean cancel. + #[tokio::test] + async fn dropping_the_reply_sender_cancels() { + let (host, mut rx) = EguiSecretPromptHost::new(egui::Context::default()); + let handle = tokio::spawn(async move { + host.request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + }); + + let queued = rx.recv().await.expect("request enqueued"); + drop(queued); // user dismissed — sender dropped without a reply. + + let err = handle.await.expect("join").expect_err("cancelled"); + assert_eq!(err.to_string(), "the passphrase prompt was cancelled"); + } + + /// If the UI receiver is gone (app shutting down), `request()` does not + /// hang — it cancels immediately. + #[tokio::test] + async fn request_cancels_when_ui_receiver_dropped() { + let (host, rx) = EguiSecretPromptHost::new(egui::Context::default()); + drop(rx); + let err = host + .request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + .expect_err("no UI to ask"); + assert_eq!(err.to_string(), "the passphrase prompt was cancelled"); + } + + /// `ActivePrompt::submit` maps the unticked checkbox to `None` and a + /// ticked one to `UntilAppClose` — the remember-policy mapping. + #[tokio::test] + async fn active_prompt_submit_maps_remember_policy() { + // Unticked → None. + let (tx, rx) = oneshot::channel(); + let mut prompt = ActivePrompt::new(QueuedPrompt { + request: SecretPromptRequest::new(hd_scope(), "My Wallet"), + reply: tx, + }); + prompt.remember = false; + prompt.submit("pw"); + let reply = rx.await.expect("reply").expect("not cancelled"); + assert_eq!(reply.remember, RememberPolicy::None); + assert_eq!(reply.passphrase.expose_secret(), "pw"); + + // Ticked → UntilAppClose. + let (tx, rx) = oneshot::channel(); + let mut prompt = ActivePrompt::new(QueuedPrompt { + request: SecretPromptRequest::new(hd_scope(), "My Wallet"), + reply: tx, + }); + prompt.remember = true; + prompt.submit("pw"); + let reply = rx.await.expect("reply").expect("not cancelled"); + assert_eq!(reply.remember, RememberPolicy::UntilAppClose); + } + + /// `ActivePrompt::cancel` answers the one-shot with a cancel. + #[tokio::test] + async fn active_prompt_cancel_resolves_cancelled() { + let (tx, rx) = oneshot::channel(); + let mut prompt = ActivePrompt::new(QueuedPrompt { + request: SecretPromptRequest::new(hd_scope(), "My Wallet"), + reply: tx, + }); + prompt.cancel(); + let err = rx.await.expect("reply").expect_err("cancelled"); + assert_eq!(err.to_string(), "the passphrase prompt was cancelled"); + } +} diff --git a/src/ui/components/selection_dialog.rs b/src/ui/components/selection_dialog.rs index 5c8daa62b..bc834606a 100644 --- a/src/ui/components/selection_dialog.rs +++ b/src/ui/components/selection_dialog.rs @@ -1,8 +1,11 @@ use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::modal_chrome::{ModalChromeConfig, modal_chrome}; use crate::ui::helpers::clicked_outside_window; use crate::ui::theme::{ComponentStyles, DashColors}; use egui::{InnerResponse, Ui, WidgetText}; +pub use crate::ui::components::modal_chrome::NOTHING; + /// Result of a selection dialog interaction #[derive(Debug, Clone, PartialEq)] pub enum SelectionStatus { @@ -12,8 +15,6 @@ pub enum SelectionStatus { Canceled, } -pub const NOTHING: Option<&str> = None; - /// Response struct for the SelectionDialog component following the Component trait pattern #[derive(Debug, Clone)] pub struct SelectionDialogComponentResponse { @@ -167,48 +168,29 @@ impl SelectionDialog { impl SelectionDialog { /// Show the dialog and return the user's response fn show_dialog(&mut self, ui: &mut Ui) -> InnerResponse<Option<SelectionStatus>> { - let mut is_open = self.is_open; - - if !is_open { + if !self.is_open { return InnerResponse::new( None, ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), ); } - // Draw dark overlay behind the dialog - let screen_rect = ui.ctx().content_rect(); - let painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Middle, - egui::Id::new("selection_dialog_overlay"), - )); - painter.rect_filled(screen_rect, 0.0, DashColors::modal_overlay()); - let mut final_response = None; let mut combo_popup_id: Option<egui::Id> = None; - let window_response = egui::Window::new(self.title.clone()) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .order(egui::Order::Foreground) - .open(&mut is_open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(16), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: DashColors::popup_shadow(), - }, - fill: ui.style().visuals.window_fill, - stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), - }) - .show(ui.ctx(), |ui| { + let chrome = modal_chrome( + ui.ctx(), + ModalChromeConfig { + title: self.title.clone(), + overlay_id: egui::Id::new("selection_dialog_overlay"), + overlay_order: egui::Order::Middle, + window_order: egui::Order::Foreground, + resizable: false, + inner_margin: 16, + }, + |ui| { ui.set_min_width(300.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Message ui.add_space(10.0); @@ -259,7 +241,7 @@ impl SelectionDialog { // Cancel button if let Some(cancel_text) = &self.cancel_text { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button( ui, cancel_text.clone(), @@ -274,10 +256,11 @@ impl SelectionDialog { } }); }); - }); + }, + ); // Handle window closed via X button - if !is_open && final_response.is_none() { + if chrome.closed_via_x && final_response.is_none() { final_response = Some(SelectionStatus::Canceled); } @@ -297,10 +280,10 @@ impl SelectionDialog { } // Handle click outside window (skip if ComboBox dropdown is open) - if let Some(ref wr) = window_response + if let Some(ref wr) = chrome.window_response && final_response.is_none() && !combo_open - && clicked_outside_window(ui.ctx(), wr.response.rect) + && clicked_outside_window(ui.ctx(), wr.rect) { final_response = Some(SelectionStatus::Canceled); } @@ -310,11 +293,11 @@ impl SelectionDialog { self.status = final_response.clone(); self.is_open = false; } else { - self.is_open = is_open; + self.is_open = !chrome.closed_via_x; } - if let Some(window_response) = window_response { - InnerResponse::new(final_response, window_response.response) + if let Some(window_response) = chrome.window_response { + InnerResponse::new(final_response, window_response) } else { InnerResponse::new( final_response, diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index 137e2625a..59a2f461b 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -4,46 +4,23 @@ use crate::{ context::AppContext, ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}, }; -use egui::{ - Button, CentralPanel, Color32, Context, Frame, Margin, Response, RichText, Stroke, TextEdit, - Ui, Vec2, -}; +use egui::{Button, CentralPanel, Frame, Margin, Response, RichText, Stroke, TextEdit, Ui, Vec2}; // Re-export commonly used components pub use super::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; pub use super::selection_dialog::{SelectionDialog, SelectionStatus}; -/// Styled button variants -#[allow(dead_code)] -pub(crate) enum ButtonVariant { - Primary, - Secondary, - Danger, - Ghost, -} - -/// A styled button that follows Dash design guidelines +/// A styled primary button that follows Dash design guidelines. pub(crate) struct StyledButton { text: String, - variant: ButtonVariant, - size: ButtonSize, enabled: bool, min_width: Option<f32>, } -#[allow(dead_code)] -pub(crate) enum ButtonSize { - Small, - Medium, - Large, -} - impl StyledButton { pub fn new(text: impl Into<String>) -> Self { Self { text: text.into(), - variant: ButtonVariant::Primary, - size: ButtonSize::Medium, enabled: true, min_width: None, } @@ -54,62 +31,17 @@ impl StyledButton { } pub fn show(self, ui: &mut Ui) -> Response { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - let (text_color, bg_color, _hover_color, stroke) = match self.variant { - ButtonVariant::Primary => ( - DashColors::WHITE, - DashColors::DASH_BLUE, - DashColors::DEEP_BLUE, - None, - ), - ButtonVariant::Secondary => ( - DashColors::DASH_BLUE, - if dark_mode { - DashColors::surface(dark_mode) - } else { - DashColors::WHITE - }, - DashColors::background(dark_mode), - Some(Stroke::new(1.0, DashColors::DASH_BLUE)), - ), - ButtonVariant::Danger => ( - DashColors::WHITE, - DashColors::ERROR, - DashColors::DANGER_HOVER, - None, - ), - ButtonVariant::Ghost => ( - DashColors::text_primary(dark_mode), - Color32::TRANSPARENT, - DashColors::glass_white(dark_mode), - None, - ), - }; - - let _padding = match self.size { - ButtonSize::Small => Vec2::new(12.0, 6.0), - ButtonSize::Medium => Vec2::new(16.0, 8.0), - ButtonSize::Large => Vec2::new(20.0, 10.0), - }; - - let font_size = match self.size { - ButtonSize::Small => Typography::SCALE_SM, - ButtonSize::Medium => Typography::SCALE_BASE, - ButtonSize::Large => Typography::SCALE_LG, - }; - - let mut button = Button::new(RichText::new(self.text).size(font_size).color(text_color)) - .fill(if self.enabled { - bg_color - } else { - DashColors::DISABLED - }) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)); - - if let Some(stroke) = stroke { - button = button.stroke(stroke); - } + let mut button = Button::new( + RichText::new(self.text) + .size(Typography::SCALE_BASE) + .color(DashColors::WHITE), + ) + .fill(if self.enabled { + DashColors::DASH_BLUE + } else { + DashColors::DISABLED + }) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)); if let Some(min_width) = self.min_width { button = button.min_size(Vec2::new(min_width, 0.0)); @@ -127,7 +59,6 @@ impl StyledButton { /// Styled card component pub(crate) struct StyledCard { - title: Option<String>, padding: f32, show_border: bool, } @@ -141,29 +72,18 @@ impl Default for StyledCard { impl StyledCard { pub fn new() -> Self { Self { - title: None, padding: Spacing::CARD_PADDING, show_border: true, } } - // pub fn title(mut self, title: impl Into<String>) -> Self { - // self.title = Some(title.into()); - // self - // } - pub fn padding(mut self, padding: f32) -> Self { self.padding = padding; self } - // pub fn show_border(mut self, show: bool) -> Self { - // self.show_border = show; - // self - // } - pub fn show<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> R { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let stroke = if self.show_border { Stroke::new(1.0, DashColors::border(dark_mode)) @@ -177,17 +97,7 @@ impl StyledCard { .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) .inner_margin(egui::Margin::same(self.padding as i8)) .shadow(Shadow::medium()) - .show(ui, |ui| { - if let Some(title) = self.title { - ui.label( - RichText::new(title) - .font(Typography::heading_small()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); - } - content(ui) - }) + .show(ui, content) .inner } } @@ -198,7 +108,6 @@ pub(crate) struct StyledCheckbox<'a> { text: String, } -// #[allow(dead_code)] impl<'a> StyledCheckbox<'a> { pub fn new(checked: &'a mut bool, text: impl Into<String>) -> Self { Self { @@ -225,7 +134,6 @@ impl<'a> StyledCheckbox<'a> { pub(crate) struct GradientButton { text: String, min_width: Option<f32>, - glow: bool, app_context: Arc<AppContext>, } @@ -234,7 +142,6 @@ impl GradientButton { Self { text: text.into(), min_width: None, - glow: false, app_context: Arc::clone(app_context), } } @@ -244,11 +151,6 @@ impl GradientButton { self } - pub fn glow(mut self) -> Self { - self.glow = true; - self - } - pub fn show(self, ui: &mut Ui) -> Response { let time = ui.ctx().input(|i| i.time as f32); let animated_color = DashColors::gradient_animated(time); @@ -275,251 +177,6 @@ impl GradientButton { } } -/// Glass-morphism styled card -pub struct GlassCard { - title: Option<String>, - padding: f32, -} - -#[allow(dead_code)] -impl GlassCard { - pub fn new() -> Self { - Self { - title: None, - padding: Spacing::CARD_PADDING, - } - } - - pub fn title(mut self, title: impl Into<String>) -> Self { - self.title = Some(title.into()); - self - } - - pub fn padding(mut self, padding: f32) -> Self { - self.padding = padding; - self - } - - pub fn show<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> R { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - egui::Frame::new() - .fill(DashColors::glass_white(dark_mode)) - .stroke(Stroke::new(1.0, DashColors::glass_border(dark_mode))) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_XL)) - .inner_margin(egui::Margin::same(self.padding as i8)) - .shadow(Shadow::medium()) - .show(ui, |ui| { - if let Some(title) = self.title { - ui.label( - RichText::new(title) - .font(Typography::heading_medium()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); - } - content(ui) - }) - .inner - } -} - -impl Default for GlassCard { - fn default() -> Self { - Self::new() - } -} -/// Hero section with gradient background -pub struct HeroSection { - title: String, - subtitle: Option<String>, -} - -#[allow(dead_code)] -impl HeroSection { - pub fn new(title: impl Into<String>) -> Self { - Self { - title: title.into(), - subtitle: None, - } - } - - pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self { - self.subtitle = Some(subtitle.into()); - self - } - - pub fn show(self, ui: &mut Ui) { - let time = ui.ctx().input(|i| i.time as f32); - let gradient_color = DashColors::gradient_animated(time); - - egui::Frame::new() - .fill(gradient_color.linear_multiply(0.1)) - .stroke(Stroke::new(2.0, gradient_color)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_XL)) - .inner_margin(egui::Margin::same(Spacing::XL as i8)) - .shadow(Shadow::glow()) - .show(ui, |ui| { - ui.vertical_centered(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new(self.title) - .font(Typography::heading_large()) - .color(DashColors::text_primary(dark_mode)), - ); - - if let Some(subtitle) = self.subtitle { - ui.add_space(Spacing::SM); - ui.label( - RichText::new(subtitle) - .font(Typography::body_large()) - .color(DashColors::text_secondary(dark_mode)), - ); - } - }); - }); - - // Request repaint for animation - ui.ctx().request_repaint(); - } -} - -/// Icon with animation support -pub struct AnimatedIcon { - icon: String, - size: f32, - color: Color32, - rotation: f32, - pulse: bool, -} - -#[allow(dead_code)] -impl AnimatedIcon { - pub fn new(icon: impl Into<String>) -> Self { - Self { - icon: icon.into(), - size: Typography::SCALE_XL, - color: DashColors::DASH_BLUE, - rotation: 0.0, - pulse: false, - } - } - - pub fn size(mut self, size: f32) -> Self { - self.size = size; - self - } - - pub fn color(mut self, color: Color32) -> Self { - self.color = color; - self - } - - pub fn rotation(mut self, rotation: f32) -> Self { - self.rotation = rotation; - self - } - - pub fn pulse(mut self) -> Self { - self.pulse = true; - self - } - - pub fn show(self, ui: &mut Ui) -> Response { - let time = ui.ctx().input(|i| i.time as f32); - - let mut size = self.size; - if self.pulse { - let pulse_scale = 1.0 + 0.1 * (time * 2.0).sin(); - size *= pulse_scale; - } - - let response = ui.label(RichText::new(self.icon).size(size).color(self.color)); - - if self.rotation != 0.0 { - // Apply rotation animation - let _angle = self.rotation * time; - // Note: egui doesn't have direct rotation support for text, - // so this is a placeholder for future enhancement - } - - // Request repaint for animation - if self.pulse || self.rotation != 0.0 { - ui.ctx().request_repaint(); - } - - response - } -} - -/// Animated gradient card -pub struct AnimatedGradientCard { - title: Option<String>, - padding: f32, - gradient_index: usize, -} - -#[allow(dead_code)] -impl AnimatedGradientCard { - pub fn new() -> Self { - Self { - title: None, - padding: Spacing::CARD_PADDING, - gradient_index: 0, - } - } - - pub fn title(mut self, title: impl Into<String>) -> Self { - self.title = Some(title.into()); - self - } - - pub fn padding(mut self, padding: f32) -> Self { - self.padding = padding; - self - } - - pub fn gradient_index(mut self, index: usize) -> Self { - self.gradient_index = index; - self - } - - pub fn show<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> R { - let time = ui.ctx().input(|i| i.time as f32); - let animated_color = DashColors::gradient_animated(time); - let pastel_color = DashColors::pastel_gradient(self.gradient_index); - - egui::Frame::new() - .fill(pastel_color) - .stroke(Stroke::new(2.0, animated_color)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_XL)) - .inner_margin(egui::Margin::same(self.padding as i8)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - if let Some(title) = self.title { - ui.label( - RichText::new(title) - .font(Typography::heading_small()) - .color(DashColors::TEXT_PRIMARY), - ); - ui.add_space(Spacing::MD); - } - - // Request repaint for animation - ui.ctx().request_repaint(); - - content(ui) - }) - .inner - } -} - -impl Default for AnimatedGradientCard { - fn default() -> Self { - Self::new() - } -} - /// Helper function to style a TextEdit with consistent theme pub fn styled_text_edit_singleline(text: &mut String, dark_mode: bool) -> TextEdit<'_> { TextEdit::singleline(text) @@ -527,17 +184,9 @@ pub fn styled_text_edit_singleline(text: &mut String, dark_mode: bool) -> TextEd .background_color(DashColors::input_background(dark_mode)) } -/// Helper function to style a multiline TextEdit with consistent theme -#[allow(dead_code)] -pub fn styled_text_edit_multiline(text: &mut String, dark_mode: bool) -> TextEdit<'_> { - TextEdit::multiline(text) - .text_color(DashColors::text_primary(dark_mode)) - .background_color(DashColors::input_background(dark_mode)) -} - -/// Helper function to create an island-style central panel -pub fn island_central_panel<R>(ctx: &Context, content: impl FnOnce(&mut Ui) -> R) -> R { - let dark_mode = ctx.style().visuals.dark_mode; +/// Helper function to create an island-style central panel. +pub fn island_central_panel<R>(ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> R { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; CentralPanel::default() .frame( @@ -545,7 +194,7 @@ pub fn island_central_panel<R>(ctx: &Context, content: impl FnOnce(&mut Ui) -> R .fill(DashColors::background(dark_mode)) .inner_margin(Margin::symmetric(10, 10)), // Standard margins for all panels ) - .show(ctx, |ui| { + .show(ui, |ui| { // Calculate responsive margins based on available width, but ensure minimum spacing let available_width = ui.available_width(); let inner_margin = if available_width > 1200.0 { diff --git a/src/ui/components/subscreen_chooser_panel.rs b/src/ui/components/subscreen_chooser_panel.rs new file mode 100644 index 000000000..e1805c41c --- /dev/null +++ b/src/ui/components/subscreen_chooser_panel.rs @@ -0,0 +1,98 @@ +use crate::app::AppAction; +use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; +use egui::{Frame, Margin, Panel, RichText, ScrollArea, Ui}; + +/// A single navigation entry in a left-hand subscreen chooser panel. +pub struct SubscreenNavItem { + label: String, + is_active: bool, + action: AppAction, +} + +impl SubscreenNavItem { + /// Creates a navigation entry with its label, active state, and the action to dispatch on click. + pub fn new(label: impl Into<String>, is_active: bool, action: AppAction) -> Self { + Self { + label: label.into(), + is_active, + action, + } + } +} + +/// Builds the standard left-panel navigation button used across every subscreen chooser. +pub fn nav_button(label: &str, is_active: bool, dark_mode: bool) -> egui::Button<'static> { + let (text_color, fill, stroke) = if is_active { + (DashColors::WHITE, DashColors::DASH_BLUE, egui::Stroke::NONE) + } else { + ( + DashColors::text_primary(dark_mode), + DashColors::glass_white(dark_mode), + egui::Stroke::new(1.0, DashColors::border(dark_mode)), + ) + }; + egui::Button::new( + RichText::new(label) + .color(text_color) + .size(Typography::SCALE_SM), + ) + .fill(fill) + .stroke(stroke) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) +} + +/// Renders a left-hand subscreen chooser panel and returns the action of the clicked item. +/// +/// The panel draws an island frame containing one [`nav_button`] per item. Set `scrollable` for +/// panels whose entries may overflow the available height. +pub fn add_subscreen_chooser_panel( + ui: &mut Ui, + panel_id: &str, + resizable: bool, + scrollable: bool, + items: Vec<SubscreenNavItem>, +) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut action = AppAction::None; + + Panel::left(egui::Id::new(panel_id)) + .resizable(resizable) + .default_size(270.0) + .frame( + Frame::new() + .fill(DashColors::background(dark_mode)) + .inner_margin(Margin::symmetric(10, 10)), + ) + .show(ui, |ui| { + let available_height = ui.available_height(); + Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .inner_margin(Margin::same(Spacing::XL as i8)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) + .shadow(Shadow::elevated()) + .show(ui, |ui| { + ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); + let render = |ui: &mut Ui| { + ui.add_space(Spacing::SM); + for item in items { + let clicked = ui + .add(nav_button(&item.label, item.is_active, dark_mode)) + .clicked(); + ui.add_space(Spacing::SM); + if clicked { + action = item.action; + } + } + }; + if scrollable { + ScrollArea::vertical().show(ui, render); + } else { + ui.vertical(render); + } + }); + }); + + action +} diff --git a/src/ui/components/tokens_subscreen_chooser_panel.rs b/src/ui/components/tokens_subscreen_chooser_panel.rs index c2f2b99b5..34ddc83ad 100644 --- a/src/ui/components/tokens_subscreen_chooser_panel.rs +++ b/src/ui/components/tokens_subscreen_chooser_panel.rs @@ -1,105 +1,43 @@ +use crate::app::AppAction; use crate::context::AppContext; use crate::ui::RootScreenType; -use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; +use crate::ui::components::subscreen_chooser_panel::{ + SubscreenNavItem, add_subscreen_chooser_panel, +}; use crate::ui::tokens::tokens_screen::TokensSubscreen; -use crate::{app::AppAction, ui}; -use egui::{Context, Frame, Margin, RichText, SidePanel}; - -pub fn add_tokens_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { - let mut action = AppAction::None; - - let subscreens = vec![ - TokensSubscreen::MyTokens, - TokensSubscreen::SearchTokens, - TokensSubscreen::TokenCreator, - ]; - - let active_screen = match app_context.get_settings() { - Ok(Some(settings)) => match settings.root_screen_type { - ui::RootScreenType::RootScreenMyTokenBalances => TokensSubscreen::MyTokens, - ui::RootScreenType::RootScreenTokenSearch => TokensSubscreen::SearchTokens, - ui::RootScreenType::RootScreenTokenCreator => TokensSubscreen::TokenCreator, - _ => TokensSubscreen::MyTokens, - }, - _ => TokensSubscreen::MyTokens, // Fallback to Active screen if settings unavailable +use egui::Ui; + +pub fn add_tokens_subscreen_chooser_panel(ui: &mut Ui, app_context: &AppContext) -> AppAction { + let active = match app_context.get_app_settings().root_screen_type { + RootScreenType::RootScreenMyTokenBalances => TokensSubscreen::MyTokens, + RootScreenType::RootScreenTokenSearch => TokensSubscreen::SearchTokens, + RootScreenType::RootScreenTokenCreator => TokensSubscreen::TokenCreator, + _ => TokensSubscreen::MyTokens, }; - let dark_mode = ctx.style().visuals.dark_mode; - - SidePanel::left("tokens_subscreen_chooser_panel") - .resizable(false) - .default_width(270.0) - .frame( - Frame::new() - .fill(DashColors::background(dark_mode)) - .inner_margin(Margin::symmetric(10, 10)), + let items = [ + ( + TokensSubscreen::MyTokens, + RootScreenType::RootScreenMyTokenBalances, + ), + ( + TokensSubscreen::SearchTokens, + RootScreenType::RootScreenTokenSearch, + ), + ( + TokensSubscreen::TokenCreator, + RootScreenType::RootScreenTokenCreator, + ), + ] + .into_iter() + .map(|(subscreen, target)| { + SubscreenNavItem::new( + subscreen.display_name(), + subscreen == active, + AppAction::SetMainScreenThenGoToMainScreen(target), ) - .show(ctx, |ui| { - let available_height = ui.available_height(); - Frame::new() - .fill(DashColors::surface(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::XL as i8)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); - // Display subscreen names - ui.vertical(|ui| { - ui.add_space(Spacing::SM); - - for subscreen in subscreens { - let is_active = active_screen == subscreen; - - let button = if is_active { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::WHITE) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::DASH_BLUE) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - } else { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::text_primary(dark_mode)) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - }; - - // Show the subscreen name as a clickable option - if ui.add(button).clicked() { - // Handle navigation based on which subscreen is selected - match subscreen { - TokensSubscreen::MyTokens => { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ) - } - TokensSubscreen::SearchTokens => { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenTokenSearch, - ) - } - TokensSubscreen::TokenCreator => { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenTokenCreator, - ) - } - } - } - - ui.add_space(Spacing::SM); - } - }); - }); - }); + }) + .collect(); - action + add_subscreen_chooser_panel(ui, "tokens_subscreen_chooser_panel", false, false, items) } diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index 3ff9a6156..8427349cd 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -1,243 +1,73 @@ +use crate::app::AppAction; use crate::context::AppContext; -use crate::spv::CoreBackendMode; use crate::ui::RootScreenType; -use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; -use crate::{app::AppAction, ui}; -use egui::{Context, Frame, Margin, RichText, ScrollArea, SidePanel}; +use crate::ui::components::subscreen_chooser_panel::{ + SubscreenNavItem, add_subscreen_chooser_panel, +}; +use crate::ui::tools::ToolsSubscreen; +use egui::Ui; -#[derive(PartialEq)] -pub enum ToolsSubscreen { - PlatformInfo, - AddressBalance, - ProofLog, - TransactionViewer, - DocumentViewer, - ProofViewer, - ContractViewer, - GroveSTARK, - MasternodeListDiff, - DPNS, -} - -impl ToolsSubscreen { - /// Returns `true` when the tool only works with an RPC connection to a - /// local Dash Core node and must be disabled while the app is running on - /// its built-in SPV backend. - fn requires_core_rpc(&self) -> bool { - matches!(self, Self::MasternodeListDiff) - } -} - -impl ToolsSubscreen { - pub fn display_name(&self) -> &'static str { - match self { - Self::PlatformInfo => "Platform info", - Self::AddressBalance => "Address balance", - Self::ProofLog => "Proof logs", - Self::TransactionViewer => "Transaction deserializer", - Self::ProofViewer => "Proof deserializer", - Self::DocumentViewer => "Document deserializer", - Self::ContractViewer => "Contract deserializer", - Self::GroveSTARK => "ZK Proofs", - Self::MasternodeListDiff => "Masternode list diff inspector", - Self::DPNS => "DPNS", +pub fn add_tools_subscreen_chooser_panel(ui: &mut Ui, app_context: &AppContext) -> AppAction { + let active = match app_context.get_app_settings().root_screen_type { + RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, + RootScreenType::RootScreenToolsAddressBalanceScreen => ToolsSubscreen::AddressBalance, + RootScreenType::RootScreenToolsTransitionVisualizerScreen => { + ToolsSubscreen::TransactionViewer } - } -} - -pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { - let mut action = AppAction::None; - let dark_mode = ctx.style().visuals.dark_mode; - let is_rpc_mode = app_context.core_backend_mode() == CoreBackendMode::Rpc; - - let subscreens = vec![ - ToolsSubscreen::PlatformInfo, - ToolsSubscreen::AddressBalance, - ToolsSubscreen::ProofLog, - ToolsSubscreen::ProofViewer, - ToolsSubscreen::TransactionViewer, - ToolsSubscreen::DocumentViewer, - ToolsSubscreen::ContractViewer, - ToolsSubscreen::GroveSTARK, - ToolsSubscreen::MasternodeListDiff, - ToolsSubscreen::DPNS, - ]; - - let active_screen = match app_context.get_settings() { - Ok(Some(settings)) => match settings.root_screen_type { - ui::RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, - ui::RootScreenType::RootScreenToolsAddressBalanceScreen => { - ToolsSubscreen::AddressBalance - } - ui::RootScreenType::RootScreenToolsProofLogScreen => ToolsSubscreen::ProofLog, - ui::RootScreenType::RootScreenToolsTransitionVisualizerScreen => { - ToolsSubscreen::TransactionViewer - } - ui::RootScreenType::RootScreenToolsProofVisualizerScreen => ToolsSubscreen::ProofViewer, - ui::RootScreenType::RootScreenToolsDocumentVisualizerScreen => { - ToolsSubscreen::DocumentViewer - } - ui::RootScreenType::RootScreenToolsContractVisualizerScreen => { - ToolsSubscreen::ContractViewer - } - ui::RootScreenType::RootScreenToolsMasternodeListDiffScreen => { - ToolsSubscreen::MasternodeListDiff - } - ui::RootScreenType::RootScreenToolsGroveSTARKScreen => ToolsSubscreen::GroveSTARK, - ui::RootScreenType::RootScreenDPNSActiveContests - | ui::RootScreenType::RootScreenDPNSPastContests - | ui::RootScreenType::RootScreenDPNSOwnedNames - | ui::RootScreenType::RootScreenDPNSScheduledVotes => ToolsSubscreen::DPNS, - _ => ToolsSubscreen::PlatformInfo, - }, - _ => ToolsSubscreen::PlatformInfo, // Fallback to Active screen if settings unavailable + RootScreenType::RootScreenToolsProofVisualizerScreen => ToolsSubscreen::ProofViewer, + RootScreenType::RootScreenToolsDocumentVisualizerScreen => ToolsSubscreen::DocumentViewer, + RootScreenType::RootScreenToolsContractVisualizerScreen => ToolsSubscreen::ContractViewer, + RootScreenType::RootScreenToolsGroveSTARKScreen => ToolsSubscreen::GroveSTARK, + RootScreenType::RootScreenDPNSActiveContests + | RootScreenType::RootScreenDPNSPastContests + | RootScreenType::RootScreenDPNSOwnedNames + | RootScreenType::RootScreenDPNSScheduledVotes => ToolsSubscreen::DPNS, + _ => ToolsSubscreen::PlatformInfo, }; - SidePanel::left("tools_subscreen_chooser_panel") - .resizable(false) - .default_width(270.0) - .frame( - Frame::new() - .fill(DashColors::background(dark_mode)) - .inner_margin(Margin::symmetric(10, 10)), - ) - .show(ctx, |ui| { - let available_height = ui.available_height(); - Frame::new() - .fill(DashColors::surface(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::XL as i8)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); - ScrollArea::vertical().show(ui, |ui| { - ui.add_space(Spacing::SM); - - for subscreen in subscreens { - let is_active = active_screen == subscreen; - let requires_core_rpc = subscreen.requires_core_rpc(); - let is_enabled = is_rpc_mode || !requires_core_rpc; - - let button = if is_active { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::WHITE) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::DASH_BLUE) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - } else { - egui::Button::new( - RichText::new(subscreen.display_name()) - .color(DashColors::text_primary(dark_mode)) - .size(Typography::SCALE_SM), - ) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::Vec2::new(150.0, 28.0)) - }; - - // Show the subscreen name as a clickable option. Disable - // tools that require a Core RPC connection while running - // on the built-in SPV backend. - let mut response = ui.add_enabled(is_enabled, button); - if !is_enabled { - response = response.on_disabled_hover_text( - "This tool requires a local Dash Core node. Open Settings, switch to Expert mode, and select Local Dash Core node to enable it.", - ); - } - if response.clicked() { - // Handle navigation based on which subscreen is selected - match subscreen { - ToolsSubscreen::PlatformInfo => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsPlatformInfoScreen, - ) - } - ToolsSubscreen::AddressBalance => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsAddressBalanceScreen, - ) - } - ToolsSubscreen::ProofLog => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsProofLogScreen, - ) - } - ToolsSubscreen::TransactionViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsTransitionVisualizerScreen, - ) - } - ToolsSubscreen::ProofViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsProofVisualizerScreen, - ) - } - ToolsSubscreen::DocumentViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsDocumentVisualizerScreen, - ) - } - ToolsSubscreen::ContractViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsContractVisualizerScreen, - ) - } - ToolsSubscreen::MasternodeListDiff => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsMasternodeListDiffScreen) - } - ToolsSubscreen::GroveSTARK => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsGroveSTARKScreen) - } - ToolsSubscreen::DPNS => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenDPNSActiveContests) - } - } - } - ui.add_space(Spacing::SM); - } - }); - }); - }); - - action -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn masternode_list_diff_requires_core_rpc() { - assert!(ToolsSubscreen::MasternodeListDiff.requires_core_rpc()); - } - - #[test] - fn spv_safe_tools_do_not_require_core_rpc() { - for tool in [ + let items = [ + ( ToolsSubscreen::PlatformInfo, + RootScreenType::RootScreenToolsPlatformInfoScreen, + ), + ( ToolsSubscreen::AddressBalance, - ToolsSubscreen::ProofLog, + RootScreenType::RootScreenToolsAddressBalanceScreen, + ), + ( + ToolsSubscreen::ProofViewer, + RootScreenType::RootScreenToolsProofVisualizerScreen, + ), + ( ToolsSubscreen::TransactionViewer, + RootScreenType::RootScreenToolsTransitionVisualizerScreen, + ), + ( ToolsSubscreen::DocumentViewer, - ToolsSubscreen::ProofViewer, + RootScreenType::RootScreenToolsDocumentVisualizerScreen, + ), + ( ToolsSubscreen::ContractViewer, + RootScreenType::RootScreenToolsContractVisualizerScreen, + ), + ( ToolsSubscreen::GroveSTARK, + RootScreenType::RootScreenToolsGroveSTARKScreen, + ), + ( ToolsSubscreen::DPNS, - ] { - assert!( - !tool.requires_core_rpc(), - "Tool {} should be available in SPV mode", - tool.display_name() - ); - } - } + RootScreenType::RootScreenDPNSActiveContests, + ), + ] + .into_iter() + .map(|(subscreen, target)| { + SubscreenNavItem::new( + subscreen.display_name(), + subscreen == active, + AppAction::SetMainScreen(target), + ) + }) + .collect(); + + add_subscreen_chooser_panel(ui, "tools_subscreen_chooser_panel", false, true, items) } diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 8a0944113..3d612ef2e 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -1,54 +1,13 @@ use crate::app::{AppAction, DesiredAppAction}; -use crate::backend_task::BackendTask; -use crate::backend_task::core::CoreTask; use crate::context::AppContext; use crate::context::connection_status::OverallConnectionState; -use crate::spv::CoreBackendMode; -use crate::ui::ScreenType; +use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; +use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; +use crate::ui::state::hub_selection::HubSelection; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Shadow, Shape}; -use egui::{Align2, Context, FontId, Frame, Margin, RichText, TextureHandle, TopBottomPanel, Ui}; -use rust_embed::RustEmbed; +use crate::ui::{RootScreenType, ScreenType}; +use egui::{Align2, FontId, Frame, Margin, Panel, RichText, Ui}; use std::sync::Arc; -use tracing::error; - -#[derive(RustEmbed)] -#[folder = "icons/"] -struct Assets; - -// Function to load an icon as a texture using embedded assets -#[allow(dead_code)] -fn load_icon(ctx: &Context, path: &str) -> Option<TextureHandle> { - // Use ctx.data_mut to check if texture is already cached - ctx.data_mut(|d| d.get_temp::<TextureHandle>(egui::Id::new(path))) - .or_else(|| { - // Only do expensive operations if texture is not cached - if let Some(content) = Assets::get(path) { - // Load the image from the embedded bytes - if let Ok(image) = image::load_from_memory(&content.data) { - let size = [image.width() as usize, image.height() as usize]; - let rgba_image = image.into_rgba8(); - let pixels = rgba_image.into_raw(); - - let texture = ctx.load_texture( - path, - egui::ColorImage::from_rgba_unmultiplied(size, &pixels), - Default::default(), - ); - - // Cache the texture - ctx.data_mut(|d| d.insert_temp(egui::Id::new(path), texture.clone())); - - Some(texture) - } else { - error!("Failed to load image from embedded data at path: {}", path); - None - } - } else { - error!("Image not found in embedded assets at path: {}", path); - None - } - }) -} fn add_location_view(ui: &mut Ui, location: Vec<(&str, AppAction)>, dark_mode: bool) -> AppAction { let mut action = AppAction::None; @@ -95,13 +54,11 @@ fn add_location_view(ui: &mut Ui, location: Vec<(&str, AppAction)>, dark_mode: b action } -fn add_connection_indicator(ui: &mut Ui, app_context: &Arc<AppContext>) -> AppAction { - let mut action = AppAction::None; +fn add_connection_indicator(ui: &mut Ui, app_context: &Arc<AppContext>) { let status = app_context.connection_status(); - let backend_mode = status.backend_mode(); let overall = status.overall_state(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let circle_size = 14.0; // Five-state color: green (synced), orange (syncing/connecting), magenta (error), red (disconnected) @@ -167,56 +124,37 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc<AppContext>) -> AppAc if overall != OverallConnectionState::Disconnected { app_context.repaint_animation(ui.ctx()); } - let tip = status.tooltip_text(app_context); - let can_start_dash_qt = overall == OverallConnectionState::Disconnected - && backend_mode == CoreBackendMode::Rpc - && !status.rpc_online(); - let resp = if can_start_dash_qt { - resp.clickable_tooltip(tip) - } else { - resp.info_tooltip(tip) - }; - - if resp.clicked() && can_start_dash_qt { - let settings = app_context.get_settings().ok().flatten(); - - let (custom_path, overwrite) = settings - .map(|s| (s.dash_qt_path, s.overwrite_dash_conf)) - .unwrap_or((None, true)); - if let Some(dash_qt_path) = custom_path { - action |= AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::StartDashQT(app_context.network, dash_qt_path, overwrite), - )); - } else { - tracing::debug!( - "Dash-Qt path not set in settings, not starting Dash-Qt from connection indicator." - ); - } - } + let tip = status.tooltip_text(); + let _resp = resp.info_tooltip(tip); }); }, ); }); - action } -pub fn add_top_panel( - ctx: &Context, +/// Shared top-island scaffold: the `Panel::top`, surface Frame + radius + +/// shadow + network accent, the 2-column layout, the connection indicator, and +/// the right-button grouping. The `left` closure renders the left/breadcrumb +/// region. Both public entry points delegate here so they render identically. +fn render_top_island( + ui: &mut Ui, app_context: &Arc<AppContext>, - location: Vec<(&str, AppAction)>, + left: impl FnOnce(&mut Ui) -> AppAction, right_buttons: Vec<(&str, DesiredAppAction)>, ) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; let network_accent = DashColors::network_accent(app_context.network, dark_mode); - TopBottomPanel::top("top_panel") + Panel::top("top_panel") .frame( Frame::new() .fill(DashColors::background(dark_mode)) .inner_margin(Margin::same(10)), // 10px margin on all sides ) - .show(ctx, |ui| { + .show(ui, |ui| { // Create an island panel with rounded edges Frame::new() .fill(DashColors::surface(dark_mode)) @@ -236,8 +174,8 @@ pub fn add_top_panel( columns[0].with_layout( egui::Layout::left_to_right(egui::Align::Center), |ui| { - action |= add_connection_indicator(ui, app_context); - action |= add_location_view(ui, location, dark_mode); + add_connection_indicator(ui, app_context); + action |= left(ui); }, ); @@ -289,7 +227,7 @@ pub fn add_top_panel( ); let popup_id = ui.make_persistent_id("docs_popup"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Popup::new( popup_id, ui.ctx().clone(), @@ -328,7 +266,7 @@ pub fn add_top_panel( network_accent, ); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Popup::new( popup_id, ui.ctx().clone(), @@ -373,3 +311,158 @@ pub fn add_top_panel( action } + +/// Render the standard top panel with a plain-text breadcrumb location and the +/// grouped right-side action buttons. Unchanged public API — delegates to the +/// shared [`render_top_island`] scaffold. +pub fn add_top_panel( + ui: &mut Ui, + app_context: &Arc<AppContext>, + location: Vec<(&str, AppAction)>, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + render_top_island( + ui, + app_context, + |ui| add_location_view(ui, location, dark_mode), + right_buttons, + ) +} + +/// Render the top panel with a custom left/breadcrumb region — the Identities +/// hub switcher injects its three-segment breadcrumb here. Same island, accent, +/// connection indicator, and right column as [`add_top_panel`]. +pub fn add_top_panel_with_breadcrumb( + ui: &mut Ui, + app_context: &Arc<AppContext>, + breadcrumb: impl FnOnce(&mut Ui) -> AppAction, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> AppAction { + render_top_island(ui, app_context, breadcrumb, right_buttons) +} + +/// Standard how-to-change tooltip for an unwired wallet pill (FR-GLOBAL-NAV-2 +/// rule 3). A single translation unit; keep it a complete sentence. +const TT_WALLET_UNWIRED: &str = "Change the active wallet from the Wallets tab."; +/// Standard how-to-change tooltip for an unwired identity pill. +const TT_IDENTITY_UNWIRED: &str = "Change the active identity from the Identity Hub."; + +/// An everyday-page global-nav spec with both pills subdued (unwired) — the +/// Phase-A rollout default. Segment-1 links to the page's own root. +pub fn subdued_everyday_spec(label: impl Into<String>, target: RootScreenType) -> PageNavSpec { + PageNavSpec::unwired_everyday(label, target, TT_WALLET_UNWIRED, TT_IDENTITY_UNWIRED) +} + +/// A wallet-only global-nav spec (no identity/object pill) with the wallet pill +/// subdued (unwired). For pages with no identity context (e.g. Wallets) — +/// FR-GLOBAL-NAV-2 rule 4. +pub fn subdued_wallet_only_spec(label: impl Into<String>, target: RootScreenType) -> PageNavSpec { + PageNavSpec::new(label, target).with_wallet_pill(PillConsumption::Unwired { + tooltip: TT_WALLET_UNWIRED.to_string(), + }) +} + +/// Apply a generalized global-nav effect: wallet/identity selection updates the +/// **app-global** selection silently (no forced navigation — FR-GLOBAL-NAV-2 +/// rule 1); segment-1 navigation and add-flows route to the existing screens. +/// +/// The shared successor to the hub's `apply_breadcrumb_effect`. The hub keeps +/// its own richer applier (it also resets identity-scoped caches and opens the +/// picker); every other root page uses this one. +pub fn apply_global_nav_effect( + app_context: &Arc<AppContext>, + effect: GlobalNavEffect, +) -> AppAction { + match effect { + GlobalNavEffect::None => AppAction::None, + GlobalNavEffect::NavigateToRoot(target) => AppAction::SetMainScreen(target), + // Silent app-scoped write, NO forced navigation (FR-GLOBAL-NAV-2 rule + // 1). `set_selected_hd_wallet` also reconciles the app-global *identity* + // to the new wallet's identities as a side effect (keep-if-owned → + // first → None). On a non-Hub page this cross-axis mutation is real and + // intentional; combined with the resolution-layer MN/Evonode filter it + // must never reconcile onto a masternode/evonode identity — the FR-6 + // boundary is enforced there, not re-checked here. + GlobalNavEffect::SwitchWallet(hash) => { + app_context.set_selected_hd_wallet(Some(hash)); + AppAction::None + } + GlobalNavEffect::SelectIdentity(id) => { + app_context.set_selected_identity(Some(id)); + AppAction::None + } + // The page-scoped object selection is owned by its page (B7) and never + // writes `AppContext::selected_identity_id`. Unwired pages never emit it. + GlobalNavEffect::SelectPageObject(_) => AppAction::None, + GlobalNavEffect::AddWallet => { + AppAction::SetMainScreen(RootScreenType::RootScreenWalletsBalances) + } + GlobalNavEffect::AddIdentityCreate | GlobalNavEffect::CreateTestIdentities => { + AppAction::AddScreen(ScreenType::AddNewIdentity.create_screen(app_context)) + } + GlobalNavEffect::AddIdentityLoad => { + AppAction::AddScreen(ScreenType::AddExistingIdentity.create_screen(app_context)) + } + } +} + +/// Render the top panel with the global-nav switcher for `spec`, then apply its +/// effect. The one-call entry point every non-Hub root screen uses in place of +/// [`add_top_panel`]. Unwired specs compose no interactive dropdown, so a +/// throwaway per-frame search buffer suffices; interactive pages (the Hub, +/// later Masternodes) own a persistent [`HubSelection`] and wire the effect +/// themselves. +pub fn add_top_panel_with_global_nav( + ui: &mut Ui, + app_context: &Arc<AppContext>, + spec: PageNavSpec, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> AppAction { + let mut effect = GlobalNavEffect::None; + let mut selection = HubSelection::default(); + let mut action = render_top_island( + ui, + app_context, + |ui| { + effect = global_nav_switcher::render(ui, app_context, &spec, &mut selection); + AppAction::None + }, + right_buttons, + ); + action |= apply_global_nav_effect(app_context, effect); + 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)`. +pub fn add_top_panel_with_global_nav_capturing( + ui: &mut Ui, + app_context: &Arc<AppContext>, + spec: PageNavSpec, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> (AppAction, Option<dash_sdk::platform::Identifier>) { + let mut effect = GlobalNavEffect::None; + let mut selection = HubSelection::default(); + let mut action = render_top_island( + ui, + app_context, + |ui| { + effect = global_nav_switcher::render(ui, app_context, &spec, &mut selection); + AppAction::None + }, + right_buttons, + ); + let picked = match effect { + GlobalNavEffect::SelectPageObject(id) => Some(id), + _ => None, + }; + action |= apply_global_nav_effect(app_context, effect); + (action, picked) +} diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs deleted file mode 100644 index ef24d83e7..000000000 --- a/src/ui/components/wallet_unlock.rs +++ /dev/null @@ -1,100 +0,0 @@ -use crate::context::AppContext; -use crate::model::wallet::Wallet; -use crate::ui::MessageType; -use crate::ui::components::MessageBanner; -use crate::ui::components::password_input::PasswordInput; -use egui::Ui; -use std::sync::{Arc, RwLock}; - -pub trait ScreenWithWalletUnlock { - fn selected_wallet_ref(&self) -> &Option<Arc<RwLock<Wallet>>>; - fn password_input(&mut self) -> &mut PasswordInput; - fn app_context(&self) -> Arc<AppContext>; - - fn should_ask_for_password(&mut self) -> bool { - if let Some(wallet_guard) = self.selected_wallet_ref().clone() { - let mut wallet = wallet_guard.write().unwrap(); - if !wallet.uses_password { - if let Err(e) = wallet.wallet_seed.open_no_password() { - MessageBanner::set_global( - self.app_context().egui_ctx(), - &e, - MessageType::Error, - ); - } - false - } else { - !wallet.is_open() - } - } else { - true - } - } - - fn render_wallet_unlock_if_needed(&mut self, ui: &mut Ui) -> (bool, bool) { - if self.should_ask_for_password() { - (true, self.render_wallet_unlock(ui)) - } else { - (false, false) - } - } - - fn render_wallet_unlock(&mut self, ui: &mut Ui) -> bool { - let mut unlocked_wallet: Option<Arc<RwLock<Wallet>>> = None; - - if let Some(wallet_guard) = self.selected_wallet_ref().clone() { - let mut wallet = wallet_guard.write().unwrap(); - - if wallet.uses_password && !wallet.is_open() { - if let Some(alias) = &wallet.alias { - ui.label(format!( - "This wallet ({}) is locked. Please enter the password to unlock it:", - alias - )); - } else { - ui.label("This wallet is locked. Please enter the password to unlock it:"); - } - - ui.add_space(5.0); - - let pw_response = self.password_input().show(ui); - - let enter_pressed = pw_response.response.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)); - - ui.add_space(5.0); - - let unlock_clicked = ui.button("Unlock").clicked(); - - if enter_pressed || unlock_clicked { - let unlock_result = wallet.wallet_seed.open(self.password_input().text()); - - match unlock_result { - Ok(_) => { - unlocked_wallet = Some(wallet_guard.clone()); - } - Err(_) => { - let error_msg = if let Some(hint) = wallet.password_hint() { - format!("Incorrect Password, password hint is {}", hint) - } else { - "Incorrect Password".to_string() - }; - MessageBanner::set_global(ui.ctx(), &error_msg, MessageType::Error) - .with_auto_dismiss(std::time::Duration::from_secs(10)); - } - } - - self.password_input().clear(); - } - } - } - - if let Some(wallet_arc) = unlocked_wallet { - let app_context = self.app_context(); - app_context.handle_wallet_unlocked(&wallet_arc); - return true; - } - - false - } -} diff --git a/src/ui/components/wallet_unlock_popup.rs b/src/ui/components/wallet_unlock_popup.rs index 1f597b3fe..fec3f5e41 100644 --- a/src/ui/components/wallet_unlock_popup.rs +++ b/src/ui/components/wallet_unlock_popup.rs @@ -1,10 +1,12 @@ use crate::context::AppContext; use crate::model::wallet::Wallet; -use crate::ui::components::password_input::PasswordInput; -use crate::ui::helpers::clicked_outside_window; -use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::components::passphrase_modal::{ + KEEP_UNLOCKED_LABEL, PassphraseModalConfig, PassphraseModalOutcome, passphrase_modal, +}; +use crate::wallet_backend::poison::RwLockRecover; use egui; use std::sync::{Arc, RwLock}; +use zeroize::Zeroizing; /// Result of showing the wallet unlock popup #[derive(Debug, Clone, PartialEq)] @@ -17,13 +19,22 @@ pub enum WalletUnlockResult { Cancelled, } -/// A popup dialog for unlocking a wallet with password -/// Similar to InfoPopup and ConfirmationDialog but specialized for wallet unlock flow +/// A popup dialog for unlocking a wallet with password. +/// +/// Thin wrapper around [`passphrase_modal`]: it stores only the two domain +/// fields (`remember`, `error`) plus the open/closed flag. All chrome — +/// overlay, window, `PasswordInput`, focus tracking, dismiss handling — lives +/// in `passphrase_modal`'s egui data-cache state. pub struct WalletUnlockPopup { is_open: bool, - password_input: PasswordInput, - error_message: Option<String>, - focus_requested: bool, + /// Optional wrong-password message forwarded to `passphrase_modal`'s error + /// line. Reset on open; set on a failed unlock attempt. + error: Option<String>, + /// Whether the user opted to keep the seed in the session cache after this + /// unlock. The secure default is `false` — the seed is promoted to the + /// session cache only when the user ticks the box; otherwise the next + /// operation re-prompts. + remember: bool, } impl Default for WalletUnlockPopup { @@ -37,25 +48,22 @@ impl WalletUnlockPopup { pub fn new() -> Self { Self { is_open: false, - password_input: PasswordInput::new().with_hint_text("Enter password"), - error_message: None, - focus_requested: false, + error: None, + remember: false, } } /// Open the popup pub fn open(&mut self) { self.is_open = true; - self.password_input.clear(); - self.error_message = None; - self.focus_requested = false; + self.error = None; + self.remember = false; } /// Close the popup pub fn close(&mut self) { self.is_open = false; - self.password_input.clear(); - self.error_message = None; + self.error = None; } /// Check if the popup is currently open @@ -63,8 +71,8 @@ impl WalletUnlockPopup { self.is_open } - /// Show the popup and handle wallet unlock - /// Returns the result of the unlock attempt + /// Show the popup and handle wallet unlock. + /// Returns the result of the unlock attempt. pub fn show( &mut self, ctx: &egui::Context, @@ -75,163 +83,140 @@ impl WalletUnlockPopup { return WalletUnlockResult::Pending; } - // Draw dark overlay behind the popup - let screen_rect = ctx.content_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("wallet_unlock_popup_overlay"), - )); - painter.rect_filled(screen_rect, 0.0, DashColors::modal_overlay()); - - let mut result = WalletUnlockResult::Pending; - - // Get wallet alias for display let wallet_alias = wallet .read() .ok() .and_then(|w| w.alias.clone()) .unwrap_or_else(|| "Wallet".to_string()); - let mut is_open = true; - - let window_response = egui::Window::new("Unlock Wallet") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut is_open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: DashColors::popup_shadow(), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), - }) - .show(ctx, |ui| { - ui.set_min_width(350.0); - ui.set_max_width(400.0); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - - // Title/description - ui.label( - egui::RichText::new(format!("Enter password to unlock \"{}\":", wallet_alias)) - .color(DashColors::text_primary(dark_mode)), - ); - - ui.add_space(12.0); - - let mut attempt_unlock = false; - - let pw_response = self.password_input.show(ui); - - // Focus the password field once when popup opens - if !self.focus_requested { - pw_response.response.request_focus(); - self.focus_requested = true; - } - - // Check for Enter key - if pw_response.response.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - { - attempt_unlock = true; - } - - // Error message - if let Some(error) = &self.error_message { - ui.add_space(8.0); - ui.colored_label(DashColors::ERROR, error); - } - - ui.add_space(16.0); - - // Buttons - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Unlock button (right side) - if ComponentStyles::add_primary_button(ui, "Unlock").clicked() { - attempt_unlock = true; - } - - // Cancel button (left side) - if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() - { - result = WalletUnlockResult::Cancelled; - self.close(); + let config = PassphraseModalConfig { + window_title: "Unlock Wallet", + body: &format!("Enter password to unlock \"{wallet_alias}\":"), + hint: None, + error: self.error.as_deref(), + submit_label: "Unlock", + input_placeholder: "Enter password", + remember_label: None, + }; + + let mut remember = self.remember; + let outcome = passphrase_modal(ctx, &config, |ui| { + ui.checkbox( + &mut remember, + config.remember_label.unwrap_or(KEEP_UNLOCKED_LABEL), + ); + }); + self.remember = remember; + + match outcome { + PassphraseModalOutcome::Pending => WalletUnlockResult::Pending, + PassphraseModalOutcome::Cancel => { + self.close(); + WalletUnlockResult::Cancelled + } + PassphraseModalOutcome::Submit(text) => { + let mut wallet_guard = wallet.write_recover(); + match wallet_guard.wallet_seed.open(&text) { + Ok(_) => { + drop(wallet_guard); + // The wallet is already flipped open for display. Promote + // the just-verified seed into the session cache only when + // the user opted to keep it unlocked; the copy is zeroized + // on drop. + if self.remember { + let passphrase = Zeroizing::new((*text).clone()); + app_context.handle_wallet_unlocked(wallet, &passphrase); + } else { + // Non-remember unlock: nothing to promote — the next + // operation re-prompts (secure default). + // + // TODO(det): a non-remember unlock (this branch, no + // passphrase handed to handle_wallet_unlocked) skips + // drive_unlock_registration, so the wallet is not + // re-registered with the upstream SPV backend until the + // next launch. Deferred 2026-07-08 pending a decision on + // whether this path should re-drive registration using + // the passphrase already verified by the unlock gesture + // itself (see the recorded wallet-unlock-registration + // gap in project memory). } - - ui.add_space(8.0); - }); - }); - - // Attempt unlock if requested - if attempt_unlock { - let mut wallet_guard = wallet.write().unwrap(); - match wallet_guard.wallet_seed.open(self.password_input.text()) { - Ok(_) => { - // Notify app context that wallet was unlocked - drop(wallet_guard); // Release write lock before calling handle_wallet_unlocked - app_context.handle_wallet_unlocked(wallet); - result = WalletUnlockResult::Unlocked; - self.close(); - } - Err(_) => { - // Show error with hint if available - if let Some(hint) = wallet_guard.password_hint() { - self.error_message = - Some(format!("Incorrect password. Hint: {}", hint)); - } else { - self.error_message = Some("Incorrect password".to_string()); + self.close(); + WalletUnlockResult::Unlocked + } + Err(_) => { + self.error = Some(match wallet_guard.password_hint() { + Some(hint) => format!( + "That password did not match. Check it and try again. Hint: {hint}" + ), + None => { + "That password did not match. Check it and try again.".to_string() } - self.password_input.clear(); - } + }); + WalletUnlockResult::Pending } } - }); - - // Handle window being closed via X button - if !is_open { - result = WalletUnlockResult::Cancelled; - self.close(); - } - - // Handle Escape key - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - result = WalletUnlockResult::Cancelled; - self.close(); + } } - - // Handle click outside window - if let Some(ref wr) = window_response - && result == WalletUnlockResult::Pending - && clicked_outside_window(ctx, wr.response.rect) - { - result = WalletUnlockResult::Cancelled; - self.close(); - } - - result } } /// Helper function to check if a wallet needs unlocking pub fn wallet_needs_unlock(wallet: &Arc<RwLock<Wallet>>) -> bool { - let wallet_guard = wallet.read().unwrap(); + let wallet_guard = wallet.read_recover(); wallet_guard.uses_password && !wallet_guard.is_open() } -/// Helper function to try opening a wallet without password (for wallets that don't use passwords) -pub fn try_open_wallet_no_password(wallet: &Arc<RwLock<Wallet>>) -> Result<(), String> { - let mut wallet_guard = wallet.write().unwrap(); - if !wallet_guard.uses_password { - wallet_guard.wallet_seed.open_no_password() - } else { - Ok(()) +/// Open a no-password wallet for display. +/// +/// Flips the in-memory seed to `Open`. Signing pulls the seed just-in-time from +/// the encrypted vault — a no-password wallet signs even without this call (the +/// chokepoint's unprotected fast-path), so this is a UX convenience, not a +/// correctness gate. Password wallets are a no-op here — they unlock through the +/// password popup, which promotes the seed only when the user opts to keep the +/// wallet unlocked. +// TODO(cleanup): dead `_app_context` param — drop it and update the ~40 UI callsites. +pub fn try_open_wallet_no_password( + _app_context: &Arc<AppContext>, + wallet: &Arc<RwLock<Wallet>>, +) -> Result<(), String> { + let mut wallet_guard = wallet.write_recover(); + if wallet_guard.uses_password { + return Ok(()); + } + if let Err(detail) = wallet_guard.wallet_seed.open_no_password() { + // The raw error is a length-mismatch diagnostic (jargon). Log it + // and return a calm, jargon-free message the callsite can show. + tracing::error!(error = %detail, "Failed to open no-password wallet"); + return Err( + "This wallet's saved data looks damaged and could not be opened. \ + Re-add it from its recovery phrase to restore it." + .to_string(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remember_defaults_to_off() { + let popup = WalletUnlockPopup::new(); + assert!( + !popup.remember, + "the keep-unlocked checkbox must default to off (secure default)" + ); + } + + #[test] + fn open_resets_remember_to_off() { + let mut popup = WalletUnlockPopup::new(); + popup.remember = true; + popup.open(); + assert!( + !popup.remember, + "reopening the popup must reset the keep-unlocked choice to off" + ); } } diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index e6869b737..11e1bc244 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -11,7 +11,7 @@ use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Ui}; use std::sync::Arc; const MAX_CONTRACTS: usize = 10; @@ -143,7 +143,10 @@ impl AddContractsScreen { .collect::<Vec<_>>(), ); } - let alias_inputs = self.alias_inputs.as_mut().unwrap(); + let alias_inputs = self + .alias_inputs + .as_mut() + .expect("invariant: alias_inputs set to Some immediately above"); // Clone the options to avoid borrowing self.add_contracts_status during the UI closure let options = self.maybe_found_contracts.clone(); @@ -305,9 +308,9 @@ impl ScreenLike for AddContractsScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -317,12 +320,12 @@ impl ScreenLike for AddContractsScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenDocumentQuery, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { ui.heading("Add Contracts"); ui.add_space(10.0); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index c88403827..a577c8928 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -23,7 +23,7 @@ use dash_sdk::dpp::data_contract::document_type::{DocumentType, Index}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, Identifier}; -use egui::{CentralPanel, Context, Frame, Margin, ScrollArea, Stroke, Ui}; +use egui::{CentralPanel, Frame, Margin, ScrollArea, Stroke, Ui}; use std::collections::HashMap; use std::sync::Arc; @@ -166,7 +166,7 @@ impl DocumentQueryScreen { let available = ui.available_width(); let text_width = (available - button_width - spacing).max(100.0); // Ensure minimum width - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(&mut self.document_query) .desired_width(text_width) @@ -294,7 +294,7 @@ impl DocumentQueryScreen { }); ui.separator(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { self.show_fields_dropdown = false; } @@ -453,7 +453,7 @@ impl DocumentQueryScreen { let mut combined_string = doc_strings.join("\n\n"); // 3) Display in multiline text - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::multiline(&mut combined_string) .desired_rows(10) @@ -579,7 +579,9 @@ impl ScreenLike for DocumentQueryScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let load_contract_button = ( "Load Contracts", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddContracts)), @@ -623,7 +625,7 @@ impl ScreenLike for DocumentQueryScreen { let mut action = AppAction::None; if self.app_context.network == Network::Mainnet { action |= add_top_panel( - ctx, + ui, &self.app_context, vec![("Contracts", AppAction::None)], vec![ @@ -640,7 +642,7 @@ impl ScreenLike for DocumentQueryScreen { ); } else { action |= add_top_panel( - ctx, + ui, &self.app_context, vec![("Contracts", AppAction::None)], vec![ @@ -659,13 +661,13 @@ impl ScreenLike for DocumentQueryScreen { } action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenDocumentQuery, ); action |= add_contract_chooser_panel( - ctx, + ui, &mut self.contract_search_term, &self.app_context, &mut self.selected_data_contract, @@ -687,53 +689,55 @@ impl ScreenLike for DocumentQueryScreen { } // Custom central panel with adjusted margins for Document Query screen - let dark_mode = ctx.style().visuals.dark_mode; - - action |= CentralPanel::default() - .frame( - Frame::new() - .fill(DashColors::background(dark_mode)) - .inner_margin(Margin { - left: 10, - right: 19, // More space on the right - top: 10, - bottom: 0, // Less space on the bottom - }), - ) - .show(ctx, |ui| { - // Create an island panel with rounded edges - Frame::new() - .fill(DashColors::surface(dark_mode)) - .stroke(Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(20)) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) - .shadow(Shadow::elevated()) - .show(ui, |ui| { - MessageBanner::show_global(ui); - let mut inner_action = AppAction::None; - - // Use a vertical layout that allocates space properly - ui.vertical(|ui| { - // Input field at the top - inner_action |= self.show_input_field(ui); - - // Document display area that expands to fill available space - ui.with_layout( - egui::Layout::top_down_justified(egui::Align::LEFT), - |ui| { - inner_action |= self.show_output(ui); - }, - ); - }); - - if self.contract_to_remove.is_some() { - inner_action |= self.show_remove_contract_popup(ui); - } - inner_action - }) - .inner - }) - .inner; + let dark_mode = ctx.global_style().visuals.dark_mode; + + action |= { + CentralPanel::default() + .frame( + Frame::new() + .fill(DashColors::background(dark_mode)) + .inner_margin(Margin { + left: 10, + right: 19, // More space on the right + top: 10, + bottom: 0, // Less space on the bottom + }), + ) + .show(ui, |ui| { + // Create an island panel with rounded edges + Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new(1.0, DashColors::border_light(dark_mode))) + .inner_margin(Margin::same(20)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) + .shadow(Shadow::elevated()) + .show(ui, |ui| { + MessageBanner::show_global(ui); + let mut inner_action = AppAction::None; + + // Use a vertical layout that allocates space properly + ui.vertical(|ui| { + // Input field at the top + inner_action |= self.show_input_field(ui); + + // Document display area that expands to fill available space + ui.with_layout( + egui::Layout::top_down_justified(egui::Align::LEFT), + |ui| { + inner_action |= self.show_output(ui); + }, + ); + }); + + if self.contract_to_remove.is_some() { + inner_action |= self.show_remove_contract_popup(ui); + } + inner_action + }) + .inner + }) + .inner + }; action } diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index d1960364f..802428320 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -49,7 +49,7 @@ use dash_sdk::drive::query::WhereClause; use dash_sdk::platform::{DocumentQuery, Identifier, IdentityPublicKey}; use dash_sdk::query_types::IndexMap; use eframe::epaint::Color32; -use egui::{Context, Frame, Margin, RichText, Ui}; +use egui::{Frame, Margin, RichText, Ui}; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, RwLock}; @@ -139,10 +139,7 @@ impl DocumentActionScreen { selected_identity: Option<QualifiedIdentity>, action_type: DocumentActionType, ) -> Self { - let known_contracts = app_context - .db - .get_contracts(&app_context, None, None) - .unwrap_or_default(); + let known_contracts = app_context.get_contracts().unwrap_or_default(); let identities_map = if let Ok(identities) = app_context.load_local_user_identities() { identities @@ -155,6 +152,10 @@ impl DocumentActionScreen { let selected_contract = known_contracts.into_iter().next(); + // Seed from the app-scoped selected identity when none was passed in (W2 SYNC). + let selected_identity = + selected_identity.or_else(|| app_context.resolve_selected_identity()); + let selected_identity_string = selected_identity .as_ref() .map(|qi| qi.identity.id().to_string(Encoding::Base58)) @@ -264,7 +265,7 @@ impl DocumentActionScreen { let identities_vec: Vec<_> = self.identities_map.values().cloned().collect(); - // Identity selector + // Identity selector — SYNC: write-back via syncing_global on user pick. let response = ui.add( IdentitySelector::new( "document_action_identity_selector", @@ -275,7 +276,8 @@ impl DocumentActionScreen { .unwrap() .width(300.0) .label("Identity:") - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); // Handle identity change - auto-select key and update wallet @@ -373,7 +375,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Document ID:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.document_id_input, dark_mode, @@ -510,7 +512,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Document ID:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.document_id_input, dark_mode, @@ -570,7 +572,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Document ID:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.document_id_input, dark_mode, @@ -653,7 +655,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Document ID:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.document_id_input, dark_mode, @@ -662,7 +664,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Price (credits):"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.price_input, dark_mode, @@ -682,7 +684,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Document ID:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.document_id_input, dark_mode, @@ -691,7 +693,7 @@ impl DocumentActionScreen { ui.horizontal(|ui| { ui.label("Recipient Identity:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(styled_text_edit_singleline( &mut self.recipient_id_input, dark_mode, @@ -735,7 +737,7 @@ impl DocumentActionScreen { | DocumentPropertyType::I16 | DocumentPropertyType::U8 | DocumentPropertyType::I8 => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(val) .hint_text("integer") @@ -744,7 +746,7 @@ impl DocumentActionScreen { ); } DocumentPropertyType::F64 => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(val) .hint_text("floating-point") @@ -753,7 +755,7 @@ impl DocumentActionScreen { ); } DocumentPropertyType::String(size) => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add({ let text_edit = egui::TextEdit::singleline(val) .text_color(DashColors::text_primary(dark_mode)) @@ -766,7 +768,7 @@ impl DocumentActionScreen { }); } DocumentPropertyType::ByteArray(_size) => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(val) .hint_text("hex or base64") @@ -775,7 +777,7 @@ impl DocumentActionScreen { ); } DocumentPropertyType::Identifier => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(val) .hint_text("base58 identifier") @@ -793,7 +795,7 @@ impl DocumentActionScreen { } } DocumentPropertyType::Date => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::singleline(val) .hint_text("unix-ms") @@ -804,7 +806,7 @@ impl DocumentActionScreen { DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( egui::TextEdit::multiline(val) .hint_text("JSON value") @@ -896,7 +898,7 @@ impl DocumentActionScreen { }; ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) @@ -947,6 +949,42 @@ impl DocumentActionScreen { action } + /// Resolve the four selections every document action needs — document + /// type, contract, identity, and signing key. + /// + /// The action buttons are only enabled once all four are chosen, so a + /// missing one is a broken UI invariant rather than normal input. Instead + /// of panicking, show an actionable banner and return `None` so the caller + /// degrades to `BackendTask::None`. + #[allow(clippy::type_complexity)] + fn require_selections( + &self, + ) -> Option<( + &DocumentType, + &QualifiedContract, + &QualifiedIdentity, + &IdentityPublicKey, + )> { + match ( + self.selected_document_type.as_ref(), + self.selected_contract.as_ref(), + self.selected_identity.as_ref(), + self.selected_key.as_ref(), + ) { + (Some(doc_type), Some(contract), Some(identity), Some(key)) => { + Some((doc_type, contract, identity, key)) + } + _ => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select a document type, contract, identity, and key before continuing.", + crate::ui::MessageType::Error, + ); + None + } + } + } + fn create_document_action(&mut self) -> BackendTask { match self.action_type { DocumentActionType::Create => self.create_document_task(), @@ -961,7 +999,9 @@ impl DocumentActionScreen { fn create_document_task(&mut self) -> BackendTask { match self.try_build_document() { Ok((doc, entropy)) => { - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -977,15 +1017,15 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::BroadcastDocument( - doc, + BackendTask::DocumentTask(Box::new(DocumentTask::BroadcastDocument { + document: doc, token_payment_info, entropy, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), - ))) + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), + })) } Err(e) => { MessageBanner::set_global( @@ -1002,7 +1042,9 @@ impl DocumentActionScreen { let document_id = Identifier::from_string(&self.document_id_input, Encoding::Base58).unwrap_or_default(); - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1017,21 +1059,23 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::DeleteDocument( + BackendTask::DocumentTask(Box::new(DocumentTask::DeleteDocument { document_id, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } fn create_purchase_task(&self) -> BackendTask { let document_id = Identifier::from_string(&self.document_id_input, Encoding::Base58).unwrap_or_default(); - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1046,22 +1090,25 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::PurchaseDocument( - self.fetched_price.unwrap_or(0), + BackendTask::DocumentTask(Box::new(DocumentTask::PurchaseDocument { + price: self.fetched_price.unwrap_or(0), document_id, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } fn create_replace_task(&mut self) -> BackendTask { if let Some(_original_doc) = &self.original_doc { match self.try_build_document_from_original() { Ok((updated_doc, _entropy)) => { - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() + else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1077,14 +1124,14 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::ReplaceDocument( - updated_doc, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + BackendTask::DocumentTask(Box::new(DocumentTask::ReplaceDocument { + document: updated_doc, + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } Err(e) => { MessageBanner::set_global( @@ -1096,7 +1143,9 @@ impl DocumentActionScreen { } } } else { - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1111,14 +1160,14 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::ReplaceDocument( - DocumentV0::default().into(), - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + BackendTask::DocumentTask(Box::new(DocumentTask::ReplaceDocument { + document: DocumentV0::default().into(), + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } } @@ -1127,7 +1176,9 @@ impl DocumentActionScreen { Identifier::from_string(&self.document_id_input, Encoding::Base58).unwrap_or_default(); let price = self.price_input.parse::<u64>().unwrap_or(0); - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1142,15 +1193,15 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::SetDocumentPrice( + BackendTask::DocumentTask(Box::new(DocumentTask::SetDocumentPrice { price, document_id, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } fn create_transfer_task(&self) -> BackendTask { @@ -1159,7 +1210,9 @@ impl DocumentActionScreen { let recipient_id = Identifier::from_string(&self.recipient_id_input, Encoding::Base58).unwrap_or_default(); - let doc_type = self.selected_document_type.as_ref().unwrap(); + let Some((doc_type, contract, identity, key)) = self.require_selections() else { + return BackendTask::None; + }; let token_payment_info = doc_type @@ -1174,15 +1227,15 @@ impl DocumentActionScreen { }) }); - BackendTask::DocumentTask(Box::new(DocumentTask::TransferDocument( + BackendTask::DocumentTask(Box::new(DocumentTask::TransferDocument { document_id, - recipient_id, - doc_type.clone(), - Arc::new(self.selected_contract.as_ref().unwrap().contract.clone()), - self.selected_identity.as_ref().unwrap().clone(), - self.selected_key.as_ref().unwrap().clone(), + new_owner_id: recipient_id, + document_type: doc_type.clone(), + data_contract: Arc::new(contract.contract.clone()), + qualified_identity: identity.clone(), + identity_key: key.clone(), token_payment_info, - ))) + })) } fn try_build_document(&self) -> Result<(Document, [u8; 32]), String> { @@ -1564,9 +1617,11 @@ impl DocumentActionScreen { } impl ScreenLike for DocumentActionScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -1576,12 +1631,12 @@ impl ScreenLike for DocumentActionScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenDocumentQuery, ); - action |= island_central_panel(ctx, |ui| match &self.broadcast_status { + action |= island_central_panel(ui, |ui| match &self.broadcast_status { BroadcastStatus::Broadcasted => { let success_message = format!("{} successful!", self.action_type.display_name()); let back_button = ("Back to Contracts".to_string(), AppAction::GoToMainScreen); @@ -1662,8 +1717,13 @@ impl ScreenLike for DocumentActionScreen { self.original_doc = Some(doc.clone()); // Populate field inputs with existing values // Only include properties that are defined in the document type schema - let doc_type_properties = - self.selected_document_type.as_ref().unwrap().properties(); + let Some(doc_type) = self.selected_document_type.as_ref() else { + tracing::warn!( + "Replace results arrived without a selected document type; skipping field population" + ); + return; + }; + let doc_type_properties = doc_type.properties(); self.field_inputs = doc .properties() .iter() @@ -1792,13 +1852,14 @@ impl DocumentActionScreen { } if let Some(wallet) = &self.wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global( + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + let handle = MessageBanner::set_global( self.app_context.egui_ctx(), "Unable to open wallet. Please unlock it and try again.", crate::ui::MessageType::Error, - ) - .with_details(e); + ); + handle.disable_auto_dismiss(); + handle.with_details(e); } self.wallet_open_attempted = true; } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 89a953f1b..835c6306b 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -49,7 +49,7 @@ use dash_sdk::dpp::tokens::emergency_action::TokenEmergencyAction; use dash_sdk::dpp::tokens::token_event::TokenEvent; use dash_sdk::platform::Identifier; use dash_sdk::query_types::IndexMap; -use eframe::egui::{self, Context, RichText}; +use eframe::egui::{self, RichText}; use egui::{ScrollArea, TextStyle}; use egui_extras::{Column, TableBuilder}; use std::collections::BTreeMap; @@ -78,7 +78,7 @@ pub struct GroupActionsScreen { contract_search: String, qualified_identities: Vec<QualifiedIdentity>, identity_token_balances: IndexMap<IdentityTokenIdentifier, IdentityTokenBalance>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_str: String, // Backend task status @@ -98,7 +98,7 @@ impl GroupActionsScreen { vec![] }); - let contracts_with_group_actions = app_context.db.get_contracts(app_context, None, None).unwrap_or_default().into_iter().filter_map(|qualified_contract| { + let contracts_with_group_actions = app_context.get_contracts().unwrap_or_default().into_iter().filter_map(|qualified_contract| { let tokens = qualified_contract.contract.tokens().clone().into_iter().filter_map(|(pos, token_config)| { let change_control_rules = token_config.all_change_control_rules().into_iter().filter_map(|(name, change_control_rules)| { match change_control_rules.authorized_to_make_change_action_takers() { @@ -359,47 +359,48 @@ impl GroupActionsScreen { match token_event { TokenEvent::Mint(amount, _identifier, note_opt) => { let mut mint_screen = MintTokensScreen::new(identity_token_info, &self.app_context); - mint_screen.group_action_id = Some(action_id); - mint_screen.amount = Some(Amount::from_token( - &mint_screen.identity_token_info, + mint_screen.common.group_action_id = Some(action_id); + mint_screen.action.amount = Some(Amount::from_token( + &mint_screen.common.identity_token_info, *amount, )); - mint_screen.public_note = note_opt.clone(); + mint_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::MintTokensScreen(mint_screen)); } TokenEvent::Burn(amount, _burn_from, note_opt) => { let mut burn_screen = BurnTokensScreen::new(identity_token_info, &self.app_context); - burn_screen.group_action_id = Some(action_id); + burn_screen.common.group_action_id = Some(action_id); // Convert amount to Amount struct using the token configuration - burn_screen.amount = Some(Amount::from_token( - &burn_screen.identity_token_info, + burn_screen.action.amount = Some(Amount::from_token( + &burn_screen.common.identity_token_info, *amount, )); - burn_screen.public_note = note_opt.clone(); + burn_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::BurnTokensScreen(burn_screen)); } TokenEvent::Freeze(identifier, note_opt) => { let mut freeze_screen = FreezeTokensScreen::new(identity_token_info, &self.app_context); - freeze_screen.group_action_id = Some(action_id); - freeze_screen.freeze_identity_id = identifier.to_string(Encoding::Base58); - freeze_screen.public_note = note_opt.clone(); + freeze_screen.common.group_action_id = Some(action_id); + freeze_screen.action.freeze_identity_id = identifier.to_string(Encoding::Base58); + freeze_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::FreezeTokensScreen(freeze_screen)); } TokenEvent::Unfreeze(identifier, note_opt) => { let mut unfreeze_screen = UnfreezeTokensScreen::new(identity_token_info, &self.app_context); - unfreeze_screen.group_action_id = Some(action_id); - unfreeze_screen.unfreeze_identity_id = identifier.to_string(Encoding::Base58); - unfreeze_screen.public_note = note_opt.clone(); + unfreeze_screen.common.group_action_id = Some(action_id); + unfreeze_screen.action.unfreeze_identity_id = + identifier.to_string(Encoding::Base58); + unfreeze_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::UnfreezeTokensScreen(unfreeze_screen)); } TokenEvent::DestroyFrozenFunds(identifier, _amount, note_opt) => { let mut destroy_screen = DestroyFrozenFundsScreen::new(identity_token_info, &self.app_context); - destroy_screen.group_action_id = Some(action_id); - destroy_screen.frozen_identity_id = identifier.to_string(Encoding::Base58); - destroy_screen.public_note = note_opt.clone(); + destroy_screen.common.group_action_id = Some(action_id); + destroy_screen.action.frozen_identity_id = identifier.to_string(Encoding::Base58); + destroy_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::DestroyFrozenFundsScreen(destroy_screen)); } TokenEvent::EmergencyAction(emergency_action, note_opt) => { @@ -408,15 +409,15 @@ impl GroupActionsScreen { TokenEmergencyAction::Pause => { let mut pause_screen = PauseTokensScreen::new(identity_token_info, &self.app_context); - pause_screen.group_action_id = Some(action_id); - pause_screen.public_note = note_opt.clone(); + pause_screen.common.group_action_id = Some(action_id); + pause_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::PauseTokensScreen(pause_screen)); } TokenEmergencyAction::Resume => { let mut resume_screen = ResumeTokensScreen::new(identity_token_info, &self.app_context); - resume_screen.group_action_id = Some(action_id); - resume_screen.public_note = note_opt.clone(); + resume_screen.common.group_action_id = Some(action_id); + resume_screen.common.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::ResumeTokensScreen(resume_screen)); } } @@ -474,9 +475,9 @@ impl ScreenLike for GroupActionsScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -486,12 +487,12 @@ impl ScreenLike for GroupActionsScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenDocumentQuery, ); - let central_panel_action = island_central_panel(ctx, |ui| { + let central_panel_action = island_central_panel(ui, |ui| { ui.heading("Active Group Actions"); ui.add_space(10.0); @@ -537,6 +538,11 @@ impl ScreenLike for GroupActionsScreen { ui.add_space(10.0); + // K3 — SESSION-LOCAL: no `with_app_default` and no `syncing_global`. + // GroupActions targets a specific group and contract for one session; + // writing the user's pick back to the global selection would incorrectly + // re-point "who you are" to a group-member identity that may not be + // the user's primary operating identity. ui.add( IdentitySelector::new( "group_actions_identity_selector", diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 477a18f9e..fa7acf3c9 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -24,7 +24,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::platform::{DataContract, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; +use eframe::egui::{self, Color32, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; @@ -63,7 +63,16 @@ impl RegisterDataContractScreen { let qualified_identities: Vec<QualifiedIdentity> = app_context.load_local_user_identities().unwrap_or_default(); - let selected_qualified_identity = qualified_identities.first().cloned(); + // Seed from the app-scoped selected identity (W2 SYNC); fall back to first. + let selected_qualified_identity = app_context + .selected_identity_id() + .and_then(|id| { + qualified_identities + .iter() + .find(|qi| qi.identity.id() == id) + .cloned() + }) + .or_else(|| qualified_identities.first().cloned()); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) @@ -156,7 +165,7 @@ impl RegisterDataContractScreen { } fn ui_input_field(&mut self, ui: &mut egui::Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let response = ui.add( TextEdit::multiline(&mut self.contract_json_input) .desired_rows(12) @@ -179,7 +188,7 @@ impl RegisterDataContractScreen { }; if let Some(msg) = error_msg { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let error_color = DashColors::error_color(dark_mode); Frame::new() .fill(error_color.gamma_multiply(0.1)) @@ -224,7 +233,7 @@ impl RegisterDataContractScreen { .estimate_storage_based_fee(contract_size, 20); // ~20 seeks for tree operations let estimated_fee = registration_fee.saturating_add(storage_fee); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) @@ -249,15 +258,28 @@ impl RegisterDataContractScreen { new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); if ComponentStyles::add_primary_button(ui, "Register Contract").clicked() { - // Fire off a backend task - app_action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( - ContractTask::RegisterDataContract( - (**contract).clone(), - self.contract_alias_input.clone(), - self.selected_qualified_identity.clone().unwrap(), // unwrap should be safe here - self.selected_key.clone().unwrap(), // unwrap should be safe here - ), - ))); + // The button is only reachable once an identity and key are + // selected; guard rather than unwrap so a missing selection + // degrades to an actionable banner instead of a panic. + if let (Some(qualified_identity), Some(key)) = ( + self.selected_qualified_identity.clone(), + self.selected_key.clone(), + ) { + app_action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( + ContractTask::RegisterDataContract( + (**contract).clone(), + self.contract_alias_input.clone(), + qualified_identity, + key, + ), + ))); + } else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select an identity and key before registering the contract.", + MessageType::Error, + ); + } } } BroadcastStatus::Broadcasting => { @@ -351,9 +373,11 @@ impl ScreenLike for RegisterDataContractScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -363,12 +387,12 @@ impl ScreenLike for RegisterDataContractScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenDocumentQuery, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); } @@ -431,7 +455,8 @@ impl ScreenLike for RegisterDataContractScreen { .unwrap() .width(300.0) .label("Identity:") - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); // Handle identity change - auto-select key and update wallet @@ -501,7 +526,7 @@ impl ScreenLike for RegisterDataContractScreen { // Render wallet unlock if needed if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - let _ = try_open_wallet_no_password(wallet) + let _ = try_open_wallet_no_password(&self.app_context, wallet) .or_show_error(ui.ctx()); self.wallet_open_attempted = true; } diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index ca47f8462..32369b36d 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -26,7 +26,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; +use eframe::egui::{self, Color32, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -69,7 +69,17 @@ impl UpdateDataContractScreen { pub fn new(app_context: &Arc<AppContext>) -> Self { let qualified_identities: Vec<_> = app_context.load_local_user_identities().unwrap_or_default(); - let selected_qualified_identity = qualified_identities.first().cloned(); + + // Seed from the app-scoped selected identity (W2 SYNC); fall back to first. + let selected_qualified_identity = app_context + .selected_identity_id() + .and_then(|id| { + qualified_identities + .iter() + .find(|qi| qi.identity.id() == id) + .cloned() + }) + .or_else(|| qualified_identities.first().cloned()); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) @@ -79,7 +89,7 @@ impl UpdateDataContractScreen { let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; let known_contracts = app_context - .get_contracts(None, None) + .get_contracts() .expect("Failed to load contracts") .into_iter() .filter(|c| match &c.alias { @@ -171,7 +181,7 @@ impl UpdateDataContractScreen { ScrollArea::vertical() .max_height(ui.available_height() - 100.0) .show(ui, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let response = ui.add( TextEdit::multiline(&mut self.contract_json_input) .desired_rows(6) @@ -235,7 +245,7 @@ impl UpdateDataContractScreen { .contract_update; let estimated_fee = base_fee.saturating_add(registration_fee); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) @@ -261,14 +271,27 @@ impl UpdateDataContractScreen { new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); if ComponentStyles::add_primary_button(ui, "Update Contract").clicked() { - // Fire off a backend task - app_action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( - ContractTask::UpdateDataContract( - (**contract).clone(), - self.selected_qualified_identity.clone().unwrap(), // unwrap should be safe here - self.selected_key.clone().unwrap(), // unwrap should be safe here - ), - ))); + // The button is only reachable once an identity and key are + // selected; guard rather than unwrap so a missing selection + // degrades to an actionable banner instead of a panic. + if let (Some(qualified_identity), Some(key)) = ( + self.selected_qualified_identity.clone(), + self.selected_key.clone(), + ) { + app_action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( + ContractTask::UpdateDataContract( + (**contract).clone(), + qualified_identity, + key, + ), + ))); + } else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select an identity and key before updating the contract.", + MessageType::Error, + ); + } } } BroadcastStatus::FetchingNonce => { @@ -372,9 +395,11 @@ impl ScreenLike for UpdateDataContractScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -384,12 +409,12 @@ impl ScreenLike for UpdateDataContractScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenDocumentQuery, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); } @@ -450,7 +475,8 @@ impl ScreenLike for UpdateDataContractScreen { .unwrap() .width(300.0) .label("Identity:") - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); // Handle identity change - auto-select key and update wallet @@ -516,7 +542,8 @@ impl ScreenLike for UpdateDataContractScreen { // Render the wallet unlock if needed if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - let _ = try_open_wallet_no_password(wallet).or_show_error(ui.ctx()); + let _ = try_open_wallet_no_password(&self.app_context, wallet) + .or_show_error(ui.ctx()); self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index dccc3c5f4..d39f9dda2 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -1,6 +1,7 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; 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::qualified_identity::QualifiedIdentity; @@ -21,8 +22,9 @@ use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; -use egui::{Context, RichText, ScrollArea, TextEdit, Ui}; +use egui::{RichText, ScrollArea, TextEdit, Ui}; use std::sync::{Arc, RwLock}; const CONTACT_REQUEST_INFO_TEXT: &str = "About Contact Requests:\n\n\ @@ -41,7 +43,7 @@ enum ContactRequestStatus { pub struct AddContactScreen { pub app_context: Arc<AppContext>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, selected_key: Option<IdentityPublicKey>, username_or_id: String, @@ -56,10 +58,25 @@ pub struct AddContactScreen { impl AddContactScreen { pub fn new(app_context: Arc<AppContext>) -> Self { + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + let identities = app_context.load_local_user_identities().unwrap_or_default(); + let selected_identity = app_context + .selected_identity_id() + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .or_else(|| identities.first().cloned()); + let selected_identity_string = selected_identity + .as_ref() + .map(|qi| { + qi.identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + }) + .unwrap_or_default(); + Self { app_context, - selected_identity: None, - selected_identity_string: String::new(), + selected_identity, + selected_identity_string, selected_key: None, username_or_id: String::new(), account_label: String::new(), @@ -73,10 +90,25 @@ impl AddContactScreen { } pub fn new_with_identity_id(app_context: Arc<AppContext>, identity_id: String) -> Self { + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + let identities = app_context.load_local_user_identities().unwrap_or_default(); + let selected_identity = app_context + .selected_identity_id() + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .or_else(|| identities.first().cloned()); + let selected_identity_string = selected_identity + .as_ref() + .map(|qi| { + qi.identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + }) + .unwrap_or_default(); + Self { app_context, - selected_identity: None, - selected_identity_string: String::new(), + selected_identity, + selected_identity_string, selected_key: None, username_or_id: identity_id, account_label: String::new(), @@ -103,9 +135,10 @@ impl AddContactScreen { } // Validate username format if it looks like a username - if let Err(input) = crate::model::dpns::validate_dpns_input(&self.username_or_id) { - self.status = - ContactRequestStatus::Error(DashPayError::InvalidUsername { username: input }); + if crate::model::dpns::validate_dpns_input(&self.username_or_id).is_err() { + self.status = ContactRequestStatus::Error(DashPayError::InvalidUsername { + username: self.username_or_id.trim().to_string(), + }); return AppAction::None; } @@ -187,10 +220,12 @@ impl ScreenLike for AddContactScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // Add top panel with navigation breadcrumbs let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -200,12 +235,12 @@ impl ScreenLike for AddContactScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, DashPaySubscreen::Contacts); // Main content in island central panel - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Show success screen if request was successful @@ -232,7 +267,7 @@ impl ScreenLike for AddContactScreen { // Identity and Key selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -241,7 +276,7 @@ impl ScreenLike for AddContactScreen { } ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("From (Sender)") .strong() @@ -249,7 +284,8 @@ impl ScreenLike for AddContactScreen { ); ui.separator(); - // Identity selector + // Identity selector — SYNC: write-back via syncing_global on user pick (FR-6: + // User-only source, so no masternode can leak to the app-global identity). let response = ui.add( IdentitySelector::new( "contact_sender_identity_selector", @@ -260,7 +296,8 @@ impl ScreenLike for AddContactScreen { .unwrap() .width(300.0) .label("Identity:") - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); // Handle identity change - auto-select key and update wallet @@ -323,7 +360,7 @@ impl ScreenLike for AddContactScreen { // Loading indicator if matches!(self.status, ContactRequestStatus::Sending) { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); ui.label( RichText::new("Sending contact request...") @@ -335,7 +372,7 @@ impl ScreenLike for AddContactScreen { // Show error if any if let ContactRequestStatus::Error(ref err) = self.status { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let error_color = if dark_mode { DashColors::ERROR } else { @@ -345,7 +382,7 @@ impl ScreenLike for AddContactScreen { ui.group(|ui| { ui.horizontal(|ui| { ui.vertical(|ui| { - ui.label(RichText::new(err.user_message()).color(error_color)); + ui.label(RichText::new(err.to_string()).color(error_color)); // Show retry suggestion for recoverable errors if err.is_recoverable() { @@ -400,7 +437,7 @@ impl ScreenLike for AddContactScreen { // Contact request form ScrollArea::vertical().show(ui, |ui| { ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("To (Recipient)") .strong() @@ -443,7 +480,7 @@ impl ScreenLike for AddContactScreen { // Show summary if all required fields are filled if self.selected_identity.is_some() && !self.username_or_id.is_empty() { ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Request Summary") .strong() @@ -494,17 +531,18 @@ impl ScreenLike for AddContactScreen { } ui.group(|ui| { - let _dark_mode = ui.ctx().style().visuals.dark_mode; + let _dark_mode = ui.style().visuals.dark_mode; // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { crate::ui::components::MessageBanner::set_global( ui.ctx(), &e, MessageType::Error, - ); + ) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -578,7 +616,7 @@ impl ScreenLike for AddContactScreen { if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("About Contact Requests", CONTACT_REQUEST_INFO_TEXT); if popup.show(ui).inner { @@ -602,68 +640,28 @@ impl ScreenLike for AddContactScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - let error = DashPayError::Internal { - message: message.to_string(), - }; - self.status = ContactRequestStatus::Error(error); + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::DashPayContactRequestSent(_recipient) = result { + self.status = ContactRequestStatus::Success; + self.username_or_id.clear(); + self.account_label.clear(); + self.selected_key = None; } } - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - match result { - BackendTaskSuccessResult::DashPayContactRequestSent(_recipient) => { - // Contact request sent successfully - show success screen - self.status = ContactRequestStatus::Success; - // Clear form for next use - self.username_or_id.clear(); - self.account_label.clear(); - self.selected_key = None; + fn display_task_error(&mut self, error: &TaskError) -> bool { + match classify_send_error(error, &self.username_or_id) { + Some(dashpay_error) => { + self.status = ContactRequestStatus::Error(dashpay_error); + true } - BackendTaskSuccessResult::Message(message) => { - // TODO(RUST-002): Replace string-based error matching with structured - // error types through the task result system. This is fragile — if - // upstream error wording changes, classification silently breaks. - // See: https://github.com/dashpay/dash-evo-tool/issues/660 - if message.contains("Error") - || message.contains("Failed") - || message.contains("does not have") - { - // Try to parse structured error, fallback to generic - let error = if message.contains("ENCRYPTION key") { - DashPayError::MissingEncryptionKey - } else if message.contains("DECRYPTION key") { - DashPayError::MissingDecryptionKey - } else if message.contains("not found") && message.contains("username") { - DashPayError::UsernameResolutionFailed { - username: self.username_or_id.clone(), - } - } else if message.contains("Identity not found") { - DashPayError::IdentityNotFound { - identity_id: dash_sdk::platform::Identifier::from_string( - &self.username_or_id, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ) - .unwrap_or_else(|_| dash_sdk::platform::Identifier::random()), - } - } else if message.contains("Network") || message.contains("connection") { - DashPayError::NetworkError { - reason: message.clone(), - } - } else { - DashPayError::Internal { - message: message.clone(), - } - }; - - self.status = ContactRequestStatus::Error(error); + None => { + // No dedicated affordance: stop the spinner and let the global + // banner report the error. + if matches!(self.status, ContactRequestStatus::Sending) { + self.status = ContactRequestStatus::NotStarted; } - // Ignore other messages - they're not for this screen - } - _ => { - // Ignore results not meant for this screen + false } } } @@ -678,3 +676,117 @@ impl AddContactScreen { self.refresh(); } } + +/// Map a typed send-contact-request error onto the screen-local error category +/// that drives a dedicated affordance (key-add button, tip, retry). Returns +/// `None` when no add-contact-specific UI applies, leaving the global banner to +/// report the error. `username_or_id` is the current recipient input, used to +/// label an unresolved identity. +fn classify_send_error(error: &TaskError, username_or_id: &str) -> Option<DashPayError> { + match error { + TaskError::IdentityNotFound => Some(DashPayError::IdentityNotFound { + identity_id: dash_sdk::platform::Identifier::from_string( + username_or_id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + .ok()?, + }), + TaskError::DashPay(inner) => match inner { + DashPayError::MissingEncryptionKey => Some(DashPayError::MissingEncryptionKey), + DashPayError::MissingDecryptionKey => Some(DashPayError::MissingDecryptionKey), + DashPayError::UsernameResolutionFailed { username } => { + Some(DashPayError::UsernameResolutionFailed { + username: username.clone(), + }) + } + DashPayError::InvalidUsername { username } => Some(DashPayError::InvalidUsername { + username: username.clone(), + }), + DashPayError::AccountLabelTooLong { length, max } => { + Some(DashPayError::AccountLabelTooLong { + length: *length, + max: *max, + }) + } + DashPayError::CannotContactSelf => Some(DashPayError::CannotContactSelf), + DashPayError::ContactRequestAlreadySent { to } => { + Some(DashPayError::ContactRequestAlreadySent { to: to.clone() }) + } + DashPayError::NetworkError => Some(DashPayError::NetworkError), + _ => None, + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_missing_key_errors_for_add_key_affordance() { + let enc = classify_send_error( + &TaskError::DashPay(DashPayError::MissingEncryptionKey), + "alice.dash", + ); + assert!(matches!(enc, Some(DashPayError::MissingEncryptionKey))); + + let dec = classify_send_error( + &TaskError::DashPay(DashPayError::MissingDecryptionKey), + "alice.dash", + ); + assert!(matches!(dec, Some(DashPayError::MissingDecryptionKey))); + } + + #[test] + fn classifies_username_resolution_failure_preserving_username() { + let mapped = classify_send_error( + &TaskError::DashPay(DashPayError::UsernameResolutionFailed { + username: "bob.dash".to_string(), + }), + "bob.dash", + ); + assert!(matches!( + mapped, + Some(DashPayError::UsernameResolutionFailed { username }) if username == "bob.dash" + )); + } + + #[test] + fn recoverable_errors_map_through_so_retry_is_offered() { + let mapped = classify_send_error( + &TaskError::DashPay(DashPayError::NetworkError), + "alice.dash", + ); + let mapped = mapped.expect("network errors should be classified"); + assert!(mapped.is_recoverable()); + } + + #[test] + fn identity_not_found_with_valid_base58_maps_to_typed_variant() { + let id = dash_sdk::platform::Identifier::random() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + let mapped = classify_send_error(&TaskError::IdentityNotFound, &id); + assert!(matches!( + mapped, + Some(DashPayError::IdentityNotFound { .. }) + )); + } + + #[test] + fn identity_not_found_with_invalid_base58_falls_back_to_global_banner() { + let mapped = classify_send_error(&TaskError::IdentityNotFound, "not a valid id"); + assert!(mapped.is_none()); + } + + #[test] + fn unrelated_errors_defer_to_global_banner() { + let mapped = classify_send_error( + &TaskError::EncryptionError { + detail: "ecdh".to_string(), + }, + "alice.dash", + ); + assert!(mapped.is_none()); + } +} diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index 68baa3d90..6aeba5730 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -85,57 +85,27 @@ impl ContactDetailsScreen { screen } - /// Load contact data from local database for immediate display. + /// Initialise `contact_info` from local-only data (private notes / hidden flag). + /// Public profile fields (username, display_name, avatar, bio) are populated + /// asynchronously by `FetchContactProfile` — see `display_task_result`. fn load_from_database(&mut self) { let identity_id = self.identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Try to load the contact's public info from the dashpay_contacts table - let mut username = None; - let mut display_name = None; - let mut avatar_url = None; - let mut bio = None; - - if let Ok(stored_contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - for stored_contact in stored_contacts { - if let Ok(contact_id) = Identifier::from_bytes(&stored_contact.contact_identity_id) - && contact_id == self.contact_id - { - username = stored_contact.username; - display_name = stored_contact.display_name; - avatar_url = stored_contact.avatar_url; - // bio is stored in profiles, not contacts table - break; - } - } - } - - // Load the profile for bio if available - if let Ok(Some(profile)) = self - .app_context - .db - .load_dashpay_profile(&self.contact_id, &network_str) - { - bio = profile.bio; - // Also prefer profile display_name and avatar_url if not already set - if display_name.is_none() { - display_name = profile.display_name; - } - if avatar_url.is_none() { - avatar_url = profile.avatar_url; - } - } - // Load private contact info (nickname, notes, hidden) - let (nickname, note, is_hidden) = self - .app_context - .db - .load_contact_private_info(&identity_id, &self.contact_id) - .unwrap_or_default(); + // Load private contact info (nickname, notes, hidden) — DET-local memo, + // backed by the WalletBackend k/v sidecar post-D4c. + let (nickname, note, is_hidden) = + match self.app_context.wallet_backend().and_then(|backend| { + backend.dashpay_get_private_info(&identity_id, &self.contact_id) + }) { + Ok(Some(info)) => (info.nickname, info.notes, info.is_hidden), + Ok(None) => (String::new(), String::new(), false), + Err(e) => { + tracing::warn!( + "DashPay private-info sidecar read failed; defaulting to empty: {e:?}" + ); + (String::new(), String::new(), false) + } + }; let nickname = if nickname.is_empty() { None @@ -144,6 +114,20 @@ impl ContactDetailsScreen { }; let note = if note.is_empty() { None } else { Some(note) }; + // Preserve any public profile fields already in `contact_info`; otherwise + // start empty and let the async fetch fill them in. + let (username, display_name, avatar_url, bio) = + if let Some(existing) = self.contact_info.as_ref() { + ( + existing.username.clone(), + existing.display_name.clone(), + existing.avatar_url.clone(), + existing.bio.clone(), + ) + } else { + (None, None, None, None) + }; + self.contact_info = Some(ContactInfo { identity_id: self.contact_id, username, @@ -194,16 +178,20 @@ impl ContactDetailsScreen { info.is_hidden = self.edit_hidden; } - // Save to local database immediately + // Persist the memo to the per-network k/v sidecar so the UI has + // instant feedback while the (encrypted) Platform write below is + // in flight. Best-effort: a sidecar miss never blocks the user + // action. let identity_id = self.identity.identity.id(); - if let Err(e) = self.app_context.db.save_contact_private_info( + if let Err(e) = crate::ui::dashpay::persist_contact_private_info( + &self.app_context, &identity_id, &self.contact_id, - &self.edit_nickname, - &self.edit_note, + self.edit_nickname.clone(), + self.edit_note.clone(), self.edit_hidden, ) { - tracing::warn!("Failed to save contact private info to database: {}", e); + tracing::warn!("DashPay private-info sidecar write failed: {e:?}"); } self.editing_info = false; @@ -424,7 +412,7 @@ impl ContactDetailsScreen { ui.label("No payment history with this contact"); } else { for payment in &self.payment_history { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { // Direction indicator if payment.is_incoming { @@ -520,7 +508,7 @@ impl ScreenLike for ContactDetailsScreen { self.needs_backend_fetch = true; } - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel with contact name if available @@ -536,7 +524,7 @@ impl ScreenLike for ContactDetailsScreen { .unwrap_or_else(|| "Contact Details".to_string()); action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -546,17 +534,17 @@ impl ScreenLike for ContactDetailsScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, DashPaySubscreen::Contacts); - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Show info popup if requested if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Private Contact Information", PRIVATE_CONTACT_INFO_TEXT); if popup.show(ui).inner { @@ -597,18 +585,10 @@ impl ScreenLike for ContactDetailsScreen { .and_then(|v| v.as_text()) .map(|s| s.to_string()); - // Save profile to local database for future offline access - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_profile( - &self.contact_id, - &network_str, - display_name.as_deref(), - bio.as_deref(), - avatar_url.as_deref(), - None, // public_message - ) { - tracing::warn!("Failed to save dashpay profile to database: {}", e); - } + // `FetchContactProfile` re-queries Platform on each open, and + // the WalletBackend mirror covers identities we manage. + // Out-of-wallet contact profiles are not cached through the + // upstream seam. // Update the in-memory contact info if let Some(info) = &mut self.contact_info { diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs deleted file mode 100644 index 34d5a2a24..000000000 --- a/src/ui/dashpay/contact_info_editor.rs +++ /dev/null @@ -1,384 +0,0 @@ -use crate::app::{AppAction, DesiredAppAction}; -use crate::backend_task::dashpay::{ContactData, DashPayTask}; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; -use crate::ui::components::info_popup::InfoPopup; -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; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{MessageBanner, ResultBannerExt}; -use crate::ui::dashpay::DashPaySubscreen; -use crate::ui::identities::get_selected_wallet; -use crate::ui::theme::DashColors; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::platform::Identifier; -use egui::{RichText, ScrollArea, TextEdit, Ui}; -use std::sync::{Arc, RwLock}; - -const PRIVATE_CONTACT_INFO_TEXT: &str = "About Private Contact Information:\n\n\ - This information is encrypted and stored on Platform.\n\n\ - It is NEVER shared with the contact - only you can decrypt it.\n\n\ - Only you can see these nicknames and notes.\n\n\ - Hidden contacts can still send you payments.\n\n\ - Use this to organize and remember your contacts."; - -pub struct ContactInfoEditorScreen { - pub app_context: Arc<AppContext>, - pub identity: QualifiedIdentity, - pub contact_id: Identifier, - contact_username: Option<String>, - nickname: String, - note: String, - is_hidden: bool, - accepted_accounts: Vec<u32>, - account_input: String, - saving: bool, - show_info_popup: bool, - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, -} - -impl ContactInfoEditorScreen { - pub fn new( - app_context: Arc<AppContext>, - identity: QualifiedIdentity, - contact_id: Identifier, - ) -> Self { - // Get wallet for the identity - let selected_wallet = get_selected_wallet(&identity, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); - - Self { - app_context, - identity, - contact_id, - contact_username: None, - nickname: String::new(), - note: String::new(), - is_hidden: false, - accepted_accounts: Vec::new(), - account_input: String::new(), - saving: false, - show_info_popup: false, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - } - } - - fn load_contact_info(&mut self) -> AppAction { - // Trigger fetch from platform to get existing contact info - let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { - identity: self.identity.clone(), - })); - AppAction::BackendTask(task) - } - - fn handle_contacts_result(&mut self, contacts_data: Vec<ContactData>) { - // Find the contact info for our specific contact - for contact_data in contacts_data { - if contact_data.identity_id == self.contact_id { - self.nickname = contact_data.nickname.unwrap_or_default(); - self.note = contact_data.note.unwrap_or_default(); - self.is_hidden = contact_data.is_hidden; - // Note: accepted_accounts would come from the ContactData but we're not fully implementing it yet - break; - } - } - } - - fn save_contact_info(&mut self) -> AppAction { - self.saving = true; - - let task = BackendTask::DashPayTask(Box::new(DashPayTask::UpdateContactInfo { - identity: self.identity.clone(), - contact_id: self.contact_id, - nickname: if self.nickname.is_empty() { - None - } else { - Some(self.nickname.clone()) - }, - note: if self.note.is_empty() { - None - } else { - Some(self.note.clone()) - }, - is_hidden: self.is_hidden, - accepted_accounts: self.accepted_accounts.clone(), - })); - - AppAction::BackendTask(task) - } - - pub fn render(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; - - // Header with Back button and title - ui.horizontal(|ui| { - if ui.button("Back").clicked() { - action = AppAction::PopScreen; - } - ui.heading("Edit Private Contact Details"); - ui.add_space(5.0); - if crate::ui::helpers::info_icon_button(ui, PRIVATE_CONTACT_INFO_TEXT).clicked() { - self.show_info_popup = true; - } - }); - - ui.separator(); - - ScrollArea::vertical().show(ui, |ui| { - ui.group(|ui| { - // Contact identity - ui.horizontal(|ui| { - ui.label(RichText::new("Contact:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); - if let Some(username) = &self.contact_username { - ui.label(RichText::new(username).color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } else { - ui.label(RichText::new(format!("{}", self.contact_id)) - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } - }); - - ui.separator(); - - // Nickname field - ui.label(RichText::new("Private Nickname:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); - ui.label(RichText::new("Give this contact a custom name that ONLY YOU will see").small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - ui.add( - TextEdit::singleline(&mut self.nickname) - .hint_text("e.g., 'Mom', 'Boss', 'Alice from work'") - .desired_width(300.0) - ); - - ui.add_space(10.0); - - // Note field - ui.label(RichText::new("Private Note:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); - ui.label(RichText::new("Add notes about this contact (only visible to you)").small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - ui.add( - TextEdit::multiline(&mut self.note) - .hint_text("e.g., 'Met at Dash conference 2024', 'Owes me for lunch'") - .desired_rows(5) - .desired_width(f32::INFINITY) - ); - - ui.add_space(10.0); - - // Hidden checkbox - ui.horizontal(|ui| { - ui.checkbox(&mut self.is_hidden, "Hide this contact from my list"); - }); - if self.is_hidden { - ui.label(RichText::new("⚠️ Hidden contacts won't appear in your contact list but can still send you payments") - .small().color(DashColors::WARNING)); - } else { - ui.label(RichText::new("Contact will appear in your contact list").small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } - - ui.add_space(10.0); - - // Account references section - ui.label(RichText::new("Accepted Account Indices:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); - ui.label(RichText::new("Specify which account indices this contact can pay to (comma-separated)").small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - - ui.horizontal(|ui| { - ui.add( - TextEdit::singleline(&mut self.account_input) - .hint_text("e.g., 0, 1, 2") - .desired_width(200.0) - ); - - if ui.button("Parse").clicked() { - // Parse the account indices - self.accepted_accounts.clear(); - for part in self.account_input.split(',') { - if let Ok(index) = part.trim().parse::<u32>() - && !self.accepted_accounts.contains(&index) - { - self.accepted_accounts.push(index); - } - } - self.accepted_accounts.sort(); - - // Update the input field to show the parsed values - self.account_input = self.accepted_accounts - .iter() - .map(|i| i.to_string()) - .collect::<Vec<_>>() - .join(", "); - } - }); - - if !self.accepted_accounts.is_empty() { - ui.label(RichText::new(format!("Accepted accounts: {:?}", self.accepted_accounts)).small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } else { - ui.label(RichText::new("All accounts accepted (default)").small() - .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } - - ui.add_space(20.0); - - // Check wallet lock status before showing save button - let wallet_locked = if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - wallet_needs_unlock(wallet) - } else { - false - }; - - if wallet_locked { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to save changes.", - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button(RichText::new("❌ Cancel").size(16.0)).clicked() { - action = AppAction::PopScreen; - } - ui.add_space(10.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - }); - } else { - // Action buttons - ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - if self.saving { - ui.spinner(); - ui.label(RichText::new("Saving...").color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); - } else { - if ui.button(RichText::new("💾 Save Changes").size(16.0)).clicked() { - action = self.save_contact_info(); - } - - ui.add_space(10.0); - - if ui.button(RichText::new("❌ Cancel").size(16.0)).clicked() { - action = AppAction::PopScreen; - } - } - }); - } - }); - - }); - - action - } - - pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - } - - pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - self.saving = false; - match result { - BackendTaskSuccessResult::Message(_msg) => { - // Message display is handled globally by AppState - } - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { - self.handle_contacts_result(contacts_data); - } - _ => { - // Message display is handled globally by AppState - } - } - } -} - -impl ScreenLike for ContactInfoEditorScreen { - fn ui(&mut self, ctx: &egui::Context) -> AppAction { - let mut action = AppAction::None; - - // Add top panel with back button - let right_buttons = vec![( - "Refresh", - DesiredAppAction::Custom("refresh_contact_info".to_string()), - )]; - - action |= add_top_panel( - ctx, - &self.app_context, - vec![ - ("DashPay", AppAction::None), - ("Contact Details", AppAction::PopScreen), - ("Edit", AppAction::None), - ], - right_buttons, - ); - - // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); - action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); - - // Main content area with island styling - action |= island_central_panel(ctx, |ui| self.render(ui)); - - // Show info popup if requested - if self.show_info_popup { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - let mut popup = - InfoPopup::new("Private Contact Information", PRIVATE_CONTACT_INFO_TEXT); - if popup.show(ui).inner { - self.show_info_popup = false; - } - }); - } - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully, UI will update on next frame - } - } - - // Handle custom actions from top panel - if let AppAction::Custom(command) = &action - && command.as_str() == "refresh_contact_info" - { - action = self.load_contact_info(); - } - - action - } - - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); - } - - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - self.display_task_result(result); - } -} diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index b685f9909..9dcacbf5c 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -3,23 +3,23 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::feature_gate::FeatureGate; +use crate::context::feature_gate::FeatureGate; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::avatar::Avatar; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::info_popup::InfoPopup; 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; -use crate::ui::dashpay::DashPaySubscreen; +use crate::ui::dashpay::{DashPaySubscreen, persist_contact_private_info}; +use crate::ui::state::AvatarCache; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::Identifier; -use egui::{ColorImage, RichText, ScrollArea, TextureHandle, Ui}; -use std::collections::HashMap; +use egui::{RichText, ScrollArea, Ui}; use std::sync::Arc; -use tracing::error; const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ This is the contact's public DashPay profile.\n\n\ @@ -31,6 +31,28 @@ const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ const PRIVATE_INFO_TEXT: &str = "This information is encrypted and stored on Platform. Only you can decrypt it."; +/// Read the DET-local private memo for `(owner, contact)` from the +/// WalletBackend k/v sidecar. Returns empty strings + `is_hidden=false` +/// on a sidecar miss or read error — same defaults the screen previously +/// got from the DB fallback. +fn load_private_info_from_backend( + app_context: &AppContext, + owner: &Identifier, + contact: &Identifier, +) -> (String, String, bool) { + let Ok(backend) = app_context.wallet_backend() else { + return (String::new(), String::new(), false); + }; + match backend.dashpay_get_private_info(owner, contact) { + Ok(Some(info)) => (info.nickname, info.notes, info.is_hidden), + Ok(None) => (String::new(), String::new(), false), + Err(e) => { + tracing::warn!("DashPay private-info sidecar read failed; defaulting to empty: {e:?}"); + (String::new(), String::new(), false) + } + } +} + #[derive(Debug, Clone)] pub struct ContactPublicProfile { pub identity_id: Identifier, @@ -53,8 +75,7 @@ pub struct ContactProfileViewerScreen { notes: String, is_hidden: bool, editing_private_info: bool, - avatar_textures: HashMap<String, TextureHandle>, - avatar_loading: bool, + avatar_cache: AvatarCache, show_info_popup: Option<(&'static str, &'static str)>, } @@ -64,54 +85,25 @@ impl ContactProfileViewerScreen { identity: QualifiedIdentity, contact_id: Identifier, ) -> Self { - // Load private contact info from database - let (nickname, notes, is_hidden) = app_context - .db - .load_contact_private_info(&identity.identity.id(), &contact_id) - .unwrap_or((String::new(), String::new(), false)); - - // Try to load cached contact profile from database - let network_str = app_context.network.to_string(); - let profile = if let Ok(contacts) = app_context - .db - .load_dashpay_contacts(&identity.identity.id(), &network_str) - { - contacts - .iter() - .find(|c| { - if let Ok(id) = Identifier::from_bytes(&c.contact_identity_id) { - id == contact_id - } else { - false - } - }) - .map(|c| ContactPublicProfile { - identity_id: contact_id, - display_name: c.display_name.clone(), - public_message: c.public_message.clone(), - avatar_url: c.avatar_url.clone(), - avatar_hash: None, // Not stored in contacts table yet - avatar_fingerprint: None, // Not stored in contacts table yet - }) - } else { - None - }; - - let initial_fetch_done = profile.is_some(); // Check before moving + // Load private contact info from the WalletBackend k/v sidecar — the + // post-D4 source of truth for DET-local contact memos. + let (nickname, notes, is_hidden) = + load_private_info_from_backend(&app_context, &identity.identity.id(), &contact_id); + // Profile is populated by the async `FetchContactProfile` task on the + // first render. The local DET contact cache is gone after D3. Self { app_context, identity, contact_id, - profile, + profile: None, loading: false, - initial_fetch_done, // If we have cached data, don't auto-fetch + initial_fetch_done: false, nickname, notes, is_hidden, editing_private_info: false, - avatar_textures: HashMap::new(), - avatar_loading: false, + avatar_cache: AvatarCache::new(), show_info_popup: None, } } @@ -146,76 +138,22 @@ impl ContactProfileViewerScreen { } fn save_private_info(&mut self) -> Result<(), TaskError> { - self.app_context.db.save_contact_private_info( + // Persist the memo to the per-network k/v sidecar. Upstream owns the + // encrypted on-Platform copy; this is the DET-local plaintext overlay + // that powers the contact list and profile viewer. + persist_contact_private_info( + &self.app_context, &self.identity.identity.id(), &self.contact_id, - &self.nickname, - &self.notes, + self.nickname.clone(), + self.notes.clone(), self.is_hidden, - )?; - Ok(()) - } - - fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { - let _texture_id = format!("contact_avatar_{}", url); - let ctx_clone = ctx.clone(); - let url_clone = url.to_string(); - - // Spawn async task to fetch and load the image - tokio::spawn(async move { - match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) - .await - { - Ok(image_bytes) => { - // Try to load the image - if let Ok(image) = image::load_from_memory(&image_bytes) { - // Convert to RGBA - let rgba_image = image.to_rgba8(); - let width = rgba_image.width(); - let height = rgba_image.height(); - - // Center-crop to square if not already square - let cropped_image = if width != height { - let size = width.min(height); - let x_offset = (width - size) / 2; - let y_offset = (height - size) / 2; - image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size) - .to_image() - } else { - rgba_image - }; - - let size = [ - cropped_image.width() as usize, - cropped_image.height() as usize, - ]; - let pixels = cropped_image.into_raw(); - - // Create ColorImage - let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); - - // Request repaint to load texture in UI thread - ctx_clone.request_repaint(); - - // Store the image data temporarily for the UI thread to pick up - ctx_clone.data_mut(|data| { - data.insert_temp( - egui::Id::new(format!("contact_avatar_data_{}", url_clone)), - color_image, - ); - }); - } - } - Err(e) => { - error!("Failed to fetch contact avatar image: {}", e); - } - } - }); + ) } pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Fetch profile on first render if not already done if !self.initial_fetch_done && !self.loading { @@ -258,96 +196,17 @@ impl ContactProfileViewerScreen { egui::vec2(100.0, 120.0), egui::Layout::top_down(egui::Align::Center), |ui| { - if let Some(avatar_url) = &profile.avatar_url { - if !avatar_url.is_empty() { - let texture_id = format!("contact_avatar_{}", avatar_url); - - // Check if texture is already cached - if let Some(texture) = self.avatar_textures.get(&texture_id) - { - // Display the cached avatar image - ui.add( - egui::Image::new(texture) - .fit_to_exact_size(egui::vec2(60.0, 60.0)) - .corner_radius(5.0), - ); - } else { - // Check if image data was loaded by async task - let data_id = - format!("contact_avatar_data_{}", avatar_url); - let color_image = ui.ctx().data_mut(|data| { - data.get_temp::<ColorImage>(egui::Id::new(&data_id)) - }); - - if let Some(color_image) = color_image { - // Create texture from loaded image - let texture = ui.ctx().load_texture( - &texture_id, - color_image, - egui::TextureOptions::LINEAR, - ); - - // Display the image - ui.add( - egui::Image::new(&texture) - .fit_to_exact_size(egui::vec2(60.0, 60.0)) - .corner_radius(5.0), - ); - - // Cache the texture - self.avatar_textures.insert(texture_id, texture); - self.avatar_loading = false; - - // Clear the temporary data - ui.ctx().data_mut(|data| { - data.remove::<ColorImage>(egui::Id::new( - &data_id, - )); - }); - } else if !self.avatar_loading { - // Start loading the avatar - self.avatar_loading = true; - self.load_avatar_texture(ui.ctx(), avatar_url); - // Show spinner while loading - ui.add( - egui::Spinner::new() - .color(DashColors::DASH_BLUE), - ); - } else { - // Show loading indicator - ui.add( - egui::Spinner::new() - .color(DashColors::DASH_BLUE), - ); - } - } - ui.label( - RichText::new("Avatar") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - } else { - ui.label( - RichText::new("👤") - .size(60.0) - .color(DashColors::DEEP_BLUE), - ); - ui.label( - RichText::new("No avatar") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - } - } else { - ui.label( - RichText::new("👤").size(60.0).color(DashColors::DEEP_BLUE), - ); - ui.label( - RichText::new("No avatar") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - } + let avatar_url = profile.avatar_url.as_deref().unwrap_or(""); + let has_avatar = !avatar_url.is_empty(); + action |= Avatar::new(Some(avatar_url), 60.0) + .corner_radius(5.0) + .show(ui, &mut self.avatar_cache) + .into_action(); + ui.label( + RichText::new(if has_avatar { "Avatar" } else { "No avatar" }) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); }, ); @@ -527,17 +386,16 @@ impl ContactProfileViewerScreen { } if ui.button("Cancel").clicked() { self.editing_private_info = false; - // Reload from database - if let Ok((nick, notes, hidden)) = - self.app_context.db.load_contact_private_info( - &self.identity.identity.id(), - &self.contact_id, - ) - { - self.nickname = nick; - self.notes = notes; - self.is_hidden = hidden; - } + // Reload from the sidecar so a cancelled + // edit reverts to whatever was last saved. + let (nick, notes, hidden) = load_private_info_from_backend( + &self.app_context, + &self.identity.identity.id(), + &self.contact_id, + ); + self.nickname = nick; + self.notes = notes; + self.is_hidden = hidden; } } else if ui.button("Edit").clicked() { self.editing_private_info = true; @@ -631,12 +489,12 @@ impl ContactProfileViewerScreen { } impl ScreenLike for ContactProfileViewerScreen { - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -646,17 +504,17 @@ impl ScreenLike for ContactProfileViewerScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, DashPaySubscreen::Contacts); - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Show info popup if requested if let Some((title, text)) = self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new(title, text); if popup.show(ui).inner { self.show_info_popup = None; @@ -717,6 +575,9 @@ impl ScreenLike for ContactProfileViewerScreen { self.profile = None; } } + BackendTaskSuccessResult::DashPayAvatar { url, bytes } => { + self.avatar_cache.store(url, bytes); + } BackendTaskSuccessResult::Message(_msg) => { // Message display is handled globally by AppState } diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index c26de21f4..22ae1ea02 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -1,6 +1,7 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::dashpay::DashPayTask; 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::qualified_identity::QualifiedIdentity; @@ -54,7 +55,7 @@ pub struct ContactRequests { outgoing_requests: BTreeMap<Identifier, ContactRequest>, accepted_requests: HashSet<Identifier>, rejected_requests: HashSet<Identifier>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, active_tab: RequestTab, loading: bool, @@ -92,25 +93,25 @@ impl ContactRequests { pending_profile_fetches: HashSet::new(), }; - // Auto-select first identity on creation if available - if let Ok(identities) = app_context.load_local_qualified_identities() + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - new_self.selected_identity = Some(identities[0].clone()); - new_self.selected_identity_string = identities[0] + let selected_id = app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + new_self.selected_identity = Some(preferred.clone()); + new_self.selected_identity_string = preferred .identity .id() .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); - - // Load requests from database for this identity - new_self.load_requests_from_database(); + new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self @@ -143,14 +144,12 @@ impl ContactRequests { self.wallet_open_attempted = false; } - // Clear the requests when identity changes + // Clear the requests when identity changes. Next render dispatches + // `LoadContactRequests` via `has_fetched_requests == false`. self.incoming_requests.clear(); self.outgoing_requests.clear(); self.has_fetched_requests = false; self.pending_profile_fetches.clear(); - - // Load requests from database for the newly selected identity - self.load_requests_from_database(); } } @@ -159,211 +158,24 @@ impl ContactRequests { self.render_content(ui, false) } - fn load_requests_from_database(&mut self) { - // Load saved contact requests for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - - // Clear existing requests before loading - self.incoming_requests.clear(); - self.outgoing_requests.clear(); - - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Loading contact requests from database for identity {} on network {}", - identity_id, - network_str - ); - - // Load pending incoming requests from database - match self.app_context.db.load_pending_contact_requests( - &identity_id, - &network_str, - "received", - ) { - Ok(incoming) => { - tracing::debug!("Loaded {} incoming requests from database", incoming.len()); - for request in incoming { - if let Ok(from_id) = Identifier::from_bytes(&request.from_identity_id) { - let contact_request = ContactRequest { - request_id: Identifier::new([0; 32]), // We'll need to store this in DB - from_identity: from_id, - to_identity: identity_id, - from_username: request.to_username, // This field is misnamed in DB - from_display_name: None, - to_username: None, - to_display_name: None, - account_reference: 0, - account_label: request.account_label, - timestamp: request.created_at as u64, - auto_accept_proof: None, - }; - self.incoming_requests.insert(from_id, contact_request); - } - } - } - Err(e) => { - tracing::error!("Failed to load incoming contact requests: {}", e); - } - } - - // Load pending outgoing requests from database - match self.app_context.db.load_pending_contact_requests( - &identity_id, - &network_str, - "sent", - ) { - Ok(outgoing) => { - tracing::debug!("Loaded {} outgoing requests from database", outgoing.len()); - for request in outgoing { - if let Ok(to_id) = Identifier::from_bytes(&request.to_identity_id) { - let contact_request = ContactRequest { - request_id: Identifier::new([0; 32]), // We'll need to store this in DB - from_identity: identity_id, - to_identity: to_id, - from_username: None, - from_display_name: None, - to_username: None, - to_display_name: None, - account_reference: 0, - account_label: request.account_label, - timestamp: request.created_at as u64, - auto_accept_proof: None, - }; - self.outgoing_requests.insert(to_id, contact_request); - } - } - } - Err(e) => { - tracing::error!("Failed to load outgoing contact requests: {}", e); - } - } - - // Resolve names from local cache and mark unresolved for Platform fetch - let unresolved = self.resolve_names_from_local_cache(); - self.pending_profile_fetches.extend(unresolved); - } - } - - /// Resolve usernames and display names for contact requests using local DB cache. - /// Returns a list of identity IDs that were not found locally and need Platform fetching. + /// Collect identities whose usernames/display names still need to be fetched + /// from Platform. After D3, DET no longer caches contacts/profiles, so every + /// request with missing names is treated as unresolved and dispatched through + /// `fetch_unresolved_profiles`. fn resolve_names_from_local_cache(&mut self) -> Vec<Identifier> { - use std::collections::HashMap; - - let network_str = self.app_context.network.to_string(); - let mut unresolved_ids = Vec::new(); - - // Pre-load contacts once to avoid N+1 queries inside the loop - let contacts_by_id: HashMap<Identifier, (Option<String>, Option<String>)> = - if let Some(selected_identity) = &self.selected_identity { - let owner_id = selected_identity.identity.id(); - self.app_context - .db - .load_dashpay_contacts(&owner_id, &network_str) - .unwrap_or_default() - .into_iter() - .filter_map(|c| { - Identifier::from_bytes(&c.contact_identity_id) - .ok() - .map(|id| (id, (c.username, c.display_name))) - }) - .collect() - } else { - HashMap::new() - }; - - // Collect all identity IDs we need to look up profiles for - let incoming_ids: Vec<Identifier> = self - .incoming_requests - .values() - .filter(|r| r.from_username.is_none() && r.from_display_name.is_none()) - .map(|r| r.from_identity) - .collect(); - let outgoing_ids: Vec<Identifier> = self - .outgoing_requests - .values() - .filter(|r| r.to_username.is_none() && r.to_display_name.is_none()) - .map(|r| r.to_identity) - .collect(); + let mut unresolved_ids: Vec<Identifier> = Vec::new(); - // Pre-load profiles for all needed IDs (one DB query each, but only for unresolved) - let mut profiles_cache: HashMap<Identifier, Option<String>> = HashMap::new(); - for id in incoming_ids.iter().chain(outgoing_ids.iter()) { - if !profiles_cache.contains_key(id) { - let display_name = self - .app_context - .db - .load_dashpay_profile(id, &network_str) - .ok() - .flatten() - .and_then(|p| p.display_name); - profiles_cache.insert(*id, display_name); + for request in self.incoming_requests.values() { + if request.from_username.is_none() && request.from_display_name.is_none() { + unresolved_ids.push(request.from_identity); } } - - // Resolve names for incoming requests (need from_identity info) - for request in self.incoming_requests.values_mut() { - if request.from_username.is_some() || request.from_display_name.is_some() { - continue; - } - - let identity_id = request.from_identity; - let mut found = false; - - // Try profile cache first (has display_name) - if let Some(Some(display_name)) = profiles_cache.get(&identity_id) { - request.from_display_name = Some(display_name.clone()); - found = true; - } - - // Try contacts cache (has username and display_name) - if let Some((username, display_name)) = contacts_by_id.get(&identity_id) { - if request.from_username.is_none() { - request.from_username = username.clone(); - } - if request.from_display_name.is_none() { - request.from_display_name = display_name.clone(); - } - found = true; - } - - if !found { - unresolved_ids.push(identity_id); + for request in self.outgoing_requests.values() { + if request.to_username.is_none() && request.to_display_name.is_none() { + unresolved_ids.push(request.to_identity); } } - // Resolve names for outgoing requests (need to_identity info) - for request in self.outgoing_requests.values_mut() { - if request.to_username.is_some() || request.to_display_name.is_some() { - continue; - } - - let identity_id = request.to_identity; - let mut found = false; - - // Try profile cache first - if let Some(Some(display_name)) = profiles_cache.get(&identity_id) { - request.to_display_name = Some(display_name.clone()); - found = true; - } - - // Try contacts cache - if let Some((username, display_name)) = contacts_by_id.get(&identity_id) { - if request.to_username.is_none() { - request.to_username = username.clone(); - } - if request.to_display_name.is_none() { - request.to_display_name = display_name.clone(); - } - found = true; - } - - if !found { - unresolved_ids.push(identity_id); - } - } - - // Deduplicate unresolved_ids.sort(); unresolved_ids.dedup(); unresolved_ids @@ -414,33 +226,21 @@ impl ContactRequests { } } - // Save to local DB for future lookups - let network_str = self.app_context.network.to_string(); - let bio = doc - .get("publicMessage") - .and_then(|v| v.as_text()) - .filter(|s| !s.is_empty()); - let avatar_url = doc - .get("avatarUrl") - .and_then(|v| v.as_text()) - .filter(|s| !s.is_empty()); - - if let Err(e) = self.app_context.db.save_dashpay_profile( - &contact_id, - &network_str, - display_name.as_deref(), - bio, - avatar_url, - None, - ) { - tracing::warn!("Failed to cache profile for {}: {}", contact_id, e); - } + // Profile cache write dropped — `FetchContactProfile` re-queries + // Platform on each open, and contact identities outside our wallet + // are not mirrored through the WalletBackend seam. } pub fn trigger_fetch_requests(&mut self) -> AppAction { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { self.loading = true; + // Mark the attempt at dispatch time, not on success. A failed load + // resets `loading` in `display_message` / `display_task_error` but + // leaves this flag set, so the auto-fetch gate fires exactly once + // and a transient error can't drive a re-dispatch storm. A fresh + // attempt is opted into via `refresh()` or an identity change. + self.has_fetched_requests = true; let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactRequests { identity: identity.clone(), @@ -471,18 +271,26 @@ impl ContactRequests { // Only clear temporary states self.loading = false; - // Auto-select first identity if none selected + // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { - self.selected_identity = Some(identities[0].clone()); - self.selected_identity_string = identities[0].display_string(); + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let selected_id = self.app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + self.selected_identity = Some(preferred.clone()); + self.selected_identity_string = preferred + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); } - // Load requests from database if we have an identity selected + // Mark unfetched so the next render dispatches `LoadContactRequests`. if self.selected_identity.is_some() { - self.load_requests_from_database(); + self.has_fetched_requests = false; } AppAction::None @@ -495,6 +303,11 @@ impl ContactRequests { fn render_content(&mut self, ui: &mut Ui, show_header: bool) -> AppAction { let mut action = AppAction::None; + // Auto-fetch contact requests on first render or after identity change. + if !self.has_fetched_requests && !self.loading && self.selected_identity.is_some() { + action |= self.trigger_fetch_requests(); + } + // Trigger Platform fetches for unresolved profiles if !self.pending_profile_fetches.is_empty() { let pending: Vec<_> = self.pending_profile_fetches.drain().collect(); @@ -549,7 +362,7 @@ impl ContactRequests { // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right (only shown when not embedded) @@ -559,6 +372,8 @@ impl ContactRequests { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "requests_identity_selector", @@ -568,7 +383,8 @@ impl ContactRequests { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), // Disable "Other" option + .other_option(false) // Disable "Other" option + .syncing_global(self.app_context.clone()), ); if response.changed() { @@ -588,9 +404,7 @@ impl ContactRequests { self.selected_wallet = None; } self.wallet_open_attempted = false; - - // Load requests from database for the newly selected identity - self.load_requests_from_database(); + // Next render dispatches `LoadContactRequests` via `has_fetched_requests == false`. } }); } @@ -606,13 +420,13 @@ impl ContactRequests { // Show structured error with action buttons if any let mut dismiss_error = false; if let Some(err) = &self.error { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let error_color = if dark_mode { DashColors::ERROR } else { egui::Color32::DARK_RED }; - let error_msg = err.user_message(); + let error_msg = err.to_string(); let is_missing_encryption_key = matches!(err, DashPayError::MissingEncryptionKey); ui.group(|ui| { @@ -648,7 +462,7 @@ impl ContactRequests { } // Tabs - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { let incoming_tab = egui::Button::new(RichText::new("Incoming").color( if self.active_tab == RequestTab::Incoming { @@ -715,7 +529,7 @@ impl ContactRequests { } else { ScrollArea::vertical().id_salt("incoming_requests_scroll").show(ui, |ui| { if self.incoming_requests.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -748,7 +562,7 @@ impl ContactRequests { ui.vertical(|ui| { use dash_sdk::dpp::platform_value::string_encoding::Encoding; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Display name or username or identity ID let name = request @@ -817,8 +631,9 @@ impl ContactRequests { // Check wallet lock status before showing buttons let wallet_locked = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -897,7 +712,7 @@ impl ContactRequests { } else { ScrollArea::vertical().id_salt("outgoing_requests_scroll").show(ui, |ui| { if self.outgoing_requests.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -940,7 +755,7 @@ impl ContactRequests { ui.vertical(|ui| { use dash_sdk::dpp::platform_value::string_encoding::Encoding; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // For outgoing requests, show display name or username or truncated ID let id_str = request.to_identity.to_string(Encoding::Base58); @@ -991,7 +806,7 @@ impl ContactRequests { ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Cannot be cancelled once sent") .small() @@ -1016,16 +831,18 @@ impl ContactRequests { impl ScreenLike for ContactRequests { fn refresh_on_arrival(&mut self) { - // Load requests from database when screen is shown + // Trigger a fresh `LoadContactRequests` dispatch via auto-fetch in `render_content`. if self.selected_identity.is_some() { - self.load_requests_from_database(); + self.has_fetched_requests = false; } } - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // Create a simple central panel for rendering let mut action = AppAction::None; - egui::CentralPanel::default().show(ctx, |ui| { + egui::CentralPanel::default().show(ui, |ui| { action = self.render(ui); }); @@ -1044,17 +861,19 @@ impl ScreenLike for ContactRequests { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, _message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; + } - // TODO(RUST-002): String-based error classification — see #660 - if matches!(message_type, MessageType::Error | MessageType::Warning) { - if message.contains("ENCRYPTION key") { - self.error = Some(DashPayError::MissingEncryptionKey); - } else if message.contains("DECRYPTION key") { - self.error = Some(DashPayError::MissingDecryptionKey); + fn display_task_error(&mut self, error: &TaskError) -> bool { + self.loading = false; + match classify_request_error(error) { + Some(dashpay_error) => { + self.error = Some(dashpay_error); + true } + None => false, } } @@ -1077,7 +896,13 @@ impl ScreenLike for ContactRequests { self.has_fetched_requests = true; // Get current identity for saving to database - let current_identity_id = self.selected_identity.as_ref().unwrap().identity.id(); + let Some(selected_identity) = self.selected_identity.as_ref() else { + tracing::warn!( + "Contact requests arrived with no selected identity; skipping save" + ); + return; + }; + let current_identity_id = selected_identity.identity.id(); // Process incoming requests for (id, doc) in incoming.iter() { @@ -1107,26 +932,11 @@ impl ScreenLike for ContactRequests { }; self.incoming_requests.insert(*id, request.clone()); - - // Save to database as received request - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Saving incoming contact request to database: from={}, to={}, network={}", - from_identity, - current_identity_id, - network_str - ); - match self.app_context.db.save_contact_request( - &from_identity, - &current_identity_id, - &network_str, - None, // to_username - request.account_label.as_deref(), - "received", - ) { - Ok(id) => tracing::debug!("Saved incoming contact request with id {}", id), - Err(e) => tracing::error!("Failed to save incoming contact request: {}", e), - } + // Contact-request mirror dropped — upstream + // `incoming_contact_requests` already records this + // request, and `DashpayView::contact_requests` + // derives status from upstream presence + the + // rejected/expiry sidecars. } // Process outgoing requests @@ -1160,26 +970,11 @@ impl ScreenLike for ContactRequests { }; self.outgoing_requests.insert(*id, request.clone()); - - // Save to database as sent request - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Saving outgoing contact request to database: from={}, to={}, network={}", - current_identity_id, - to_identity, - network_str - ); - match self.app_context.db.save_contact_request( - &current_identity_id, - &to_identity, - &network_str, - None, // to_username - request.account_label.as_deref(), - "sent", - ) { - Ok(id) => tracing::debug!("Saved outgoing contact request with id {}", id), - Err(e) => tracing::error!("Failed to save outgoing contact request: {}", e), - } + // Contact-request mirror dropped — upstream + // `sent_contact_requests` already records this + // request, and `DashpayView::contact_requests` + // derives status from upstream presence + the + // rejected/expiry sidecars. } // Resolve names from local cache and trigger Platform fetches for unknowns @@ -1215,18 +1010,57 @@ impl ScreenLike for ContactRequests { BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { // Message display is handled globally by AppState } - BackendTaskSuccessResult::Message(msg) => { - // Check if this is an error message about missing keys - if msg.contains("ENCRYPTION key") { - self.error = Some(DashPayError::MissingEncryptionKey); - } else if msg.contains("DECRYPTION key") { - self.error = Some(DashPayError::MissingDecryptionKey); - } - // Other messages are handled globally by AppState - } _ => { // Ignore other results } } } } + +/// Map a typed accept/reject error onto the screen-local error category that +/// drives a dedicated affordance (the "Add Encryption Key" button). Returns +/// `None` when no request-specific UI applies, leaving the global banner to +/// report the error. +fn classify_request_error(error: &TaskError) -> Option<DashPayError> { + match error { + TaskError::DashPay(DashPayError::MissingEncryptionKey) => { + Some(DashPayError::MissingEncryptionKey) + } + TaskError::DashPay(DashPayError::MissingDecryptionKey) => { + Some(DashPayError::MissingDecryptionKey) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_missing_encryption_key() { + let mapped = + classify_request_error(&TaskError::DashPay(DashPayError::MissingEncryptionKey)); + assert!(matches!(mapped, Some(DashPayError::MissingEncryptionKey))); + } + + #[test] + fn classifies_missing_decryption_key() { + let mapped = + classify_request_error(&TaskError::DashPay(DashPayError::MissingDecryptionKey)); + assert!(matches!(mapped, Some(DashPayError::MissingDecryptionKey))); + } + + #[test] + fn unrelated_dashpay_error_defers_to_global_banner() { + let mapped = + classify_request_error(&TaskError::DashPay(DashPayError::MissingAuthenticationKey)); + assert!(mapped.is_none()); + } + + #[test] + fn unrelated_task_error_defers_to_global_banner() { + let mapped = classify_request_error(&TaskError::DocumentNotFound); + assert!(mapped.is_none()); + } +} diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index b851aa627..0c5ee3efa 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -4,17 +4,19 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::components::ResultBannerExt; +use crate::ui::components::avatar::Avatar; use crate::ui::components::identity_selector::IdentitySelector; 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::theme::DashColors; use crate::ui::{MessageType, ScreenLike, ScreenType}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use egui::{ColorImage, Frame, Margin, RichText, ScrollArea, TextureHandle, Ui}; -use std::collections::{BTreeMap, HashSet}; +use egui::{Frame, Margin, RichText, ScrollArea, Ui}; +use std::collections::BTreeMap; use std::sync::Arc; #[derive(Debug, Clone)] @@ -59,7 +61,7 @@ pub enum ContactsTab { pub struct ContactsList { pub app_context: Arc<AppContext>, contacts: BTreeMap<Identifier, Contact>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, search_query: String, message: Option<(String, MessageType)>, @@ -68,8 +70,7 @@ pub struct ContactsList { show_hidden: bool, search_filter: SearchFilter, sort_order: SortOrder, - avatar_textures: BTreeMap<String, TextureHandle>, // Cache for avatar textures by URL - avatars_loading: HashSet<String>, // Track which avatars are being loaded + avatar_cache: AvatarCache, /// Current active tab active_tab: ContactsTab, /// Embedded contact requests component @@ -90,98 +91,43 @@ impl ContactsList { show_hidden: false, search_filter: SearchFilter::All, sort_order: SortOrder::Name, - avatar_textures: BTreeMap::new(), - avatars_loading: HashSet::new(), + avatar_cache: AvatarCache::new(), active_tab: ContactsTab::Contacts, contact_requests: ContactRequests::new(app_context.clone()), }; - // Auto-select first identity on creation if available - if let Ok(identities) = app_context.load_local_qualified_identities() + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { - new_self.selected_identity = Some(identities[0].clone()); - new_self.selected_identity_string = - identities[0].identity.id().to_string(Encoding::Base58); - - // Load contacts from database for this identity - new_self.load_contacts_from_database(); + let selected_id = app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + new_self.selected_identity = Some(preferred.clone()); + new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); } new_self } - fn load_contacts_from_database(&mut self) { - // Load saved contacts for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Load saved contacts from database - if let Ok(stored_contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - for stored_contact in stored_contacts { - // Convert stored contact to Contact struct - if let Ok(contact_id) = - Identifier::from_bytes(&stored_contact.contact_identity_id) - { - let contact = Contact { - identity_id: contact_id, - username: stored_contact.username.clone(), - display_name: stored_contact.display_name.clone().or_else(|| { - Some(format!( - "Contact ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: stored_contact.avatar_url.clone(), - bio: None, // Bio could be loaded from profile if needed - nickname: None, // Will be loaded separately from contact_private_info - is_hidden: false, // Will be loaded separately from contact_private_info - account_reference: 0, // This would need to be loaded from contactInfo document - created_at: Some(stored_contact.created_at), - }; - - // Only add if contact status is accepted - if stored_contact.contact_status == "accepted" { - self.contacts.insert(contact_id, contact); - } - } - } - - // Also load private contact info to populate nickname and hidden status - if let Ok(private_infos) = self - .app_context - .db - .load_all_contact_private_info(&identity_id) - { - for info in private_infos { - if let Ok(contact_id) = Identifier::from_bytes(&info.contact_identity_id) - && let Some(contact) = self.contacts.get_mut(&contact_id) - { - contact.nickname = if info.nickname.is_empty() { - None - } else { - Some(info.nickname) - }; - contact.is_hidden = info.is_hidden; - } - } - } - } - } - } - + /// Auto-fetch on view: reads contacts from offline rehydrated state plus + /// the DET contact-profile cache, so the list paints without a network + /// round-trip. An explicit refresh uses + /// [`Self::trigger_refresh_contacts`] to re-fetch from the network. pub fn trigger_fetch_contacts(&mut self) -> AppAction { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { self.loading = true; self.message = None; // Clear any existing message - - let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { + // Mark the attempt at dispatch time, not on success. The error + // handlers reset `loading` but leave this flag set, so a failed + // load fires the auto-fetch gate exactly once instead of + // re-dispatching every frame. `refresh()` / an identity change + // opts back into a fresh attempt. + self.has_loaded = true; + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactsOffline { identity: identity.clone(), })); @@ -191,6 +137,47 @@ impl ContactsList { AppAction::None } + /// Re-fetch contacts and their profiles from the network, refreshing the + /// DET caches. Triggered by an explicit user refresh, not by entering the + /// view — the view itself serves the offline cache. + /// + /// Clears the in-memory rendered-texture cache so a contact whose avatar + /// changed at the same URL repaints in-session: the textures are keyed by + /// URL and never expire on their own, so without this an already-rendered + /// avatar would keep showing the stale image even after the network read + /// re-fetches and re-caches the new bytes. + pub fn trigger_refresh_contacts(&mut self) -> AppAction { + let Some(identity) = self.selected_identity.clone() else { + return AppAction::None; + }; + + self.loading = true; + self.message = None; + self.has_loaded = true; + self.clear_avatar_render_state(); + + // An explicit refresh means "give me the latest", so drop the DET + // avatar disk cache too: otherwise a within-TTL hit would short-circuit + // the re-fetch and keep serving the old bytes for an unchanged URL. + // Best-effort — a clear miss only costs one re-fetch. + if let Ok(backend) = self.app_context.wallet_backend() + && let Err(e) = backend.avatar_cache().clear() + { + tracing::debug!(error = ?e, "Failed to clear avatar cache on refresh"); + } + + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadContacts { identity }, + ))) + } + + /// Drop the in-memory avatar render state so the next render re-derives each + /// avatar from the (re-fetched) cache. Shared by the explicit Refresh path + /// and the identity-change reset. + fn clear_avatar_render_state(&mut self) { + self.avatar_cache.invalidate(); + } + pub fn fetch_contacts(&mut self) -> AppAction { self.trigger_fetch_contacts() } @@ -210,102 +197,57 @@ impl ContactsList { self.message = None; self.loading = false; - // Auto-select first identity if none selected + // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { - self.selected_identity = Some(identities[0].clone()); - self.selected_identity_string = identities[0].identity.id().to_string(Encoding::Base58); + let selected_id = self.app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + self.selected_identity = Some(preferred.clone()); + self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); } - // Load contacts from database if we have an identity selected and no contacts loaded - if self.selected_identity.is_some() && self.contacts.is_empty() { - self.load_contacts_from_database(); - } + // Trigger backend fetch if we have an identity selected and no contacts loaded. + // The result populates `self.contacts` via `display_task_result`. + let action = if self.selected_identity.is_some() && self.contacts.is_empty() { + self.trigger_fetch_contacts() + } else { + AppAction::None + }; // Also refresh contact requests let _ = self.contact_requests.refresh(); - AppAction::None - } - - /// Load an avatar image from a URL asynchronously - fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { - // Mark as loading - self.avatars_loading.insert(url.to_string()); - - let ctx_clone = ctx.clone(); - let url_clone = url.to_string(); - - // Spawn async task to fetch and load the image - tokio::spawn(async move { - match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) - .await - { - Ok(image_bytes) => { - // Try to load the image - if let Ok(image) = image::load_from_memory(&image_bytes) { - // Convert to RGBA - let rgba_image = image.to_rgba8(); - let width = rgba_image.width(); - let height = rgba_image.height(); - - // Center-crop to square if not already square - let cropped_image = if width != height { - let size = width.min(height); - let x_offset = (width - size) / 2; - let y_offset = (height - size) / 2; - image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size) - .to_image() - } else { - rgba_image - }; - - let size = [ - cropped_image.width() as usize, - cropped_image.height() as usize, - ]; - let pixels = cropped_image.into_raw(); - - // Create ColorImage - let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); - - // Request repaint to load texture in UI thread - ctx_clone.request_repaint(); - - // Store the image data temporarily for the UI thread to pick up - ctx_clone.data_mut(|data| { - data.insert_temp( - egui::Id::new(format!("contact_avatar_data_{}", url_clone)), - color_image, - ); - }); - } - } - Err(e) => { - tracing::warn!("Failed to fetch contact avatar image: {}", e); - } - } - }); + action } pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; + + // Auto-fetch contacts on first render or after identity change. + if !self.has_loaded && !self.loading && self.selected_identity.is_some() { + action = self.trigger_fetch_contacts(); + } // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header section with identity selector on the right + let mut refresh_action = AppAction::None; ui.horizontal(|ui| { ui.heading("Contacts"); if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "contacts_identity_selector", @@ -315,28 +257,42 @@ impl ContactsList { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); if response.changed() { - // Clear contacts and avatar caches when identity changes + // Clear contacts and avatar caches when identity changes. + // The next render dispatches the offline read via + // `has_loaded == false`. self.contacts.clear(); - self.avatar_textures.clear(); - self.avatars_loading.clear(); + self.clear_avatar_render_state(); self.message = None; self.loading = false; - - // Load contacts from database for the newly selected identity - self.load_contacts_from_database(); + self.has_loaded = false; // Sync selected identity to contact_requests self.contact_requests .set_selected_identity(self.selected_identity.clone()); } + + // Explicit refresh: re-fetch contacts and profiles from the + // network (the view itself serves the offline cache). + if ui + .add_enabled(!self.loading, egui::Button::new("Refresh")) + .on_hover_text("Fetch the latest contacts and profiles from the network.") + .clicked() + { + refresh_action = self.trigger_refresh_contacts(); + } }); } }); + if !matches!(refresh_action, AppAction::None) { + action = refresh_action; + } + ui.separator(); // Tab bar @@ -703,7 +659,7 @@ impl ContactsList { .id_salt("contacts_list_scroll") .show(ui, |ui| { if self.contacts.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -738,7 +694,7 @@ impl ContactsList { }); }); } else if filtered_contacts.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -762,108 +718,15 @@ impl ContactsList { }); }); } else { - // Collect avatar URLs that need to be loaded - let mut avatars_to_load: Vec<String> = Vec::new(); - for contact in filtered_contacts { - let avatar_url_clone = contact.avatar_url.clone(); ui.group(|ui| { ui.horizontal(|ui| { // Avatar display ui.vertical(|ui| { ui.add_space(5.0); - const AVATAR_SIZE: f32 = 40.0; - - if let Some(ref url) = avatar_url_clone { - if !url.is_empty() { - let texture_id = format!("contact_avatar_{}", url); - - // Check if texture is already cached - if let Some(texture) = - self.avatar_textures.get(&texture_id) - { - // Display the cached avatar image - ui.add( - egui::Image::new(texture) - .fit_to_exact_size(egui::vec2( - AVATAR_SIZE, - AVATAR_SIZE, - )) - .corner_radius(AVATAR_SIZE / 2.0), - ); - } else { - // Check if image data was loaded by async task - let data_id = - format!("contact_avatar_data_{}", url); - let color_image = ui.ctx().data_mut(|data| { - data.get_temp::<ColorImage>(egui::Id::new( - &data_id, - )) - }); - - if let Some(color_image) = color_image { - // Create texture from loaded image - let texture = ui.ctx().load_texture( - &texture_id, - color_image, - egui::TextureOptions::LINEAR, - ); - - // Display the image - ui.add( - egui::Image::new(&texture) - .fit_to_exact_size(egui::vec2( - AVATAR_SIZE, - AVATAR_SIZE, - )) - .corner_radius(AVATAR_SIZE / 2.0), - ); - - // Cache the texture and clear loading state - self.avatar_textures - .insert(texture_id.clone(), texture); - self.avatars_loading.remove(url); - - // Clear the temporary data - ui.ctx().data_mut(|data| { - data.remove::<ColorImage>(egui::Id::new( - &data_id, - )); - }); - } else if !self.avatars_loading.contains(url) { - // Queue for loading - avatars_to_load.push(url.clone()); - // Show spinner while loading - ui.add( - egui::Spinner::new() - .size(AVATAR_SIZE) - .color(DashColors::DASH_BLUE), - ); - } else { - // Show loading indicator - ui.add( - egui::Spinner::new() - .size(AVATAR_SIZE) - .color(DashColors::DASH_BLUE), - ); - } - } - } else { - // Empty URL, show default emoji - ui.label( - RichText::new("👤") - .size(AVATAR_SIZE) - .color(DashColors::DEEP_BLUE), - ); - } - } else { - // No avatar URL, show default emoji - ui.label( - RichText::new("👤") - .size(AVATAR_SIZE) - .color(DashColors::DEEP_BLUE), - ); - } + action |= Avatar::new(contact.avatar_url.as_deref(), 40.0) + .show(ui, &mut self.avatar_cache) + .into_action(); }); ui.add_space(10.0); @@ -878,7 +741,7 @@ impl ContactsList { .cloned() .unwrap_or_else(|| "Unknown".to_string()); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Add hidden indicator to name if contact is hidden let display_name = if contact.is_hidden { @@ -937,13 +800,30 @@ impl ContactsList { let new_hidden = !contact.is_hidden; if let Some(identity) = &self.selected_identity { let owner_id = identity.identity.id(); - if let Err(e) = - self.app_context.db.set_contact_hidden( - &owner_id, - &contact.identity_id, - new_hidden, - ) - { + // Preserve the existing memo, flipping only the + // hidden flag. + let existing = self + .app_context + .wallet_backend() + .ok() + .and_then(|b| { + b.dashpay_get_private_info( + &owner_id, + &contact.identity_id, + ) + .ok() + .flatten() + }) + .unwrap_or_default(); + let sidecar_result = persist_contact_private_info( + &self.app_context, + &owner_id, + &contact.identity_id, + existing.nickname, + existing.notes, + new_hidden, + ); + if let Err(e) = sidecar_result { self.message = Some(( format!("Failed to update contact: {}", e), MessageType::Error, @@ -963,23 +843,35 @@ impl ContactsList { if self.app_context.is_developer_mode() && ui.button("Pay").clicked() { - action = AppAction::AddScreen( - ScreenType::DashPaySendPayment( - self.selected_identity.clone().unwrap(), - contact.identity_id, - ) - .create_screen(&self.app_context), - ); + if let Some(identity) = self.selected_identity.clone() { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + identity, + contact.identity_id, + ) + .create_screen(&self.app_context), + ); + } else { + tracing::warn!( + "Pay clicked with no selected identity; ignoring" + ); + } } if ui.button("View Profile").clicked() { - action = AppAction::AddScreen( - ScreenType::DashPayContactProfileViewer( - self.selected_identity.clone().unwrap(), - contact.identity_id, - ) - .create_screen(&self.app_context), - ); + if let Some(identity) = self.selected_identity.clone() { + action = AppAction::AddScreen( + ScreenType::DashPayContactProfileViewer( + identity, + contact.identity_id, + ) + .create_screen(&self.app_context), + ); + } else { + tracing::warn!( + "View Profile clicked with no selected identity; ignoring" + ); + } } }, ); @@ -987,11 +879,6 @@ impl ContactsList { }); ui.add_space(4.0); } - - // Load any avatars that were queued - for url in avatars_to_load { - self.load_avatar_texture(ui.ctx(), &url); - } } }); @@ -1005,15 +892,15 @@ impl ContactsList { impl ScreenLike for ContactsList { fn refresh_on_arrival(&mut self) { - // Load contacts from database when screen is shown + // Trigger a fresh `LoadContacts` dispatch via the auto-fetch in `render()`. if self.selected_identity.is_some() && self.contacts.is_empty() { - self.load_contacts_from_database(); + self.has_loaded = false; } } - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; - egui::CentralPanel::default().show(ctx, |ui| { + egui::CentralPanel::default().show(ui, |ui| { action = self.render(ui); }); action @@ -1025,6 +912,13 @@ impl ScreenLike for ContactsList { } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + // Avatar results arrive independently of the contact-list load; route + // them without disturbing the list's loading state. + if let BackendTaskSuccessResult::DashPayAvatar { url, bytes } = result { + self.avatar_cache.store(url, bytes); + return; + } + self.loading = false; match result { @@ -1056,108 +950,42 @@ impl ScreenLike for ContactsList { self.message = None; } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { - // Clear existing contacts + // 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(); - - // Save contacts to database if we have a selected identity - if let Some(identity) = &self.selected_identity { - let owner_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Clear all existing contacts for this identity from database first - // This prevents stale contacts from persisting - self.app_context - .db - .clear_dashpay_contacts(&owner_id, &network_str) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - - // Convert ContactData to Contact structs and save to database - for contact_data in contacts_data { - // Skip self-contacts (where contact is the same as the owner) - if contact_data.identity_id == owner_id { - continue; - } - let contact = Contact { - identity_id: contact_data.identity_id, - username: contact_data.username.clone(), - display_name: contact_data.display_name.clone().or_else(|| { - Some(format!( - "Contact ({})", - &contact_data.identity_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: contact_data.avatar_url.clone(), - bio: contact_data.bio.clone(), - nickname: contact_data.nickname.clone(), - is_hidden: contact_data.is_hidden, - account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort - }; - self.contacts.insert(contact_data.identity_id, contact); - - // Save to database - self.app_context - .db - .save_dashpay_contact( - &owner_id, - &contact_data.identity_id, - &network_str, - contact_data.username.as_deref(), - contact_data.display_name.as_deref(), - contact_data.avatar_url.as_deref(), - None, // public_message - not yet fetched - "accepted", // Only accepted contacts are returned from load_contacts - ) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - - // Save private info if present - if let Some(nickname) = &contact_data.nickname { - self.app_context - .db - .save_contact_private_info( - &owner_id, - &contact_data.identity_id, - nickname, - &contact_data.note.unwrap_or_default(), - contact_data.is_hidden, - ) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - } - } - } else { - // No selected identity, just populate in-memory - for contact_data in contacts_data { - let contact = Contact { - identity_id: contact_data.identity_id, - username: contact_data.username, - display_name: contact_data.display_name.or_else(|| { - Some(format!( - "Contact ({})", - &contact_data.identity_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: contact_data.avatar_url, - bio: contact_data.bio, - nickname: contact_data.nickname, - is_hidden: contact_data.is_hidden, - account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort - }; - self.contacts.insert(contact_data.identity_id, contact); + 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 + .as_ref() + .is_some_and(|owner| *owner == contact_data.identity_id) + { + continue; } + let contact = Contact { + identity_id: contact_data.identity_id, + username: contact_data.username, + display_name: contact_data.display_name.or_else(|| { + Some(format!( + "Contact ({})", + &contact_data.identity_id.to_string(Encoding::Base58)[0..8] + )) + }), + avatar_url: contact_data.avatar_url, + bio: contact_data.bio, + nickname: contact_data.nickname, + is_hidden: contact_data.is_hidden, + account_reference: contact_data.account_reference, + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + ), // Fallback to current time for filter/sort + }; + self.contacts.insert(contact_data.identity_id, contact); } // Mark as loaded and clear message @@ -1201,27 +1029,11 @@ impl ScreenLike for ContactsList { if let Some(url) = &avatar_url { contact.avatar_url = Some(url.clone()); } - - // Save updated profile to database if we have a selected identity - if let Some(identity) = &self.selected_identity { - let owner_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_contact( - &owner_id, - &contact_id, - &network_str, - contact.username.as_deref(), - contact.display_name.as_deref(), - contact.avatar_url.as_deref(), - public_message.as_deref(), - "accepted", - ) { - tracing::warn!( - "Failed to save updated contact profile to database: {}", - e - ); - } - } + // `DashpayView::contacts` reads contact identities from the + // upstream wallet and cross-references the public + // DashPayProfile via the backend task on demand, so this + // message is not cached locally. + let _ = public_message; } } _ => { diff --git a/src/ui/dashpay/dashpay_screen.rs b/src/ui/dashpay/dashpay_screen.rs index 8f97e6218..fd60c954b 100644 --- a/src/ui/dashpay/dashpay_screen.rs +++ b/src/ui/dashpay/dashpay_screen.rs @@ -1,12 +1,13 @@ use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; 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; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use egui::{Context, Ui}; +use egui::Ui; use std::sync::Arc; use super::contacts_list::ContactsList; @@ -72,7 +73,7 @@ impl ScreenLike for DashPayScreen { self.refresh(); } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel with action buttons based on current subscreen @@ -106,22 +107,23 @@ impl ScreenLike for DashPayScreen { DashPaySubscreen::ProfileSearch => vec![], }; - action |= add_top_panel( - ctx, + // TODO: wire wallet/identity selection consumption for the DashPay page. + action |= add_top_panel_with_global_nav( + ui, &self.app_context, - vec![("DashPay", AppAction::None)], + subdued_everyday_spec("DashPay", RootScreenType::RootScreenDashpay), right_buttons, ); // Highlight Dashpay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); // DashPay subscreen chooser panel on the left side of the content area action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, self.dashpay_subscreen); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, self.dashpay_subscreen); // Main content area with island styling - action |= island_central_panel(ctx, |ui| self.render_subscreen(ui)); + action |= island_central_panel(ui, |ui| self.render_subscreen(ui)); // Handle custom actions from top panel buttons if let AppAction::Custom(command) = &action { @@ -197,4 +199,19 @@ impl ScreenLike for DashPayScreen { } } } + + fn display_task_error(&mut self, error: &TaskError) -> bool { + // Forward to the active subscreen so its typed-error classification + // runs. Without this the embedded ContactRequests never sees the + // error, so a missing-encryption-key failure never surfaces its + // inline "Add Encryption Key" recovery affordance. + match self.dashpay_subscreen { + DashPaySubscreen::Contacts => self + .contacts_list + .contact_requests + .display_task_error(error), + DashPaySubscreen::Profile | DashPaySubscreen::Payments => false, + DashPaySubscreen::ProfileSearch => false, + } + } } diff --git a/src/ui/dashpay/mod.rs b/src/ui/dashpay/mod.rs index d6d81f77c..67ad9e36f 100644 --- a/src/ui/dashpay/mod.rs +++ b/src/ui/dashpay/mod.rs @@ -1,6 +1,5 @@ pub mod add_contact_screen; pub mod contact_details; -pub mod contact_info_editor; pub mod contact_profile_viewer; pub mod contact_requests; pub mod contacts_list; @@ -16,11 +15,37 @@ pub use dashpay_screen::{DashPayScreen, DashPaySubscreen}; pub use profile_search::ProfileSearchScreen; use crate::app::AppAction; +use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::ContactPrivateInfo; use crate::ui::ScreenType; use crate::ui::theme::DashColors; use chrono::{LocalResult, TimeZone, Utc}; use chrono_humanize::HumanTime; +use dash_sdk::platform::Identifier; + +/// Persist a contact's DET-local private memo (nickname / notes / hidden flag) +/// to the WalletBackend k/v sidecar — the single save path shared by the +/// contact list, contact details, and profile-viewer inline editors. +/// +/// Upstream owns the encrypted on-Platform copy; this is the local plaintext +/// overlay that powers offline-friendly contact display. +pub(crate) fn persist_contact_private_info( + app_context: &AppContext, + owner_id: &Identifier, + contact_id: &Identifier, + nickname: String, + notes: String, + is_hidden: bool, +) -> Result<(), TaskError> { + let backend = app_context.wallet_backend()?; + let info = ContactPrivateInfo { + nickname, + notes, + is_hidden, + }; + backend.dashpay_set_private_info(owner_id, contact_id, &info) +} /// Format a Unix timestamp (seconds or milliseconds) as a human-readable /// relative time string (e.g. "3 hours ago", "just now"). @@ -53,7 +78,7 @@ use std::sync::Arc; /// Renders a styled "No Identities Loaded" card for DashPay screens. /// Returns an AppAction if the user clicks the "Load Identity" button. pub fn render_no_identities_card(ui: &mut Ui, app_context: &Arc<AppContext>) -> AppAction { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index e647e0db3..4c3293837 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -2,10 +2,12 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::dashpay::{MAX_AVATAR_URL_CHARS, ProfileFieldError}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; +use crate::ui::components::avatar::Avatar; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -16,24 +18,24 @@ use crate::ui::components::wallet_unlock_popup::{ use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::helpers::clicked_outside_window; use crate::ui::identities::get_selected_wallet; +use crate::ui::state::AvatarCache; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use egui::{ColorImage, Frame, Margin, RichText, ScrollArea, TextEdit, TextureHandle, Ui}; -use std::collections::HashMap; +use egui::{Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; use std::sync::{Arc, RwLock}; const PROFILE_GUIDELINES_INFO_TEXT: &str = "Profile Guidelines:\n\n\ Display names can include any UTF-8 characters (emojis, symbols, etc.).\n\n\ Display names are limited to 25 characters.\n\n\ Bios are limited to 140 characters.\n\n\ - Avatar URLs should point to publicly accessible images (max 500 chars).\n\n\ + Avatar URLs should point to publicly accessible images (max 2048 chars).\n\n\ Profiles are public and visible to all DashPay users."; const AVATAR_URL_INFO_TEXT: &str = "Avatar Image Guidelines:\n\n\ The URL must point to a publicly accessible image.\n\n\ Recommended: Square images (e.g., 256x256 or 512x512 pixels).\n\n\ Supported formats: JPEG, PNG, WebP, or GIF.\n\n\ - Maximum URL length: 500 characters.\n\n\ + Maximum URL length: 2048 characters.\n\n\ Example URL:\nhttps://example.com/images/avatar.jpg\n\n\ Tip: Use image hosting services like Imgur, Cloudinary, or your own server."; @@ -45,41 +47,9 @@ pub struct DashPayProfile { pub avatar_bytes: Option<Vec<u8>>, } -#[derive(Debug, Clone, PartialEq)] -pub enum ValidationError { - DisplayNameTooLong(usize), - DisplayNameEmpty, - BioTooLong(usize), - InvalidAvatarUrl(String), - AvatarUrlTooLong(usize), -} - -impl ValidationError { - pub fn message(&self) -> String { - match self { - ValidationError::DisplayNameTooLong(len) => { - format!("Display name is {} characters, must be 25 or less", len) - } - ValidationError::DisplayNameEmpty => "Display name cannot be empty".to_string(), - ValidationError::BioTooLong(len) => { - format!("Bio is {} characters, must be 140 or less", len) - } - ValidationError::InvalidAvatarUrl(url) => { - format!( - "Invalid avatar URL: '{}'. Must start with http:// or https://", - url - ) - } - ValidationError::AvatarUrlTooLong(len) => { - format!("Avatar URL is {} characters, must be 500 or less", len) - } - } - } -} - pub struct ProfileScreen { pub app_context: Arc<AppContext>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, profile: Option<DashPayProfile>, editing: bool, @@ -89,14 +59,13 @@ pub struct ProfileScreen { loading: bool, saving: bool, // Track if we're saving vs loading profile_load_attempted: bool, - validation_errors: Vec<ValidationError>, + validation_errors: Vec<ProfileFieldError>, has_unsaved_changes: bool, original_display_name: String, original_bio: String, original_avatar_url: String, - avatar_textures: HashMap<String, TextureHandle>, // Cache for avatar textures - avatar_loading: bool, // Track if avatar is being loaded - pending_action: Option<Box<AppAction>>, // Action to execute on next frame + avatar_cache: AvatarCache, + pending_action: Option<Box<AppAction>>, // Action to execute on next frame show_info_popup: bool, show_avatar_info_popup: bool, show_avatar_url_popup: bool, // Show avatar URL when clicking on avatar in view mode @@ -127,8 +96,7 @@ impl ProfileScreen { original_display_name: String::new(), original_bio: String::new(), original_avatar_url: String::new(), - avatar_textures: HashMap::new(), - avatar_loading: false, + avatar_cache: AvatarCache::new(), pending_action: None, show_info_popup: false, show_avatar_info_popup: false, @@ -141,59 +109,20 @@ impl ProfileScreen { confirmation_dialog: None, }; - // Auto-select identity on creation - prefer one with a profile - if let Ok(identities) = app_context.load_local_qualified_identities() + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + // Profile is loaded asynchronously by `LoadProfile` dispatch in `render()`. + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - // Try to find an identity with an actual profile (not just a "no profile" marker) - let network_str = app_context.network.to_string(); - tracing::info!( - "ProfileScreen::new - checking {} identities on network {}", - identities.len(), - network_str - ); + let selected_id = app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); - let mut selected_idx = 0; - for (idx, identity) in identities.iter().enumerate() { - let identity_id = identity.identity.id(); - tracing::debug!("Checking identity {} for profile in DB", identity_id); - match app_context - .db - .load_dashpay_profile(&identity_id, &network_str) - { - Ok(Some(profile)) => { - tracing::debug!( - "Found profile for identity {}: display_name={:?}", - identity_id, - profile.display_name - ); - if profile.display_name.is_some() - || profile.bio.is_some() - || profile.avatar_url.is_some() - { - // Check if this is an actual profile with data (not a "no profile" marker) - selected_idx = idx; - tracing::info!("Selected identity {} with profile", identity_id); - break; - } - } - Ok(None) => { - tracing::debug!("No profile in DB for identity {}", identity_id); - } - Err(e) => { - tracing::error!( - "Error loading profile for identity {}: {}", - identity_id, - e - ); - } - } - } - - new_self.selected_identity = Some(identities[selected_idx].clone()); - new_self.selected_identity_string = identities[selected_idx] + new_self.selected_identity = Some(preferred.clone()); + new_self.selected_identity_string = preferred .identity .id() .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); @@ -203,50 +132,20 @@ impl ProfileScreen { new_self.selected_identity_string ); - // Get wallet for the selected identity - new_self.selected_wallet = - get_selected_wallet(&identities[selected_idx], Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); - - // Load profile from database for this identity - new_self.load_profile_from_database(); + new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self } fn validate_profile(&mut self) { - self.validation_errors.clear(); - - // Display name validation - if self.edit_display_name.trim().is_empty() { - self.validation_errors - .push(ValidationError::DisplayNameEmpty); - } else if self.edit_display_name.len() > 25 { - self.validation_errors - .push(ValidationError::DisplayNameTooLong( - self.edit_display_name.len(), - )); - } - - // Bio validation - if self.edit_bio.len() > 140 { - self.validation_errors - .push(ValidationError::BioTooLong(self.edit_bio.len())); - } - - // Avatar URL validation - if !self.edit_avatar_url.trim().is_empty() { - let url = self.edit_avatar_url.trim(); - if url.len() > 500 { - self.validation_errors - .push(ValidationError::AvatarUrlTooLong(url.len())); - } else if !url.starts_with("http://") && !url.starts_with("https://") { - self.validation_errors - .push(ValidationError::InvalidAvatarUrl(url.to_string())); - } - } + self.validation_errors = crate::model::dashpay::validate_profile_fields( + &self.edit_display_name, + &self.edit_bio, + &self.edit_avatar_url, + ); } fn check_for_changes(&mut self) { @@ -259,73 +158,11 @@ impl ProfileScreen { self.validation_errors.is_empty() } + /// Reset the load-attempted flag so the next `render()` re-dispatches + /// `LoadProfile`. The local DET profile cache is gone after D3 — the + /// async result populates `self.profile` via `display_task_result`. fn load_profile_from_database(&mut self) { - // Load saved profile for the selected identity from database - if let Some(identity) = &self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - tracing::debug!( - "Loading profile from database for identity {} on network {}", - identity_id, - network_str - ); - - // Load profile from database - match self - .app_context - .db - .load_dashpay_profile(&identity_id, &network_str) - { - Ok(Some(stored_profile)) => { - tracing::debug!( - "Found profile in database: display_name={:?}, bio={:?}, avatar_url={:?}", - stored_profile.display_name, - stored_profile.bio, - stored_profile.avatar_url - ); - // Check if this is a "no profile exists" marker (all fields are None) - if stored_profile.display_name.is_none() - && stored_profile.bio.is_none() - && stored_profile.avatar_url.is_none() - { - // This is a cached "no profile" state - self.profile = None; - self.profile_load_attempted = true; - } else { - // This is an actual profile with data - self.profile = Some(DashPayProfile { - display_name: stored_profile.display_name.unwrap_or_default(), - bio: stored_profile.bio.unwrap_or_default(), - avatar_url: stored_profile.avatar_url.unwrap_or_default(), - avatar_bytes: stored_profile.avatar_bytes, - }); - - // Update edit fields with loaded profile - if let Some(ref profile) = self.profile { - self.edit_display_name = profile.display_name.clone(); - self.edit_bio = profile.bio.clone(); - self.edit_avatar_url = profile.avatar_url.clone(); - - // Store original values for change detection - self.original_display_name = profile.display_name.clone(); - self.original_bio = profile.bio.clone(); - self.original_avatar_url = profile.avatar_url.clone(); - } - - // Mark as loaded from cache - self.profile_load_attempted = true; - } - } - Ok(None) => { - tracing::debug!("No profile found in database for identity {}", identity_id); - } - Err(e) => { - tracing::error!("Error loading profile from database: {}", e); - } - } - } + self.profile_load_attempted = false; } pub fn trigger_load_profile(&mut self) -> AppAction { @@ -345,13 +182,21 @@ impl ProfileScreen { // This prevents stuck loading states self.loading = false; - // Auto-select first identity if none selected + // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { - self.selected_identity = Some(identities[0].clone()); - self.selected_identity_string = identities[0].display_string(); + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let selected_id = self.app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + self.selected_identity = Some(preferred.clone()); + self.selected_identity_string = preferred + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); } // Load profile from database if we have an identity selected and no profile loaded @@ -454,106 +299,6 @@ impl ProfileScreen { self.has_unsaved_changes = false; } - /// Load avatar texture from network (fetches bytes and processes them) - fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { - let ctx_clone = ctx.clone(); - let url_clone = url.to_string(); - - // Spawn async task to fetch and load the image - tokio::spawn(async move { - match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) - .await - { - Ok(image_bytes) => { - Self::process_avatar_bytes_async(ctx_clone, url_clone, image_bytes, true); - } - Err(e) => { - tracing::warn!("Failed to fetch avatar image: {}", e); - } - } - }); - } - - /// Load avatar texture from cached bytes synchronously - /// Returns the ColorImage if successful, or None if processing failed - fn process_avatar_bytes_sync(image_bytes: &[u8]) -> Option<ColorImage> { - // Try to load the image - if let Ok(image) = image::load_from_memory(image_bytes) { - // Convert to RGBA - let rgba_image = image.to_rgba8(); - let width = rgba_image.width(); - let height = rgba_image.height(); - - // Center-crop to square if not already square - let cropped_image = if width != height { - let size = width.min(height); - let x_offset = (width - size) / 2; - let y_offset = (height - size) / 2; - image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size).to_image() - } else { - rgba_image - }; - - let size = [ - cropped_image.width() as usize, - cropped_image.height() as usize, - ]; - let pixels = cropped_image.into_raw(); - - Some(ColorImage::from_rgba_unmultiplied(size, &pixels)) - } else { - None - } - } - - /// Process avatar bytes asynchronously and store result for UI thread - /// If `from_network` is true, also stores the raw bytes for database caching - fn process_avatar_bytes_async( - ctx: egui::Context, - url: String, - image_bytes: Vec<u8>, - from_network: bool, - ) { - // Try to load the image - if let Ok(image) = image::load_from_memory(&image_bytes) { - // Convert to RGBA - let rgba_image = image.to_rgba8(); - let width = rgba_image.width(); - let height = rgba_image.height(); - - // Center-crop to square if not already square - let cropped_image = if width != height { - let size = width.min(height); - let x_offset = (width - size) / 2; - let y_offset = (height - size) / 2; - image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size).to_image() - } else { - rgba_image - }; - - let size = [ - cropped_image.width() as usize, - cropped_image.height() as usize, - ]; - let pixels = cropped_image.into_raw(); - - // Create ColorImage - let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); - - // Request repaint to load texture in UI thread - ctx.request_repaint(); - - // Store the image data temporarily for the UI thread to pick up - ctx.data_mut(|data| { - data.insert_temp(egui::Id::new(format!("avatar_data_{}", url)), color_image); - // Only store raw bytes if fetched from network (for database caching) - if from_network { - data.insert_temp(egui::Id::new(format!("avatar_bytes_{}", url)), image_bytes); - } - }); - } - } - fn show_success_screen(&mut self, ui: &mut Ui) -> AppAction { let success_message = if self.was_creating_new { "DashPay Profile Created Successfully!" @@ -591,6 +336,15 @@ impl ProfileScreen { action = *pending; } + // Auto-dispatch `LoadProfile` on first render or after identity change. + if !self.profile_load_attempted + && !self.loading + && self.selected_identity.is_some() + && matches!(action, AppAction::None) + { + action = self.trigger_load_profile(); + } + // Show success screen if profile was just created/updated if self.show_success { return self.show_success_screen(ui); @@ -599,7 +353,7 @@ impl ProfileScreen { // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right @@ -608,6 +362,8 @@ impl ProfileScreen { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "profile_identity_selector", @@ -617,7 +373,8 @@ impl ProfileScreen { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), // Disable "Other" option + .other_option(false) // Disable "Other" option + .syncing_global(self.app_context.clone()), ); if response.changed() { @@ -628,8 +385,8 @@ impl ProfileScreen { self.editing = false; self.validation_errors.clear(); self.has_unsaved_changes = false; - self.avatar_loading = false; - // Don't clear avatar_textures - they're keyed by URL so can be reused + // Avatar cache is keyed by URL, so it is reused across + // identities without a reset. // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { @@ -662,7 +419,7 @@ impl ProfileScreen { // Profile loading status - styled card when no profile loaded if !self.profile_load_attempted && !self.loading { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -692,7 +449,7 @@ impl ProfileScreen { // Loading or saving indicator if self.loading || self.saving { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); let status_text = if self.saving { "Saving profile..." @@ -710,7 +467,7 @@ impl ProfileScreen { // Main editing panel (left side) ui.vertical(|ui| { ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { ui.label( RichText::new("Edit Profile") @@ -828,17 +585,17 @@ impl ProfileScreen { ); // Avatar URL character count - let url_count = self.edit_avatar_url.len(); - let url_count_color = if url_count > 500 { + let url_count = self.edit_avatar_url.chars().count(); + let url_count_color = if url_count > MAX_AVATAR_URL_CHARS { egui::Color32::RED - } else if url_count > 450 { + } else if url_count > MAX_AVATAR_URL_CHARS - 50 { egui::Color32::ORANGE } else { DashColors::text_secondary(dark_mode) }; if !self.edit_avatar_url.is_empty() { ui.label( - RichText::new(format!("{}/500", url_count)) + RichText::new(format!("{url_count}/{MAX_AVATAR_URL_CHARS}")) .small() .color(url_count_color), ); @@ -872,8 +629,9 @@ impl ProfileScreen { // Check wallet lock status before showing save button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -1011,152 +769,21 @@ impl ProfileScreen { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { - // Check if we have an avatar URL and try to display it - if !profile.avatar_url.is_empty() { - let texture_id = - format!("avatar_{}", profile.avatar_url); - - // Check if texture is already cached in memory - if let Some(texture) = - self.avatar_textures.get(&texture_id) - { - // Display the cached avatar image (clickable) - let image_response = ui.add( - egui::Image::new(texture) - .fit_to_exact_size(egui::vec2(80.0, 80.0)) - .corner_radius(8.0) - .sense(egui::Sense::click()), - ).clickable_tooltip("Click to view avatar URL"); - if image_response.clicked() { - self.show_avatar_url_popup = true; - } - } else { - // Check if image data was loaded by async task from network - let data_id = - format!("avatar_data_{}", profile.avatar_url); - let bytes_id = - format!("avatar_bytes_{}", profile.avatar_url); - let color_image = ui.ctx().data_mut(|data| { - data.get_temp::<ColorImage>(egui::Id::new( - &data_id, - )) - }); - let fetched_bytes: Option<Vec<u8>> = ui.ctx().data_mut(|data| { - data.get_temp::<Vec<u8>>(egui::Id::new( - &bytes_id, - )) - }); - - if let Some(color_image) = color_image { - // Create texture from loaded image - let texture = ui.ctx().load_texture( - &texture_id, - color_image, - egui::TextureOptions::LINEAR, - ); - - // Display the image (clickable) - let image_response = ui.add( - egui::Image::new(&texture) - .fit_to_exact_size(egui::vec2(80.0, 80.0)) - .corner_radius(8.0) - .sense(egui::Sense::click()), - ).clickable_tooltip("Click to view avatar URL"); - if image_response.clicked() { - self.show_avatar_url_popup = true; - } - - // Cache the texture in memory - self.avatar_textures - .insert(texture_id, texture); - self.avatar_loading = false; - - // Save avatar bytes to database for caching - if let Some(bytes) = fetched_bytes - && let Some(ref identity) = self.selected_identity - { - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_profile_avatar_bytes( - &identity_id, - &network_str, - Some(&bytes), - ) { - tracing::error!("Failed to save avatar bytes to database: {}", e); - } else { - tracing::debug!("Saved avatar bytes to database ({} bytes)", bytes.len()); - } - // Update the profile's avatar_bytes in memory - if let Some(ref mut p) = self.profile { - p.avatar_bytes = Some(bytes); - } - } - - // Clear the temporary data - ui.ctx().data_mut(|data| { - data.remove::<ColorImage>(egui::Id::new( - &data_id, - )); - data.remove::<Vec<u8>>(egui::Id::new( - &bytes_id, - )); - }); - } else if !self.avatar_loading { - // Check if we have cached bytes from database - if let Some(ref avatar_bytes) = profile.avatar_bytes { - // Process cached bytes synchronously to avoid spinner - if let Some(color_image) = Self::process_avatar_bytes_sync(avatar_bytes) { - let texture = ui.ctx().load_texture( - &texture_id, - color_image, - egui::TextureOptions::LINEAR, - ); - let image_response = ui.add( - egui::Image::new(&texture) - .fit_to_exact_size(egui::vec2(80.0, 80.0)) - .corner_radius(8.0) - .sense(egui::Sense::click()), - ).clickable_tooltip("Click to view avatar URL"); - if image_response.clicked() { - self.show_avatar_url_popup = true; - } - self.avatar_textures.insert(texture_id, texture); - } else { - // Failed to process cached bytes, fetch from network - self.avatar_loading = true; - self.load_avatar_texture( - ui.ctx(), - &profile.avatar_url, - ); - ui.add( - egui::Spinner::new() - .color(DashColors::DASH_BLUE), - ); - } - } else { - // No cached bytes, fetch from network - self.avatar_loading = true; - self.load_avatar_texture( - ui.ctx(), - &profile.avatar_url, - ); - // Show spinner while loading - ui.add( - egui::Spinner::new() - .color(DashColors::DASH_BLUE), - ); - } - } else { - // Show loading indicator - ui.add( - egui::Spinner::new() - .color(DashColors::DASH_BLUE), - ); - } - } - } else { - // No avatar URL, show default emoji - ui.label(RichText::new("👤").size(80.0).color(DashColors::DEEP_BLUE)); + // Seed the cache with locally-known bytes + // so a stored avatar renders without a + // network round-trip. + if let Some(bytes) = profile.avatar_bytes.clone() { + self.avatar_cache.seed(&profile.avatar_url, bytes); + } + let response = Avatar::new(Some(profile.avatar_url.as_str()), 80.0) + .corner_radius(8.0) + .clickable("Click to view avatar URL") + .show(ui, &mut self.avatar_cache); + if let Some(task) = response.fetch { + action |= AppAction::BackendTask(task); + } + if response.clicked { + self.show_avatar_url_popup = true; } }); }); @@ -1173,7 +800,7 @@ impl ProfileScreen { if let Some(identity) = &self.selected_identity && !identity.dpns_names.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(format!( "@{}", @@ -1215,7 +842,7 @@ impl ProfileScreen { ui.separator(); // Bio - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Bio:") .strong() @@ -1237,7 +864,7 @@ impl ProfileScreen { }); } else if self.profile_load_attempted { // No profile exists (only show after we've tried to load) - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -1280,7 +907,7 @@ impl ProfileScreen { if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ui.ctx(), |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Profile Guidelines", PROFILE_GUIDELINES_INFO_TEXT); if popup.show(ui).inner { @@ -1293,7 +920,7 @@ impl ProfileScreen { if self.show_avatar_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ui.ctx(), |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Avatar Image Guidelines", AVATAR_URL_INFO_TEXT); if popup.show(ui).inner { self.show_avatar_info_popup = false; @@ -1305,7 +932,6 @@ impl ProfileScreen { if self.show_avatar_url_popup { if let Some(profile) = &self.profile { let avatar_url = profile.avatar_url.clone(); - let texture_id = format!("avatar_{}", avatar_url); // Draw modal overlay let screen_rect = ui.ctx().content_rect(); @@ -1324,7 +950,7 @@ impl ProfileScreen { ui.add_space(5.0); // Display larger avatar image - if let Some(texture) = self.avatar_textures.get(&texture_id) { + if let Some(texture) = self.avatar_cache.ready_texture(&avatar_url) { ui.add( egui::Image::new(texture) .fit_to_exact_size(egui::vec2(200.0, 200.0)) @@ -1335,7 +961,7 @@ impl ProfileScreen { ui.add_space(10.0); // Show URL in smaller, secondary text - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(&avatar_url) .small() @@ -1410,6 +1036,13 @@ impl ProfileScreen { } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + // Avatar results arrive independently of profile load/save; route them + // without disturbing those loading states. + if let BackendTaskSuccessResult::DashPayAvatar { url, bytes } = result { + self.avatar_cache.store(url, bytes); + return; + } + // Always clear loading and saving states first self.loading = false; self.saving = false; @@ -1424,25 +1057,11 @@ impl ProfileScreen { // Preserve cached avatar bytes if URL hasn't changed let avatar_bytes = if avatar_url_changed { - // URL changed, clear cached bytes and texture so new avatar is fetched - self.avatar_textures - .remove(&format!("avatar_{}", old_avatar_url.unwrap_or_default())); - self.avatar_loading = false; - - // Clear old avatar bytes from database since URL changed - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - let _ = self.app_context.db.save_dashpay_profile_avatar_bytes( - &identity_id, - &network_str, - None, - ); - } + // URL changed: drop the stale cached avatar so the new URL re-fetches. + self.avatar_cache.invalidate(); None } else { - // URL same, keep existing cached bytes + // URL same, keep existing in-memory bytes self.profile.as_ref().and_then(|p| p.avatar_bytes.clone()) }; @@ -1453,95 +1072,39 @@ impl ProfileScreen { avatar_bytes, }); - // Save profile to database for caching - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - if let Err(e) = self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - Some(&display_name), - Some(&bio), - Some(&avatar_url), - None, // public_message not used in profile screen yet - ) { - tracing::error!("Failed to cache profile in database: {}", e); - } - } + // Profile cache write dropped — `load_profile` / + // `update_profile` already mirror through the D3 seam + // (`WalletBackend::dashpay_set_profile`), so DashpayView + // is the single source of truth. // Profile loaded successfully - no need to show a message } else { // No profile found - clear any existing profile and show create button self.profile = None; - - // Save "no profile" state to database to avoid repeated network queries - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Save with all fields as None to indicate "no profile exists" - // This prevents unnecessary network queries on app restart - if let Err(e) = self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - None, // display_name - None, // bio - None, // avatar_url - None, // public_message - ) { - tracing::error!( - "Failed to cache 'no profile' state in database: {}", - e - ); - } - } + // The "no profile" sentinel write dropped: the next fetch + // re-resolves via Platform, and `DashpayView::profile` + // already returns None when the upstream wallet has no + // DashPayProfile bound to the owner. // Don't show a message - let the UI show "Create Profile" button } } BackendTaskSuccessResult::DashPayProfileUpdated(_identity_id) => { - // Profile was successfully created/updated - // Save the profile data to database BEFORE clearing edit fields + // Profile was successfully created/updated; the upstream + // mirror (`update_profile` → `dashpay_set_profile`) is the + // authoritative write, so we only refresh local in-memory + // state for instant UI feedback. if let Some(ref identity) = self.selected_identity { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); let display_name = self.edit_display_name.trim(); let bio = self.edit_bio.trim(); let avatar_url = self.edit_avatar_url.trim(); - tracing::info!( - "Saving profile to database: identity={}, network={}, display_name={:?}, bio={:?}, avatar_url={:?}", - identity_id, - network_str, - display_name, - bio, - avatar_url + tracing::debug!( + identity = %identity_id, + "DashPay profile updated; refreshing in-memory copy" ); - // Save to database - match self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - if display_name.is_empty() { - None - } else { - Some(display_name) - }, - if bio.is_empty() { None } else { Some(bio) }, - if avatar_url.is_empty() { - None - } else { - Some(avatar_url) - }, - None, - ) { - Ok(_) => tracing::info!("Profile saved to database successfully"), - Err(e) => tracing::error!("Failed to save profile to database: {}", e), - } - // Update in-memory profile (preserve existing avatar_bytes if URL didn't change) let existing_avatar_bytes = self.profile.as_ref().and_then(|p| { if p.avatar_url == avatar_url { diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index a3cecaef2..526ddc4d0 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -78,7 +78,7 @@ impl ProfileSearchScreen { // Use any available identity for viewing (just needed for context) let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { crate::ui::components::MessageBanner::set_global( @@ -109,7 +109,7 @@ impl ProfileSearchScreen { pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Header ui.horizontal(|ui| { @@ -259,12 +259,12 @@ impl ProfileSearchScreen { } impl ScreenLike for ProfileSearchScreen { - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel - consistent with other DashPay subscreens action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -277,17 +277,17 @@ impl ScreenLike for ProfileSearchScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); // Add DashPay subscreen chooser panel action |= add_dashpay_subscreen_chooser_panel( - ctx, + ui, &self.app_context, DashPaySubscreen::ProfileSearch, // Use ProfileSearch as the active subscreen ); // Main content area with island styling - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Handle custom action from top panel button if let AppAction::Custom(command) = &action @@ -303,7 +303,7 @@ impl ScreenLike for ProfileSearchScreen { if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("About Profile Search", PROFILE_SEARCH_INFO_TEXT); if popup.show(ui).inner { diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 412c690f8..711d43f6e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; -use crate::backend_task::dashpay::auto_accept_proof::generate_auto_accept_proof; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -38,7 +39,7 @@ const ACCOUNT_INDEX_INFO_TEXT: &str = "Account Index:\n\n\ pub struct QRCodeGeneratorScreen { pub app_context: Arc<AppContext>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, account_index: String, validity_hours: String, @@ -66,83 +67,78 @@ impl QRCodeGeneratorScreen { wallet_open_attempted: false, }; - // Auto-select first identity on creation if available - if let Ok(identities) = app_context.load_local_qualified_identities() + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; - new_self.selected_identity = Some(identities[0].clone()); - new_self.selected_identity_string = - identities[0].identity.id().to_string(Encoding::Base58); + let selected_id = app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + + new_self.selected_identity = Some(preferred.clone()); + new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self } - fn generate_qr_code(&mut self) { - if let Some(identity) = &self.selected_identity { - let account_idx = match self.account_index.parse::<u32>() { - Ok(v) => v, - Err(_) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid account index number", - MessageType::Error, - ); - return; - } - }; - - let validity = match self.validity_hours.parse::<u32>() { - Ok(v) if v > 0 && v <= 720 => v, // Max 30 days - _ => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Validity hours must be between 1 and 720", - MessageType::Error, - ); - return; - } - }; - - match generate_auto_accept_proof(identity, account_idx, validity) { - Ok(proof_data) => { - let qr_string = proof_data.to_qr_string(); - self.generated_qr_data = Some(qr_string); - MessageBanner::set_global( - self.app_context.egui_ctx(), - "QR code generated successfully", - MessageType::Success, - ); - } - Err(e) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - format!("Failed to generate QR code: {}", e), - MessageType::Error, - ); - } - } - } else { + /// Dispatch the auto-accept QR build to the backend, which resolves the key + /// and derives the proof through the JIT chokepoint (no seed in the UI). + fn generate_qr_code(&mut self) -> AppAction { + let Some(identity) = &self.selected_identity else { MessageBanner::set_global( self.app_context.egui_ctx(), "Please select an identity first", MessageType::Error, ); - } + return AppAction::None; + }; + + let account_idx = match self.account_index.parse::<u32>() { + Ok(v) => v, + Err(_) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid account index number", + MessageType::Error, + ); + return AppAction::None; + } + }; + + let validity = match self.validity_hours.parse::<u32>() { + Ok(v) if v > 0 && v <= 720 => v, // Max 30 days + _ => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Validity hours must be between 1 and 720", + MessageType::Error, + ); + return AppAction::None; + } + }; + + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::GenerateAutoAcceptQrCode { + identity: identity.clone(), + account_reference: account_idx, + validity_hours: validity, + }, + ))) } pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Header with info icon ui.horizontal(|ui| { @@ -164,7 +160,7 @@ impl QRCodeGeneratorScreen { // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -190,6 +186,8 @@ impl QRCodeGeneratorScreen { RichText::new("Identity:").color(DashColors::text_primary(dark_mode)), ); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "qr_identity_selector", @@ -199,7 +197,8 @@ impl QRCodeGeneratorScreen { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); if response.changed() { @@ -269,8 +268,9 @@ impl QRCodeGeneratorScreen { // Check wallet lock status before showing generate button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -294,7 +294,7 @@ impl QRCodeGeneratorScreen { } else { ui.horizontal(|ui| { if ui.button("Generate QR Code").clicked() { - self.generate_qr_code(); + action |= self.generate_qr_code(); } if self.generated_qr_data.is_some() @@ -399,12 +399,12 @@ impl QRCodeGeneratorScreen { } impl ScreenLike for QRCodeGeneratorScreen { - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -414,23 +414,23 @@ impl ScreenLike for QRCodeGeneratorScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); // Add DashPay subscreen chooser panel action |= add_dashpay_subscreen_chooser_panel( - ctx, + ui, &self.app_context, DashPaySubscreen::Contacts, // Use Contacts as the active subscreen since QR Generator is launched from there ); // Main content area with island styling - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Show info popup if requested if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("About Contact QR Codes", QR_CODE_INFO_TEXT); if popup.show(ui).inner { self.show_info_popup = false; @@ -444,4 +444,10 @@ impl ScreenLike for QRCodeGeneratorScreen { fn display_message(&mut self, _message: &str, _message_type: MessageType) { // Banner display is handled globally by AppState; no side-effects needed. } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr_string) = result { + self.generated_qr_data = Some(qr_string); + } + } } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index cea322cf8..4215eea65 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -25,7 +25,7 @@ use std::sync::{Arc, RwLock}; pub struct QRScannerScreen { pub app_context: Arc<AppContext>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, qr_data_input: String, parsed_qr_data: Option<AutoAcceptProofData>, @@ -37,10 +37,29 @@ pub struct QRScannerScreen { impl QRScannerScreen { pub fn new(app_context: Arc<AppContext>) -> Self { + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + let identities = app_context.load_local_user_identities().unwrap_or_default(); + let selected_identity = { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + app_context + .selected_identity_id() + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .or_else(|| identities.first().cloned()) + }; + let selected_identity_string = selected_identity + .as_ref() + .map(|qi| { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + qi.identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + }) + .unwrap_or_default(); + Self { app_context, - selected_identity: None, - selected_identity_string: String::new(), + selected_identity, + selected_identity_string, qr_data_input: String::new(), parsed_qr_data: None, sending: false, @@ -150,7 +169,7 @@ impl QRScannerScreen { // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -169,6 +188,8 @@ impl QRScannerScreen { ui.horizontal(|ui| { ui.label("Identity:"); + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). ui.add( IdentitySelector::new( "qr_scanner_identity_selector", @@ -178,7 +199,8 @@ impl QRScannerScreen { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); }); @@ -260,8 +282,9 @@ impl QRScannerScreen { // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -331,12 +354,14 @@ impl QRScannerScreen { } impl ScreenLike for QRScannerScreen { - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; // Add top panel action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -346,14 +371,14 @@ impl ScreenLike for QRScannerScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); // Add DashPay subscreen chooser panel action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, DashPaySubscreen::Contacts); // Main content area with island styling - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Show wallet unlock popup if open if self.wallet_unlock_popup.is_open() diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index c3ca3d169..4aa8d4ac3 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -81,24 +81,9 @@ impl SendPaymentScreen { } fn load_contact_info(&mut self) { - let owner_id = self.from_identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Ok(contacts) = self - .app_context - .db - .load_dashpay_contacts(&owner_id, &network_str) - { - let contact_bytes = self.to_contact_id.to_buffer().to_vec(); - if let Some(contact) = contacts - .iter() - .find(|c| c.contact_identity_id == contact_bytes) - { - self.to_contact_name = contact - .username - .clone() - .or_else(|| contact.display_name.clone()); - } - } + // The DET contacts cache is gone after D3; the recipient name is supplied + // via the routing screen (see ContactDetailsScreen / ContactsList) or + // remains None and the UI falls back to displaying the contact ID. } fn send_payment(&mut self) -> AppAction { @@ -133,9 +118,10 @@ impl SendPaymentScreen { return AppAction::None; } - // Get amount in Dash (convert from duffs) - let amount_dash = match self.amount.dash_to_duffs() { - Ok(duffs) => duffs as f64 / 100_000_000.0, + // Resolve the amount in duffs at the UI edge — no floating-point value + // crosses into the backend. + let amount_duffs = match self.amount.dash_to_duffs() { + Ok(duffs) => duffs, Err(e) => { MessageBanner::set_global( self.app_context.egui_ctx(), @@ -153,7 +139,7 @@ impl SendPaymentScreen { DashPayTask::SendPaymentToContact { identity: self.from_identity.clone(), contact_id: self.to_contact_id, - amount_dash, + amount_duffs, memo: if self.memo.is_empty() { None } else { @@ -207,8 +193,9 @@ impl SendPaymentScreen { // Check wallet unlock let needs_unlock = if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -235,13 +222,13 @@ impl SendPaymentScreen { ui.group(|ui| { // From identity ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("From:") .strong() .color(DashColors::text_primary(dark_mode)), ); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(self.from_identity.to_string()) .color(DashColors::text_primary(dark_mode)), @@ -250,7 +237,7 @@ impl SendPaymentScreen { // Wallet Balance (from wallet, not identity) ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Wallet Balance:") .strong() @@ -258,7 +245,10 @@ impl SendPaymentScreen { ); let balance_dash = if let Some(wallet) = &self.selected_wallet { if let Ok(wallet_guard) = wallet.read() { - wallet_guard.confirmed_balance_duffs() as f64 / 100_000_000.0 + self.app_context + .snapshot_balance(&wallet_guard.seed_hash()) + .spendable() as f64 + / 100_000_000.0 } else { 0.0 } @@ -275,17 +265,17 @@ impl SendPaymentScreen { // To contact ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("To:") .strong() .color(DashColors::text_primary(dark_mode)), ); if let Some(name) = &self.to_contact_name { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new(name).color(DashColors::text_primary(dark_mode))); } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(format!("{}", self.to_contact_id)) .color(DashColors::text_primary(dark_mode)), @@ -295,10 +285,14 @@ impl SendPaymentScreen { ui.separator(); - // Amount input - use wallet balance for max + // Amount input - use the spendable wallet balance for max, so it + // matches the coin selector (confirmed + unconfirmed) and does + // not understate IS-locked funds awaiting their local flag. let max_balance = if let Some(wallet) = &self.selected_wallet { if let Ok(wallet_guard) = wallet.read() { - wallet_guard.confirmed_balance_duffs() + self.app_context + .snapshot_balance(&wallet_guard.seed_hash()) + .spendable() } else { 0 } @@ -325,7 +319,7 @@ impl SendPaymentScreen { ui.add_space(10.0); // Memo field - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Memo (optional):") .strong() @@ -337,7 +331,7 @@ impl SendPaymentScreen { .desired_rows(3) .desired_width(f32::INFINITY), ); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(format!("{}/100 characters", self.memo.len())) .small() @@ -399,12 +393,14 @@ impl ScreenLike for SendPaymentScreen { self.refresh(); } - fn ui(&mut self, ctx: &egui::Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; // Add top panel action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("DashPay", AppAction::None), @@ -414,17 +410,17 @@ impl ScreenLike for SendPaymentScreen { ); // Highlight DashPay in the main left panel - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenDashpay); action |= - add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Payments); + add_dashpay_subscreen_chooser_panel(ui, &self.app_context, DashPaySubscreen::Payments); - action |= island_central_panel(ctx, |ui| self.render(ui)); + action |= island_central_panel(ui, |ui| self.render(ui)); // Show info popup if requested if self.show_info_popup { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Payment Guidelines", PAYMENT_GUIDELINES_INFO_TEXT); if popup.show(ui).inner { @@ -465,7 +461,7 @@ impl ScreenLike for SendPaymentScreen { // Payment History Component (used in main DashPay screen) pub struct PaymentHistory { pub app_context: Arc<AppContext>, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_identity_string: String, payments: Vec<PaymentRecord>, loading: bool, @@ -493,89 +489,31 @@ impl PaymentHistory { has_searched: false, }; - // Auto-select first identity on creation if available - if let Ok(identities) = app_context.load_local_qualified_identities() + // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - new_self.selected_identity = Some(identities[0].clone()); - new_self.selected_identity_string = - identities[0].identity.id().to_string(Encoding::Base58); - - // Load payments from database for this identity - new_self.load_payments_from_database(); + let selected_id = app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + new_self.selected_identity = Some(preferred.clone()); + new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); } new_self } - fn load_payments_from_database(&mut self) { - // Load saved payment history for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - - // Clear existing payments before loading - self.payments.clear(); - - // Load payment history from database (limit 100) - if let Ok(stored_payments) = self.app_context.db.load_payment_history(&identity_id, 100) - { - for payment in stored_payments { - // Determine if incoming or outgoing based on identity - let is_incoming = payment.to_identity_id == identity_id.to_buffer().to_vec(); - let contact_id = if is_incoming { - payment.from_identity_id - } else { - payment.to_identity_id - }; - - // Try to resolve contact name - let contact_name = if let Ok(contact_id) = Identifier::from_bytes(&contact_id) { - // First check if we have a saved contact with username - let network_str = self.app_context.network.to_string(); - if let Ok(contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - contacts - .iter() - .find(|c| c.contact_identity_id == contact_id.to_buffer().to_vec()) - .and_then(|c| c.username.clone().or(c.display_name.clone())) - .unwrap_or_else(|| { - format!( - "Unknown ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - ) - }) - } else { - format!( - "Unknown ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - ) - } - } else { - "Unknown".to_string() - }; - - let payment_record = PaymentRecord { - tx_id: payment.tx_id, - contact_name, - amount: Credits::from(payment.amount as u64), - is_incoming, - timestamp: payment.created_at as u64, - memo: payment.memo, - }; - - self.payments.push(payment_record); - } - } - } - } - pub fn trigger_fetch_payment_history(&mut self) -> AppAction { if let Some(identity) = &self.selected_identity { self.loading = true; + // Mark the attempt at dispatch time, not on success. A failed load + // resets `loading` in `display_message` but leaves this flag set, so + // the auto-fetch gate fires exactly once and a transient error can't + // drive a re-dispatch storm. A fresh attempt is opted into via + // `refresh()` or an identity change. + self.has_searched = true; let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadPaymentHistory { identity: identity.clone(), @@ -591,28 +529,39 @@ impl PaymentHistory { // Don't clear if we have data, just clear temporary states self.loading = false; - // Auto-select first identity if none selected + // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { - self.selected_identity = Some(identities[0].clone()); - self.selected_identity_string = identities[0].display_string(); + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let selected_id = self.app_context.selected_identity_id(); + let preferred = selected_id + .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) + .unwrap_or_else(|| identities[0].clone()); + self.selected_identity = Some(preferred.clone()); + self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); } - // Load payments from database if we have an identity selected and no payments loaded + // Reset the fetched flag if we have no payments; next render dispatches + // `LoadPaymentHistory` via `has_searched == false`. if self.selected_identity.is_some() && self.payments.is_empty() { - self.load_payments_from_database(); + self.has_searched = false; } } pub fn render(&mut self, ui: &mut Ui) -> AppAction { - let action = AppAction::None; + let mut action = AppAction::None; + + // Auto-dispatch `LoadPaymentHistory` on first render or after identity change. + if !self.has_searched && !self.loading && self.selected_identity.is_some() { + action = self.trigger_fetch_payment_history(); + } // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right @@ -621,6 +570,8 @@ impl PaymentHistory { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "payment_history_identity_selector", @@ -630,14 +581,15 @@ impl PaymentHistory { .selected_identity(&mut self.selected_identity) .unwrap() .width(300.0) - .other_option(false), // Disable "Other" option + .other_option(false) // Disable "Other" option + .syncing_global(self.app_context.clone()), ); if response.changed() { self.refresh(); - - // Load payments from database for the newly selected identity - self.load_payments_from_database(); + // The next render dispatches `LoadPaymentHistory` + // via `has_searched == false`. + self.has_searched = false; } }); } @@ -650,7 +602,7 @@ impl PaymentHistory { } if self.selected_identity.is_none() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Please select an identity to view payment history") .color(DashColors::text_primary(dark_mode)), @@ -670,7 +622,7 @@ impl PaymentHistory { // Payment list ScrollArea::vertical().show(ui, |ui| { if self.payments.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -696,7 +648,7 @@ impl PaymentHistory { } else { for payment in &self.payments { ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { // Avatar placeholder ui.vertical(|ui| { @@ -786,7 +738,11 @@ impl PaymentHistory { } pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. + // Banner display is handled globally by AppState; this is only for + // side-effects. Settle the spinner so a failed `LoadPaymentHistory` + // doesn't strand the widget on the loading state (`has_searched` was + // already set at dispatch, so this won't re-trigger the auto-fetch). + self.loading = false; } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -807,8 +763,13 @@ impl PaymentHistory { let contact_id = if contact_name.contains("(") && contact_name.contains(")") { // Extract ID from format "Unknown (abcd1234)" - let start = contact_name.find('(').unwrap() + 1; - let end = contact_name.find(')').unwrap(); + let start = contact_name + .find('(') + .expect("invariant: '(' present per the contains check above") + + 1; + let end = contact_name + .find(')') + .expect("invariant: ')' present per the contains check above"); let _id_str = &contact_name[start..end]; // This is likely a partial base58 ID, we'd need the full ID // For now, we'll use a placeholder @@ -830,22 +791,11 @@ impl PaymentHistory { }, }; self.payments.push(payment); - - // Save to database - let (from_id, to_id, payment_type) = if is_incoming { - (contact_id, identity_id, "received") - } else { - (identity_id, contact_id, "sent") - }; - - let _ = self.app_context.db.save_payment( - &tx_id, - &from_id, - &to_id, - amount as i64, - if memo.is_empty() { None } else { Some(&memo) }, - payment_type, - ); + // `payments::send_payment_to_contact` records outgoing payments + // through `WalletBackend::dashpay_record_payment` + the + // payment-timestamp sidecar, so the upstream wallet is the + // single source of truth; no mirror is needed here. + let _ = (contact_id, identity_id, tx_id, amount, memo, is_incoming); } } else { // No selected identity, just populate in-memory diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index c4d39b218..290f441d8 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1,3 +1,4 @@ +use crate::wallet_backend::poison::MutexRecover; use std::sync::{Arc, Mutex}; use tracing::error; @@ -7,7 +8,7 @@ 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 eframe::egui::{self, Button, Color32, ComboBox, Context, Label, RichText, Ui}; +use eframe::egui::{self, Button, Color32, ComboBox, Label, RichText, Ui}; use egui_extras::{Column, TableBuilder}; use itertools::Itertools; @@ -22,7 +23,7 @@ use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_choo use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; @@ -173,8 +174,7 @@ impl DPNSScreen { )); let voting_identities = app_context - .db - .get_local_voting_identities(app_context) + .load_local_voting_identities() .unwrap_or_default(); let user_identities = app_context.load_local_user_identities().unwrap_or_default(); @@ -290,7 +290,7 @@ impl DPNSScreen { ui.add_space(10.0); if self.dpns_subscreen != DPNSSubscreen::ScheduledVotes { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Please check back later or try refreshing the list.").color(DashColors::text_primary(dark_mode))); ui.add_space(20.0); if StyledButton::primary("Refresh").show(ui).clicked() { @@ -316,7 +316,7 @@ impl DPNSScreen { } } } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let text_color = DashColors::text_primary(dark_mode); ui.label( RichText::new("To schedule votes, go to the Active Contests subscreen, click your choices, and then click the 'Vote' button in the top-right.").color(text_color) @@ -334,13 +334,13 @@ impl DPNSScreen { /// Show the Active Contests table fn render_table_active_contests(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); ui.text_edit_singleline(&mut self.active_filter_term); }); let contested_names = { - let guard = self.contested_names.lock().unwrap(); + let guard = self.contested_names.lock_recover(); let mut cn = guard.clone(); if !self.active_filter_term.is_empty() { let mut filter_lc = self.active_filter_term.to_lowercase(); @@ -403,7 +403,7 @@ impl DPNSScreen { } }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Contestants").color(DashColors::text_primary(dark_mode)), ); @@ -465,7 +465,7 @@ impl DPNSScreen { (contested_name.normalized_contested_name.clone(), None) }; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let label_response = ui.label( RichText::new(used_name) .color(DashColors::text_primary(dark_mode)), @@ -479,7 +479,7 @@ impl DPNSScreen { row.col(|ui| { let label_text = format!("{}", locked_votes); let dark_green = Color32::from_rgb(0, 100, 0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let normal_color = DashColors::text_primary(dark_mode); let text_widget = if is_locked_votes_bold { RichText::new(label_text).strong().color(dark_green) @@ -581,7 +581,7 @@ impl DPNSScreen { // Ending Time row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(ending_time) = contested_name.end_time { if let LocalResult::Single(dt) = Utc.timestamp_millis_opt(ending_time as i64) @@ -609,7 +609,7 @@ impl DPNSScreen { // Last Updated row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(last_updated) = contested_name.last_updated { if let LocalResult::Single(dt) = Utc.timestamp_opt(last_updated as i64, 0) @@ -658,13 +658,13 @@ impl DPNSScreen { /// Show a Past Contests table fn render_table_past_contests(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); ui.text_edit_singleline(&mut self.past_filter_term); }); let contested_names = { - let guard = self.contested_names.lock().unwrap(); + let guard = self.contested_names.lock_recover(); let mut cn = guard.clone(); cn.retain(|c| c.awarded_to.is_some() || c.state == ContestState::Locked); // 1) Filter by `past_filter_term` @@ -729,7 +729,7 @@ impl DPNSScreen { body.row(25.0, |mut row| { // Name row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(&contested_name.normalized_contested_name) .color(DashColors::text_primary(dark_mode)), @@ -737,7 +737,7 @@ impl DPNSScreen { }); // Ended Time row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(ended_time) = contested_name.end_time { if let LocalResult::Single(dt) = Utc.timestamp_millis_opt(ended_time as i64) @@ -763,7 +763,7 @@ impl DPNSScreen { }); // Last Updated row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(last_updated) = contested_name.last_updated { if let LocalResult::Single(dt) = Utc.timestamp_opt(last_updated as i64, 0) @@ -795,7 +795,7 @@ impl DPNSScreen { }); // Awarded To row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; match contested_name.state { ContestState::Unknown => { ui.label( @@ -835,13 +835,13 @@ impl DPNSScreen { /// Show the Owned DPNS names table fn render_table_local_dpns_names(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); ui.text_edit_singleline(&mut self.owned_filter_term); }); let mut filtered_names = { - let guard = self.local_dpns_names.lock().unwrap(); + let guard = self.local_dpns_names.lock_recover(); let mut name_infos = guard.clone(); if !self.owned_filter_term.is_empty() { let filter_lc = self.owned_filter_term.to_lowercase(); @@ -906,7 +906,7 @@ impl DPNSScreen { } }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Actions").color(DashColors::text_primary(dark_mode)), ); @@ -923,14 +923,14 @@ impl DPNSScreen { }; body.row(25.0, |mut row| { row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(&display_name) .color(DashColors::text_primary(dark_mode)), ); }); row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(identifier.to_string(Encoding::Base58)) .color(DashColors::text_primary(dark_mode)), @@ -943,7 +943,7 @@ impl DPNSScreen { .map(|dt| dt.to_string()) .unwrap_or_else(|| "Invalid timestamp".to_string()); row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(dt).color(DashColors::text_primary(dark_mode)), ); @@ -958,7 +958,6 @@ impl DPNSScreen { }; if let Err(e) = self .app_context - .db .set_identity_alias(&identifier, Some(&alias_with_suffix)) { MessageBanner::set_global( @@ -989,7 +988,7 @@ impl DPNSScreen { fn render_table_scheduled_votes(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; let mut sorted_votes = { - let guard = self.scheduled_votes.lock().unwrap(); + let guard = self.scheduled_votes.lock_recover(); guard.clone() }; // Sort by contested_name or time @@ -1020,13 +1019,13 @@ impl DPNSScreen { } }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Voter").color(DashColors::text_primary(dark_mode)), ); }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Vote Choice").color(DashColors::text_primary(dark_mode)), ); @@ -1037,13 +1036,13 @@ impl DPNSScreen { } }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Status").color(DashColors::text_primary(dark_mode)), ); }); header.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Actions").color(DashColors::text_primary(dark_mode)), ); @@ -1074,7 +1073,7 @@ impl DPNSScreen { }); // Time row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let LocalResult::Single(dt) = Utc.timestamp_millis_opt(vote.0.unix_timestamp as i64) { @@ -1099,7 +1098,7 @@ impl DPNSScreen { }); // Status row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; match vote.1 { ScheduledVoteCastingStatus::NotStarted => { ui.label( @@ -1266,7 +1265,7 @@ impl DPNSScreen { fn show_bulk_schedule_popup_window(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Cast or Schedule Votes").color(DashColors::text_primary(dark_mode)), ); @@ -1283,7 +1282,7 @@ impl DPNSScreen { ui.add_space(5.0); ui.colored_label(Color32::DARK_RED, "No masternode identities loaded. Please go to the Identities screen to load your masternodes."); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { self.show_bulk_schedule_popup = false; } @@ -1295,7 +1294,7 @@ impl DPNSScreen { ui.add_space(5.0); ui.colored_label(Color32::DARK_RED, "No votes selected. Please click the votes you want to cast or schedule in the Active Contests screen."); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { self.show_bulk_schedule_popup = false; } @@ -1305,7 +1304,7 @@ impl DPNSScreen { egui::ScrollArea::vertical().show(ui, |ui| { // Show which votes were clicked ui.group(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Selected Votes:").color(DashColors::text_primary(dark_mode)), ); @@ -1327,7 +1326,7 @@ impl DPNSScreen { ResourceVoteChoice::TowardsIdentity(id) => id.to_string(Encoding::Base58), other => other.to_string(), }; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(format!( "{} => {} | Contest ends at {}", @@ -1341,7 +1340,7 @@ impl DPNSScreen { ui.add_space(10.0); // Show each identity + let user pick None / Immediate / Scheduled - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Select cast method for each node:") .color(DashColors::text_primary(dark_mode)), @@ -1349,7 +1348,7 @@ impl DPNSScreen { ui.add_space(10.0); ui.group(|ui| { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Set all:").color(DashColors::text_primary(dark_mode))); // A ComboBox to pick No Vote / Cast Now / Schedule @@ -1410,7 +1409,7 @@ impl DPNSScreen { ref mut minutes, } = self.set_all_option { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Schedule In:") .color(DashColors::text_primary(dark_mode)), @@ -1436,7 +1435,7 @@ impl DPNSScreen { .alias .clone() .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(format!("Identity: {}", label)) .color(DashColors::text_primary(dark_mode)), @@ -1448,8 +1447,7 @@ impl DPNSScreen { if self.bulk_identity_options.len() <= i { let voting_identities = self .app_context - .db - .get_local_voting_identities(&self.app_context) + .load_local_voting_identities() .unwrap_or_default(); // Initialize ephemeral bulk-schedule state to hidden let identity_count = voting_identities.len(); @@ -1512,7 +1510,7 @@ impl DPNSScreen { minutes, } = current_option { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Schedule In:") .color(DashColors::text_primary(dark_mode)), @@ -1550,7 +1548,7 @@ impl DPNSScreen { } ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { self.selected_votes.clear(); self.show_bulk_schedule_popup = false; @@ -1567,7 +1565,7 @@ impl DPNSScreen { // Elapsed time is shown in the global banner } VoteHandlingStatus::SchedulingVotes => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Scheduling votes...").color(DashColors::text_primary(dark_mode)), ); @@ -1683,36 +1681,36 @@ impl DPNSScreen { if let Some(message) = &self.bulk_schedule_message { match message.0 { MessageType::Error => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("❌").color(DashColors::text_primary(dark_mode)), ); if message.1.contains("Successes") { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Only some votes succeeded") .color(DashColors::text_primary(dark_mode)), ); } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("No votes succeeded") .color(DashColors::text_primary(dark_mode)), ); } ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new(message.1.clone()) .color(DashColors::text_primary(dark_mode)), ); } MessageType::Success => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("🎉").color(DashColors::text_primary(dark_mode)), ); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading( RichText::new("Successfully casted and scheduled all votes") .color(DashColors::text_primary(dark_mode)), @@ -1724,7 +1722,7 @@ impl DPNSScreen { } VoteHandlingStatus::Failed(message) => { // This means there was a DET-side error, not Platform-side - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading(RichText::new("❌").color(DashColors::text_primary(dark_mode))); ui.heading( RichText::new("Error casting and scheduling votes (DET-side)") @@ -1739,7 +1737,7 @@ impl DPNSScreen { } ui.add_space(20.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_primary_button(ui, "Go back to Active Contests").clicked() { self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; self.show_bulk_schedule_popup = false; @@ -1769,9 +1767,9 @@ impl DPNSScreen { impl ScreenLike for DPNSScreen { fn refresh(&mut self) { self.scheduled_vote_cast_in_progress = false; - let mut contested_names = self.contested_names.lock().unwrap(); - let mut dpns_names = self.local_dpns_names.lock().unwrap(); - let mut scheduled_votes = self.scheduled_votes.lock().unwrap(); + let mut contested_names = self.contested_names.lock_recover(); + let mut dpns_names = self.local_dpns_names.lock_recover(); + let mut scheduled_votes = self.scheduled_votes.lock_recover(); match self.dpns_subscreen { DPNSSubscreen::Active => { @@ -1819,8 +1817,7 @@ impl ScreenLike for DPNSScreen { fn refresh_on_arrival(&mut self) { self.voting_identities = self .app_context - .db - .get_local_voting_identities(&self.app_context) + .load_local_voting_identities() .unwrap_or_default(); self.user_identities = self .app_context @@ -1853,7 +1850,7 @@ impl ScreenLike for DPNSScreen { BackendTaskSuccessResult::DPNSVoteResults(results) => { let errors: Vec<String> = results .iter() - .filter_map(|(_, _, r)| r.as_ref().err().cloned()) + .filter_map(|(_, _, r)| r.as_ref().err().map(|e| e.to_string())) .collect(); let successes: Vec<String> = results .iter() @@ -1898,6 +1895,20 @@ impl ScreenLike for DPNSScreen { self.bulk_schedule_message = Some((MessageType::Success, "Votes scheduled".to_string())); } + BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { + // The periodic sweep is about to cast these votes; reflect that + // in the list so the user sees them move before results land. + self.scheduled_vote_cast_in_progress = true; + if let Ok(mut guard) = self.scheduled_votes.lock() { + for vote in &votes { + if let Some((_, status)) = guard.iter_mut().find(|(v, _)| { + v.contested_name == vote.contested_name && v.voter_id == vote.voter_id + }) { + *status = ScheduledVoteCastingStatus::InProgress; + } + } + } + } BackendTaskSuccessResult::CastScheduledVote(vote) => { self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() @@ -1917,10 +1928,12 @@ impl ScreenLike for DPNSScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let has_identity_that_can_register = !self.user_identities.is_empty(); let has_active_contests = { - let guard = self.contested_names.lock().unwrap(); + let guard = self.contested_names.lock_recover(); !guard.is_empty() }; @@ -1998,10 +2011,11 @@ impl ScreenLike for DPNSScreen { ); } - let mut action = add_top_panel( - ctx, + // TODO: wire wallet/identity selection consumption for the DPNS page. + let mut action = add_top_panel_with_global_nav( + ui, &self.app_context, - vec![("DPNS", AppAction::None)], + subdued_everyday_spec("DPNS", RootScreenType::RootScreenDPNSActiveContests), right_buttons, ); @@ -2014,19 +2028,19 @@ impl ScreenLike for DPNSScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenToolsPlatformInfoScreen, ); // Tools area chooser - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); // DPNS subscreen chooser - action |= add_dpns_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_dpns_subscreen_chooser_panel(ui, self.app_context.as_ref()); // Main panel - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Bulk-schedule ephemeral popup if self.show_bulk_schedule_popup { @@ -2043,7 +2057,7 @@ impl ScreenLike for DPNSScreen { match self.dpns_subscreen { DPNSSubscreen::Active => { let has_any = { - let guard = self.contested_names.lock().unwrap(); + let guard = self.contested_names.lock_recover(); !guard.is_empty() }; if has_any { @@ -2054,7 +2068,7 @@ impl ScreenLike for DPNSScreen { } DPNSSubscreen::Past => { let has_any = { - let guard = self.contested_names.lock().unwrap(); + let guard = self.contested_names.lock_recover(); !guard.is_empty() }; if has_any { @@ -2065,7 +2079,7 @@ impl ScreenLike for DPNSScreen { } DPNSSubscreen::Owned => { let has_any = { - let guard = self.local_dpns_names.lock().unwrap(); + let guard = self.local_dpns_names.lock_recover(); !guard.is_empty() }; if has_any { @@ -2076,7 +2090,7 @@ impl ScreenLike for DPNSScreen { } DPNSSubscreen::ScheduledVotes => { let has_any = { - let guard = self.scheduled_votes.lock().unwrap(); + let guard = self.scheduled_votes.lock_recover(); !guard.is_empty() }; if has_any { diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 24768f296..946c390ed 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -47,10 +47,6 @@ use egui::{Color32, ComboBox, Response, Ui}; use super::tokens::tokens_screen::IdentityTokenInfo; -/// Layout of labels and buttons in the UI fails to vertically align properly containers that contain buttons and other items (labels, text fields, etc.). -/// This constant provides a constant padding to be used in such cases to ensure proper alignment. -pub const BUTTON_ADJUSTMENT_PADDING_TOP: f32 = 15.0; - /// Formats a key label for display in combo boxes and lists. /// Returns a string like "Key 0 | AUTHENTICATION | CRITICAL | ECDSA_SECP256K1" pub fn format_key_label(key: &IdentityPublicKey) -> String { @@ -260,42 +256,6 @@ pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { .map_err(|e| e.to_string()) } -/// Returns the newly selected key (if changed), otherwise the existing one. -// Allow dead_code: This function provides UI for key selection within identities, -// useful for identity-based operations and key management interfaces -pub fn render_key_selector( - ui: &mut Ui, - selected_identity: &QualifiedIdentity, - selected_key: &Option<IdentityPublicKey>, -) -> Option<IdentityPublicKey> { - let mut new_selected_key = selected_key.clone(); - - ui.horizontal(|ui| { - ui.label("Key:"); - ComboBox::from_id_salt("key_selector") - .selected_text( - selected_key - .as_ref() - .map(|k| format!("Key {} Security {}", k.id(), k.security_level())) - .unwrap_or_else(|| "Choose key…".into()), - ) - .show_ui(ui, |cb| { - for key_ref in selected_identity.available_authentication_keys_non_master() { - let key = &key_ref.identity_public_key; - let label = format!("Key {} Security {}", key.id(), key.security_level()); - if cb - .selectable_label(Some(key) == selected_key.as_ref(), label) - .clicked() - { - new_selected_key = Some(key.clone()); - } - } - }); - }); - - new_selected_key -} - /// Transaction types that require specific key filtering #[derive(Clone, Copy, Debug, PartialEq)] pub enum TransactionType { @@ -375,6 +335,161 @@ impl TransactionType { /// Key chooser that filters keys based on transaction type and dev mode. /// Use this when you already have a specific identity and just need to select a key. +/// Whether `identity` has (a) a loaded private key eligible for the transaction and +/// (b) an eligible public key whose private key is not yet loaded. +fn key_eligibility( + identity: &QualifiedIdentity, + allowed_purposes: &[Purpose], + allowed_security_levels: &[SecurityLevel], +) -> (bool, bool) { + let has_suitable_keys_with_private = + identity + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level()) + }); + + let has_eligible_public_keys_without_private = + identity.identity.public_keys().iter().any(|(_, pub_key)| { + let basic_ok = allowed_purposes.contains(&pub_key.purpose()) + && allowed_security_levels.contains(&pub_key.security_level()); + let has_private = identity + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); + basic_ok && !has_private + }); + + ( + has_suitable_keys_with_private, + has_eligible_public_keys_without_private, + ) +} + +/// Renders the "no eligible key" notice plus an "Add New Key" button for `identity`. +fn render_no_eligible_key_group( + ui: &mut Ui, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, + transaction_type: TransactionType, + has_eligible_public_keys_without_private: bool, +) -> AppAction { + let mut action = AppAction::None; + ui.group(|ui| { + ui.set_min_width(220.0); + ui.vertical(|ui| { + ui.label("No eligible key. This transaction type requires:"); + ui.label(format!("{} key", transaction_type.label())); + + if has_eligible_public_keys_without_private { + ui.label( + "This identity has an eligible public key, but its private key isn't loaded into Dash Evo Tool yet.", + ); + ui.label( + "Load the private key from the Identities screen, or add a new key with the button below.", + ); + } + + ui.add_space(5.0); + + if ui.button("Add New Key to Identity").clicked() { + action = AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + identity.clone(), + app_context, + ))); + } + }); + }); + action +} + +/// Renders a ComboBox listing `identity`'s loaded private keys eligible for the transaction. +/// +/// With `identity` `None`, prompts the user to pick an identity first. Developer mode also +/// lists otherwise-ineligible keys, tagged via [`format_key_label_dev`]. +#[allow(clippy::too_many_arguments)] +fn render_key_combo( + ui: &mut Ui, + combo_id: &str, + width: f32, + identity: Option<&QualifiedIdentity>, + selected_key: &mut Option<IdentityPublicKey>, + allowed_purposes: &[Purpose], + allowed_security_levels: &[SecurityLevel], + is_dev_mode: bool, +) { + ComboBox::from_id_salt(combo_id) + .width(width) + .selected_text( + selected_key + .as_ref() + .map(format_key_label) + .unwrap_or_else(|| "Select Key…".into()), + ) + .show_ui(ui, |kui| { + let Some(qi) = identity else { + kui.label("Pick an identity first"); + return; + }; + for key_ref in qi.private_keys.identity_public_keys() { + let key = &key_ref.1.identity_public_key; + + let is_allowed = is_dev_mode + || (allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level())); + if !is_allowed { + continue; + } + + let is_dev_override = is_dev_mode + && (!allowed_purposes.contains(&key.purpose()) + || !allowed_security_levels.contains(&key.security_level())); + let label = if is_dev_override { + format_key_label_dev(key) + } else { + format_key_label(key) + }; + + if kui + .selectable_label(selected_key.as_ref() == Some(key), label) + .clicked() + { + *selected_key = Some(key.clone()); + } + } + }); +} + +/// Renders a centered title + description block (used above success-screen buttons). +fn render_info_section(ui: &mut Ui, title: &str, description: &str) { + ui.add_space(24.0); + let description_width = 500.0_f32.min(ui.available_width() - 40.0); + ui.allocate_ui_with_layout( + egui::Vec2::new(description_width, 0.0), + egui::Layout::top_down(egui::Align::Center), + |ui| { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + egui::RichText::new(title) + .size(16.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(description) + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }, + ); +} + pub fn add_key_chooser( ui: &mut Ui, app_context: &Arc<AppContext>, @@ -408,100 +523,33 @@ pub fn add_key_chooser_with_doc_type( let allowed_purposes = transaction_type.allowed_purposes(); let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); - // Check for keys with private keys loaded - let has_suitable_keys_with_private = - identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| { - let key = &key_ref.1.identity_public_key; - - allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }); - - // Check if there are eligible public keys without private keys - let has_eligible_public_keys_without_private = - identity.identity.public_keys().iter().any(|(_, pub_key)| { - let basic_ok = allowed_purposes.contains(&pub_key.purpose()) - && allowed_security_levels.contains(&pub_key.security_level()); - - let has_private = identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); - - basic_ok && !has_private - }); + let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = + key_eligibility(identity, &allowed_purposes, &allowed_security_levels); if !is_dev_mode && !has_suitable_keys_with_private { - // Show message and buttons when no suitable keys - ui.group(|ui| { - ui.set_min_width(220.0); - ui.vertical(|ui| { - ui.label("No eligible key. This transaction type requires:"); - ui.label(format!("{} key", transaction_type.label())); - - if has_eligible_public_keys_without_private { - ui.label( - "This Identity has an eligible public key but the private key isn't loaded.", - ); - } - - ui.add_space(5.0); - - if ui.button("Add New Key to Identity").clicked() { - action = AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - identity.clone(), - app_context, - ))); - } - }); - }); + action = render_no_eligible_key_group( + ui, + app_context, + identity, + transaction_type, + has_eligible_public_keys_without_private, + ); } else { - // Show key combo box ui.horizontal(|ui| { ui.vertical(|ui| { ui.add_space(15.0); ui.label("Key:"); }); - ComboBox::from_id_salt("key_chooser_combo") - .width(300.0) - .selected_text( - selected_key - .as_ref() - .map(format_key_label) - .unwrap_or_else(|| "Select Key...".into()), - ) - .show_ui(ui, |kui| { - for key_ref in identity.private_keys.identity_public_keys() { - let key = &key_ref.1.identity_public_key; - - let is_allowed = is_dev_mode - || (allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level())); - - if is_allowed { - let is_dev_override = is_dev_mode - && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels.contains(&key.security_level())); - let label = if is_dev_override { - format_key_label_dev(key) - } else { - format_key_label(key) - }; - - if kui - .selectable_label(selected_key.as_ref() == Some(key), label) - .clicked() - { - *selected_key = Some(key.clone()); - } - } - } - }); + render_key_combo( + ui, + "key_chooser_combo", + 300.0, + Some(identity), + selected_key, + &allowed_purposes, + &allowed_security_levels, + is_dev_mode, + ); }); } @@ -566,10 +614,7 @@ where for qi in identities { let label = identity_display_label(qi); if iui - .selectable_label( - selected_identity.as_ref() == Some(qi), - label, - ) + .selectable_label(selected_identity.as_ref() == Some(qi), label) .clicked() { *selected_identity = Some(qi.clone()); @@ -584,121 +629,39 @@ where ui.label("Key:"); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - // Check if selected identity has suitable keys - let mut show_combo = true; - if let Some(qi) = selected_identity { - let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels = - compute_allowed_security_levels(transaction_type, document_type); - - // Check for keys with private keys loaded - let has_suitable_keys_with_private = qi - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| { - let key = &key_ref.1.identity_public_key; - - allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }); - - // Check if there are eligible public keys without private keys - let has_eligible_public_keys_without_private = qi - .identity - .public_keys() - .iter() - .any(|(_, pub_key)| { - // Check if this public key meets the criteria - let basic_ok = allowed_purposes.contains(&pub_key.purpose()) - && allowed_security_levels.contains(&pub_key.security_level()); - - // Check if we don't have the private key for this public key - let has_private = qi.private_keys - .identity_public_keys() - .iter() - .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); - - basic_ok && !has_private - }); + let allowed_purposes = transaction_type.allowed_purposes(); + let allowed_security_levels = + compute_allowed_security_levels(transaction_type, document_type); + // Show the "no eligible key" notice instead of the combo when the selected + // identity has no loaded eligible key (outside developer mode). + let mut show_combo = true; + if let Some(qi) = selected_identity.as_ref() { + let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = + key_eligibility(qi, &allowed_purposes, &allowed_security_levels); if !is_dev_mode && !has_suitable_keys_with_private { show_combo = false; - // Show message and buttons in a proper group/frame - ui.group(|ui| { - ui.set_min_width(220.0); // Match the combo box width - ui.vertical(|ui| { - // Identity has eligible keys but private keys not loaded - ui.label("⚠ No eligible key. This transaction type requires:"); - ui.label(format!("• {} key", transaction_type.label())); - - if has_eligible_public_keys_without_private { - ui.label( - "This Identity already has an eligible public key but the private key isn't loaded into Dash Evo Tool yet.", - ); - ui.label("Go to the Identities screen to load an existing private key, or use the button below to add a new key:"); - } - - ui.add_space(5.0); - - // Always show option to add new key - if ui.button("Add New Key to Identity").clicked() { - action = AppAction::AddScreen(Screen::AddKeyScreen( - AddKeyScreen::new( - qi.clone(), - app_context, - ), - )); - } - }); - }); + action = render_no_eligible_key_group( + ui, + app_context, + qi, + transaction_type, + has_eligible_public_keys_without_private, + ); } } if show_combo { - ComboBox::from_id_salt("key_combo") - .width(220.0) - .selected_text( - selected_key - .as_ref() - .map(format_key_label) - .unwrap_or_else(|| "Select Key…".into()), - ) - .show_ui(ui, |kui| { - if let Some(qi) = selected_identity { - let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels = - compute_allowed_security_levels(transaction_type, document_type); - - for key_ref in qi.private_keys.identity_public_keys() { - let key = &key_ref.1.identity_public_key; - - let is_allowed = is_dev_mode - || (allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level())); - - if is_allowed { - let is_dev_override = is_dev_mode - && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels.contains(&key.security_level())); - let label = if is_dev_override { - format_key_label_dev(key) - } else { - format_key_label(key) - }; - - if kui - .selectable_label(selected_key.as_ref() == Some(key), label) - .clicked() - { - *selected_key = Some(key.clone()); - } - } - } - } else { - kui.label("Pick an identity first"); - } - }); + render_key_combo( + ui, + "key_combo", + 220.0, + selected_identity.as_ref(), + selected_key, + &allowed_purposes, + &allowed_security_levels, + is_dev_mode, + ); } }); ui.end_row(); @@ -714,7 +677,7 @@ pub fn add_contract_doc_type_chooser_with_filtering( selected_contract: &mut Option<QualifiedContract>, selected_doc_type: &mut Option<DocumentType>, ) { - let contracts = app_context.get_contracts(None, None).unwrap_or_default(); + let contracts = app_context.get_contracts().unwrap_or_default(); let search_term_lowercase = search_term.to_lowercase(); let filtered = contracts.iter().filter(|qc| { contract_display_label(qc) @@ -958,7 +921,6 @@ pub fn show_success_screen_with_info( info_section: Option<(&str, &str)>, ) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.vertical_centered(|ui| { ui.add_space(if info_section.is_some() { 60.0 } else { 100.0 }); @@ -967,27 +929,7 @@ pub fn show_success_screen_with_info( // Optional info section (above buttons) if let Some((title, description)) = info_section { - ui.add_space(24.0); - - let description_width = 500.0_f32.min(ui.available_width() - 40.0); - ui.allocate_ui_with_layout( - egui::Vec2::new(description_width, 0.0), - egui::Layout::top_down(egui::Align::Center), - |ui| { - ui.label( - egui::RichText::new(title) - .size(16.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - ui.label( - egui::RichText::new(description) - .size(14.0) - .color(DashColors::text_secondary(dark_mode)), - ); - }, - ); + render_info_section(ui, title, description); } ui.add_space(20.0); @@ -1041,7 +983,6 @@ pub fn show_group_token_success_screen_with_fee( fee_info: Option<(&str, &str)>, ) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.vertical_centered(|ui| { ui.add_space(if fee_info.is_some() { 60.0 } else { 100.0 }); @@ -1058,27 +999,7 @@ pub fn show_group_token_success_screen_with_fee( // Optional fee info section if let Some((title, description)) = fee_info { - ui.add_space(24.0); - - let description_width = 500.0_f32.min(ui.available_width() - 40.0); - ui.allocate_ui_with_layout( - egui::Vec2::new(description_width, 0.0), - egui::Layout::top_down(egui::Align::Center), - |ui| { - ui.label( - egui::RichText::new(title) - .size(16.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - ui.label( - egui::RichText::new(description) - .size(14.0) - .color(DashColors::text_secondary(dark_mode)), - ); - }, - ); + render_info_section(ui, title, description); } ui.add_space(20.0); @@ -1112,19 +1033,3 @@ pub fn show_group_token_success_screen_with_fee( }); action } - -/// Check if a string looks like a Platform Bech32m address. -/// -/// Delegates to [`is_platform_address_string`] which uses the canonical -/// HRP constants and case-insensitive comparison. -pub fn is_platform_address(s: &str) -> bool { - is_platform_address_string(s) -} - -/// Human-readable hint for Platform address input fields. -pub const PLATFORM_ADDRESS_HINT: &str = - "Enter a Platform address starting with \"dash1\" or \"tdash1\"."; - -/// Example Platform address prefixes for error messages. -pub const PLATFORM_ADDRESS_EXAMPLES: &str = - "Valid prefixes are \"dash1\" for mainnet and \"tdash1\" for testnet."; diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index da42d6e89..e953ca841 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::identity::{IdentityInputToLoad, IdentityTask}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::identity_discovery::validate_search_index; use crate::model::qualified_identity::IdentityType; use crate::model::wallet::Wallet; use crate::ui::components::info_popup::InfoPopup; @@ -13,63 +14,16 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::ui::identities::funding_common::wallet_selection_combo; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{MessageType, ScreenLike}; -use bip39::rand::{prelude::IteratorRandom, thread_rng}; -use dash_sdk::dashcore_rpc::dashcore::Network; +use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use eframe::egui::Context; use egui::{Color32, ComboBox, RichText, Ui}; -use serde::Deserialize; -use std::fs; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -#[derive(Debug, Clone, Deserialize)] -struct MasternodeInfo { - #[serde(rename = "pro-tx-hash")] - pro_tx_hash: String, - owner: KeyInfo, - voter: KeyInfo, -} - -#[derive(Debug, Clone, Deserialize)] -struct HPMasternodeInfo { - #[serde(rename = "protx-tx-hash")] - protx_tx_hash: String, - owner: KeyInfo, - voter: KeyInfo, - payout: KeyInfo, -} - -#[derive(Debug, Clone, Deserialize)] -struct KeyInfo { - #[serde(rename = "private_key")] - private_key: String, -} - -#[derive(Debug, Clone, Deserialize)] -struct TestnetNodes { - masternodes: std::collections::HashMap<String, MasternodeInfo>, - hp_masternodes: std::collections::HashMap<String, HPMasternodeInfo>, -} - -fn load_testnet_nodes_from_yml(file_path: &str) -> Result<Option<TestnetNodes>, String> { - let file_content = match fs::read_to_string(file_path) { - Ok(content) => content, - Err(_) => return Ok(None), - }; - serde_yaml_ng::from_str::<TestnetNodes>(&file_content) - .map(Some) - .map_err(|e| { - format!( - "Failed to parse YAML file '{}': {}. Please check the file format.", - file_path, e - ) - }) -} - #[derive(Clone, Copy, PartialEq, Eq)] enum LoadIdentityMode { IdentityId, @@ -77,6 +31,34 @@ enum LoadIdentityMode { DpnsName, } +impl LoadIdentityMode { + /// Alex-facing tab label per design-spec §B.11, kept short enough for a + /// narrow tab chip. See [`Self::description`] for the fuller caption. + fn tab_label(self) -> &'static str { + match self { + LoadIdentityMode::IdentityId => "Identity ID & private key", + LoadIdentityMode::Wallet => "From my wallet", + LoadIdentityMode::DpnsName => "My username", + } + } + + /// Full jargon-free sentence shown as a caption under the tab row, + /// expanding on the selected tab's intent (design-spec §B.11). + fn description(self) -> &'static str { + match self { + LoadIdentityMode::IdentityId => { + "Enter the identity ID and the private key for an identity that already exists on Dash Platform." + } + LoadIdentityMode::Wallet => { + "Look through this wallet for identities you've already registered from it." + } + LoadIdentityMode::DpnsName => { + "Enter a DPNS username to find its identity, then provide the private key." + } + } + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum WalletIdentitySearchMode { SpecificIndex, @@ -100,7 +82,6 @@ pub struct AddExistingIdentityScreen { payout_address_private_key_input: PasswordInput, keys_input: Vec<PasswordInput>, add_identity_status: AddIdentityStatus, - testnet_loaded_nodes: Option<TestnetNodes>, selected_wallet: Option<Arc<RwLock<Wallet>>>, identity_associated_with_wallet: bool, wallet_unlock_popup: WalletUnlockPopup, @@ -119,18 +100,7 @@ pub struct AddExistingIdentityScreen { impl AddExistingIdentityScreen { pub fn new(app_context: &Arc<AppContext>) -> Self { - let selected_wallet = app_context.wallets.read().unwrap().values().next().cloned(); - let (testnet_loaded_nodes, init_error) = if app_context.network == Network::Testnet { - match load_testnet_nodes_from_yml(".testnet_nodes.yml") { - Ok(nodes) => (nodes, None), - Err(e) => (None, Some(e)), - } - } else { - (None, None) - }; - if let Some(err) = init_error { - MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); - } + let selected_wallet = app_context.wallets.read_recover().values().next().cloned(); Self { identity_id_input: String::new(), identity_type: IdentityType::User, @@ -146,7 +116,6 @@ impl AddExistingIdentityScreen { .with_monospace(), keys_input: vec![], add_identity_status: AddIdentityStatus::NotStarted, - testnet_loaded_nodes, selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), @@ -166,30 +135,13 @@ impl AddExistingIdentityScreen { fn render_by_identity(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - // Advanced: Testnet quick-fill buttons - if self.show_advanced_options - && self.app_context.network == Network::Testnet - && self.testnet_loaded_nodes.is_some() - { - ui.horizontal(|ui| { - if ui.button("Fill Random HPMN").clicked() { - self.fill_random_hpmn(); - } - if ui.button("Fill Random Masternode").clicked() { - self.fill_random_masternode(); - } - }); - ui.add_space(10.0); - } - let wallets_snapshot: Vec<(String, Arc<RwLock<Wallet>>)> = { - let wallets_guard = self.app_context.wallets.read().unwrap(); + let wallets_guard = self.app_context.wallets.read_recover(); wallets_guard .values() .map(|wallet| { let alias = wallet - .read() - .unwrap() + .read_recover() .alias .clone() .unwrap_or_else(|| "Unnamed Wallet".to_string()); @@ -282,8 +234,9 @@ impl AddExistingIdentityScreen { if wallet_still_loaded { // Try to open wallet without password if it doesn't use one if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(selected_wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, selected_wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -349,21 +302,20 @@ impl AddExistingIdentityScreen { // Advanced: Identity Type selector if self.show_advanced_options { + // This generic screen loads User identities only. Masternode + // and Evonode identities have a dedicated flow on the + // Masternodes tab (`ui/masternodes/load_form.rs`), so the old + // Masternode/Evonode options are removed here to avoid a + // second, competing entry point (§10.2 / TC-FR4-22, FR-6). ui.label("Identity Type:"); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { egui::ComboBox::from_id_salt("identity_type_selector") .selected_text(format!("{:?}", self.identity_type)) .show_ui(ui, |ui| { - ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); ui.selectable_value( &mut self.identity_type, - IdentityType::Masternode, - "Masternode", - ); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Evonode, - "Evonode", + IdentityType::User, + "User", ); }); }); @@ -473,7 +425,7 @@ impl AddExistingIdentityScreen { .fill(if is_valid_id { DashColors::DASH_BLUE } else { - ComponentStyles::button_disabled_fill(ui.ctx().style().visuals.dark_mode) + ComponentStyles::button_disabled_fill(ui.style().visuals.dark_mode) }) .frame(true) .corner_radius(3.0); @@ -510,47 +462,31 @@ impl AddExistingIdentityScreen { fn render_wallet_selection(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { if self.app_context.has_wallet.load(Ordering::Relaxed) { - let wallets = &self.app_context.wallets.read().unwrap(); - let wallet_aliases: Vec<String> = wallets - .values() - .map(|wallet| { + let wallets: Vec<_> = self + .app_context + .wallets + .read() + .map(|guard| guard.values().cloned().collect()) + .unwrap_or_default(); + + let clicked = wallet_selection_combo( + ui, + "select_existing_wallet", + &wallets, + self.selected_wallet.as_ref(), + |wallet| { wallet .read() - .unwrap() - .alias - .clone() + .ok() + .and_then(|w| w.alias.clone()) .unwrap_or_else(|| "Unnamed Wallet".to_string()) - }) - .collect(); - - let selected_wallet_alias = self - .selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()?.alias.clone()) - .unwrap_or_else(|| "Select".to_string()); - - // Display the ComboBox for wallet selection - ComboBox::from_label("") - .selected_text(selected_wallet_alias.clone()) - .show_ui(ui, |ui| { - for (idx, wallet) in wallets.values().enumerate() { - let wallet_alias = wallet_aliases[idx].clone(); - - let is_selected = self - .selected_wallet - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); - - if ui - .selectable_label(is_selected, wallet_alias.clone()) - .clicked() - { - // Update the selected wallet - self.selected_wallet = Some(wallet.clone()); - self.wallet_open_attempted = false; - } - } - }); + }, + |_| true, + ); + if let Some(wallet) = clicked { + self.selected_wallet = Some(wallet); + self.wallet_open_attempted = false; + } ui.add_space(20.0); } else { @@ -591,12 +527,16 @@ impl AddExistingIdentityScreen { return action; }; - let wallet = self.selected_wallet.as_ref().unwrap(); + let wallet = self + .selected_wallet + .as_ref() + .expect("invariant: selected_wallet checked Some above"); // Try to open wallet without password if it doesn't use one if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -642,9 +582,7 @@ impl AddExistingIdentityScreen { let identity_index_label = match self.wallet_search_mode { WalletIdentitySearchMode::SpecificIndex => "Identity index:", - WalletIdentitySearchMode::UpToIndex => { - "Highest identity index to search (inclusive, max 29):" - } + WalletIdentitySearchMode::UpToIndex => "Search depth to start from:", }; ui.horizontal(|ui| { @@ -658,7 +596,7 @@ impl AddExistingIdentityScreen { } WalletIdentitySearchMode::UpToIndex => { ui.label( - "Searches each derivation index starting at 0 up to the provided index (inclusive).", + "Searches from index 0 with a rolling five-index lookahead, going deeper each time an identity is found. The number sets the minimum depth to search.", ); } } @@ -696,27 +634,49 @@ impl AddExistingIdentityScreen { handle.with_elapsed(); self.refresh_banner = Some(handle); - // Parse identity index input - if let Ok(identity_index) = self.identity_index_input.trim().parse::<u32>() { - let wallet_ref = self.selected_wallet.as_ref().unwrap().clone().into(); - action = AppAction::BackendTask(BackendTask::IdentityTask( - match self.wallet_search_mode { - WalletIdentitySearchMode::SpecificIndex => { - IdentityTask::SearchIdentityFromWallet(wallet_ref, identity_index) - } - WalletIdentitySearchMode::UpToIndex => { - IdentityTask::SearchIdentitiesUpToIndex(wallet_ref, identity_index) - } - }, - )); - } else { - // Handle invalid index input - self.add_identity_status = AddIdentityStatus::NotStarted; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid identity index", - MessageType::Error, - ); + // Parse and bound-check the identity index (model validator is the + // single source of truth for the sane range). + match self + .identity_index_input + .trim() + .parse::<u32>() + .ok() + .map(validate_search_index) + { + Some(Ok(identity_index)) => { + let wallet_ref = self + .selected_wallet + .as_ref() + .expect("invariant: selected_wallet checked Some above") + .clone() + .into(); + action = AppAction::BackendTask(BackendTask::IdentityTask( + match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => { + IdentityTask::SearchIdentityFromWallet(wallet_ref, identity_index) + } + WalletIdentitySearchMode::UpToIndex => { + IdentityTask::SearchIdentitiesUpToIndex(wallet_ref, identity_index) + } + }, + )); + } + Some(Err(error)) => { + self.add_identity_status = AddIdentityStatus::NotStarted; + MessageBanner::set_global( + self.app_context.egui_ctx(), + error.to_string(), + MessageType::Error, + ); + } + None => { + self.add_identity_status = AddIdentityStatus::NotStarted; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Enter a whole number for the identity index, then try again.", + MessageType::Error, + ); + } } } action @@ -729,13 +689,12 @@ impl AddExistingIdentityScreen { ui.add_space(15.0); let wallets_snapshot: Vec<(String, Arc<RwLock<Wallet>>)> = { - let wallets_guard = self.app_context.wallets.read().unwrap(); + let wallets_guard = self.app_context.wallets.read_recover(); wallets_guard .values() .map(|wallet| { let alias = wallet - .read() - .unwrap() + .read_recover() .alias .clone() .unwrap_or_else(|| "Unnamed Wallet".to_string()); @@ -843,7 +802,7 @@ impl AddExistingIdentityScreen { .fill(if is_valid { DashColors::DASH_BLUE } else { - ComponentStyles::button_disabled_fill(ui.ctx().style().visuals.dark_mode) + ComponentStyles::button_disabled_fill(ui.style().visuals.dark_mode) }) .frame(true) .corner_radius(3.0); @@ -863,7 +822,7 @@ impl AddExistingIdentityScreen { let selected_wallet_seed_hash = if self.identity_associated_with_wallet { self.selected_wallet .as_ref() - .map(|wallet| wallet.read().unwrap().seed_hash()) + .map(|wallet| wallet.read_recover().seed_hash()) } else { None }; @@ -888,7 +847,7 @@ impl AddExistingIdentityScreen { let selected_wallet_seed_hash = if self.identity_associated_with_wallet { self.selected_wallet .as_ref() - .map(|wallet| wallet.read().unwrap().seed_hash()) + .map(|wallet| wallet.read_recover().seed_hash()) } else { None }; @@ -907,45 +866,18 @@ impl AddExistingIdentityScreen { .collect(), derive_keys_from_wallets: self.identity_associated_with_wallet, selected_wallet_seed_hash, + // Legacy load screen has no password field; the optional load-time + // encryption (FR-8) is exposed on the new Masternodes load form (B4). + encryption_password: None, + // Legacy User re-load: preserve the historical overwrite/upsert + // behaviour (re-loading to add keys is a supported User workflow). + load_mode: crate::backend_task::identity::IdentityLoadMode::Overwrite, }; AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::LoadIdentity( identity_input, ))) } - fn fill_random_hpmn(&mut self) { - if let Some((name, hpmn)) = self - .testnet_loaded_nodes - .as_ref() - .and_then(|nodes| nodes.hp_masternodes.iter().choose(&mut thread_rng())) - { - self.identity_id_input = hpmn.protx_tx_hash.clone(); - self.identity_type = IdentityType::Evonode; - self.alias_input = name.clone(); - self.voting_private_key_input - .set_text(hpmn.voter.private_key.clone()); - self.owner_private_key_input - .set_text(hpmn.owner.private_key.clone()); - self.payout_address_private_key_input - .set_text(hpmn.payout.private_key.clone()); - } - } - - fn fill_random_masternode(&mut self) { - if let Some((name, masternode)) = self - .testnet_loaded_nodes - .as_ref() - .and_then(|nodes| nodes.masternodes.iter().choose(&mut thread_rng())) - { - self.identity_id_input = masternode.pro_tx_hash.clone(); - self.identity_type = IdentityType::Masternode; - self.alias_input = name.clone(); - self.voting_private_key_input - .set_text(masternode.voter.private_key.clone()); - self.owner_private_key_input - .set_text(masternode.owner.private_key.clone()); - } - } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { let success_text = self @@ -1035,6 +967,15 @@ impl ScreenLike for AddExistingIdentityScreen { self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; } + BackendTaskSuccessResult::IdentitiesLoaded { count } => { + self.refresh_banner.take_and_clear(); + self.success_message = Some(if count == 1 { + "Successfully loaded 1 identity from your wallet.".to_string() + } else { + format!("Successfully loaded {count} identities from your wallet.") + }); + self.add_identity_status = AddIdentityStatus::Complete; + } BackendTaskSuccessResult::Message(msg) => { // Check if this is a final success message or a progress update if msg.starts_with("Successfully loaded") || msg.starts_with("Finished loading") { @@ -1073,9 +1014,11 @@ impl ScreenLike for AddExistingIdentityScreen { self.add_identity_status = AddIdentityStatus::Complete; } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -1085,12 +1028,12 @@ impl ScreenLike for AddExistingIdentityScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Error display is handled by the global MessageBanner @@ -1117,24 +1060,23 @@ impl ScreenLike for AddExistingIdentityScreen { let mut mode_changed = false; ui.horizontal(|ui| { - mode_changed |= ui - .selectable_value( - &mut self.mode, - LoadIdentityMode::IdentityId, - "By Identity ID", - ) - .changed(); - mode_changed |= ui - .selectable_value(&mut self.mode, LoadIdentityMode::Wallet, "By Wallet") - .changed(); - mode_changed |= ui - .selectable_value( - &mut self.mode, - LoadIdentityMode::DpnsName, - "By DPNS Name", - ) - .changed(); + for mode in [ + LoadIdentityMode::IdentityId, + LoadIdentityMode::Wallet, + LoadIdentityMode::DpnsName, + ] { + mode_changed |= ui + .selectable_value(&mut self.mode, mode, mode.tab_label()) + .changed(); + } }); + ui.add_space(6.0); + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(self.mode.description()) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); ui.add_space(15.0); if mode_changed { @@ -1148,7 +1090,7 @@ impl ScreenLike for AddExistingIdentityScreen { } LoadIdentityMode::Wallet => { let wallets_len = { - let wallets = self.app_context.wallets.read().unwrap(); + let wallets = self.app_context.wallets.read_recover(); wallets.len() }; inner_action |= self.render_by_wallet(ui, wallets_len); @@ -1168,7 +1110,7 @@ impl ScreenLike for AddExistingIdentityScreen { if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Load Identity Information", &show_pop_up_info_text); if popup.show(ui).inner { @@ -1192,3 +1134,35 @@ impl ScreenLike for AddExistingIdentityScreen { action } } + +#[cfg(test)] +mod load_identity_mode_tests { + use super::LoadIdentityMode; + + const ALL_MODES: [LoadIdentityMode; 3] = [ + LoadIdentityMode::IdentityId, + LoadIdentityMode::Wallet, + LoadIdentityMode::DpnsName, + ]; + + /// Exhaustive over the enum so a new mode forces a copy decision here + /// instead of an unlabeled tab. + #[test] + fn tab_label_and_description_are_jargon_free_for_every_mode() { + for mode in ALL_MODES { + let label = mode.tab_label(); + let description = mode.description(); + assert!(!label.is_empty()); + assert!( + description.ends_with('.'), + "description should be a complete sentence: {description}" + ); + for jargon in ["asset lock", "derivation path", "BIP", "SDK"] { + assert!( + !description.to_lowercase().contains(&jargon.to_lowercase()), + "description must not leak jargon ({jargon}): {description}" + ); + } + } + } +} diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs index 78e202bf0..87261dacb 100644 --- a/src/ui/identities/add_new_identity_screen/by_platform_address.rs +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -7,16 +7,14 @@ use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dpp::address_funds::PlatformAddress; use egui::{Color32, ComboBox, RichText, Ui}; -/// Constants for credit/DASH conversion -const CREDITS_PER_DUFF: u64 = 1000; - impl AddNewIdentityScreen { fn show_platform_address_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); + let wallet = selected_wallet.read_recover(); let total_platform_balance: u64 = wallet .platform_address_info @@ -24,12 +22,10 @@ impl AddNewIdentityScreen { .map(|info| info.balance) .sum(); - let dash_balance = total_platform_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.horizontal(|ui| { ui.label(format!( - "Total Platform Address Balance: {:.8} DASH", - dash_balance + "Total Platform Address Balance: {}", + format_credits_as_dash(total_platform_balance) )); }); } else { @@ -54,7 +50,7 @@ impl AddNewIdentityScreen { let network = self.app_context.network; let platform_addresses: Vec<(String, PlatformAddress, u64)> = if let Some(wallet_arc) = &self.selected_wallet { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read_recover(); wallet .platform_addresses(network) .into_iter() @@ -103,11 +99,10 @@ impl AddNewIdentityScreen { }) .unwrap_or_else(|| "Select a Platform address".to_string()); - ComboBox::from_label("Platform Address") + ComboBox::from_label("Platform address") .selected_text(selected_addr_display) .show_ui(ui, |ui| { for (bech32_addr_str, platform_addr, balance) in &platform_addresses { - let dash_balance = *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; // Truncate Bech32m address for display in dropdown let addr_display = if bech32_addr_str.len() > 20 { format!( @@ -118,7 +113,7 @@ impl AddNewIdentityScreen { } else { bech32_addr_str.clone() }; - let label = format!("{} ({:.4} DASH)", addr_display, dash_balance); + let label = format!("{} ({})", addr_display, format_credits_as_dash(*balance)); let is_selected = self .selected_platform_address_for_funding .as_ref() @@ -152,7 +147,7 @@ impl AddNewIdentityScreen { }); // Calculate estimated fee for identity creation (needed for max amount calculation) - let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let key_count = self.identity_keys.others.len() + 1; // +1 for master key let input_count = if self.selected_platform_address_for_funding.is_some() { 1 } else { @@ -202,17 +197,20 @@ impl AddNewIdentityScreen { // Show selected amount info if let Some((_, amount)) = &self.selected_platform_address_for_funding { - let dash_amount = *amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("Will use: {:.8} DASH", dash_amount)); + ui.label(format!("Will use: {}", format_credits_as_dash(*amount))); } ui.add_space(20.0); // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); + let step = self + .step + .read() + .map(|s| *s) + .unwrap_or(WalletFundedScreenStep::ChooseFundingMethod); // Display estimated fee before action button (reuse already calculated value) - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(egui::Margin::symmetric(10, 8)) @@ -232,6 +230,8 @@ impl AddNewIdentityScreen { }); ui.add_space(10.0); + self.render_alias_input(ui, step_number + 1); + // Create Identity button let can_create = self.selected_platform_address_for_funding.is_some() && self diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index e857cbf2e..1ed7e975f 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -5,84 +5,72 @@ use crate::ui::components::message_banner::MessageBanner; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; -use crate::ui::theme::DashColors; -use egui::{RichText, Ui}; +use crate::ui::identities::funding_common::{ + FundingAssetLockPicker, actionable_asset_locks, asset_lock_address, asset_lock_status_label, +}; +use crate::ui::theme::{ComponentStyles, DashColors}; +use egui::{Color32, RichText, Ui}; impl AddNewIdentityScreen { fn render_choose_funding_asset_lock(&mut self, ui: &mut egui::Ui) { - // Ensure a wallet is selected - let Some(selected_wallet) = self.selected_wallet.clone() else { - ui.label("No wallet selected."); - return; + let tracked = match actionable_asset_locks( + ui, + &mut self.asset_lock_cache, + self.selected_wallet.as_ref(), + ) { + FundingAssetLockPicker::Handled => return, + FundingAssetLockPicker::Available(tracked) => tracked, }; - // Read the wallet to access unused asset locks - let wallet = selected_wallet.read().unwrap(); - - if wallet.unused_asset_locks.is_empty() { - ui.label("No unused asset locks available."); - return; - } - - ui.heading("Select an unused asset lock:"); + ui.heading("Select the unfinished funding to use:"); ui.add_space(8.0); - // Track the index of the currently selected asset lock (if any) - let selected_index = self.funding_asset_lock.as_ref().and_then(|(_, proof, _)| { - wallet - .unused_asset_locks - .iter() - .position(|(_, _, _, _, p)| p.as_ref() == Some(proof)) - }); - - // Display the asset locks in a scrollable area egui::ScrollArea::vertical() .auto_shrink([false, true]) .min_scrolled_height(180.0) .show(ui, |ui| { - for (index, (tx, address, amount, islock, proof)) in - wallet.unused_asset_locks.iter().enumerate() - { + for lock in &tracked { + let is_selected = self.funding_asset_lock == Some(lock.out_point); ui.group(|ui| { ui.vertical(|ui| { - let tx_id = tx.txid().to_string(); - let lock_amount = *amount as f64 * 1e-8; // Convert to DASH - let is_locked = if islock.is_some() { "Yes" } else { "No" }; - - // Display asset lock information with "Selected" if this one is selected - if Some(index) == selected_index { - ui.colored_label(DashColors::SUCCESS, "Selected asset lock"); + if is_selected { + ui.colored_label(DashColors::SUCCESS, "Selected"); } - ui.label(format!("TxID: {}", tx_id)); - ui.label(format!("Address: {}", address)); - ui.label(format!("Amount: {:.8} DASH", lock_amount)); - ui.label(format!("InstantLock: {}", is_locked)); + if self.show_advanced_options { + ui.label(format!("TxID: {}", lock.out_point.txid)); + ui.label(format!("Vout: {}", lock.out_point.vout)); + } + if let Some(address) = + asset_lock_address(lock, self.app_context.network) + { + ui.label(format!("Address: {address}")); + } + ui.label(format!( + "Amount: {:.8} DASH", + lock.amount as f64 * 1e-8 + )); + ui.label(format!("Status: {}", asset_lock_status_label(&lock.status))); ui.add_space(6.0); - if let Some(asset_lock_proof) = proof { + if lock.proof.is_some() { if ui.button("Select").clicked() { - self.funding_asset_lock = Some(( - tx.clone(), - asset_lock_proof.clone(), - address.clone(), - )); - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.funding_asset_lock = Some(lock.out_point); + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ReadyToCreate; + } } } else if ui.button("Select").clicked() { MessageBanner::set_global( ui.ctx(), - "Asset lock proof is not yet available — the transaction may not be chain-locked yet. Please try again later.", + "This funding isn't ready to use yet. Wait for it to be confirmed on the Dash network, then try again.", MessageType::Warning, ); } }); }); - - ui.add_space(6.0); // Add space between each entry + ui.add_space(6.0); } }); } @@ -93,28 +81,25 @@ impl AddNewIdentityScreen { step_number: u32, ) -> AppAction { let mut action = AppAction::None; - - // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); + let step = self + .step + .read() + .map(|s| *s) + .unwrap_or(WalletFundedScreenStep::ChooseFundingMethod); ui.heading( - format!( - "{}. Choose the unused asset lock that you would like to use.", - step_number - ) - .as_str(), + format!("{step_number}. Choose the unfinished funding you'd like to use.").as_str(), ); ui.add_space(10.0); self.render_choose_funding_asset_lock(ui); - // Display estimated fee before action button - let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let key_count = self.identity_keys.others.len() + 1; // +1 for master key let estimated_fee = self .app_context .fee_estimator() .estimate_identity_create(key_count); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(egui::Margin::symmetric(10, 8)) @@ -134,23 +119,32 @@ impl AddNewIdentityScreen { }); ui.add_space(10.0); - if ui.button("Create Identity").clicked() { + self.render_alias_input(ui, step_number + 1); + + let can_create = self.funding_asset_lock.is_some(); + let button = egui::Button::new(RichText::new("Create Identity").color(Color32::WHITE)) + .fill(if can_create { + DashColors::DASH_BLUE + } else { + ComponentStyles::button_disabled_fill(dark_mode) + }) + .frame(true) + .corner_radius(3.0); + if ui.add_enabled(can_create, button).clicked() { action |= self.register_identity_clicked(FundingMethod::UseUnusedAssetLock); } ui.add_space(20.0); - { - ui.vertical_centered(|ui| match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); - } + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); ui.add_space(40.0); action diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index 45abb21d9..5f967a3c6 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -1,19 +1,30 @@ use crate::app::AppAction; use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::RootScreenType; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; +use crate::ui::identities::funding_common::spendable_covers_minimum; use crate::ui::theme::DashColors; use egui::{Color32, RichText, Ui}; impl AddNewIdentityScreen { fn show_wallet_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet + let wallet = match selected_wallet.read() { + Ok(w) => w, + Err(_) => { + ui.label("Wallet is busy. Try again in a moment."); + return; + } + }; - let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback + let spendable_balance: u64 = self + .app_context + .snapshot_balance(&wallet.seed_hash()) + .spendable(); - let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units + let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units ui.horizontal(|ui| { ui.label(format!("Wallet Balance: {:.8} DASH", dash_balance)); @@ -23,6 +34,57 @@ impl AddNewIdentityScreen { } } + /// If the selected wallet can't cover even the estimated identity-creation + /// fee, render design-spec §B.1's "not enough Dash" banner with a link to + /// the Wallets screen (design-spec calls it "Go to Receive"; this app has + /// no separate top-level Receive screen, so the link goes to Wallets, + /// where the user's receiving address lives) and report that the caller + /// should stop rendering this step. Returns `None` when the balance is + /// sufficient, or when no wallet is selected (handled earlier by the + /// caller's own no-wallet gate). + fn render_insufficient_wallet_balance_banner(&self, ui: &mut egui::Ui) -> Option<AppAction> { + let selected_wallet = self.selected_wallet.as_ref()?; + let spendable_duffs = match selected_wallet.read() { + Ok(w) => self + .app_context + .snapshot_balance(&w.seed_hash()) + .spendable(), + Err(_) => { + ui.label("Wallet is busy. Try again in a moment."); + return Some(AppAction::None); + } + }; + + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + let minimum_credits = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + + if spendable_covers_minimum(spendable_duffs, minimum_credits) { + return None; + } + + ui.add_space(8.0); + ui.colored_label( + DashColors::WARNING, + format!( + "Your wallet does not have enough Dash to create an identity yet. \ + Add at least {amount} to continue.", + amount = format_credits_as_dash(minimum_credits) + ), + ); + ui.add_space(8.0); + let mut action = AppAction::None; + if ui.button("Go to Wallets").clicked() { + action = AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenWalletsBalances, + ); + } + ui.add_space(10.0); + Some(action) + } + pub fn render_ui_by_using_unused_balance( &mut self, ui: &mut Ui, @@ -40,10 +102,18 @@ impl AddNewIdentityScreen { self.show_wallet_balance(ui); ui.add_space(5.0); + if let Some(insufficient_action) = self.render_insufficient_wallet_balance_banner(ui) { + return insufficient_action; + } + self.render_funding_amount_input(ui); // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); + let step = self + .step + .read() + .map(|s| *s) + .unwrap_or(WalletFundedScreenStep::ChooseFundingMethod); // Check if we have a valid amount before showing the button let has_valid_amount = self @@ -57,13 +127,13 @@ impl AddNewIdentityScreen { } // Display estimated fee before action button - let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let key_count = self.identity_keys.others.len() + 1; // +1 for master key let estimated_fee = self .app_context .fee_estimator() .estimate_identity_create(key_count); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(egui::Margin::symmetric(10, 8)) @@ -83,6 +153,8 @@ impl AddNewIdentityScreen { }); ui.add_space(10.0); + self.render_alias_input(ui, step_number + 1); + let button = egui::Button::new(RichText::new("Create Identity").color(Color32::WHITE)) .fill(DashColors::DASH_BLUE) .frame(true) diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs deleted file mode 100644 index d8c173e59..000000000 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::error::TaskError; -use crate::backend_task::identity::{ - IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, -}; -use crate::ui::MessageType; -use crate::ui::components::MessageBanner; -use crate::ui::identities::add_new_identity_screen::{ - AddNewIdentityScreen, WalletFundedScreenStep, -}; -use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; -use eframe::epaint::TextureHandle; -use egui::Ui; -use std::sync::Arc; - -impl AddNewIdentityScreen { - fn render_qr_code(&mut self, ui: &mut egui::Ui, amount: f64) -> Result<(), TaskError> { - let (address, _should_check_balance) = { - // Scope the write lock to ensure it's dropped before calling `start_balance_check`. - - if let Some(wallet_guard) = self.selected_wallet.as_ref() { - // Get the receive address - if self.funding_address.is_none() { - let mut wallet = wallet_guard.write().unwrap(); - let receive_address = wallet - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; - let core_wallet_name = wallet.core_wallet_name.clone(); - drop(wallet); - - if let Some(has_address) = self.core_has_funding_address { - if !has_address { - self.app_context.ensure_address_imported( - &receive_address, - core_wallet_name.as_deref(), - Some("Managed by Dash Evo Tool"), - )?; - } - self.funding_address = Some(receive_address); - } else { - self.app_context.ensure_address_imported( - &receive_address, - core_wallet_name.as_deref(), - Some("Managed by Dash Evo Tool"), - )?; - self.funding_address = Some(receive_address); - self.core_has_funding_address = Some(true); - } - - // Extract the address to return it outside this scope - (self.funding_address.as_ref().unwrap().clone(), true) - } else { - (self.funding_address.as_ref().unwrap().clone(), false) - } - } else { - return Err(TaskError::WalletNotFound); - } - }; - - // if should_check_balance { - // // Now `address` is available, and all previous borrows are dropped. - // self.start_balance_check(&address, ui.ctx()); - // } - - let pay_uri = format!("{}?amount={:.4}", address.to_qr_uri(), amount); - - // Generate the QR code image - if let Ok(qr_image) = generate_qr_code_image(&pay_uri) { - let texture: TextureHandle = - ui.ctx() - .load_texture("qr_code", qr_image, egui::TextureOptions::LINEAR); - ui.image(&texture); - } else { - ui.label("Failed to generate QR code."); - } - - ui.add_space(10.0); - - ui.label(&pay_uri); - ui.add_space(5.0); - - if ui.button("Copy Address").clicked() { - if let Err(e) = copy_to_clipboard(pay_uri.as_str()) { - self.copied_to_clipboard = Some(Some(e)); - } else { - self.copied_to_clipboard = Some(None); - } - } - - if let Some(error) = self.copied_to_clipboard.as_ref() { - ui.add_space(5.0); - if let Some(error) = error { - ui.label(format!("Failed to copy to clipboard: {}", error)); - } else { - ui.label("Address copied to clipboard."); - } - } - - Ok(()) - } - - pub fn render_ui_by_wallet_qr_code(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { - // Update state when funds land on the QR funding address - if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( - &self.step, - self.selected_wallet.as_ref(), - self.funding_address.as_ref(), - ) { - self.funding_utxo = Some(utxo); - } - - // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); - - ui.add_space(10.0); - - ui.heading( - format!( - "{}. Select how much you would like to transfer?", - step_number - ) - .as_str(), - ); - - ui.add_space(8.0); - - self.render_funding_amount_input(ui); - - if step == WalletFundedScreenStep::WaitingOnFunds { - ui.ctx() - .request_repaint_after(std::time::Duration::from_secs(1)); - } - - // Get the amount in DASH from the Amount struct - let Some(amount) = &self.funding_amount else { - return AppAction::None; - }; - - let amount_dash = amount.value() as f64 / 100_000_000_000.0; // credits to DASH - - if amount_dash <= 0.0 { - return AppAction::None; - } - - let response = ui.with_layout( - egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), - |ui| { - if let Err(e) = self.render_qr_code(ui, amount_dash) { - MessageBanner::set_global( - ui.ctx(), - e.to_string(), - MessageType::Error, - ); - } - - ui.add_space(20.0); - - // Handle FundsReceived action regardless of error state - if step == WalletFundedScreenStep::FundsReceived { - let Some(selected_wallet) = &self.selected_wallet else { - return AppAction::None; - }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { - let identity_input = IdentityRegistrationInfo { - alias_input: self.alias_input.clone(), - keys: self.identity_keys.clone(), - wallet: Arc::clone(selected_wallet), // Clone the Arc reference - wallet_identity_index: self.identity_id_number, - identity_funding_method: RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, - self.identity_id_number, - ), - }; - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; - - // Create the backend task to register the identity - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RegisterIdentity(identity_input), - )); - } - } - - { - match step { - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); - } - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - } - } - AppAction::None - }, - ); - - ui.add_space(40.0); - - response.inner - } -} diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index edcf46404..c9f517170 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -1,17 +1,19 @@ mod by_platform_address; mod by_using_unused_asset_lock; mod by_using_unused_balance; -mod by_wallet_qr_code; mod success_screen; -use crate::app::AppAction; +use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::core::CoreItem; +use crate::backend_task::error::TaskError; use crate::backend_task::identity::{ - IdentityKeys, IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, - default_identity_key_specs, + IdentityKeyEntry, IdentityKeySpecs, IdentityRegistrationInfo, IdentityTask, + RegisterIdentityFundingMethod, default_identity_key_specs, }; +use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::secret::Secret; use crate::model::wallet::Wallet; use crate::ui::components::MessageBanner; @@ -22,70 +24,85 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::identities::funding_common::WalletFundedScreenStep; +use crate::ui::identities::funding_common::{ + FundingMethod, WalletFundedScreenStep, funding_method_after_switch, + max_amount_after_fee_reserve, spendable_covers_minimum, wallet_selection_combo, +}; +use crate::ui::state::TrackedAssetLockCache; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; +use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::dashcore::secp256k1::hashes::hex::DisplayHex; -use dash_sdk::dpp::dashcore::{OutPoint, Transaction, TxOut}; +use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::Identifier; -use eframe::egui::Context; -use egui::ahash::HashSet; use egui::{Align, Button, Color32, ComboBox, ScrollArea, Ui}; use egui_extras::{Column, TableBuilder}; +use std::collections::HashMap; +use std::collections::HashSet; use crate::model::amount::Amount; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; -use std::cmp::PartialEq; -use std::fmt; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; pub const MAX_IDENTITY_INDEX: u32 = 30; -#[derive(Debug, PartialEq, Eq, Copy, Clone)] -pub enum FundingMethod { - NoSelection, - UseUnusedAssetLock, - UseWalletBalance, - AddressWithQRCode, - /// Use Platform Address credits - UsePlatformAddress, -} - -impl fmt::Display for FundingMethod { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match self { - FundingMethod::NoSelection => "Select funding method", - FundingMethod::AddressWithQRCode => "Address with QR Code", - FundingMethod::UseWalletBalance => "Wallet Balance", - FundingMethod::UseUnusedAssetLock => "Unused Asset Lock (recommended)", - FundingMethod::UsePlatformAddress => "Platform Address", - }; - write!(f, "{}", output) - } +/// Compose a wallet-picker entry as `alias — spendable-balance in DASH`. +/// +/// The balance shown is always the wallet's **spendable** amount (never the +/// total): only spendable funds can pay for identity creation, so surfacing +/// the total here would invite the very insufficient-funds surprise this +/// label exists to prevent. A pure function so the wording is testable +/// without constructing a real wallet/balance snapshot. +fn format_wallet_picker_label(alias: &str, spendable_duffs: u64) -> String { + format!("{alias} — {}", Amount::dash_from_duffs(spendable_duffs)) } pub struct AddNewIdentityScreen { identity_id_number: u32, step: Arc<RwLock<WalletFundedScreenStep>>, - funding_asset_lock: Option<(Transaction, AssetLockProof, Address)>, + /// Outpoint of an asset lock tracked by the upstream `AssetLockManager`, + /// chosen by the user from the picker. Routed to the backend as + /// `RegisterIdentityFundingMethod::UseAssetLock`; the upstream signer + /// re-derives the credit-output key from the seed. + funding_asset_lock: Option<OutPoint>, selected_wallet: Option<Arc<RwLock<Wallet>>>, - core_has_funding_address: Option<bool>, funding_address: Option<Address>, funding_method: Arc<RwLock<FundingMethod>>, + /// Whether the user has explicitly picked a funding method (as opposed to + /// the screen's own default pre-selection). Once true, a wallet switch + /// preserves the chosen method instead of recomputing the default. + user_chose_funding_method: bool, funding_amount: Option<Amount>, funding_amount_input: Option<AmountInput>, - funding_utxo: Option<(OutPoint, TxOut, Address)>, alias_input: String, copied_to_clipboard: Option<Option<String>>, - identity_keys: IdentityKeys, + /// The chosen key set, public-only. Populated from the identity-auth + /// public-key cache (D4b); `master` is `None` until the cache is warm, + /// which gates registration (fail-closed, RK-2). + identity_keys: IdentityKeySpecs, + /// `true` while a [`WalletTask::WarmIdentityAuthPubkeys`] task is in flight + /// for the current identity index, so the warm is not re-dispatched every + /// frame and the UI can show a "preparing keys" hint. + warming_identity_keys: bool, + /// A queued cache-warm request: (seed hash, identity index). Set when the + /// chooser reads a cold cache; drained at the end of `ui()` into a + /// [`WalletTask::WarmIdentityAuthPubkeys`] task. + pending_warm_request: Option<([u8; 32], u32)>, + /// Per-key-id revealed WIFs (advanced mode "Show WIF"), derived on demand + /// via [`WalletTask::DeriveKeyForDisplay`]. Id 0 is the master key. Each is + /// zeroize-on-drop and cleared when the key set is rebuilt. + revealed_wifs: HashMap<u32, Secret>, + /// A queued "derive WIF for display" request: (key id, derivation path). + /// Drained at the end of `ui()` into a `DeriveKeyForDisplay` task so the + /// seed is fetched just-in-time and only the WIF returns. + pending_wif_request: Option<(u32, DerivationPath)>, wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, show_pop_up_info: Option<String>, @@ -104,6 +121,10 @@ pub struct AddNewIdentityScreen { show_advanced_options: bool, /// Fee result from completed identity registration completed_fee_result: Option<FeeResult>, + /// Tracked asset locks for the selected wallet, fetched off the UI thread + /// via the App Task System. Backs both the funding-method gate and the + /// asset-lock picker. + asset_lock_cache: TrackedAssetLockCache, } impl AddNewIdentityScreen { @@ -118,7 +139,7 @@ impl AddNewIdentityScreen { let mut selected_wallet = None; if app_context.has_wallet.load(Ordering::Relaxed) { - let wallets = &app_context.wallets.read().unwrap(); + let wallets = &app_context.wallets.read_recover(); // If a specific wallet seed hash is provided, use that wallet if let Some(seed_hash) = wallet_seed_hash && let Some(wallet) = wallets.get(&seed_hash) @@ -133,25 +154,28 @@ impl AddNewIdentityScreen { } } + // The funding-method pre-selection is applied by `update_wallet` below + // (and re-applied on every later wallet switch while the user has not + // made an explicit choice), so the chooser always tracks a wallet the + // pre-selection can actually be funded from. let mut created = Self { identity_id_number: 0, // updated later step: Arc::new(RwLock::new(WalletFundedScreenStep::ChooseFundingMethod)), funding_asset_lock: None, selected_wallet: None, // updated later - core_has_funding_address: None, funding_address: None, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), + user_chose_funding_method: false, funding_amount: None, funding_amount_input: None, - funding_utxo: None, alias_input: String::new(), copied_to_clipboard: None, // updated later - identity_keys: IdentityKeys { - master_private_key: None, - master_private_key_type: KeyType::ECDSA_HASH160, - keys_input: vec![], - }, + identity_keys: IdentityKeySpecs::empty(), + warming_identity_keys: false, + pending_warm_request: None, + revealed_wifs: HashMap::new(), + pending_wif_request: None, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, show_pop_up_info: None, @@ -163,6 +187,7 @@ impl AddNewIdentityScreen { platform_funding_amount_input: None, show_advanced_options: false, completed_fee_result: None, + asset_lock_cache: TrackedAssetLockCache::default(), }; if let Some(wallet) = selected_wallet { @@ -172,85 +197,104 @@ impl AddNewIdentityScreen { created } - /// Ensure that identity keys are correctly set up and generated. - /// - /// If the master key is not set, it generates a new master key and derives other keys from it. - /// Otherwise, it updates the existing keys based on the current wallet and identity index. - /// - /// ## Return value + /// Default number of keys (master + additional) the chooser warms and reads + /// from the auth-pubkey cache. + fn default_key_count(&self) -> u32 { + let dashpay_contract_id = self.app_context.dashpay_contract.id(); + // master (index 0) + the default additional keys. + default_identity_key_specs(dashpay_contract_id).len() as u32 + 1 + } + + /// Read the chosen identity keys from the auth-pubkey cache (D4b), + /// seed-free, for the current wallet + identity index. /// - /// * Ok(()) when the keys are correctly set up. - /// * Err(String) if there was an error during the process, e.g. wallet not open - pub fn ensure_correct_identity_keys(&mut self) -> Result<(), String> { - if self.identity_keys.master_private_key.is_some() { - return match self.update_identity_key() { - Ok(true) => Ok(()), - Ok(false) => Err("failed to update identity keys".to_string()), - Err(e) => Err(format!("failed to update identity keys: {}", e)), - }; + /// The chooser shows and submits **public** keys; the private keys are + /// derived just-in-time at registration through the JIT chokepoint. On a + /// cache hit this builds the [`IdentityKeySpecs`] entirely without the seed. + /// On a miss it leaves the key set empty (registration stays disabled, + /// fail-closed RK-2) and queues a cache warm (drained at the end of `ui()` + /// into a [`WalletTask::WarmIdentityAuthPubkeys`] task); the next frame + /// reads the now-warm cache. + pub fn ensure_correct_identity_keys(&mut self) { + self.revealed_wifs.clear(); + + let Some(wallet_lock) = self.selected_wallet.clone() else { + self.identity_keys = IdentityKeySpecs::empty(); + return; + }; + + let (seed_hash, is_open) = { + let wallet = wallet_lock.read_recover(); + (wallet.seed_hash(), wallet.is_open()) + }; + if !is_open { + self.identity_keys = IdentityKeySpecs::empty(); + return; } - if let Some(wallet_lock) = &self.selected_wallet { - // sanity checks - { - let wallet = wallet_lock.read().unwrap(); - if !wallet.is_open() { - return Err(format!( - "wallet {} is not open", - wallet - .alias - .as_ref() - .unwrap_or(&wallet.seed_hash().to_lower_hex_string()) - )); - } - } + let network = self.app_context.network; + let identity_index = self.identity_id_number; + let dashpay_contract_id = self.app_context.dashpay_contract.id(); + let default_keys = default_identity_key_specs(dashpay_contract_id); - let app_context = &self.app_context; - let identity_id_number = self.identity_id_number; - - // Get default key configuration - let dashpay_contract_id = app_context.dashpay_contract.id(); - let default_keys = default_identity_key_specs(dashpay_contract_id); - - let mut wallet = wallet_lock.write().expect("wallet lock failed"); - let master_key = wallet.identity_authentication_ecdsa_private_key( - app_context, - app_context.network, - identity_id_number, - 0, - )?; - - let other_keys = default_keys - .into_iter() - .enumerate() - .map( - |(i, (key_type, purpose, security_level, contract_bounds))| { - Ok(( - wallet.identity_authentication_ecdsa_private_key( - app_context, - app_context.network, - identity_id_number, - (i + 1).try_into().expect("key index must fit u32"), // key index 0 is the master key - )?, - key_type, - purpose, - security_level, - contract_bounds, - )) - }, - ) - .collect::<Result<Vec<_>, String>>()?; + let Ok(backend) = self.app_context.wallet_backend() else { + self.identity_keys = IdentityKeySpecs::empty(); + return; + }; + let cache = backend.auth_pubkey_cache().get(network, &seed_hash); + + // Master key at index 0. + let Some(master_pk) = cache.get(network, identity_index, 0) else { + self.queue_warm_identity_keys(seed_hash, identity_index); + return; + }; + let master = IdentityKeyEntry::from_cached_public_key( + master_pk, + network, + identity_index, + 0, + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + None, + ); - self.identity_keys = IdentityKeys { - master_private_key: Some(master_key), - master_private_key_type: KeyType::ECDSA_HASH160, - keys_input: other_keys, + let mut others = Vec::with_capacity(default_keys.len()); + for (i, (key_type, purpose, security_level, contract_bounds)) in + default_keys.into_iter().enumerate() + { + let key_index = (i + 1) as u32; + let Some(pk) = cache.get(network, identity_index, key_index) else { + self.queue_warm_identity_keys(seed_hash, identity_index); + return; }; + others.push(IdentityKeyEntry::from_cached_public_key( + pk, + network, + identity_index, + key_index, + key_type, + purpose, + security_level, + contract_bounds, + )); + } - Ok(()) - } else { - Err("no wallet selected".to_string()) + self.identity_keys = IdentityKeySpecs::new(Some(master), others); + self.warming_identity_keys = false; + } + + /// Queue a cache warm for the current identity index and mark the key set + /// not-ready so registration stays disabled (fail-closed). The request is + /// dispatched once at the end of `ui()`; `warming_identity_keys` prevents + /// re-dispatch on subsequent frames while it is in flight. + fn queue_warm_identity_keys(&mut self, seed_hash: [u8; 32], identity_index: u32) { + self.identity_keys = IdentityKeySpecs::empty(); + if self.warming_identity_keys { + return; } + self.warming_identity_keys = true; + self.pending_warm_request = Some((seed_hash, identity_index)); } fn render_identity_index_input(&mut self, ui: &mut egui::Ui) { @@ -264,7 +308,7 @@ impl AddNewIdentityScreen { // Check if we have access to the selected wallet if let Some(wallet_guard) = self.selected_wallet.as_ref() { - let wallet = wallet_guard.read().unwrap(); + let wallet = wallet_guard.read_recover(); let used_indices: HashSet<u32> = wallet.identities.keys().cloned().collect(); // Modify the selected text to include "(used)" if the current index is used @@ -311,67 +355,62 @@ impl AddNewIdentityScreen { } }); - // If the index has changed, update the identity key + // If the index has changed, refresh the identity keys from the cache. if index_changed { - self.ensure_correct_identity_keys() - .expect("failed to update identity key"); + self.ensure_correct_identity_keys(); } } + /// Build the wallet-picker label (`alias — spendable balance`) for one + /// wallet, reading its spendable balance from the display snapshot. + /// + /// Poison-tolerant: if the wallet lock is poisoned, falls back to a plain + /// "Unnamed Wallet" label rather than panicking. Takes `&AppContext` + /// (not `&self`) so the ComboBox closure can call it via a field-level + /// borrow, leaving the closure's other `self` field writes undisturbed. + fn wallet_picker_label(app_context: &AppContext, wallet: &Arc<RwLock<Wallet>>) -> String { + let Some((seed_hash, alias)) = wallet.read().ok().map(|w| { + let alias = w + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + (w.seed_hash(), alias) + }) else { + return "Unnamed Wallet".to_string(); + }; + let spendable_duffs = app_context.snapshot_balance(&seed_hash).spendable(); + format_wallet_picker_label(&alias, spendable_duffs) + } + fn render_wallet_selection(&mut self, ui: &mut Ui) -> bool { - let mut selected_wallet = None; + let mut clicked_wallet = None; let rendered = if self.app_context.has_wallet.load(Ordering::Relaxed) { - let wallets = &self.app_context.wallets.read().unwrap(); - if wallets.len() > 1 { - // Retrieve the alias of the currently selected wallet, if any - let selected_wallet_alias = self - .selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()?.alias.clone()) - .unwrap_or_else(|| "Select".to_string()); + let wallets: Vec<_> = self + .app_context + .wallets + .read() + .map(|guard| guard.values().cloned().collect()) + .unwrap_or_default(); - ui.heading( - "1. Choose the wallet to use in which this identities keys will come from.", + if wallets.len() > 1 { + ui.heading("1. Choose which wallet this identity's keys will come from."); + + // Show each wallet's spendable balance next to its alias so + // funding sufficiency is visible before choosing. + let app_context = self.app_context.clone(); + clicked_wallet = wallet_selection_combo( + ui, + "select_wallet", + &wallets, + self.selected_wallet.as_ref(), + |wallet| Self::wallet_picker_label(&app_context, wallet), + |_| true, ); - - // Display the ComboBox for wallet selection - ComboBox::from_id_salt("select_wallet") - .selected_text(selected_wallet_alias) - .show_ui(ui, |ui| { - for wallet in wallets.values() { - let wallet_alias = wallet - .read() - .ok() - .and_then(|w| w.alias.clone()) - .unwrap_or_else(|| "Unnamed Wallet".to_string()); - - let is_selected = self - .selected_wallet - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); - - if ui.selectable_label(is_selected, wallet_alias).clicked() { - // Update the selected wallet - selected_wallet = Some(wallet.clone()); - // Reset the funding address - self.funding_address = None; - // Reset the funding asset lock - self.funding_asset_lock = None; - // Reset the funding UTXO - self.funding_utxo = None; - // Reset the copied to clipboard state - self.copied_to_clipboard = None; - // Reset the step to choose funding method - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ChooseFundingMethod; - } - } - }); true - } else if let Some(wallet) = wallets.values().next() { + } else if let Some(wallet) = wallets.first() { if self.selected_wallet.is_none() { - // Automatically select the only available wallet - selected_wallet = Some(wallet.clone()); + // Automatically select the only available wallet. + clicked_wallet = Some(wallet.clone()); } false } else { @@ -381,27 +420,77 @@ impl AddNewIdentityScreen { false }; - if let Some(wallet) = selected_wallet { + if let Some(wallet) = clicked_wallet { + // A wallet switch invalidates funding chosen for the previous + // wallet; `update_wallet` re-derives the funding method/step. + self.funding_address = None; + self.funding_asset_lock = None; + self.copied_to_clipboard = None; self.update_wallet(wallet); } rendered } - /// Update selected wallet and trigger all dependent actions, like updating identity keys - /// and identity index. + /// Whether `wallet` can cover the estimated identity-creation fee out of its + /// spendable balance — the same sufficiency check as the "not enough Dash" + /// banner in `by_using_unused_balance.rs`, so a dust balance (positive but + /// below the fee) is never treated as fundable. Poison-tolerant: a busy + /// wallet lock reads as "cannot afford" rather than panicking. + fn wallet_can_afford_creation(app_context: &AppContext, wallet: &Arc<RwLock<Wallet>>) -> bool { + let Ok(w) = wallet.read() else { + return false; + }; + let spendable_duffs = app_context.snapshot_balance(&w.seed_hash()).spendable(); + let key_count = default_identity_key_specs(app_context.dashpay_contract.id()).len() + 1; + let minimum_credits = app_context + .fee_estimator() + .estimate_identity_create(key_count); + spendable_covers_minimum(spendable_duffs, minimum_credits) + } + + /// Update selected wallet and trigger all dependent actions, like updating + /// identity keys and identity index. /// - /// This function is called whenever a wallet was changed in the UI or unlocked + /// Called whenever the wallet changes in the UI or is unlocked. While the + /// user has not explicitly chosen a funding method, the default + /// pre-selection is recomputed for the new wallet; an explicit choice is + /// preserved across the switch. fn update_wallet(&mut self, wallet: Arc<RwLock<Wallet>>) { - let is_open = wallet.read().expect("wallet lock poisoned").is_open(); + let is_open = wallet.read().is_ok_and(|w| w.is_open()); self.selected_wallet = Some(wallet); self.wallet_open_attempted = false; self.identity_id_number = self.next_identity_id(); + let can_afford = self + .selected_wallet + .as_ref() + .is_some_and(|wallet| Self::wallet_can_afford_creation(&self.app_context, wallet)); + let current = ( + self.funding_method + .read() + .map(|m| *m) + .unwrap_or(FundingMethod::NoSelection), + self.step + .read() + .map(|s| *s) + .unwrap_or(WalletFundedScreenStep::ChooseFundingMethod), + ); + let (method, step) = + funding_method_after_switch(self.user_chose_funding_method, current, can_afford); + if let Ok(mut m) = self.funding_method.write() { + *m = method; + } + if let Ok(mut s) = self.step.write() { + *s = step; + } + if is_open { - self.ensure_correct_identity_keys() - .expect("failed to initialize keys") + // A new wallet/index resets any in-flight warm so the cold cache + // for the new selection is read fresh. + self.warming_identity_keys = false; + self.ensure_correct_identity_keys(); } } @@ -414,8 +503,7 @@ impl AddNewIdentityScreen { self.selected_wallet .as_ref() .unwrap() - .read() - .unwrap() + .read_recover() .identities .keys() .copied() @@ -429,7 +517,9 @@ impl AddNewIdentityScreen { return; }; let funding_method_arc = self.funding_method.clone(); - let mut funding_method = funding_method_arc.write().unwrap(); // Write lock on funding_method + let Ok(mut funding_method) = funding_method_arc.write() else { + return; + }; ComboBox::from_id_salt("funding_method") .selected_text(format!("{}", *funding_method)) @@ -439,19 +529,30 @@ impl AddNewIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::NoSelection, - "Please select funding method", + format!("{}", FundingMethod::NoSelection), ) .changed() { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ChooseFundingMethod; + // Deselecting returns to auto-default behavior so a later + // wallet switch may re-recommend a method. + self.user_chose_funding_method = false; + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } self.funding_amount = None; self.funding_amount_input = None; } let (has_unused_asset_lock, has_balance) = { - let wallet = selected_wallet.read().unwrap(); - (wallet.has_unused_asset_lock(), wallet.has_balance()) + let wallet = selected_wallet.read_recover(); + let seed_hash = wallet.seed_hash(); + // Offer the option on a failed fetch too, so the user can + // reach the picker's Retry rather than the option vanishing. + ( + self.asset_lock_cache.has_unused(&seed_hash) + || self.asset_lock_cache.is_failed(&seed_hash), + self.app_context.snapshot_has_balance(&seed_hash), + ) }; if has_unused_asset_lock @@ -459,14 +560,15 @@ impl AddNewIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseUnusedAssetLock, - "Unused Evo Funding Locks (recommended)", + format!("{}", FundingMethod::UseUnusedAssetLock), ) .changed() { - self.ensure_correct_identity_keys() - .expect("failed to initialize keys"); - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.user_chose_funding_method = true; + self.ensure_correct_identity_keys(); + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ReadyToCreate; + } self.funding_amount = None; self.funding_amount_input = None; } @@ -475,32 +577,20 @@ impl AddNewIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseWalletBalance, - "Wallet Balance", + format!("{}", FundingMethod::UseWalletBalance), ) .changed() { + self.user_chose_funding_method = true; self.funding_amount = None; self.funding_amount_input = None; - let mut step = self.step.write().unwrap(); // Write lock on step - *step = WalletFundedScreenStep::ReadyToCreate; - } - if ui - .selectable_value( - &mut *funding_method, - FundingMethod::AddressWithQRCode, - "Address with QR Code", - ) - .changed() - { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingOnFunds; - self.funding_amount = None; - self.funding_amount_input = None; + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ReadyToCreate; + } } - // Check if wallet has Platform address balance let has_platform_balance = { - let wallet = selected_wallet.read().unwrap(); + let wallet = selected_wallet.read_recover(); wallet .platform_address_info .values() @@ -511,14 +601,15 @@ impl AddNewIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UsePlatformAddress, - "Platform Address", + format!("{}", FundingMethod::UsePlatformAddress), ) .changed() { - self.ensure_correct_identity_keys() - .expect("failed to initialize keys"); - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.user_chose_funding_method = true; + self.ensure_correct_identity_keys(); + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ReadyToCreate; + } self.platform_funding_amount = None; self.platform_funding_amount_input = None; self.selected_platform_address_for_funding = None; @@ -564,6 +655,9 @@ impl AddNewIdentityScreen { // Render additional key options only if "Advanced" mode is selected if self.in_key_selection_advanced_mode { + if self.warming_identity_keys && !self.identity_keys.has_master() { + ui.label("Preparing identity keys…"); + } // Render all keys in one grid self.render_keys_input(ui); } else { @@ -573,17 +667,23 @@ impl AddNewIdentityScreen { fn render_keys_input(&mut self, ui: &mut egui::Ui) { let mut keys_to_remove = vec![]; - let has_master_key = self.identity_keys.master_private_key.is_some(); - let has_other_keys = !self.identity_keys.keys_input.is_empty(); + // Per-row "Show WIF" requests collected inside the table closure and + // applied after, to avoid borrowing `self` while the table borrows + // `self.identity_keys`. Each is (key id, derivation path). + let mut wif_requests: Vec<(u32, DerivationPath)> = vec![]; + let has_master_key = self.identity_keys.master.is_some(); + let has_other_keys = !self.identity_keys.others.is_empty(); if has_master_key || has_other_keys { let row_height = 30.0; // Use a lighter stripe color that doesn't clash with comboboxes let original_stripe_color = ui.visuals().faint_bg_color; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.visuals_mut().faint_bg_color = DashColors::stripe(dark_mode); + let revealed_wifs = &self.revealed_wifs; + TableBuilder::new(ui) .striped(true) .resizable(true) @@ -615,16 +715,19 @@ impl AddNewIdentityScreen { }) .body(|mut body| { // Render master key first - if let Some((master_key, _)) = self.identity_keys.master_private_key { + if let Some(master) = self.identity_keys.master.as_mut() { body.row(row_height, |mut row| { row.col(|ui| { ui.label("Master Key"); }); row.col(|ui| { - let wif = Secret::new(master_key.to_wif()); - // INTENTIONAL(CODE-003): WIF displayed as plaintext label — user-initiated key view. - // Secret wrapper provides zeroize-on-drop for the Rust-side variable. - ui.label(wif.expose_secret()); + Self::render_wif_cell( + ui, + 0, + &master.derivation_path, + revealed_wifs, + &mut wif_requests, + ); }); row.col(|_ui| { // No purpose for master key @@ -632,18 +735,15 @@ impl AddNewIdentityScreen { row.col(|ui| { ui.vertical(|ui| { ComboBox::from_id_salt("master_key_type") - .selected_text(format!( - "{:?}", - self.identity_keys.master_private_key_type - )) + .selected_text(format!("{:?}", master.key_type)) .show_ui(ui, |ui| { ui.selectable_value( - &mut self.identity_keys.master_private_key_type, + &mut master.key_type, KeyType::ECDSA_SECP256K1, "ECDSA_SECP256K1", ); ui.selectable_value( - &mut self.identity_keys.master_private_key_type, + &mut master.key_type, KeyType::ECDSA_HASH160, "ECDSA_HASH160", ); @@ -660,61 +760,63 @@ impl AddNewIdentityScreen { } // Render other keys - for (i, ((key, _), key_type, purpose, security_level, _contract_bounds)) in - self.identity_keys.keys_input.iter_mut().enumerate() - { + for (i, entry) in self.identity_keys.others.iter_mut().enumerate() { + let key_id = (i + 1) as u32; body.row(row_height, |mut row| { row.col(|ui| { ui.label(format!("Key {}", i + 1)); }); row.col(|ui| { - let wif = Secret::new(key.to_wif()); - // INTENTIONAL(CODE-003): WIF displayed as plaintext label — user-initiated key view. - // Secret wrapper provides zeroize-on-drop for the Rust-side variable. - ui.label(wif.expose_secret()); + Self::render_wif_cell( + ui, + key_id, + &entry.derivation_path, + revealed_wifs, + &mut wif_requests, + ); }); row.col(|ui| { ui.vertical(|ui| { - let prev_purpose = *purpose; + let prev_purpose = entry.purpose; ComboBox::from_id_salt(format!("purpose_combo_{}", i)) - .selected_text(format!("{:?}", purpose)) + .selected_text(format!("{:?}", entry.purpose)) .show_ui(ui, |ui| { ui.selectable_value( - purpose, + &mut entry.purpose, Purpose::AUTHENTICATION, "AUTHENTICATION", ); ui.selectable_value( - purpose, + &mut entry.purpose, Purpose::TRANSFER, "TRANSFER", ); ui.selectable_value( - purpose, + &mut entry.purpose, Purpose::ENCRYPTION, "ENCRYPTION", ); ui.selectable_value( - purpose, + &mut entry.purpose, Purpose::DECRYPTION, "DECRYPTION", ); }); // Auto-set security level when purpose changes - if *purpose != prev_purpose { - match *purpose { + if entry.purpose != prev_purpose { + match entry.purpose { Purpose::TRANSFER => { - *security_level = SecurityLevel::CRITICAL; + entry.security_level = SecurityLevel::CRITICAL; } Purpose::ENCRYPTION | Purpose::DECRYPTION => { - *security_level = SecurityLevel::MEDIUM; + entry.security_level = SecurityLevel::MEDIUM; } Purpose::AUTHENTICATION => { - if *security_level != SecurityLevel::CRITICAL - && *security_level != SecurityLevel::HIGH - && *security_level != SecurityLevel::MEDIUM + if entry.security_level != SecurityLevel::CRITICAL + && entry.security_level != SecurityLevel::HIGH + && entry.security_level != SecurityLevel::MEDIUM { - *security_level = SecurityLevel::CRITICAL; + entry.security_level = SecurityLevel::CRITICAL; } } _ => {} @@ -725,15 +827,15 @@ impl AddNewIdentityScreen { row.col(|ui| { ui.vertical(|ui| { ComboBox::from_id_salt(format!("key_type_combo_{}", i)) - .selected_text(format!("{:?}", key_type)) + .selected_text(format!("{:?}", entry.key_type)) .show_ui(ui, |ui| { ui.selectable_value( - key_type, + &mut entry.key_type, KeyType::ECDSA_HASH160, "ECDSA_HASH160", ); ui.selectable_value( - key_type, + &mut entry.key_type, KeyType::ECDSA_SECP256K1, "ECDSA_SECP256K1", ); @@ -743,29 +845,29 @@ impl AddNewIdentityScreen { row.col(|ui| { ui.vertical(|ui| { ComboBox::from_id_salt(format!("security_level_combo_{}", i)) - .selected_text(format!("{:?}", security_level)) + .selected_text(format!("{:?}", entry.security_level)) .show_ui(ui, |ui| { - if *purpose == Purpose::TRANSFER { - *security_level = SecurityLevel::CRITICAL; + if entry.purpose == Purpose::TRANSFER { + entry.security_level = SecurityLevel::CRITICAL; ui.label("Locked to CRITICAL"); - } else if *purpose == Purpose::ENCRYPTION - || *purpose == Purpose::DECRYPTION + } else if entry.purpose == Purpose::ENCRYPTION + || entry.purpose == Purpose::DECRYPTION { - *security_level = SecurityLevel::MEDIUM; + entry.security_level = SecurityLevel::MEDIUM; ui.label("Locked to MEDIUM"); } else { ui.selectable_value( - security_level, + &mut entry.security_level, SecurityLevel::CRITICAL, "CRITICAL", ); ui.selectable_value( - security_level, + &mut entry.security_level, SecurityLevel::HIGH, "HIGH", ); ui.selectable_value( - security_level, + &mut entry.security_level, SecurityLevel::MEDIUM, "MEDIUM", ); @@ -786,9 +888,17 @@ impl AddNewIdentityScreen { ui.visuals_mut().faint_bg_color = original_stripe_color; } - // Remove keys marked for deletion + // Apply any "Show WIF" request — only the most recent click matters. + if let Some(request) = wif_requests.pop() { + self.pending_wif_request = Some(request); + } + + // Remove keys marked for deletion (revealed WIFs become stale). + if !keys_to_remove.is_empty() { + self.revealed_wifs.clear(); + } for i in keys_to_remove.iter().rev() { - self.identity_keys.keys_input.remove(*i); + self.identity_keys.others.remove(*i); } // Add new key input entry @@ -802,29 +912,51 @@ impl AddNewIdentityScreen { } } + /// Render the advanced-mode WIF cell for one key: the revealed WIF if + /// already derived, otherwise a "Show WIF" button that queues a + /// just-in-time backend derivation. The seed never reaches `ui()` — only + /// the derived WIF (wrapped in [`Secret`]) comes back via a backend task. + fn render_wif_cell( + ui: &mut egui::Ui, + key_id: u32, + derivation_path: &DerivationPath, + revealed_wifs: &HashMap<u32, Secret>, + wif_requests: &mut Vec<(u32, DerivationPath)>, + ) { + if let Some(wif) = revealed_wifs.get(&key_id) { + // WIF displayed as plaintext label — user-initiated key view. + // Secret wrapper provides zeroize-on-drop for the Rust-side variable. + ui.label(wif.expose_secret()); + } else if ui.button("Show WIF").clicked() { + wif_requests.push((key_id, derivation_path.clone())); + } + } + fn register_identity_clicked(&mut self, funding_method: FundingMethod) -> AppAction { let Some(selected_wallet) = &self.selected_wallet else { return AppAction::None; }; - if self.identity_keys.master_private_key.is_none() { + // Fail-closed: the key set is only populated once the auth-pubkey cache + // is warm. A cold cache leaves `master` empty, so registration is + // blocked until the keys are ready (RK-2). + if !self.identity_keys.has_master() { return AppAction::None; }; match funding_method { FundingMethod::UseUnusedAssetLock => { - if let Some((tx, funding_asset_lock, address)) = self.funding_asset_lock.clone() { + if let Some(out_point) = self.funding_asset_lock { let identity_input = IdentityRegistrationInfo { alias_input: self.alias_input.clone(), keys: self.identity_keys.clone(), wallet: Arc::clone(selected_wallet), // Clone the Arc reference wallet_identity_index: self.identity_id_number, - identity_funding_method: RegisterIdentityFundingMethod::UseAssetLock( - address, - Box::new(funding_asset_lock), - Box::new(tx), - ), + identity_funding_method: RegisterIdentityFundingMethod::UseAssetLock { + out_point, + identity_index: self.identity_id_number, + }, }; - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; AppAction::BackendTask(BackendTask::IdentityTask( @@ -846,8 +978,8 @@ impl AddNewIdentityScreen { return AppAction::None; } - let seed = selected_wallet.read().unwrap().wallet_seed.clone(); - tracing::debug!(selected_wallet = ?selected_wallet,?seed, "funding with wallet balance"); + let wallet_seed_hash = hex::encode(selected_wallet.read_recover().seed_hash()); + tracing::debug!(wallet_seed_hash, "funding with wallet balance"); let identity_input = IdentityRegistrationInfo { alias_input: self.alias_input.clone(), keys: self.identity_keys.clone(), @@ -859,7 +991,7 @@ impl AddNewIdentityScreen { ), }; - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::WaitingForAssetLock; // Create the backend task to register the identity @@ -888,7 +1020,7 @@ impl AddNewIdentityScreen { return AppAction::None; } - let wallet_seed_hash = selected_wallet.read().unwrap().seed_hash(); + let wallet_seed_hash = selected_wallet.read_recover().seed_hash(); let mut inputs = std::collections::BTreeMap::new(); inputs.insert(platform_addr, amount); @@ -905,7 +1037,7 @@ impl AddNewIdentityScreen { }, }; - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RegisterIdentity( @@ -917,20 +1049,42 @@ impl AddNewIdentityScreen { } fn render_funding_amount_input(&mut self, ui: &mut egui::Ui) { - let funding_method = *self.funding_method.read().unwrap(); - - // Calculate max amount if using wallet balance - let max_amount_credits = if funding_method == FundingMethod::UseWalletBalance { - self.selected_wallet.as_ref().map(|wallet| { - let wallet = wallet.read().unwrap(); - // Convert duffs to credits (1 duff = 1000 credits) - wallet.total_balance_duffs() * 1000 - }) - } else { - None - }; - - let show_max_button = funding_method == FundingMethod::UseWalletBalance; + let funding_method = *self.funding_method.read_recover(); + + // Only apply the max-amount restriction when using wallet balance; + // reserve the estimated identity-creation fee out of the spendable + // balance so "Max" never offers more than the coin selector can + // actually use, mirroring the Top-Up wizard's equivalent input. + let (max_amount_credits, show_max_button, fee_hint) = + if funding_method == FundingMethod::UseWalletBalance { + let spendable_duffs = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok()) + .map(|wallet| { + self.app_context + .snapshot_balance(&wallet.seed_hash()) + .spendable() + }) + .unwrap_or(0); + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + let max_with_fee_reserved = + max_amount_after_fee_reserve(spendable_duffs, estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + )), + ) + } else { + (None, false, None) + }; let amount_input = self.funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) @@ -943,7 +1097,8 @@ impl AddNewIdentityScreen { // Update max amount and max button visibility dynamically amount_input .set_max_amount(max_amount_credits) - .set_show_max_button(show_max_button); + .set_show_max_button(show_max_button) + .set_max_exceeded_hint(fee_hint); let response = amount_input.show(ui); response.inner.update(&mut self.funding_amount); @@ -951,81 +1106,109 @@ impl AddNewIdentityScreen { ui.add_space(10.0); } - /// Update existing identity keys based on the current wallet and identity index. - /// - /// When the wallet is updated, we need to ensure that all the private keys are - /// generated with the correct parameters (seed, derivation path, etc.). + /// The optional local-alias step (design-spec §B.10: fund-first). /// - /// If the master key is not set, this function is a no-op and returns Ok(false). - fn update_identity_key(&mut self) -> Result<bool, String> { - if let Some(wallet_guard) = self.selected_wallet.as_ref() { - let mut wallet = wallet_guard.write().unwrap(); - let identity_index = self.identity_id_number; - - // Update the master private key and keys input from the wallet - self.identity_keys.master_private_key = - Some(wallet.identity_authentication_ecdsa_private_key( - &self.app_context, - self.app_context.network, - identity_index, - 0, - )?); - - // Update the additional keys input (preserving contract bounds) - self.identity_keys.keys_input = self - .identity_keys - .keys_input - .iter() - .enumerate() - .map( - |(key_index, (_, key_type, purpose, security_level, contract_bounds))| { - Ok(( - wallet.identity_authentication_ecdsa_private_key( - &self.app_context, - self.app_context.network, - identity_index, - key_index as u32 + 1, - )?, - *key_type, - *purpose, - *security_level, - contract_bounds.clone(), - )) - }, - ) - .collect::<Result<_, String>>()?; + /// Rendered by each funding-method branch just before its Create/Register + /// button, once the amount or lock for that method is chosen. This is a + /// Dash Evo Tool alias stored locally, not a DPNS username. + fn render_alias_input(&mut self, ui: &mut egui::Ui, step_number: u32) { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - Ok(true) - } else { - Ok(false) + ui.horizontal(|ui| { + ui.heading(format!("{step_number}. Set a local alias (optional).")); + crate::ui::helpers::info_icon_button( + ui, + "This is a local alias stored only in Dash Evo Tool to help you identify this identity.\n\n\ + This is NOT a DPNS username. DPNS names are registered on-chain after creating the identity.\n\n\ + You can change this alias anytime from the identity details screen.", + ); + }); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Alias:"); + let dark_mode = ui.style().visuals.dark_mode; + ui.add( + egui::TextEdit::singleline(&mut self.alias_input) + .hint_text( + egui::RichText::new("e.g., My Main Identity") + .color(DashColors::text_secondary(dark_mode)), + ) + .desired_width(250.0), + ); + }); + + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + egui::RichText::new("Note: This is a Dash Evo Tool alias, not a DPNS username.") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(10.0); + } + + /// The key id (0 = master, others id = index + 1) whose derivation path + /// matches `path`, used to file a returned WIF into the right row. + fn key_id_for_path(&self, path: &DerivationPath) -> Option<u32> { + if let Some(master) = &self.identity_keys.master + && &master.derivation_path == path + { + return Some(0); } + self.identity_keys + .others + .iter() + .position(|entry| &entry.derivation_path == path) + .map(|i| (i + 1) as u32) } + /// Add one advanced-mode key at the next index, reading its **public** key + /// from the auth-pubkey cache. On a cache miss the next index is warmed + /// (the key appears once the cache fills); manually added keys carry no + /// contract bounds. fn add_identity_key( &mut self, key_type: KeyType, purpose: Purpose, security_level: SecurityLevel, ) { - if let Some(wallet_guard) = self.selected_wallet.as_ref() { - let mut wallet = wallet_guard.write().unwrap(); - let new_key_index = self.identity_keys.keys_input.len() as u32 + 1; - - // Add a new key with default parameters (no contract bounds for manually added keys) - self.identity_keys.keys_input.push(( - wallet - .identity_authentication_ecdsa_private_key( - &self.app_context, - self.app_context.network, - self.identity_id_number, + let Some(wallet_lock) = self.selected_wallet.clone() else { + return; + }; + let seed_hash = wallet_lock.read_recover().seed_hash(); + let network = self.app_context.network; + let identity_index = self.identity_id_number; + let new_key_index = self.identity_keys.others.len() as u32 + 1; + + let Ok(backend) = self.app_context.wallet_backend() else { + return; + }; + let cache = backend.auth_pubkey_cache().get(network, &seed_hash); + match cache.get(network, identity_index, new_key_index) { + Some(public_key) => { + self.identity_keys + .others + .push(IdentityKeyEntry::from_cached_public_key( + public_key, + network, + identity_index, new_key_index, - ) - .expect("expected to have decrypted wallet"), - key_type, - purpose, - security_level, - None, // No contract bounds for manually added keys - )); + key_type, + purpose, + security_level, + None, + )); + } + None => { + // Warm enough keys to cover the new index; the chooser rebuilds + // (and the key appears) once the cache is filled. + self.warming_identity_keys = false; + self.pending_warm_request = Some((seed_hash, identity_index)); + } } } } @@ -1035,39 +1218,51 @@ impl ScreenLike for AddNewIdentityScreen { if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so we stop showing "Waiting for Platform acknowledgement". // The error itself is displayed by the global MessageBanner. - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::ReadyToCreate; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + match &backend_task_success_result { + BackendTaskSuccessResult::IdentityAuthPubkeysWarmed { .. } => { + // Cache is now warm; re-read the public keys for the current + // selection (cache hit, no seed access). + self.warming_identity_keys = false; + self.ensure_correct_identity_keys(); + return; + } + BackendTaskSuccessResult::WalletKeyForDisplay { + derivation_path, + wif, + .. + } => { + if let Some(key_id) = self.key_id_for_path(derivation_path) { + self.revealed_wifs.insert(key_id, wif.clone()); + } + return; + } + BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks } => { + self.asset_lock_cache.store(*seed_hash, locks.clone()); + return; + } + _ => {} + } + if let BackendTaskSuccessResult::RegisteredIdentity(qualified_identity, fee_result) = backend_task_success_result { self.successful_qualified_identity_id = Some(qualified_identity.identity.id()); self.completed_fee_result = Some(fee_result); - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::Success; return; } - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); let current_step = *step; match current_step { WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => { - if let Some(funding_address) = self.funding_address.as_ref() - && let BackendTaskSuccessResult::CoreItem( - CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), - ) = &backend_task_success_result - { - for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == address { - *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((*outpoint, tx_out.clone(), address.clone())) - } - } - } - } + WalletFundedScreenStep::WaitingOnFunds => {} WalletFundedScreenStep::FundsReceived => {} WalletFundedScreenStep::ReadyToCreate => {} WalletFundedScreenStep::WaitingForAssetLock => { @@ -1083,7 +1278,7 @@ impl ScreenLike for AddNewIdentityScreen { return false; }; if let Some(wallet) = &self.selected_wallet { - let wallet = wallet.read().unwrap(); + let wallet = wallet.read_recover(); wallet.known_addresses.contains_key(&address) } else { false @@ -1097,9 +1292,19 @@ impl ScreenLike for AddNewIdentityScreen { WalletFundedScreenStep::Success => {} } } - fn ui(&mut self, ctx: &Context) -> AppAction { + + fn display_task_error(&mut self, _error: &TaskError) -> bool { + // Flip an in-flight asset-lock fetch to a retryable state so the picker + // shows a Retry button instead of a permanent "Loading…". + self.asset_lock_cache.mark_loading_failed(); + false + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -1109,16 +1314,16 @@ impl ScreenLike for AddNewIdentityScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; ScrollArea::vertical().show(ui, |ui| { - let step = {*self.step.read().unwrap()}; + let step = {*self.step.read_recover()}; if step == WalletFundedScreenStep::Success { inner_action |= self.show_success(ui); return; @@ -1142,16 +1347,31 @@ impl ScreenLike for AddNewIdentityScreen { } if self.selected_wallet.is_none() { + ui.add_space(10.0); + ui.colored_label( + DashColors::WARNING, + "You need a wallet before you can create an identity.", + ); + ui.add_space(8.0); + if ui.button("Set up a wallet").clicked() { + inner_action |= AppAction::SetMainScreenThenGoToMainScreen( + crate::ui::RootScreenType::RootScreenWalletsBalances, + ); + } return; }; // Check if wallet needs unlocking - let wallet = self.selected_wallet.as_ref().unwrap(); + let wallet = self + .selected_wallet + .as_ref() + .expect("invariant: selected_wallet checked Some above"); // Try to open wallet without password if it doesn't use one if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -1179,8 +1399,11 @@ impl ScreenLike for AddNewIdentityScreen { // Display the heading with an info icon that shows a tooltip on hover ui.horizontal(|ui| { - let wallet_guard = self.selected_wallet.as_ref().unwrap(); - let wallet = wallet_guard.read().unwrap(); + let wallet_guard = self + .selected_wallet + .as_ref() + .expect("invariant: selected_wallet checked Some above"); + let wallet = wallet_guard.read_recover(); if wallet.identities.is_empty() { ui.heading(format!( "{}. Choose an identity index for the wallet. Leaving this 0 is recommended.", @@ -1241,41 +1464,10 @@ impl ScreenLike for AddNewIdentityScreen { ui.separator(); ui.add_space(10.0); - // Local alias input section - ui.horizontal(|ui| { - ui.heading(format!("{}. Set a local alias (optional).", step_number)); - crate::ui::helpers::info_icon_button( - ui, - "This is a local alias stored only in Dash Evo Tool to help you identify this identity.\n\n\ - This is NOT a DPNS username. DPNS names are registered on-chain after creating the identity.\n\n\ - You can change this alias anytime from the identity details screen.", - ); - }); - step_number += 1; - - ui.add_space(8.0); - - ui.horizontal(|ui| { - ui.label("Alias:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(&mut self.alias_input) - .hint_text(egui::RichText::new("e.g., My Main Identity").color(DashColors::text_secondary(dark_mode))) - .desired_width(250.0), - ); - }); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - egui::RichText::new("Note: This is a Dash Evo Tool nickname, not a DPNS username.") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - + // Fund-first (design-spec §B.10): the funding method chooser is the + // first everyday-facing step. The local alias (optional) moves to a + // later step, rendered just before the Create button for whichever + // funding method is chosen (see `render_alias_input`). ui.heading( format!("{}. Choose your funding method.", step_number).as_str() ); @@ -1287,7 +1479,7 @@ impl ScreenLike for AddNewIdentityScreen { ui.separator(); // Extract the funding method from the RwLock to minimize borrow scope - let funding_method = *self.funding_method.read().unwrap(); + let funding_method = *self.funding_method.read_recover(); if funding_method == FundingMethod::NoSelection { return; @@ -1301,9 +1493,6 @@ impl ScreenLike for AddNewIdentityScreen { FundingMethod::UseWalletBalance => { inner_action |= self.render_ui_by_using_unused_balance(ui, step_number); }, - FundingMethod::AddressWithQRCode => { - inner_action |= self.render_ui_by_wallet_qr_code(ui, step_number) - }, FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); }, @@ -1316,7 +1505,7 @@ impl ScreenLike for AddNewIdentityScreen { if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Identity Information", &show_pop_up_info_text); if popup.show(ui).inner { self.show_pop_up_info = None; @@ -1337,6 +1526,91 @@ impl ScreenLike for AddNewIdentityScreen { } } + // Drain the queued end-of-frame backend reads into one concurrent batch + // so none clobbers another (`AppAction`'s `|=` keeps only the last + // value). + let mut pending_tasks: Vec<BackendTask> = Vec::new(); + + // Auth-pubkey cache warm (cold-cache cover for the chooser, RK-2). One + // in-flight at a time via `warming_identity_keys`. + if let Some((seed_hash, identity_index)) = self.pending_warm_request.take() { + // Warm at least the default range, plus a margin for any + // advanced-mode keys already added beyond it. + let key_count = self + .default_key_count() + .max(self.identity_keys.others.len() as u32 + 2); + pending_tasks.push(BackendTask::WalletTask( + WalletTask::WarmIdentityAuthPubkeys { + seed_hash, + identity_index, + key_count, + }, + )); + } + + // "Show WIF" derivation (advanced mode); the seed is fetched + // just-in-time in the backend and only the WIF returns. + if let Some((_key_id, derivation_path)) = self.pending_wif_request.take() + && let Some(wallet) = &self.selected_wallet + { + let seed_hash = wallet.read_recover().seed_hash(); + pending_tasks.push(BackendTask::WalletTask(WalletTask::DeriveKeyForDisplay { + seed_hash, + derivation_path, + })); + } + + // Fetch the selected wallet's tracked asset locks once (off the UI + // thread) so the funding-method gate and the picker can read them. + if let Some(wallet) = &self.selected_wallet { + let seed_hash = wallet.read_recover().seed_hash(); + if let Some(task) = self.asset_lock_cache.ensure_requested(seed_hash) { + pending_tasks.push(task); + } + } + + match pending_tasks.len() { + 0 => {} + 1 => action |= AppAction::BackendTask(pending_tasks.pop().expect("len == 1")), + _ => { + action |= + AppAction::BackendTasks(pending_tasks, BackendTasksExecutionMode::Concurrent) + } + } + action } } + +#[cfg(test)] +mod funding_method_tests { + use super::format_wallet_picker_label; + + /// The picker label pairs the wallet alias with its spendable balance, + /// rendered in DASH, so the user can compare wallets before choosing one. + /// 0.5 DASH == 50_000_000 duffs. + #[test] + fn wallet_picker_label_shows_spendable_balance_in_dash() { + assert_eq!( + format_wallet_picker_label("Main", 50_000_000), + "Main — 0.5 DASH" + ); + } + + /// A zero-balance wallet still renders a well-formed label rather than an + /// empty or unit-less string. + #[test] + fn wallet_picker_label_renders_zero_balance() { + assert_eq!(format_wallet_picker_label("Empty", 0), "Empty — 0 DASH"); + } + + /// Structural guard: the label keeps the alias, an em-dash separator, and + /// the DASH unit — the shape UI code and any future i18n extraction rely on. + #[test] + fn wallet_picker_label_keeps_alias_separator_and_unit() { + let label = format_wallet_picker_label("Savings", 12_345_678); + assert!(label.starts_with("Savings"), "keeps the alias: {label}"); + assert!(label.contains(" — "), "uses an em-dash separator: {label}"); + assert!(label.ends_with(" DASH"), "shows the DASH unit: {label}"); + } +} diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 98c35555e..5ede930ea 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -1,13 +1,115 @@ -use arboard::Clipboard; +use crate::model::wallet::Wallet; +use crate::ui::state::TrackedAssetLockCache; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::Network; +use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use eframe::epaint::{Color32, ColorImage}; -use egui::Vec2; +use egui::{ComboBox, Ui, Vec2}; use image::Luma; +use platform_wallet::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; use qrcode::QrCode; +use std::fmt; use std::sync::{Arc, RwLock}; -use crate::model::wallet::Wallet; -use dash_sdk::dashcore_rpc::dashcore::Address; -use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; +/// How the user chooses to fund an identity operation. Shared by the +/// create-identity and top-up screens (both render the same chooser), so the +/// enum, its labels, and the default pre-selection live in one place. +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum FundingMethod { + NoSelection, + UseUnusedAssetLock, + UseWalletBalance, + /// Use Platform Address credits. + UsePlatformAddress, +} + +impl fmt::Display for FundingMethod { + /// Everyday-user labels. `UseUnusedAssetLock` deliberately avoids "asset + /// lock" jargon — in the create context it reads as recovering an + /// interrupted setup. Top-Up uses [`FundingMethod::top_up_label`] instead. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match self { + FundingMethod::NoSelection => "Select how to fund", + FundingMethod::UseWalletBalance => "From your wallet (recommended)", + FundingMethod::UseUnusedAssetLock => "Recover an unfinished funding", + FundingMethod::UsePlatformAddress => "Use a Platform address", + }; + write!(f, "{}", output) + } +} + +impl FundingMethod { + /// Top-Up-context label. Shares [`Display`]'s wording except for + /// `UseUnusedAssetLock`: an existing identity being topped up was never + /// mid-setup, so "recover an unfinished funding" doesn't fit — it just + /// reuses an existing funding transaction. + pub fn top_up_label(&self) -> &'static str { + match self { + FundingMethod::NoSelection => "Select how to fund", + FundingMethod::UseWalletBalance => "From your wallet (recommended)", + FundingMethod::UseUnusedAssetLock => "Use an existing funding transaction", + FundingMethod::UsePlatformAddress => "Use a Platform address", + } + } +} + +/// The funding-method chooser's starting state for a wallet that either does +/// or doesn't have spendable balance (§B.9: pre-select `UseWalletBalance` +/// only when it is actually available; otherwise start unselected rather +/// than land on a method the ComboBox itself wouldn't offer). A pure function +/// so the decision is testable without constructing a real wallet/balance +/// snapshot. +pub fn default_funding_state(wallet_has_balance: bool) -> (FundingMethod, WalletFundedScreenStep) { + if wallet_has_balance { + ( + FundingMethod::UseWalletBalance, + WalletFundedScreenStep::ReadyToCreate, + ) + } else { + ( + FundingMethod::NoSelection, + WalletFundedScreenStep::ChooseFundingMethod, + ) + } +} + +/// Resolve the funding method and step after the selected wallet changes. +/// +/// While the user has not made an explicit choice, the screen re-applies its +/// default pre-selection for the new wallet, so the chooser always tracks a +/// wallet the current selection can actually be funded from. Once a method has +/// been chosen explicitly, a wallet switch preserves it untouched — a switch +/// must never silently change a funding method the user picked on purpose. +pub fn funding_method_after_switch( + user_chose_method: bool, + current: (FundingMethod, WalletFundedScreenStep), + new_wallet_has_balance: bool, +) -> (FundingMethod, WalletFundedScreenStep) { + if user_chose_method { + current + } else { + default_funding_state(new_wallet_has_balance) + } +} + +/// Whether a wallet holding `spendable_duffs` can cover `minimum_credits` of +/// platform fees. Shared by the Create-Identity and Top-Up wallet-balance +/// funding gates so the duffs -> credits conversion has one source of truth. +pub fn spendable_covers_minimum(spendable_duffs: u64, minimum_credits: u64) -> bool { + spendable_duffs.saturating_mul(CREDITS_PER_DUFF) >= minimum_credits +} + +/// The largest amount, in credits, a "Max" button can safely offer from a +/// wallet holding `spendable_duffs`, after reserving `fee_credits` for the +/// platform fee. Built on `spendable_duffs` (not the wallet's `total`, which +/// also counts immature/locked funds coin selection cannot touch) so the +/// offered amount never exceeds what the wallet can actually send. +pub fn max_amount_after_fee_reserve(spendable_duffs: u64, fee_credits: u64) -> u64 { + spendable_duffs + .saturating_mul(CREDITS_PER_DUFF) + .saturating_sub(fee_credits) +} #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub enum WalletFundedScreenStep { @@ -20,6 +122,134 @@ pub enum WalletFundedScreenStep { Success, } +/// A calm, jargon-free sentence describing where a funding asset lock is in its +/// lifecycle. Shown to the Everyday User when they pick an existing asset lock +/// to fund an identity, so they never see a raw `Debug` enum. +pub fn asset_lock_status_label(status: &AssetLockStatus) -> &'static str { + match status { + AssetLockStatus::Built => "Prepared, not yet sent to the network.", + AssetLockStatus::Broadcast => "Sent to the network. Waiting for confirmation.", + AssetLockStatus::InstantSendLocked => "Confirmed and ready to use.", + AssetLockStatus::ChainLocked => "Confirmed and ready to use.", + AssetLockStatus::Consumed => "Already used to fund an identity.", + } +} + +/// The Dash address that received the locked funds for this asset lock, derived +/// from the lock transaction's credit output. Returns `None` when the address +/// cannot be derived (e.g. a non-standard output). Lets the user tell two asset +/// locks apart by address as well as transaction id. +/// +/// Mirrors the upstream recovery derivation, which reads the first credit output +/// of the asset-lock payload (asset locks built here carry a single credit +/// output). +pub fn asset_lock_address(lock: &TrackedAssetLock, network: Network) -> Option<Address> { + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &lock.transaction.special_transaction_payload + else { + return None; + }; + let output = payload.credit_outputs.first()?; + Address::from_script(&output.script_pubkey, network).ok() +} + +/// Render a wallet-picker ComboBox and return the wallet the user clicked this +/// frame, if any. `label_fn` supplies each entry's text and the closed-box text +/// for the current selection; `enabled_fn` greys out wallets that cannot serve +/// the active funding method. Reset-on-change is the caller's responsibility — +/// act on the returned wallet. Shared by the create-identity, top-up, and +/// add-existing-identity screens so the picker scaffolding lives in one place. +pub fn wallet_selection_combo( + ui: &mut Ui, + id_salt: &str, + wallets: &[Arc<RwLock<Wallet>>], + selected: Option<&Arc<RwLock<Wallet>>>, + mut label_fn: impl FnMut(&Arc<RwLock<Wallet>>) -> String, + mut enabled_fn: impl FnMut(&Arc<RwLock<Wallet>>) -> bool, +) -> Option<Arc<RwLock<Wallet>>> { + let selected_text = match selected { + Some(wallet) => label_fn(wallet), + None => "Select".to_string(), + }; + + let mut clicked = None; + ComboBox::from_id_salt(id_salt) + .selected_text(selected_text) + .show_ui(ui, |ui| { + for wallet in wallets { + let is_selected = selected.is_some_and(|s| Arc::ptr_eq(s, wallet)); + let label = label_fn(wallet); + let enabled = enabled_fn(wallet); + ui.add_enabled_ui(enabled, |ui| { + if ui.selectable_label(is_selected, label).clicked() { + clicked = Some(wallet.clone()); + } + }); + } + }); + clicked +} + +/// Outcome of the shared unused-funding picker gate. +pub enum FundingAssetLockPicker { + /// A gate message (no wallet / busy / load failed / loading / none found) + /// was already rendered; there is nothing for the caller to show. + Handled, + /// Asset locks still usable to fund an identity, for the caller to render. + Available(Vec<TrackedAssetLock>), +} + +/// Render the shared load/gate states for the unused-funding picker and return +/// the actionable asset locks (Built / Broadcast / IS-Locked / Chain-Locked — +/// never Consumed) for the selected wallet. The create-identity and top-up +/// screens differ only in how each lock row looks, so this owns the identical +/// wallet-resolution, retry, loading, and empty states. +pub fn actionable_asset_locks( + ui: &mut Ui, + cache: &mut TrackedAssetLockCache, + selected_wallet: Option<&Arc<RwLock<Wallet>>>, +) -> FundingAssetLockPicker { + let Some(wallet) = selected_wallet else { + ui.label("No wallet selected."); + return FundingAssetLockPicker::Handled; + }; + + let seed_hash = match wallet.read() { + Ok(w) => w.seed_hash(), + Err(_) => { + ui.label("Wallet is busy. Try again in a moment."); + return FundingAssetLockPicker::Handled; + } + }; + + if cache.is_failed(&seed_hash) { + ui.label("Couldn't load your unfinished funding."); + if ui.button("Retry").clicked() { + cache.invalidate_one(&seed_hash); + } + return FundingAssetLockPicker::Handled; + } + + let Some(all_tracked) = cache.get(&seed_hash) else { + ui.label("Loading your unfinished funding…"); + return FundingAssetLockPicker::Handled; + }; + + // Consumed locks are tracked for history but cannot fund an identity. + let tracked: Vec<TrackedAssetLock> = all_tracked + .iter() + .filter(|t| !matches!(t.status, AssetLockStatus::Consumed)) + .cloned() + .collect(); + + if tracked.is_empty() { + ui.label("No unfinished funding was found."); + return FundingAssetLockPicker::Handled; + } + + FundingAssetLockPicker::Available(tracked) +} + // Function to generate a QR code image from the address pub fn generate_qr_code_image(pay_uri: &str) -> Result<ColorImage, qrcode::types::QrError> { // Generate the QR code @@ -46,49 +276,203 @@ pub fn generate_qr_code_image(pay_uri: &str) -> Result<ColorImage, qrcode::types }) } -pub fn copy_to_clipboard(text: &str) -> Result<(), String> { - let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; - clipboard - .set_text(text.to_string()) - .map_err(|e| e.to_string()) -} +#[cfg(test)] +mod tests { + use super::*; -pub fn capture_qr_funding_utxo_if_available( - step: &Arc<RwLock<WalletFundedScreenStep>>, - wallet: Option<&Arc<RwLock<Wallet>>>, - funding_address: Option<&Address>, -) -> Option<(OutPoint, TxOut, Address)> { - if !matches!( - *step.read().expect("wallet funding step lock poisoned"), - WalletFundedScreenStep::WaitingOnFunds - ) { - return None; + #[test] + fn status_label_is_user_facing_for_every_variant() { + // Exhaustive over the enum so a new variant forces a copy decision here + // instead of silently falling back to a Debug render in the UI. + for status in [ + AssetLockStatus::Built, + AssetLockStatus::Broadcast, + AssetLockStatus::InstantSendLocked, + AssetLockStatus::ChainLocked, + AssetLockStatus::Consumed, + ] { + let label = asset_lock_status_label(&status); + assert!(label.ends_with('.'), "label should be a sentence: {label}"); + let debug = format!("{status:?}"); + assert_ne!(label, debug, "label must not be the Debug repr"); + assert!( + !label.contains("AssetLockStatus") && !label.contains("InstantSendLocked"), + "label must not leak enum jargon: {label}" + ); + } } - let address = funding_address.cloned()?; + #[test] + fn exact_balance_covers_minimum() { + let minimum_credits = 10 * CREDITS_PER_DUFF; + assert!(spendable_covers_minimum(10, minimum_credits)); + } - let wallet_arc = wallet?; + #[test] + fn one_credit_short_of_minimum_is_insufficient() { + let minimum_credits = 10 * CREDITS_PER_DUFF + 1; + assert!(!spendable_covers_minimum(10, minimum_credits)); + } - let candidate_utxo = { - let wallet = wallet_arc - .read() - .expect("wallet lock poisoned while checking funding UTXO"); - wallet.utxos.get(&address).and_then(|utxos| { - utxos - .iter() - .filter(|(_, tx_out)| tx_out.value > 0) - .max_by_key(|(_, tx_out)| tx_out.value) - .map(|(outpoint, tx_out)| (*outpoint, tx_out.clone())) - }) - }; + #[test] + fn one_credit_above_minimum_is_sufficient() { + let minimum_credits = 10 * CREDITS_PER_DUFF - 1; + assert!(spendable_covers_minimum(10, minimum_credits)); + } - if let Some((outpoint, tx_out)) = candidate_utxo { - let mut step = step - .write() - .expect("wallet funding step write lock poisoned"); - *step = WalletFundedScreenStep::FundsReceived; - Some((outpoint, tx_out, address)) - } else { - None + #[test] + fn zero_spendable_never_covers_a_positive_minimum() { + assert!(!spendable_covers_minimum(0, 1)); + } + + #[test] + fn conversion_does_not_overflow_on_extreme_values() { + assert!(spendable_covers_minimum(u64::MAX, u64::MAX)); + } + + #[test] + fn max_amount_reserves_fee_from_spendable_duffs() { + let spendable_duffs = 10; + let fee_credits = 500; + assert_eq!( + max_amount_after_fee_reserve(spendable_duffs, fee_credits), + spendable_duffs * CREDITS_PER_DUFF - fee_credits + ); + } + + #[test] + fn max_amount_saturates_to_zero_when_fee_exceeds_spendable() { + assert_eq!(max_amount_after_fee_reserve(1, u64::MAX), 0); + } + + #[test] + fn max_amount_does_not_overflow_on_extreme_values() { + assert_eq!(max_amount_after_fee_reserve(u64::MAX, 0), u64::MAX); + } + + /// A wallet with spendable balance defaults to the recommended path, + /// pre-selected and ready to go. + #[test] + fn default_funding_state_prefers_wallet_balance_when_available() { + assert_eq!( + default_funding_state(true), + ( + FundingMethod::UseWalletBalance, + WalletFundedScreenStep::ReadyToCreate + ) + ); + } + + /// A wallet with nothing to fund from must not default to a method the + /// ComboBox itself wouldn't offer — that was the fresh-wallet dead end. It + /// starts unselected instead. + #[test] + fn default_funding_state_falls_back_to_no_selection_without_balance() { + assert_eq!( + default_funding_state(false), + ( + FundingMethod::NoSelection, + WalletFundedScreenStep::ChooseFundingMethod + ) + ); + } + + /// Exhaustive over the enum so a new variant forces a copy decision here + /// instead of silently falling back to a Debug render in the UI. + #[test] + fn display_is_jargon_free_for_every_variant() { + for method in [ + FundingMethod::NoSelection, + FundingMethod::UseUnusedAssetLock, + FundingMethod::UseWalletBalance, + FundingMethod::UsePlatformAddress, + ] { + let label = format!("{method}"); + let debug = format!("{method:?}"); + assert_ne!(label, debug, "label must not be the Debug repr"); + assert!( + !label.contains("Asset Lock") && !label.contains("asset lock"), + "label must not leak asset-lock jargon: {label}" + ); + } + } + + #[test] + fn use_wallet_balance_is_the_recommended_primary_path() { + assert_eq!( + format!("{}", FundingMethod::UseWalletBalance), + "From your wallet (recommended)" + ); + } + + /// Top-Up's asset-lock label must not describe "recovering" a funding + /// setup — an identity being topped up already exists and was never + /// mid-creation. Every other variant keeps `Display`'s wording. + #[test] + fn top_up_label_differs_only_for_asset_lock() { + assert_eq!( + FundingMethod::UseUnusedAssetLock.top_up_label(), + "Use an existing funding transaction" + ); + assert_ne!( + FundingMethod::UseUnusedAssetLock.top_up_label(), + format!("{}", FundingMethod::UseUnusedAssetLock) + ); + + for method in [ + FundingMethod::NoSelection, + FundingMethod::UseWalletBalance, + FundingMethod::UsePlatformAddress, + ] { + assert_eq!(method.top_up_label(), format!("{method}")); + } + } + + /// F3 case 1: with no explicit choice yet, a wallet switch re-applies the + /// screen's default pre-selection for the new wallet. Switching from a + /// funded wallet to an unfunded one drops the stale `UseWalletBalance` + /// pre-selection back to `NoSelection` instead of keeping a method the new + /// wallet can't fund. + #[test] + fn switch_without_explicit_choice_recomputes_default() { + let stale = ( + FundingMethod::UseWalletBalance, + WalletFundedScreenStep::ReadyToCreate, + ); + assert_eq!( + funding_method_after_switch(false, stale, false), + ( + FundingMethod::NoSelection, + WalletFundedScreenStep::ChooseFundingMethod + ) + ); + // And onto a funded wallet it recommends the wallet-balance path. + assert_eq!( + funding_method_after_switch( + false, + ( + FundingMethod::NoSelection, + WalletFundedScreenStep::ChooseFundingMethod + ), + true + ), + ( + FundingMethod::UseWalletBalance, + WalletFundedScreenStep::ReadyToCreate + ) + ); + } + + /// F3 case 2: once the user has explicitly chosen a method, a wallet switch + /// preserves it untouched — even when the new wallet's balance would have + /// produced a different default. + #[test] + fn switch_after_explicit_choice_preserves_selection() { + let chosen = ( + FundingMethod::UseUnusedAssetLock, + WalletFundedScreenStep::ReadyToCreate, + ); + assert_eq!(funding_method_after_switch(true, chosen, true), chosen); + assert_eq!(funding_method_after_switch(true, chosen, false), chosen); } } diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 2411c8631..73b1b0eab 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -10,7 +10,7 @@ use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedId use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{ConfirmationDialog, ConfirmationStatus, island_central_panel}; -use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::clicked_outside_window; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; @@ -22,6 +22,8 @@ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; +use crate::wallet_backend::poison::MutexRecover; +use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; @@ -97,7 +99,7 @@ impl IdentitiesScreen { editing_alias_value: String::new(), }; - if let Ok(saved_ids) = screen.app_context.db.load_identity_order() { + if let Ok(saved_ids) = screen.app_context.load_identity_order() { // reorder the IndexMap screen.reorder_map_to(saved_ids); screen.use_custom_order = true; @@ -109,7 +111,7 @@ impl IdentitiesScreen { /// Reorders `self.identities` to match the order of the provided list of IDs. /// Any IDs not present in the provided list are left in their current position. fn reorder_map_to(&self, new_order: Vec<Identifier>) { - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); if lock.is_empty() || new_order.is_empty() { return; } @@ -196,7 +198,7 @@ impl IdentitiesScreen { IdentitiesSortOrder::Descending => ordering.reverse(), } }); - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); *lock = list .iter() .map(|qi| (qi.identity.id(), qi.clone())) @@ -219,7 +221,7 @@ impl IdentitiesScreen { } fn show_alias(&mut self, ui: &mut Ui, qualified_identity: &QualifiedIdentity) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(alias) = &qualified_identity.alias { ui.label(RichText::new(alias).color(DashColors::text_primary(dark_mode))); @@ -262,7 +264,7 @@ impl IdentitiesScreen { // Up/down reorder methods fn move_identity_up(&mut self, identity_id: &Identifier) { - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); if let Some(idx) = lock.get_index_of(identity_id) && idx > 0 { @@ -274,7 +276,7 @@ impl IdentitiesScreen { // arrow down fn move_identity_down(&mut self, identity_id: &Identifier) { - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); if let Some(idx) = lock.get_index_of(identity_id) && idx + 1 < lock.len() { @@ -286,16 +288,16 @@ impl IdentitiesScreen { // Save the current index order to DB fn save_current_order(&self) { - let lock = self.identities.lock().unwrap(); + let lock = self.identities.lock_recover(); let all_ids = lock.keys().cloned().collect::<Vec<_>>(); drop(lock); - self.app_context.db.save_identity_order(all_ids).ok(); + self.app_context.save_identity_order(all_ids).ok(); } /// This method merges the ephemeral-sorted `Vec` back into the IndexMap /// so the IndexMap is updated to the user’s currently displayed order. fn update_index_map_to_current_ephemeral(&self, ephemeral_list: Vec<QualifiedIdentity>) { - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); // basically reorder the underlying IndexMap to match ephemeral_list for (desired_idx, qi) in ephemeral_list.into_iter().enumerate() { let id = qi.identity.id(); @@ -311,9 +313,9 @@ impl IdentitiesScreen { if let Some(in_wallet_text) = self.wallet_seed_hash_cache.get(wallet_seed_hash) { return Some(in_wallet_text.clone()); } - let wallets = self.app_context.wallets.read().unwrap(); + let wallets = self.app_context.wallets.read_recover(); for wallet in wallets.values() { - let wallet_guard = wallet.read().unwrap(); + let wallet_guard = wallet.read_recover(); if &wallet_guard.seed_hash() == wallet_seed_hash { let in_wallet_text = if let Some(alias) = wallet_guard.alias.as_ref() { alias.clone() @@ -380,7 +382,7 @@ impl IdentitiesScreen { } fn render_no_identities_view(&self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Optionally put everything in a framed "card"-like container Frame::group(ui.style()) @@ -615,7 +617,7 @@ impl IdentitiesScreen { let actions_popup_id = ui.make_persistent_id(format!("actions_popup_{}", qualified_identity.identity.id().to_string(Encoding::Base58))); egui::Popup::from_toggle_button_response(&actions_response).id(actions_popup_id) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.ctx().style().visuals.dark_mode))) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.style().visuals.dark_mode))) .show(|ui| { ui.set_min_width(150.0); @@ -725,12 +727,12 @@ impl IdentitiesScreen { let popup_id = ui.make_persistent_id(format!("keys_popup_{}", qualified_identity.identity.id().to_string(Encoding::Base58))); egui::Popup::from_toggle_button_response(&button_response).id(popup_id) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.ctx().style().visuals.dark_mode))) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.style().visuals.dark_mode))) .show(|ui| { // Wrap in a scroll area so popups with many keys are accessible let max_popup_height = ui.ctx().content_rect().height() * 0.6; egui::ScrollArea::vertical().max_height(max_popup_height).show(ui, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Main Identity Keys if !public_keys.is_empty() { @@ -873,11 +875,10 @@ impl IdentitiesScreen { match self .app_context - .db - .delete_local_qualified_identity(&identity_id, &self.app_context) + .delete_local_qualified_identity(&identity_id) { Ok(_) => { - let mut lock = self.identities.lock().unwrap(); + let mut lock = self.identities.lock_recover(); lock.shift_remove(&identity_id); } Err(e) => { @@ -889,7 +890,8 @@ impl IdentitiesScreen { self.app_context.egui_ctx(), format!("Failed to remove identity: {}", e), MessageType::Error, - ); + ) + .disable_auto_dismiss(); } } @@ -897,10 +899,10 @@ impl IdentitiesScreen { &identity_to_remove.associated_voter_identity { let voter_identity_id = voter_identity.id(); - if let Err(e) = self.app_context.db.delete_local_qualified_identity( - &voter_identity_id, - &self.app_context, - ) { + if let Err(e) = self + .app_context + .delete_local_qualified_identity(&voter_identity_id) + { tracing::warn!( "Failed to delete voter identity from database: {}", e @@ -919,11 +921,9 @@ impl IdentitiesScreen { } fn show_alias_edit_popup(&mut self, ctx: &Context) -> AppAction { - if self.editing_alias_identity.is_none() { + let Some(identity_id) = self.editing_alias_identity else { return AppAction::None; - } - - let identity_id = self.editing_alias_identity.unwrap(); + }; // Draw dark overlay behind the popup let screen_rect = ctx.content_rect(); @@ -947,13 +947,13 @@ impl IdentitiesScreen { spread: 0, color: DashColors::popup_shadow(), }, - fill: ctx.style().visuals.window_fill, + fill: ctx.global_style().visuals.window_fill, stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), }) .show(ctx, |ui| { ui.set_min_width(300.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Enter a new alias for this identity:") @@ -996,7 +996,7 @@ impl IdentitiesScreen { .set_identity_alias(&identity_id, new_alias.as_deref()) { Ok(()) => { - let mut identities = self.identities.lock().unwrap(); + let mut identities = self.identities.lock_recover(); if let Some(identity_to_update) = identities.get_mut(&identity_id) { identity_to_update.alias = new_alias; } @@ -1029,7 +1029,7 @@ impl IdentitiesScreen { impl ScreenLike for IdentitiesScreen { fn refresh(&mut self) { - let mut identities = self.identities.lock().unwrap(); + let mut identities = self.identities.lock_recover(); *identities = self .app_context .load_local_qualified_identities() @@ -1040,7 +1040,7 @@ impl ScreenLike for IdentitiesScreen { drop(identities); // Keep order after refreshing - if let Ok(saved_ids) = self.app_context.db.load_identity_order() { + if let Ok(saved_ids) = self.app_context.load_identity_order() { self.reorder_map_to(saved_ids); self.use_custom_order = true; } @@ -1080,7 +1080,9 @@ impl ScreenLike for IdentitiesScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut right_buttons = if !self.app_context.has_wallet.load(Ordering::Relaxed) { vec![ ( @@ -1102,12 +1104,11 @@ impl ScreenLike for IdentitiesScreen { "Load Identity", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddExistingIdentity)), )); - if !self.identities.lock().unwrap().is_empty() { + if !self.identities.lock_recover().is_empty() { // Create a vec of RefreshIdentity(identity) DesiredAppAction for each identity let backend_tasks: Vec<BackendTask> = self .identities - .lock() - .unwrap() + .lock_recover() .values() .map(|qi| BackendTask::IdentityTask(IdentityTask::RefreshIdentity(qi.clone()))) .collect(); @@ -1120,21 +1121,22 @@ impl ScreenLike for IdentitiesScreen { )); } - let mut action = add_top_panel( - ctx, + // TODO: wire wallet/identity selection consumption for the Identities page. + let mut action = add_top_panel_with_global_nav( + ui, &self.app_context, - vec![("Identities", AppAction::None)], + subdued_everyday_spec("Identities", RootScreenType::RootScreenIdentities), right_buttons, ); - action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenIdentities); + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenIdentities); let identities_vec = { - let guard = self.identities.lock().unwrap(); + let guard = self.identities.lock_recover(); guard.values().cloned().collect::<Vec<_>>() }; - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; if identities_vec.is_empty() { self.render_no_identities_view(ui); diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 344dcc54a..3ba219bec 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -26,7 +26,7 @@ use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; -use eframe::egui::{self, Context, Frame, Margin}; +use eframe::egui::{self, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -186,7 +186,9 @@ impl AddKeyScreen { // Convert the input string to bytes (hex decoding) match hex::decode(self.private_key_input.text()) { Ok(private_key_bytes_vec) if private_key_bytes_vec.len() == 32 => { - let private_key_bytes = private_key_bytes_vec.try_into().unwrap(); + let private_key_bytes: [u8; 32] = private_key_bytes_vec + .try_into() + .expect("invariant: length checked to be 32 in the match guard"); let public_key_data_result = self.key_type.public_key_data_from_private_key_data( &private_key_bytes, self.app_context.network, @@ -233,7 +235,9 @@ impl AddKeyScreen { key_type: self.key_type, purpose: self.purpose, security_level: self.security_level, - data: public_key_data_result.unwrap().into(), + data: public_key_data_result + .expect("invariant: Err handled in the preceding branch") + .into(), read_only: false, disabled_at: None, contract_bounds, @@ -249,7 +253,9 @@ impl AddKeyScreen { format!("Issue verifying private key: {}", err), MessageType::Error, ); - } else if validation_result.unwrap() { + } else if validation_result + .expect("invariant: Err handled in the preceding branch") + { let new_qualified_key = QualifiedIdentityPublicKey { identity_public_key: new_key.into(), in_wallet_at_derivation_path: None, @@ -385,9 +391,11 @@ impl ScreenLike for AddKeyScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -397,12 +405,12 @@ impl ScreenLike for AddKeyScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Show the success screen if the key was added successfully @@ -418,8 +426,9 @@ impl ScreenLike for AddKeyScreen { && let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -650,7 +659,7 @@ impl ScreenLike for AddKeyScreen { let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_update(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 0c70cbba3..036801c44 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -1,11 +1,15 @@ use crate::app::AppAction; +use crate::backend_task::identity::IdentityTask; +use crate::backend_task::wallet::WalletTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::secret::Secret; use crate::model::wallet::Wallet; +use crate::model::wallet::passphrase::validate_single_key_passphrase; use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::info_popup::InfoPopup; @@ -18,24 +22,28 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; +use crate::wallet_backend::IdentityKeyView; +use crate::wallet_backend::poison::RwLockRecover; +use crate::wallet_backend::secret_seam::SecretScheme; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; -use dash_sdk::dpp::dashcore::sign_message::signed_msg_hash; +use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; use dash_sdk::dpp::dashcore::{Address, PrivateKey, PubkeyHash, ScriptHash}; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::KeyType::BIP13_SCRIPT_HASH; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; -use eframe::egui::{self, Context}; +use eframe::egui::{self}; use egui::{Color32, RichText, ScrollArea}; use std::sync::{Arc, RwLock}; +use zxcvbn::zxcvbn; pub struct KeyInfoScreen { pub identity: QualifiedIdentity, @@ -54,26 +62,160 @@ pub struct KeyInfoScreen { view_private_key_even_if_encrypted_or_in_wallet: bool, show_pop_up_info: Option<String>, remove_private_key_dialog: Option<ConfirmationDialog>, + /// A queued "derive private key for display" request for a wallet-derived + /// key. Drained at the end of `ui()` into a `WalletTask::DeriveKeyForDisplay` + /// backend task — the seed is fetched just-in-time and only the WIF returns. + pending_key_display_request: Option<DerivationPath>, + /// `true` once a display derivation has been dispatched, so the same + /// request is not re-queued every frame while the result is in flight. + key_display_requested: bool, + /// A queued "sign message" request for a wallet-derived key. Drained at the + /// end of `ui()` into a `WalletTask::SignMessageWithKey` backend task — the + /// seed is fetched just-in-time and only the public signature returns. + pending_sign_request: Option<DerivationPath>, + /// A queued "derive for display" request for a vault-backed (`InVault`) + /// identity key. Drained into `WalletTask::DeriveIdentityKeyForDisplay`. + pending_identity_key_display: bool, + /// A queued "sign message" request for a vault-backed identity key. Drained + /// into `WalletTask::SignMessageWithIdentityKey`. + pending_identity_sign: bool, + /// Identity key password protection: cached at-rest protection status of + /// this identity's vault keys. `None` until first probed; invalidated + /// after a migration so the status line re-reads the vault. + protection_status: Option<IdentityProtectionStatus>, + /// Which step of the opt-in / opt-out flow is active. + protection_stage: ProtectionStage, + /// The danger confirmation dialog gating the active flow. + protection_confirm: Option<ConfirmationDialog>, + /// Opt-in password entry (new password + confirmation + hint). + protection_new_password: PasswordInput, + protection_confirm_password: PasswordInput, + protection_hint: String, + /// Opt-out password entry (verify the current password). + protection_verify_password: PasswordInput, + /// Inline validation error for the protection password form. + protection_form_error: Option<String>, + /// True while a Protect/Unprotect task is in flight (disables the + /// action button so the same migration is not dispatched twice). + protection_in_flight: bool, + /// A queued opt-in dispatch (password + hint), drained in `ui()`. + pending_protect: Option<(Secret, Option<String>)>, + /// A queued opt-out dispatch (current password), drained in `ui()`. + pending_unprotect: Option<Secret>, } -// /// The prefix for signed messages using Dash's message signing protocol. -// pub const DASH_SIGNED_MSG_PREFIX: &[u8] = b"\x19Dash Signed Message:\n"; -// -// pub fn signed_msg_hash(msg: &str) -> sha256d::Hash { -// let mut engine = sha256d::Hash::engine(); -// engine.input(DASH_SIGNED_MSG_PREFIX); -// let msg_len = encode::VarInt(msg.len() as u64); -// msg_len.consensus_encode(&mut engine).expect("engines don't error"); -// engine.input(msg.as_bytes()); -// sha256d::Hash::from_engine(engine) -// } +/// At-rest protection posture of an identity's vault-stored keys. +#[derive(Clone, Copy, PartialEq, Eq)] +enum IdentityProtectionStatus { + /// No keys live in the identity vault (e.g. only wallet-derived keys); the + /// per-identity protection control does not apply. + NoVaultKeys, + /// Every vault key is keyless (Tier-1) — signs prompt-free (the default). + Unprotected, + /// Every vault key is password-protected (Tier-2). + Protected, + /// A partial state (some protected, some not) — typically a crash mid + /// migration. The UI offers "Finish protecting". + Mixed, +} + +/// Which step of the Key Protection opt-in / opt-out flow is on screen. +#[derive(Default, Clone, Copy, PartialEq, Eq)] +enum ProtectionStage { + /// Status line + action button only. + #[default] + Idle, + /// The danger warning before opt-in is showing. + ConfirmAdd, + /// The new-password form (opt-in) is showing. + EnterNewPassword, + /// The danger warning before opt-out is showing. + ConfirmRemove, + /// The verify-password form (opt-out) is showing. + EnterVerifyPassword, +} impl ScreenLike for KeyInfoScreen { fn refresh(&mut self) {} - fn ui(&mut self, ctx: &Context) -> AppAction { + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + match backend_task_success_result { + BackendTaskSuccessResult::WalletKeyForDisplay { wif, .. } => { + // The backend derived the key just-in-time; reconstruct the + // RPC private key from the WIF only to render WIF + hex. The + // seed never crossed into the UI. + match RPCPrivateKey::from_wif(wif.expose_secret()) { + Ok(private_key) => self.decrypted_private_key = Some(private_key), + Err(e) => { + self.key_display_requested = false; + let banner = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not display the private key. Please retry.", + MessageType::Error, + ); + banner.with_details(e); + banner.disable_auto_dismiss(); + } + } + } + BackendTaskSuccessResult::WalletMessageSigned { signature, .. } => { + self.signed_message = Some(signature); + } + BackendTaskSuccessResult::IdentityKeyForDisplay { wif, .. } => { + match RPCPrivateKey::from_wif(wif.expose_secret()) { + Ok(private_key) => self.decrypted_private_key = Some(private_key), + Err(e) => { + self.key_display_requested = false; + let banner = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not display the private key. Please retry.", + MessageType::Error, + ); + banner.with_details(e); + banner.disable_auto_dismiss(); + } + } + } + BackendTaskSuccessResult::IdentityMessageSigned { signature, .. } => { + self.signed_message = Some(signature); + } + BackendTaskSuccessResult::IdentityKeysProtected { .. } => { + self.protection_in_flight = false; + self.protection_status = None; // re-probe the vault on next render + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This identity's keys are now password-protected. You will be asked for the password each time they sign.", + MessageType::Success, + ); + } + BackendTaskSuccessResult::IdentityKeysUnprotected { .. } => { + self.protection_in_flight = false; + self.protection_status = None; // re-probe the vault on next render + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Password protection removed. This identity's keys will now sign automatically.", + MessageType::Success, + ); + } + _ => {} + } + } + + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // A migration that failed surfaces as an error banner (set centrally by + // AppState); clear the in-flight gate so the user can retry, and + // re-probe the vault in case a partial change landed. + if self.protection_in_flight && matches!(message_type, MessageType::Error) { + self.protection_in_flight = false; + self.protection_status = None; + } + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -83,16 +225,16 @@ impl ScreenLike for KeyInfoScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let inner_action = AppAction::None; ScrollArea::vertical().show(ui, |ui| { - let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); + let text_primary = DashColors::text_primary(ui.style().visuals.dark_mode); ui.heading(RichText::new("Key Information").color(text_primary)); ui.add_space(10.0); @@ -316,7 +458,7 @@ impl ScreenLike for KeyInfoScreen { .color(ui.visuals().text_color()), ); let wif = Secret::new(private_key.to_wif()); - // INTENTIONAL(CODE-003): WIF displayed as plaintext label — user-initiated key view. + // WIF displayed as plaintext label — user-initiated key view. // Secret wrapper provides zeroize-on-drop for the Rust-side variable. ui.label( RichText::new(wif.expose_secret()) @@ -331,7 +473,7 @@ impl ScreenLike for KeyInfoScreen { .color(ui.visuals().text_color()), ); let private_key_hex = Secret::new(hex::encode(clear)); - // INTENTIONAL(CODE-003): WIF displayed as plaintext label — user-initiated key view. + // WIF displayed as plaintext label — user-initiated key view. // Secret wrapper provides zeroize-on-drop for the Rust-side variable. ui.label( RichText::new(private_key_hex.expose_secret()) @@ -365,84 +507,17 @@ impl ScreenLike for KeyInfoScreen { && self.selected_wallet.is_some() { if let Some(private_key) = self.decrypted_private_key { - egui::Grid::new("private_key_grid_wallet") - .num_columns(2) - .spacing([10.0, 10.0]) - .show(ui, |ui| { - ui.label( - RichText::new("Private Key (WIF):") - .strong() - .color(ui.visuals().text_color()), - ); - let wif = Secret::new(private_key.to_wif()); - ui.label( - RichText::new(wif.expose_secret()) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - - ui.label( - RichText::new("Private Key (Hex):") - .strong() - .color(ui.visuals().text_color()), - ); - let private_key_hex = Secret::new(hex::encode( - private_key.inner.secret_bytes(), - )); - ui.label( - RichText::new(private_key_hex.expose_secret()) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - }); + Self::render_decrypted_key_grid(ui, &private_key); } else { - let wallet = - self.selected_wallet.as_ref().unwrap().read().unwrap(); - match wallet.private_key_at_derivation_path( + Self::queue_key_display( + &mut self.pending_key_display_request, + &mut self.key_display_requested, &derivation_path.derivation_path, - self.app_context.network, - ) { - Ok(private_key) => { - egui::Grid::new("private_key_grid_wallet2") - .num_columns(2) - .spacing([10.0, 10.0]) - .show(ui, |ui| { - ui.label( - RichText::new("Private Key (WIF):") - .strong() - .color(ui.visuals().text_color()), - ); - let wif = Secret::new(private_key.to_wif()); - ui.label( - RichText::new(wif.expose_secret()) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - - ui.label( - RichText::new("Private Key (Hex):") - .strong() - .color(ui.visuals().text_color()), - ); - let private_key_hex = Secret::new(hex::encode( - private_key.inner.secret_bytes(), - )); - ui.label( - RichText::new( - private_key_hex.expose_secret(), - ) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - }); - - self.decrypted_private_key = Some(private_key); - } - Err(e) => { - ui.label(format!("Error: {}", e)); - return; - } - } + ); + ui.label( + RichText::new("Deriving private key…") + .color(ui.visuals().text_color()), + ); } self.render_sign_input(ui); } else if self.wallet_open { @@ -453,54 +528,18 @@ impl ScreenLike for KeyInfoScreen { self.view_private_key_even_if_encrypted_or_in_wallet = true; self.view_wallet_unlock = true; } - if self.decrypted_private_key.is_none() { - let wallet = - self.selected_wallet.as_ref().unwrap().read().unwrap(); - match wallet.private_key_at_derivation_path( + if let Some(private_key) = self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, &private_key); + } else { + Self::queue_key_display( + &mut self.pending_key_display_request, + &mut self.key_display_requested, &derivation_path.derivation_path, - self.app_context.network, - ) { - Ok(private_key) => { - egui::Grid::new("private_key_grid_wallet2") - .num_columns(2) - .spacing([10.0, 10.0]) - .show(ui, |ui| { - ui.label( - RichText::new("Private Key (WIF):") - .strong() - .color(ui.visuals().text_color()), - ); - let wif = Secret::new(private_key.to_wif()); - ui.label( - RichText::new(wif.expose_secret()) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - - ui.label( - RichText::new("Private Key (Hex):") - .strong() - .color(ui.visuals().text_color()), - ); - let private_key_hex = Secret::new(hex::encode( - private_key.inner.secret_bytes(), - )); - ui.label( - RichText::new( - private_key_hex.expose_secret(), - ) - .color(ui.visuals().text_color()), - ); - ui.end_row(); - }); - - self.decrypted_private_key = Some(private_key); - } - Err(e) => { - ui.label(format!("Error: {}", e)); - return; - } - } + ); + ui.label( + RichText::new("Deriving private key…") + .color(ui.visuals().text_color()), + ); } self.render_sign_input(ui); } else { @@ -517,6 +556,28 @@ impl ScreenLike for KeyInfoScreen { } } } + PrivateKeyData::InVault => { + // Vault-backed identity key: the raw bytes are + // fetched just-in-time by a backend task. The UI + // only ever sees the derived WIF for display. + ui.label( + RichText::new( + "This signing key is stored securely on this device.", + ) + .color(text_primary), + ); + ui.add_space(10.0); + if let Some(private_key) = self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, &private_key); + } else if ui.button("View Private Key").clicked() { + self.pending_identity_key_display = true; + self.key_display_requested = true; + } + self.render_sign_input(ui); + ui.add_space(10.0); + ui.separator(); + self.render_key_protection_section(ui); + } } } else { ui.label(RichText::new("Enter Private Key:").color(text_primary)); @@ -532,8 +593,9 @@ impl ScreenLike for KeyInfoScreen { && let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -579,7 +641,7 @@ impl ScreenLike for KeyInfoScreen { if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Sign Message Info", &show_pop_up_info_text); if popup.show(ui).inner { self.show_pop_up_info = None; @@ -587,6 +649,85 @@ impl ScreenLike for KeyInfoScreen { }); } + // Drain queued wallet-key requests into backend tasks that fetch the + // seed just-in-time and derive/sign off the UI thread. Only the public + // result (WIF for display, signature) returns to the UI. + if let Some(seed_hash) = self.wallet_seed_hash() { + if let Some(derivation_path) = self.pending_key_display_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::DeriveKeyForDisplay { + seed_hash, + derivation_path, + }, + )); + } + if let Some(derivation_path) = self.pending_sign_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::SignMessageWithKey { + seed_hash, + derivation_path, + message: self.message_input.clone(), + key_type: self.key.key_type(), + }, + )); + } + } + + // Vault-backed (InVault) identity-key requests: the raw key is fetched + // JIT in the backend and only the public WIF / signature returns. + let identity_id = self.identity.identity.id(); + let target: PrivateKeyTarget = self.key.purpose().into(); + let key_id = self.key.id(); + if std::mem::take(&mut self.pending_identity_key_display) { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::DeriveIdentityKeyForDisplay { + identity_id, + target: target.clone(), + key_id, + }, + )); + } + if std::mem::take(&mut self.pending_identity_sign) { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::SignMessageWithIdentityKey { + identity_id, + target, + key_id, + message: self.message_input.clone(), + key_type: self.key.key_type(), + }, + )); + } + + // Drain a queued identity-key protection opt-in / opt-out. + if let Some((password, hint)) = self.pending_protect.take() { + MessageBanner::set_global( + ctx, + "Protecting this identity's keys. Please wait.", + MessageType::Info, + ); + action |= AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::ProtectIdentityKeys { + identity_id, + password, + hint, + }, + )); + } + if let Some(password) = self.pending_unprotect.take() { + MessageBanner::set_global( + ctx, + "Removing password protection. Please wait.", + MessageType::Info, + ); + action |= AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::UnprotectIdentityKeys { + identity_id, + password, + }, + )); + } + action } } @@ -600,7 +741,7 @@ impl KeyInfoScreen { ) -> Self { let selected_wallet = if let Some((_, Some(wallet_derivation_path))) = private_key_data.as_ref() { - let wallets = app_context.wallets.read().unwrap(); + let wallets = app_context.wallets.read_recover(); wallets .get(&wallet_derivation_path.wallet_seed_hash) .cloned() @@ -626,6 +767,22 @@ impl KeyInfoScreen { view_private_key_even_if_encrypted_or_in_wallet: false, show_pop_up_info: None, remove_private_key_dialog: None, + pending_key_display_request: None, + key_display_requested: false, + pending_sign_request: None, + pending_identity_key_display: false, + pending_identity_sign: false, + protection_status: None, + protection_stage: ProtectionStage::Idle, + protection_confirm: None, + protection_new_password: PasswordInput::new().with_hint_text("New password"), + protection_confirm_password: PasswordInput::new().with_hint_text("Confirm password"), + protection_hint: String::new(), + protection_verify_password: PasswordInput::new().with_hint_text("Current password"), + protection_form_error: None, + protection_in_flight: false, + pending_protect: None, + pending_unprotect: None, } } @@ -633,7 +790,10 @@ impl KeyInfoScreen { // Convert the input string to bytes (hex decoding) let private_key_bytes = match hex::decode(self.private_key_input.text()) { Ok(private_key_bytes_vec) if private_key_bytes_vec.len() == 32 => { - private_key_bytes_vec.try_into().unwrap() + let bytes: [u8; 32] = private_key_bytes_vec + .try_into() + .expect("invariant: length checked to be 32 in the match guard"); + bytes } Ok(_) => { MessageBanner::set_global( @@ -665,7 +825,7 @@ impl KeyInfoScreen { format!("Issue verifying private key {}", err), MessageType::Error, ); - } else if validation_result.unwrap() { + } else if validation_result.expect("invariant: Err handled in the preceding branch") { // If valid, store the private key in the context and reset the input field self.private_key_data = Some((PrivateKeyData::Clear(private_key_bytes), None)); self.identity.private_keys.insert_non_encrypted( @@ -680,7 +840,8 @@ impl KeyInfoScreen { self.app_context.egui_ctx(), format!("Issue saving: {}", e), MessageType::Error, - ); + ) + .disable_auto_dismiss(); } } else { MessageBanner::set_global( @@ -692,7 +853,7 @@ impl KeyInfoScreen { } fn render_sign_input(&mut self, ui: &mut egui::Ui) { - let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); + let text_primary = DashColors::text_primary(ui.style().visuals.dark_mode); ui.add_space(10.0); ui.separator(); ui.add_space(10.0); @@ -742,60 +903,122 @@ impl KeyInfoScreen { } fn sign_message(&mut self) { - // Check that we have a private key - if let Some((private_key_data, _)) = &self.private_key_data { - let private_key_bytes = match (private_key_data, self.decrypted_private_key.as_ref()) { - (PrivateKeyData::Clear(bytes), _) | (PrivateKeyData::AlwaysClear(bytes), _) => { - *bytes - } - (_, Some(private_key)) => private_key.inner.secret_bytes(), - // Other cases may not have the private key directly - _ => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Private key is not available.", - MessageType::Error, - ); - return; - } - }; - - // Use the key type to determine how to sign - match self.key.key_type() { - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { - // Sign the message using ECDSA - let secp = Secp256k1::new(); + let Some((private_key_data, _)) = &self.private_key_data else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); + return; + }; - let message_hash = signed_msg_hash(self.message_input.as_str()); - let message = Message::from_digest(*message_hash.as_byte_array()); + if !matches!( + self.key.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unsupported key type for signing.", + MessageType::Error, + ); + return; + } - let secret_key = SecretKey::from_byte_array(&private_key_bytes).unwrap(); + match private_key_data { + // Keys that carry their own plaintext sign locally — no wallet seed + // is involved, so there is nothing to fetch through the chokepoint. + PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => { + self.signed_message = Some(Self::sign_ecdsa_local(bytes, &self.message_input)); + } + // Wallet-derived keys sign in the backend: the seed is fetched + // just-in-time through the JIT chokepoint and only the public + // signature returns. Queue the request; `ui()` dispatches it. + PrivateKeyData::AtWalletDerivationPath(wdp) => { + self.pending_sign_request = Some(wdp.derivation_path.clone()); + } + PrivateKeyData::Encrypted(_) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); + } + // Vault-backed identity key: signs in the backend via the JIT + // chokepoint (InVault route). Queue the request; `ui()` dispatches it. + PrivateKeyData::InVault => { + self.pending_identity_sign = true; + } + } + } - let signature = secp.sign_ecdsa(&message, &secret_key); + /// Sign `message` with a locally-held ECDSA secret, returning the + /// Base64-encoded Dash signed-message envelope. Used only for keys that + /// already carry their plaintext in the UI — never for wallet-derived keys. + /// + /// The envelope is a recoverable signature: a header byte (`27 + recId`, + /// `+4` for a compressed key) followed by the 64-byte signature. These keys + /// are compressed by convention, so a verifier can recover the signer's + /// public key and address from the signature alone. + fn sign_ecdsa_local(private_key_bytes: &[u8; 32], message: &str) -> String { + let secp = Secp256k1::new(); + let message_hash = signed_msg_hash(message); + let digest = Message::from_digest(*message_hash.as_byte_array()); + let secret_key = SecretKey::from_byte_array(private_key_bytes) + .expect("clear private key is a valid 32-byte secret"); + let recoverable = secp.sign_ecdsa_recoverable(&digest, &secret_key); + MessageSignature::new(recoverable, true).to_base64() + } - // Serialize the signature - let mut serialized_signature = signature.serialize_compact().to_vec(); - serialized_signature.insert(0, 32); + /// Render the WIF + hex of an already-derived private key. The key is + /// derived in the backend via `WalletTask::DeriveKeyForDisplay` and the + /// reconstructed [`RPCPrivateKey`] passed here only for rendering. + fn render_decrypted_key_grid(ui: &mut egui::Ui, private_key: &RPCPrivateKey) { + egui::Grid::new("private_key_grid_wallet") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); + let wif = Secret::new(private_key.to_wif()); + ui.label(RichText::new(wif.expose_secret()).color(ui.visuals().text_color())); + ui.end_row(); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = Secret::new(hex::encode(private_key.inner.secret_bytes())); + ui.label( + RichText::new(private_key_hex.expose_secret()).color(ui.visuals().text_color()), + ); + ui.end_row(); + }); + } - // Encode to Base64 - let signature_base64 = STANDARD.encode(serialized_signature); + /// Queue a one-shot "derive private key for display" request the first time + /// a wallet-derived key needs to be shown. Idempotent within a view session + /// via `requested`, so the backend task is dispatched once, not every frame. + fn queue_key_display( + pending: &mut Option<DerivationPath>, + requested: &mut bool, + path: &DerivationPath, + ) { + if !*requested { + *pending = Some(path.clone()); + *requested = true; + } + } - self.signed_message = Some(signature_base64); - } - _ => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Unsupported key type for signing.", - MessageType::Error, - ); - } - } - } else { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Private key is not available.", - MessageType::Error, - ); + /// The HD wallet this key derives from, or `None` for keys that carry their + /// own plaintext. Used to scope the JIT chokepoint for display/sign tasks. + fn wallet_seed_hash(&self) -> Option<crate::model::wallet::WalletSeedHash> { + match self.private_key_data.as_ref()? { + (PrivateKeyData::AtWalletDerivationPath(wdp), _) => Some(wdp.wallet_seed_hash), + _ => None, } } @@ -818,10 +1041,329 @@ impl KeyInfoScreen { ui.ctx(), format!("Issue saving: {}", e), MessageType::Error, - ); + ) + .disable_auto_dismiss(); + } + } + } + } + } + + // --- Identity key password protection (per-identity at-rest key encryption) --- + + /// At-rest protection posture of this identity's vault keys, by probing the + /// vault scheme of each key. Cheap (a handful of local vault reads). Cached + /// in `protection_status`; invalidated after a migration. + fn compute_protection_status(&self) -> IdentityProtectionStatus { + let Ok(backend) = self.app_context.wallet_backend() else { + return IdentityProtectionStatus::NoVaultKeys; + }; + let id = self.identity.identity.id().to_buffer(); + let view = IdentityKeyView::new(backend.secret_store(), id); + let (mut protected, mut unprotected) = (0usize, 0usize); + for (target, key_id) in self.identity.private_keys.keys_set() { + match view.scheme(&target, key_id) { + Ok(SecretScheme::Protected) => protected += 1, + Ok(SecretScheme::Unprotected) => unprotected += 1, + // Absent (wallet-derived / resident-plaintext) or a transient + // vault error: not a protectable vault key — ignore it. + _ => {} + } + } + match (protected, unprotected) { + (0, 0) => IdentityProtectionStatus::NoVaultKeys, + (_, 0) => IdentityProtectionStatus::Protected, + (0, _) => IdentityProtectionStatus::Unprotected, + _ => IdentityProtectionStatus::Mixed, + } + } + + /// Render the collapsible "Key Protection" section (default closed). Hidden + /// entirely when the identity has no vault-stored keys. + fn render_key_protection_section(&mut self, ui: &mut egui::Ui) { + if self.protection_status.is_none() { + let status = self.compute_protection_status(); + self.protection_status = Some(status); + } + let status = self + .protection_status + .unwrap_or(IdentityProtectionStatus::NoVaultKeys); + if status == IdentityProtectionStatus::NoVaultKeys { + return; + } + let dark_mode = ui.style().visuals.dark_mode; + + egui::CollapsingHeader::new("Key Protection") + .default_open(false) + .show(ui, |ui| { + let status_text = match status { + IdentityProtectionStatus::Unprotected => { + "This identity's keys sign automatically. No password is required." + } + IdentityProtectionStatus::Protected => { + "This identity's keys require a password each time they sign." } + IdentityProtectionStatus::Mixed => { + "Password protection for this identity's keys is incomplete. Finish protecting them with the same password you set." + } + IdentityProtectionStatus::NoVaultKeys => "", + }; + ui.label( + RichText::new(status_text).color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + match self.protection_stage { + ProtectionStage::Idle => self.render_protection_idle(ui, status), + ProtectionStage::EnterNewPassword => self.render_new_password_form(ui), + ProtectionStage::EnterVerifyPassword => self.render_verify_password_form(ui), + // The confirm dialogs draw as modals (below), not inline. + ProtectionStage::ConfirmAdd | ProtectionStage::ConfirmRemove => {} } + }); + + // The danger confirmation dialog (opt-in / opt-out) draws as a modal. + self.handle_protection_confirm(ui); + } + + /// The idle status row: the action button whose meaning depends on the + /// current protection posture. + fn render_protection_idle(&mut self, ui: &mut egui::Ui, status: IdentityProtectionStatus) { + let (label, is_add) = match status { + IdentityProtectionStatus::Protected => ("Remove password protection…", false), + IdentityProtectionStatus::Mixed => ("Finish protecting…", true), + _ => ("Add password protection…", true), + }; + let resp = ui.add_enabled(!self.protection_in_flight, egui::Button::new(label)); + if resp.clicked() { + if is_add { + self.open_add_confirm(); + } else { + self.open_remove_confirm(); } } + if self.protection_in_flight { + ui.add_space(4.0); + ui.label( + RichText::new("Working…") + .color(DashColors::text_secondary(ui.style().visuals.dark_mode)), + ); + } + } + + /// Open the danger warning before opt-in. + fn open_add_confirm(&mut self) { + self.protection_form_error = None; + self.protection_new_password.clear(); + self.protection_confirm_password.clear(); + self.protection_hint.clear(); + self.protection_stage = ProtectionStage::ConfirmAdd; + self.protection_confirm = Some( + ConfirmationDialog::new( + "Protect this identity's keys with a password?", + "Adding a password means this identity's keys will ask for the password each time they are used to sign. Keep this in mind:\n\n\ + • If you forget the password, these keys cannot be recovered. There is no reset option.\n\n\ + • Automatic tools (such as scripts or the command-line interface) will no longer be able to sign with this identity without the password.\n\n\ + Are you sure you want to continue?", + ) + .danger_mode(true) + .confirm_text(Some("Yes, add protection")) + .cancel_text(Some("Cancel")) + .open(true), + ); + } + + /// Open the danger warning before opt-out. + fn open_remove_confirm(&mut self) { + self.protection_form_error = None; + self.protection_verify_password.clear(); + self.protection_stage = ProtectionStage::ConfirmRemove; + self.protection_confirm = Some( + ConfirmationDialog::new( + "Remove password protection?", + "Removing the password means this identity's keys will sign automatically without any password. Anyone with access to this device could use them to sign on behalf of this identity.\n\n\ + You will need to enter the current password to confirm this change.", + ) + .danger_mode(true) + .confirm_text(Some("Yes, remove protection")) + .cancel_text(Some("Cancel")) + .open(true), + ); } + + /// Drive the danger confirmation dialog; on confirm, advance to the + /// matching password form; on cancel, return to idle. + fn handle_protection_confirm(&mut self, ui: &mut egui::Ui) { + let Some(dialog) = self.protection_confirm.as_mut() else { + return; + }; + let response = dialog.show(ui); + if let Some(result) = response.inner.dialog_response { + self.protection_confirm = None; + match (self.protection_stage, result) { + (ProtectionStage::ConfirmAdd, ConfirmationStatus::Confirmed) => { + self.protection_stage = ProtectionStage::EnterNewPassword; + } + (ProtectionStage::ConfirmRemove, ConfirmationStatus::Confirmed) => { + self.protection_stage = ProtectionStage::EnterVerifyPassword; + } + _ => self.protection_stage = ProtectionStage::Idle, + } + } + } + + /// The opt-in password form: new password + confirmation + strength + hint. + fn render_new_password_form(&mut self, ui: &mut egui::Ui) { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(format!( + "This password protects the signing keys for {}.", + self.identity + )) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + + ui.label("New password:"); + self.protection_new_password.show(ui); + ui.add_space(4.0); + let pw = self.protection_new_password.text().to_string(); + render_password_strength(ui, &pw); + + ui.add_space(8.0); + ui.label("Confirm password:"); + self.protection_confirm_password.show(ui); + + ui.add_space(8.0); + ui.label( + "Password hint (optional — visible in plain text. Do not use the password itself as a hint.):", + ); + ui.add(egui::TextEdit::singleline(&mut self.protection_hint).hint_text("Password hint")); + + if let Some(err) = &self.protection_form_error { + ui.add_space(6.0); + ui.colored_label(DashColors::ERROR, err); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Protect keys").clicked() { + self.submit_new_password(); + } + if ui.button("Cancel").clicked() { + self.cancel_protection_flow(); + } + }); + } + + /// The opt-out password form: verify the current password. + fn render_verify_password_form(&mut self, ui: &mut egui::Ui) { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(format!( + "Enter the current password for the signing keys for {}.", + self.identity + )) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + self.protection_verify_password.show(ui); + + if let Some(err) = &self.protection_form_error { + ui.add_space(6.0); + ui.colored_label(DashColors::ERROR, err); + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Verify and remove").clicked() { + self.submit_verify_password(); + } + if ui.button("Cancel").clicked() { + self.cancel_protection_flow(); + } + }); + } + + /// Validate the opt-in form and queue the `ProtectIdentityKeys` dispatch. + fn submit_new_password(&mut self) { + let pw = self.protection_new_password.text().to_string(); + let confirm = self.protection_confirm_password.text().to_string(); + if let Err(e) = validate_single_key_passphrase(&pw, &confirm) { + self.protection_form_error = Some(e.to_string()); + return; + } + let hint = { + let h = self.protection_hint.trim(); + if h.is_empty() { + None + } else { + Some(h.to_string()) + } + }; + self.pending_protect = Some((Secret::new(pw), hint)); + self.finish_protection_flow(); + } + + /// Queue the `UnprotectIdentityKeys` dispatch (the backend verifies the + /// password — a wrong one returns a typed error, no client-side oracle). + fn submit_verify_password(&mut self) { + let pw = self.protection_verify_password.text().to_string(); + if pw.is_empty() { + self.protection_form_error = + Some("Enter the current password to remove protection.".to_string()); + return; + } + self.pending_unprotect = Some(Secret::new(pw)); + self.finish_protection_flow(); + } + + /// Mark a migration as dispatched: clear the forms, flip to in-flight, and + /// return to the idle status row. + fn finish_protection_flow(&mut self) { + self.protection_in_flight = true; + self.protection_form_error = None; + self.protection_new_password.clear(); + self.protection_confirm_password.clear(); + self.protection_verify_password.clear(); + self.protection_hint.clear(); + self.protection_stage = ProtectionStage::Idle; + } + + /// Abandon the active flow with no change. + fn cancel_protection_flow(&mut self) { + self.protection_form_error = None; + self.protection_new_password.clear(); + self.protection_confirm_password.clear(); + self.protection_verify_password.clear(); + self.protection_hint.clear(); + self.protection_stage = ProtectionStage::Idle; + } +} + +/// Render a zxcvbn-backed password-strength bar (0–4 score). Mirrors the +/// wallet-creation strength UI so the two surfaces feel identical. +fn render_password_strength(ui: &mut egui::Ui, password: &str) { + let score = if password.is_empty() { + 0u8 + } else { + u8::from(zxcvbn(password, &[]).score()) + }; + let fraction = f32::from(score) / 4.0; + let (fill, label) = match score { + 0 => (DashColors::STRENGTH_WEAK, "None"), + 1 => (DashColors::STRENGTH_WEAK, "Very weak"), + 2 => (DashColors::STRENGTH_FAIR, "Weak"), + 3 => (DashColors::STRENGTH_GOOD, "Strong"), + _ => (DashColors::STRENGTH_STRONG, "Very strong"), + }; + ui.horizontal(|ui| { + ui.label("Password strength:"); + ui.add( + egui::ProgressBar::new(fraction) + .desired_width(180.0) + .text(label) + .fill(fill), + ); + }); } diff --git a/src/ui/identities/keys/keys_screen.rs b/src/ui/identities/keys/keys_screen.rs index ce3034797..3dd421d4b 100644 --- a/src/ui/identities/keys/keys_screen.rs +++ b/src/ui/identities/keys/keys_screen.rs @@ -4,7 +4,7 @@ use crate::ui::ScreenLike; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use eframe::egui::{self, Context}; +use eframe::egui::{self}; use std::sync::Arc; pub struct KeysScreen { @@ -15,8 +15,8 @@ pub struct KeysScreen { impl ScreenLike for KeysScreen { fn refresh(&mut self) {} - fn ui(&mut self, ctx: &Context) -> AppAction { - egui::CentralPanel::default().show(ctx, |ui| { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("Identity Keys"); egui::ScrollArea::vertical().show(ui, |ui| { diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 08d57d147..ca46f8181 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -12,7 +12,9 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; +use crate::ui::components::{ + MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, ResultBannerExt, +}; use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; use crate::ui::theme::{DashColors, ResponseExt}; use crate::ui::{MessageType, ScreenLike}; @@ -61,14 +63,28 @@ pub struct RegisterDpnsNameScreen { completed_fee_result: Option<FeeResult>, // Source of navigation to this screen pub source: RegisterDpnsNameSource, - refresh_banner: Option<BannerHandle>, + /// Bucket A overlay-adoption pattern: a button-less full-window block raised + /// when the bounded registration is dispatched and torn down on every + /// terminal result. It replaces the old progress banner and, by blocking the + /// whole window, closes the double-submit hole the banner left open. + op_overlay: Option<OverlayHandle>, } impl RegisterDpnsNameScreen { pub fn new(app_context: &Arc<AppContext>, source: RegisterDpnsNameSource) -> Self { let qualified_identities: Vec<_> = app_context.load_local_user_identities().unwrap_or_default(); - let selected_qualified_identity = qualified_identities.first().cloned(); + + // Seed from the app-scoped selected identity (W2 SYNC); fall back to first. + let selected_qualified_identity = app_context + .selected_identity_id() + .and_then(|id| { + qualified_identities + .iter() + .find(|qi| qi.identity.id() == id) + .cloned() + }) + .or_else(|| qualified_identities.first().cloned()); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { get_selected_wallet(identity, Some(app_context), None) @@ -124,7 +140,7 @@ impl RegisterDpnsNameScreen { show_advanced_options: false, completed_fee_result: None, source, - refresh_banner: None, + op_overlay: None, } } @@ -180,7 +196,7 @@ impl RegisterDpnsNameScreen { fn render_identity_id_selection(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; - // Identity selector + // Identity selector — SYNC: write-back via syncing_global on user pick. let response = ui.add( IdentitySelector::new( "dpns_register_identity_selector", @@ -191,7 +207,8 @@ impl RegisterDpnsNameScreen { .unwrap() .width(300.0) .label("Identity:") - .other_option(false), + .other_option(false) + .syncing_global(self.app_context.clone()), ); // Handle identity change - auto-select key and update wallet @@ -270,6 +287,37 @@ impl RegisterDpnsNameScreen { ))) } + /// Dispatch the registration and raise the Bucket A blocking overlay. + /// + /// The overlay is raised only when a real task is produced (an identity and a + /// signing key are selected), so a no-op click never strands a block. The + /// full-window block is the in-progress feedback that replaces the old banner + /// and prevents a second submit while the first is in flight. + fn begin_registration(&mut self, ctx: &Context) -> AppAction { + let action = self.register_dpns_name_clicked(); + if matches!(action, AppAction::BackendTask(_)) { + self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; + self.raise_progress_overlay(ctx); + } + action + } + + fn raise_progress_overlay(&mut self, ctx: &Context) { + self.op_overlay.raise( + ctx, + "Registering your username on the network.", + OverlayConfig::default(), + ); + } + + /// Test seam: run the exact production overlay-raise the Register button uses, + /// so the Bucket A adoption (raise + guaranteed teardown) is exercisable in + /// kittests without funding an identity. Mirrors `force_input_for_test`. + #[doc(hidden)] + pub fn raise_progress_overlay_for_test(&mut self, ctx: &Context) { + self.raise_progress_overlay(ctx); + } + pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { let action = crate::ui::helpers::show_success_screen_with_info( ui, @@ -301,23 +349,28 @@ impl RegisterDpnsNameScreen { impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. + // Tear down the blocking overlay on the error terminal path so a + // failed registration can never hard-lock the window. if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); + self.op_overlay.take_and_clear(); self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // Tear down the blocking overlay on the success terminal path. if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); + self.op_overlay.take_and_clear(); self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // Build breadcrumbs based on where we came from let breadcrumbs = match self.source { RegisterDpnsNameSource::Dpns => vec![ @@ -338,18 +391,18 @@ impl ScreenLike for RegisterDpnsNameScreen { ], }; - let mut action = add_top_panel(ctx, &self.app_context, breadcrumbs, vec![]); + let mut action = add_top_panel(ui, &self.app_context, breadcrumbs, vec![]); // Use the appropriate left panel highlight based on source let root_screen = match self.source { RegisterDpnsNameSource::Dpns => crate::ui::RootScreenType::RootScreenDPNSActiveContests, RegisterDpnsNameSource::Identities => crate::ui::RootScreenType::RootScreenIdentities, }; - action |= add_left_panel(ctx, &self.app_context, root_screen); + action |= add_left_panel(ui, &self.app_context, root_screen); // Don't show the tools/dpns subscreen chooser panels for this screen - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; egui::ScrollArea::vertical() @@ -410,8 +463,9 @@ impl ScreenLike for RegisterDpnsNameScreen { if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -485,7 +539,7 @@ impl ScreenLike for RegisterDpnsNameScreen { // Fee estimation let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_create(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) @@ -551,11 +605,7 @@ impl ScreenLike for RegisterDpnsNameScreen { .disabled_tooltip(&hover_text) .clicked() { - self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; - let handle = MessageBanner::set_global(ui.ctx(), "Registering DPNS name...", MessageType::Info); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - inner_action = self.register_dpns_name_clicked(); + inner_action = self.begin_registration(ui.ctx()); } ui.add_space(10.0); diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index 59d2f21fb..1b8631c28 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -26,7 +26,7 @@ impl TopUpIdentityScreen { step_number: u32, ) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.heading(format!( "{}. Select a Platform address to use for top-up.", @@ -76,7 +76,7 @@ impl TopUpIdentityScreen { let addr_display = platform_addr.to_bech32m_string(network); let response = ui.selectable_label( is_selected, - format!("{} - {}", addr_display, Self::format_credits(*balance)), + format!("{} - {}", addr_display, format_credits_as_dash(*balance)), ); if response.clicked() { @@ -134,9 +134,12 @@ impl TopUpIdentityScreen { if let Some((_, _, balance)) = &self.selected_platform_address { ui.add_space(10.0); ui.label( - RichText::new(format!("Available: {}", Self::format_credits(*balance))) - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), + RichText::new(format!( + "Available: {}", + format_credits_as_dash(*balance) + )) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), ); } }); @@ -175,7 +178,7 @@ impl TopUpIdentityScreen { let can_top_up = self.selected_platform_address.is_some() && has_valid_amount && self.wallet.is_some(); - let step = { *self.step.read().unwrap() }; + let step = self.current_step(); ui.horizontal(|ui| { let button_text = match step { @@ -234,12 +237,6 @@ impl TopUpIdentityScreen { .collect() } - /// Format credits as DASH equivalent - fn format_credits(credits: Credits) -> String { - let dash_equivalent = credits as f64 / 1000.0 / 100_000_000.0; - format!("{:.8} DASH", dash_equivalent) - } - /// Validate and create the top-up task fn validate_and_top_up_from_platform(&mut self) -> Result<AppAction, String> { let (_, platform_addr, available_balance) = self @@ -260,8 +257,8 @@ impl TopUpIdentityScreen { if amount > available_balance { return Err(format!( "Insufficient balance. Available: {}, Requested: {}", - Self::format_credits(available_balance), - Self::format_credits(amount) + format_credits_as_dash(available_balance), + format_credits_as_dash(amount) )); } @@ -279,11 +276,7 @@ impl TopUpIdentityScreen { let mut inputs: BTreeMap<PlatformAddress, Credits> = BTreeMap::new(); inputs.insert(platform_addr, amount); - // Update step - { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; - } + self.set_step(WalletFundedScreenStep::WaitingForPlatformAcceptance); Ok(AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::TopUpIdentityFromPlatformAddresses { diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index dfca0e870..2a8400c4b 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -2,77 +2,59 @@ use crate::app::AppAction; use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::MessageType; use crate::ui::components::message_banner::MessageBanner; -use crate::ui::identities::add_new_identity_screen::FundingMethod; +use crate::ui::identities::funding_common::{ + FundingAssetLockPicker, FundingMethod, actionable_asset_locks, asset_lock_address, + asset_lock_status_label, +}; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use crate::ui::theme::DashColors; use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { fn render_choose_funding_asset_lock(&mut self, ui: &mut egui::Ui) { - // Ensure a wallet is selected - let Some(selected_wallet) = self.wallet.clone() else { - ui.label("No wallet selected."); - return; - }; + let tracked = + match actionable_asset_locks(ui, &mut self.asset_lock_cache, self.wallet.as_ref()) { + FundingAssetLockPicker::Handled => return, + FundingAssetLockPicker::Available(tracked) => tracked, + }; - // Read the wallet to access unused asset locks - let wallet = selected_wallet.read().unwrap(); + ui.heading("Select the unfinished funding to use:"); - if wallet.unused_asset_locks.is_empty() { - ui.label("No unused asset locks available."); - return; - } - - ui.heading("Select an unused asset lock:"); - - // Track the index of the currently selected asset lock (if any) - let selected_index = self.funding_asset_lock.as_ref().and_then(|(_, proof, _)| { - wallet - .unused_asset_locks - .iter() - .position(|(_, _, _, _, p)| p.as_ref() == Some(proof)) - }); - - // Display the asset locks in a scrollable area egui::ScrollArea::vertical().show(ui, |ui| { - for (index, (tx, address, amount, islock, proof)) in - wallet.unused_asset_locks.iter().enumerate() - { + for lock in &tracked { ui.horizontal(|ui| { - let tx_id = tx.txid().to_string(); - let lock_amount = *amount as f64 * 1e-8; // Convert to DASH - let is_locked = if islock.is_some() { "Yes" } else { "No" }; - - // Display asset lock information with "Selected" if this one is selected - let selected_text = if Some(index) == selected_index { + let selected_text = if self.funding_asset_lock == Some(lock.out_point) { " (Selected)" } else { "" }; - + let address_text = match asset_lock_address(lock, self.app_context.network) { + Some(address) => format!(", Address: {address}"), + None => String::new(), + }; ui.label(format!( - "TxID: {}, Address: {}, Amount: {:.8} DASH, InstantLock: {}{}", - tx_id, address, lock_amount, is_locked, selected_text + "TxID: {}, Vout: {}{}, Amount: {:.8} DASH, Status: {}{}", + lock.out_point.txid, + lock.out_point.vout, + address_text, + lock.amount as f64 * 1e-8, + asset_lock_status_label(&lock.status), + selected_text, )); - - if let Some(asset_lock_proof) = proof { + if lock.proof.is_some() { if ui.button("Select").clicked() { - self.funding_asset_lock = - Some((tx.clone(), asset_lock_proof.clone(), address.clone())); - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.funding_asset_lock = Some(lock.out_point); + self.set_step(WalletFundedScreenStep::ReadyToCreate); } } else if ui.button("Select").clicked() { MessageBanner::set_global( ui.ctx(), - "Asset lock proof is not yet available — the transaction may not be chain-locked yet. Please try again later.", + "This funding isn't ready to use yet. Wait for it to be confirmed on the Dash network, then try again.", MessageType::Warning, ); } }); - - ui.add_space(5.0); // Add space between each entry + ui.add_space(5.0); } }); } @@ -83,26 +65,19 @@ impl TopUpIdentityScreen { step_number: u32, ) -> AppAction { let mut action = AppAction::None; - - // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); + let step = self.current_step(); ui.heading( - format!( - "{}. Choose the unused asset lock that you would like to use.", - step_number - ) - .as_str(), + format!("{step_number}. Choose the unfinished funding you'd like to use.").as_str(), ); ui.add_space(10.0); self.render_choose_funding_asset_lock(ui); ui.add_space(10.0); - // Fee estimation display let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) @@ -124,7 +99,6 @@ impl TopUpIdentityScreen { ui.add_space(10.0); - // Top up button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 5f1227baa..8bb70208c 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -1,6 +1,7 @@ use crate::app::AppAction; use crate::model::fee_estimation::format_credits_as_dash; -use crate::ui::identities::add_new_identity_screen::FundingMethod; +use crate::ui::RootScreenType; +use crate::ui::identities::funding_common::{FundingMethod, spendable_covers_minimum}; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use crate::ui::theme::DashColors; use egui::{Color32, Frame, Margin, RichText, Ui}; @@ -8,11 +9,20 @@ use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { fn show_wallet_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.wallet { - let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet + let wallet = match selected_wallet.read() { + Ok(w) => w, + Err(_) => { + ui.label("Wallet is busy. Try again in a moment."); + return; + } + }; - let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback + let spendable_balance: u64 = self + .app_context + .snapshot_balance(&wallet.seed_hash()) + .spendable(); - let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units + let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units ui.horizontal(|ui| { ui.label(format!("Wallet Balance: {:.8} DASH", dash_balance)); @@ -22,6 +32,51 @@ impl TopUpIdentityScreen { } } + /// If the selected wallet can't cover even the estimated top-up fee, + /// render an equivalent of the Create-Identity "not enough Dash" banner + /// with a link to the Wallets screen, and report that the caller should + /// stop rendering this step. Returns `None` when the balance is + /// sufficient, or when no wallet is selected (handled earlier by the + /// caller's own no-wallet gate). + fn render_insufficient_wallet_balance_banner(&self, ui: &mut egui::Ui) -> Option<AppAction> { + let selected_wallet = self.wallet.as_ref()?; + let spendable_duffs = match selected_wallet.read() { + Ok(w) => self + .app_context + .snapshot_balance(&w.seed_hash()) + .spendable(), + Err(_) => { + ui.label("Wallet is busy. Try again in a moment."); + return Some(AppAction::None); + } + }; + + let minimum_credits = self.app_context.fee_estimator().estimate_identity_topup(); + + if spendable_covers_minimum(spendable_duffs, minimum_credits) { + return None; + } + + ui.add_space(8.0); + ui.colored_label( + DashColors::WARNING, + format!( + "Your wallet does not have enough Dash to top up this identity yet. \ + Add at least {amount} to continue.", + amount = format_credits_as_dash(minimum_credits) + ), + ); + ui.add_space(8.0); + let mut action = AppAction::None; + if ui.button("Go to Wallets").clicked() { + action = AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenWalletsBalances, + ); + } + ui.add_space(10.0); + Some(action) + } + pub fn render_ui_by_using_unused_balance( &mut self, ui: &mut Ui, @@ -38,20 +93,27 @@ impl TopUpIdentityScreen { self.show_wallet_balance(ui); ui.add_space(5.0); + if let Some(insufficient_action) = self.render_insufficient_wallet_balance_banner(ui) { + return insufficient_action; + } + self.top_up_funding_amount_input(ui); // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); + let step = self.current_step(); - let Ok(_) = self.funding_amount.parse::<f64>() else { + // Only show the fee estimate and Top Up button once a positive amount + // is entered — otherwise clicking Top Up would silently no-op. + let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); + if !has_valid_amount { return action; - }; + } // Fee estimation display let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs deleted file mode 100644 index 3e4264a2a..000000000 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ /dev/null @@ -1,190 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::error::TaskError; -use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; -use crate::ui::MessageType; -use crate::ui::components::MessageBanner; -use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; -use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; -use eframe::epaint::TextureHandle; -use egui::Ui; -use std::sync::Arc; - -impl TopUpIdentityScreen { - fn render_qr_code(&mut self, ui: &mut egui::Ui, amount: f64) -> Result<(), TaskError> { - let address = { - if let Some(wallet_guard) = self.wallet.as_ref() { - // Get the receive address from the selected wallet - if self.funding_address.is_none() { - let mut wallet = wallet_guard.write().unwrap(); - let receive_address = wallet - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; - let core_wallet_name = wallet.core_wallet_name.clone(); - drop(wallet); - - self.app_context.ensure_address_imported( - &receive_address, - core_wallet_name.as_deref(), - Some("Managed by Dash Evo Tool"), - )?; - - self.funding_address = Some(receive_address.clone()); - receive_address - } else { - self.funding_address.as_ref().unwrap().clone() - } - } else { - return Err(TaskError::WalletNotFound); - } - }; - - let pay_uri = format!("{}?amount={:.4}", address.to_qr_uri(), amount); - - // Generate the QR code image - if let Ok(qr_image) = generate_qr_code_image(&pay_uri) { - let texture: TextureHandle = - ui.ctx() - .load_texture("qr_code", qr_image, egui::TextureOptions::LINEAR); - ui.image(&texture); - } else { - ui.label("Failed to generate QR code."); - } - - ui.add_space(15.0); - - ui.label(&pay_uri); - ui.add_space(5.0); - - if ui.button("Copy Address").clicked() { - if let Err(e) = copy_to_clipboard(pay_uri.as_str()) { - self.copied_to_clipboard = Some(Some(e)); - } else { - self.copied_to_clipboard = Some(None); - } - } - - if let Some(error) = self.copied_to_clipboard.as_ref() { - ui.add_space(5.0); - if let Some(error) = error { - ui.label(format!("Failed to copy to clipboard: {}", error)); - } else { - ui.label("Address copied to clipboard."); - } - } - - Ok(()) - } - - pub fn render_ui_by_wallet_qr_code(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { - // Update state when the QR funding address receives funds - if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( - &self.step, - self.wallet.as_ref(), - self.funding_address.as_ref(), - ) { - self.funding_utxo = Some(utxo); - } - - // Extract the step from the RwLock to minimize borrow scope - let step = *self.step.read().unwrap(); - - ui.heading( - format!( - "{}. Select how much you would like to transfer?", - step_number - ) - .as_str(), - ); - - ui.add_space(8.0); - - self.top_up_funding_amount_input(ui); - - if step == WalletFundedScreenStep::WaitingOnFunds { - ui.ctx() - .request_repaint_after(std::time::Duration::from_secs(1)); - } - - let response = ui.vertical_centered(|ui| { - // Only try to render QR code if we have a valid amount - if let Ok(amount_dash) = self.funding_amount.parse::<f64>() { - if amount_dash > 0.0 { - if let Err(e) = self.render_qr_code(ui, amount_dash) { - MessageBanner::set_global( - ui.ctx(), - "Failed to render QR code", - MessageType::Error, - ) - .with_details(e); - } - } else { - ui.label("Please enter an amount greater than 0"); - } - } else if !self.funding_amount.is_empty() { - ui.label("Please enter a valid amount"); - } - - ui.add_space(20.0); - - // Handle FundsReceived action regardless of error state - if step == WalletFundedScreenStep::FundsReceived { - let Some(selected_wallet) = &self.wallet else { - return AppAction::None; - }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { - let wallet_index = self.identity.wallet_index.unwrap_or(u32::MAX >> 1); - let top_up_index = self - .identity - .top_ups - .keys() - .max() - .cloned() - .map(|i| i + 1) - .unwrap_or_default(); - let identity_input = IdentityTopUpInfo { - qualified_identity: self.identity.clone(), - wallet: Arc::clone(selected_wallet), - identity_funding_method: TopUpIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, - wallet_index, - top_up_index, - ), - }; - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; - - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::TopUpIdentity(identity_input), - )); - } - } - - match step { - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); - } - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - } - AppAction::None - }); - - ui.add_space(40.0); - - response.inner - } -} diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 4f00be289..7a6628655 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -1,11 +1,11 @@ mod by_platform_address; mod by_using_unused_asset_lock; mod by_using_unused_balance; -mod by_wallet_qr_code; mod success_screen; -use crate::app::AppAction; +use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::core::CoreItem; +use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; @@ -23,18 +23,19 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::identities::add_new_identity_screen::FundingMethod; -use crate::ui::identities::funding_common::WalletFundedScreenStep; +use crate::ui::identities::funding_common::{ + FundingMethod, WalletFundedScreenStep, default_funding_state, max_amount_after_fee_reserve, + spendable_covers_minimum, wallet_selection_combo, +}; +use crate::ui::state::TrackedAssetLockCache; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::{Credits, Duffs}; -use dash_sdk::dpp::dashcore::{OutPoint, Transaction, TxOut}; +use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::AssetLockProof; -use eframe::egui::Context; use egui::{ComboBox, ScrollArea, Ui}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; @@ -45,14 +46,16 @@ and create the asset lock transaction to top up your identity."; pub struct TopUpIdentityScreen { pub identity: QualifiedIdentity, step: Arc<RwLock<WalletFundedScreenStep>>, - funding_asset_lock: Option<(Transaction, AssetLockProof, Address)>, + /// Outpoint of an asset lock tracked by the upstream `AssetLockManager`, + /// chosen by the user from the picker. Routed to the backend as + /// `TopUpIdentityFundingMethod::UseAssetLock`. + funding_asset_lock: Option<OutPoint>, wallet: Option<Arc<RwLock<Wallet>>>, funding_address: Option<Address>, funding_method: Arc<RwLock<FundingMethod>>, funding_amount: String, funding_amount_exact: Option<Duffs>, funding_amount_input: Option<AmountInput>, - funding_utxo: Option<(OutPoint, TxOut, Address)>, copied_to_clipboard: Option<Option<String>>, wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, @@ -64,6 +67,10 @@ pub struct TopUpIdentityScreen { platform_top_up_amount_input: Option<AmountInput>, /// Fee result from completed top-up completed_fee_result: Option<FeeResult>, + /// Tracked asset locks per wallet, fetched off the UI thread via the App + /// Task System. Backs the funding-method gate, the wallet selector, and the + /// asset-lock picker. + asset_lock_cache: TrackedAssetLockCache, } impl TopUpIdentityScreen { @@ -78,7 +85,6 @@ impl TopUpIdentityScreen { funding_amount: "".to_string(), funding_amount_exact: None, funding_amount_input: None, - funding_utxo: None, copied_to_clipboard: None, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, @@ -88,6 +94,52 @@ impl TopUpIdentityScreen { platform_top_up_amount: None, platform_top_up_amount_input: None, completed_fee_result: None, + asset_lock_cache: TrackedAssetLockCache::default(), + } + } + + /// Current funding step, defaulting to the initial chooser step if the lock + /// is momentarily poisoned rather than panicking. + fn current_step(&self) -> WalletFundedScreenStep { + self.step + .read() + .map(|s| *s) + .unwrap_or(WalletFundedScreenStep::ChooseFundingMethod) + } + + /// Set the funding step, silently skipping the write if the lock is + /// poisoned (a poisoned step lock never blocks the UI). + fn set_step(&self, step: WalletFundedScreenStep) { + if let Ok(mut s) = self.step.write() { + *s = step; + } + } + + /// Current funding method, defaulting to `NoSelection` if the lock is + /// momentarily poisoned rather than panicking. + fn current_funding_method(&self) -> FundingMethod { + self.funding_method + .read() + .map(|m| *m) + .unwrap_or(FundingMethod::NoSelection) + } + + /// Whether `wallet` currently has the resources the given funding method + /// needs. A busy wallet lock reads as "no resources" rather than panicking. + fn wallet_has_resources_for( + &self, + wallet: &Arc<RwLock<Wallet>>, + method: FundingMethod, + ) -> bool { + let Ok(w) = wallet.read() else { + return false; + }; + match method { + FundingMethod::UseWalletBalance => { + self.app_context.snapshot_has_balance(&w.seed_hash()) + } + FundingMethod::UseUnusedAssetLock => self.asset_lock_cache.has_unused(&w.seed_hash()), + _ => true, } } @@ -96,76 +148,67 @@ impl TopUpIdentityScreen { let mut step_update_method: Option<FundingMethod> = None; let rendered = if self.app_context.has_wallet.load(Ordering::Relaxed) { - let wallets_guard = self.app_context.wallets.read().unwrap(); - let wallets = &*wallets_guard; + let wallets: Vec<_> = self + .app_context + .wallets + .read() + .map(|guard| guard.values().cloned().collect()) + .unwrap_or_default(); if wallets.len() > 1 { - // Cache current funding method to avoid holding the lock across UI callbacks - let funding_method = *self.funding_method.read().unwrap(); - - // Retrieve the alias of the currently selected wallet, if any - let selected_wallet_alias = self - .wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()?.alias.clone()) - .unwrap_or_else(|| "Select".to_string()); - - // Display the ComboBox for wallet selection - ComboBox::from_id_salt("select_wallet") - .selected_text(selected_wallet_alias) - .show_ui(ui, |ui| { - for wallet in wallets.values() { - let (wallet_alias, has_required_resources) = { - let wallet_read = wallet.read().unwrap(); - let alias = wallet_read - .alias - .clone() - .unwrap_or_else(|| "Unnamed Wallet".to_string()); - - let has_resources = match funding_method { - FundingMethod::UseWalletBalance => wallet_read.has_balance(), - FundingMethod::UseUnusedAssetLock => { - wallet_read.has_unused_asset_lock() - } - _ => true, - }; - - (alias, has_resources) - }; - - let is_selected = self - .wallet - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); - - ui.add_enabled_ui(has_required_resources, |ui| { - if ui.selectable_label(is_selected, wallet_alias).clicked() { - selected_wallet_update = Some(wallet.clone()); - step_update_method = Some(funding_method); - } - }); - } - }); + let funding_method = self.current_funding_method(); + selected_wallet_update = wallet_selection_combo( + ui, + "select_wallet", + &wallets, + self.wallet.as_ref(), + |wallet| { + wallet + .read() + .ok() + .and_then(|w| w.alias.clone()) + .unwrap_or_else(|| "Unnamed Wallet".to_string()) + }, + |wallet| self.wallet_has_resources_for(wallet, funding_method), + ); + if selected_wallet_update.is_some() { + step_update_method = Some(funding_method); + } true - } else if let Some(wallet) = wallets.values().next() { + } else if let Some(wallet) = wallets.first() { if self.wallet.is_none() { - // Cache current funding method to avoid holding the lock across updates - let funding_method = *self.funding_method.read().unwrap(); - - // Check if the wallet has the required resources - let has_required_resources = { - let wallet_read = wallet.read().unwrap(); - match funding_method { - FundingMethod::UseWalletBalance => wallet_read.has_balance(), - FundingMethod::UseUnusedAssetLock => { - wallet_read.has_unused_asset_lock() - } - _ => true, + // §B.9 / QA-006: the very first time a wallet resolves with + // nothing chosen yet, apply the same pre-selection the + // create-identity wizard uses (`default_funding_state`) — + // recommend `UseWalletBalance` only when this wallet can + // actually cover the estimated top-up fee. A dust or locked + // balance (positive but below the fee) must not pre-select a + // path the next render blocks on. + if self.current_funding_method() == FundingMethod::NoSelection { + let can_afford = wallet + .read() + .ok() + .map(|w| { + let spendable = self + .app_context + .snapshot_balance(&w.seed_hash()) + .spendable(); + let minimum = + self.app_context.fee_estimator().estimate_identity_topup(); + spendable_covers_minimum(spendable, minimum) + }) + .unwrap_or(false); + let (recommended, _) = default_funding_state(can_afford); + if let Ok(mut m) = self.funding_method.write() { + *m = recommended; } - }; + } - if has_required_resources { - // Automatically select the only available wallet from app_context + let funding_method = self.current_funding_method(); + if funding_method != FundingMethod::NoSelection + && self.wallet_has_resources_for(wallet, funding_method) + { + // Automatically select the only available wallet. selected_wallet_update = Some(wallet.clone()); step_update_method = Some(funding_method); } @@ -183,15 +226,13 @@ impl TopUpIdentityScreen { self.wallet_open_attempted = false; self.funding_address = None; self.funding_asset_lock = None; - self.funding_utxo = None; self.funding_amount_input = None; self.copied_to_clipboard = None; if let Some(method) = step_update_method { self.update_step_after_wallet_change(method); } else { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ChooseFundingMethod; + self.set_step(WalletFundedScreenStep::ChooseFundingMethod); } } @@ -200,40 +241,48 @@ impl TopUpIdentityScreen { /// Adjust the current step to match the funding method after a wallet switch. fn update_step_after_wallet_change(&mut self, funding_method: FundingMethod) { - let mut step = self.step.write().unwrap(); - *step = match funding_method { - FundingMethod::AddressWithQRCode => WalletFundedScreenStep::WaitingOnFunds, + self.set_step(match funding_method { FundingMethod::UseUnusedAssetLock | FundingMethod::UseWalletBalance | FundingMethod::UsePlatformAddress => WalletFundedScreenStep::ReadyToCreate, FundingMethod::NoSelection => WalletFundedScreenStep::ChooseFundingMethod, - }; + }); } fn render_funding_method(&mut self, ui: &mut egui::Ui) { let funding_method_arc = self.funding_method.clone(); - let mut funding_method = funding_method_arc.write().unwrap(); + let Ok(mut funding_method) = funding_method_arc.write() else { + return; + }; // Check if any wallet has unused asset locks, balance, or Platform address balance let (has_any_unused_asset_lock, has_any_balance, has_any_platform_balance) = { - let wallets = self.app_context.wallets.read().unwrap(); let mut has_unused_asset_lock = false; let mut has_balance = false; let mut has_platform_balance = false; - for wallet in wallets.values() { - let wallet = wallet.read().unwrap(); - if wallet.has_unused_asset_lock() { - has_unused_asset_lock = true; - } - if wallet.has_balance() { - has_balance = true; - } - if wallet.total_platform_balance() > 0 { - has_platform_balance = true; - } - if has_unused_asset_lock && has_balance && has_platform_balance { - break; // No need to check further + if let Ok(wallets) = self.app_context.wallets.read() { + for wallet in wallets.values() { + let Ok(wallet) = wallet.read() else { + continue; + }; + let seed_hash = wallet.seed_hash(); + // Offer the option on a failed fetch too, so the user can + // reach the picker's Retry rather than the option vanishing. + if self.asset_lock_cache.has_unused(&seed_hash) + || self.asset_lock_cache.is_failed(&seed_hash) + { + has_unused_asset_lock = true; + } + if self.app_context.snapshot_has_balance(&seed_hash) { + has_balance = true; + } + if wallet.total_platform_balance() > 0 { + has_platform_balance = true; + } + if has_unused_asset_lock && has_balance && has_platform_balance { + break; // No need to check further + } } } @@ -241,13 +290,13 @@ impl TopUpIdentityScreen { }; ComboBox::from_id_salt("funding_method") - .selected_text(format!("{}", *funding_method)) + .selected_text(funding_method.top_up_label()) .height(200.0) .show_ui(ui, |ui| { ui.selectable_value( &mut *funding_method, FundingMethod::NoSelection, - "Please select funding method", + FundingMethod::NoSelection.top_up_label(), ); ui.add_enabled_ui(has_any_unused_asset_lock, |ui| { @@ -255,12 +304,11 @@ impl TopUpIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseUnusedAssetLock, - "Unused Asset Locks", + FundingMethod::UseUnusedAssetLock.top_up_label(), ) .changed() { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.set_step(WalletFundedScreenStep::ReadyToCreate); } }); @@ -269,12 +317,11 @@ impl TopUpIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseWalletBalance, - "Wallet Balance", + FundingMethod::UseWalletBalance.top_up_label(), ) .changed() { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.set_step(WalletFundedScreenStep::ReadyToCreate); } }); @@ -283,26 +330,13 @@ impl TopUpIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UsePlatformAddress, - "Platform Address", + FundingMethod::UsePlatformAddress.top_up_label(), ) .changed() { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; + self.set_step(WalletFundedScreenStep::ReadyToCreate); } }); - - if ui - .selectable_value( - &mut *funding_method, - FundingMethod::AddressWithQRCode, - "Address with QR Code", - ) - .changed() - { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingOnFunds; - } }); } @@ -312,19 +346,27 @@ impl TopUpIdentityScreen { }; match funding_method { FundingMethod::UseUnusedAssetLock => { - if let Some((tx, funding_asset_lock, address)) = self.funding_asset_lock.clone() { + if let Some(out_point) = self.funding_asset_lock { + let identity_index = self.identity.wallet_index.unwrap_or(u32::MAX >> 1); + let top_up_index = self + .identity + .top_ups + .keys() + .max() + .cloned() + .map(|i| i + 1) + .unwrap_or_default(); let identity_input = IdentityTopUpInfo { qualified_identity: self.identity.clone(), wallet: Arc::clone(selected_wallet), - identity_funding_method: TopUpIdentityFundingMethod::UseAssetLock( - address, - Box::new(funding_asset_lock), - Box::new(tx), - ), + identity_funding_method: TopUpIdentityFundingMethod::UseAssetLock { + out_point, + identity_index, + top_up_index, + }, }; - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + self.set_step(WalletFundedScreenStep::WaitingForPlatformAcceptance); AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::TopUpIdentity( identity_input, @@ -358,8 +400,7 @@ impl TopUpIdentityScreen { ), }; - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; + self.set_step(WalletFundedScreenStep::WaitingForAssetLock); // Create the backend task to top_up the identity AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::TopUpIdentity( @@ -371,23 +412,25 @@ impl TopUpIdentityScreen { } fn top_up_funding_amount_input(&mut self, ui: &mut egui::Ui) { - let funding_method = *self.funding_method.read().unwrap(); + let funding_method = self.current_funding_method(); - // Only apply max amount restriction when using wallet balance - // For QR code funding, funds come from external source so no max applies + // Only apply max amount restriction when using wallet balance. let (max_amount, show_max_button, fee_hint) = if funding_method == FundingMethod::UseWalletBalance { - let max_amount_duffs = self + let max_spendable_duffs = self .wallet .as_ref() - .map(|w| w.read().unwrap().total_balance_duffs()) + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) .unwrap_or(0); - // Convert Duffs to Credits (1 Duff = 1000 Credits) - let total_credits = max_amount_duffs * 1000; - // Reserve estimated fees so "Max" doesn't exceed spendable amount let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); - let max_with_fee_reserved = total_credits.saturating_sub(estimated_fee); + let max_with_fee_reserved = + max_amount_after_fee_reserve(max_spendable_duffs, estimated_fee); ( Some(max_with_fee_reserved), true, @@ -434,82 +477,74 @@ impl ScreenLike for TopUpIdentityScreen { // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so UI is not stuck on waiting messages - let mut step = self.step.write().unwrap(); - if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance - || *step == WalletFundedScreenStep::WaitingForAssetLock + let step = self.current_step(); + if step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || step == WalletFundedScreenStep::WaitingForAssetLock { - *step = WalletFundedScreenStep::ReadyToCreate; + self.set_step(WalletFundedScreenStep::ReadyToCreate); } } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks } = + backend_task_success_result + { + self.asset_lock_cache.store(seed_hash, locks); + return; + } + if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity, fee_result) = backend_task_success_result { self.identity = qualified_identity; self.completed_fee_result = Some(fee_result); self.funding_address = None; - self.funding_utxo = None; self.funding_amount.clear(); self.funding_amount_exact = None; self.funding_amount_input = None; self.copied_to_clipboard = None; - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::Success; + self.set_step(WalletFundedScreenStep::Success); return; } - let mut step = self.step.write().unwrap(); - let current_step = *step; - match current_step { - WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => { - if let Some(funding_address) = self.funding_address.as_ref() - && let BackendTaskSuccessResult::CoreItem( - CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), - ) = &backend_task_success_result - { - for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == address { - *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((*outpoint, tx_out.clone(), address.clone())) - } - } - } - } - WalletFundedScreenStep::FundsReceived => {} - WalletFundedScreenStep::ReadyToCreate => {} - WalletFundedScreenStep::WaitingForAssetLock => { - if let BackendTaskSuccessResult::CoreItem( - CoreItem::ReceivedAvailableUTXOTransaction(tx, _), - ) = &backend_task_success_result - && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = - &tx.special_transaction_payload - && asset_lock_payload.credit_outputs.iter().any(|tx_out| { - let Ok(address) = - Address::from_script(&tx_out.script_pubkey, self.app_context.network) - else { - return false; - }; - if let Some(wallet) = &self.wallet { - let wallet = wallet.read().unwrap(); - wallet.known_addresses.contains_key(&address) - } else { - false - } - }) - { - *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + if self.current_step() == WalletFundedScreenStep::WaitingForAssetLock + && let BackendTaskSuccessResult::CoreItem(CoreItem::ReceivedAvailableUTXOTransaction( + tx, + _, + )) = &backend_task_success_result + && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = + &tx.special_transaction_payload + && asset_lock_payload.credit_outputs.iter().any(|tx_out| { + let Ok(address) = + Address::from_script(&tx_out.script_pubkey, self.app_context.network) + else { + return false; + }; + match &self.wallet { + Some(wallet) => wallet + .read() + .is_ok_and(|w| w.known_addresses.contains_key(&address)), + None => false, } - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => {} - WalletFundedScreenStep::Success => {} + }) + { + self.set_step(WalletFundedScreenStep::WaitingForPlatformAcceptance); } } - fn ui(&mut self, ctx: &Context) -> AppAction { + + fn display_task_error(&mut self, _error: &TaskError) -> bool { + // Flip an in-flight asset-lock fetch to a retryable state so the picker + // shows a Retry button instead of a permanent "Loading…". + self.asset_lock_cache.mark_loading_failed(); + false + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -519,16 +554,16 @@ impl ScreenLike for TopUpIdentityScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; ScrollArea::vertical().show(ui, |ui| { - let step = { *self.step.read().unwrap() }; + let step = self.current_step(); if step == WalletFundedScreenStep::Success { inner_action |= self.show_success(ui); return; @@ -574,18 +609,22 @@ impl ScreenLike for TopUpIdentityScreen { ui.add_space(10.0); // Extract the funding method from the RwLock to minimize borrow scope - let funding_method = *self.funding_method.read().unwrap(); + let funding_method = self.current_funding_method(); if funding_method == FundingMethod::NoSelection { return; } if funding_method == FundingMethod::UseWalletBalance || funding_method == FundingMethod::UseUnusedAssetLock - || funding_method == FundingMethod::AddressWithQRCode || funding_method == FundingMethod::UsePlatformAddress { // Check if there's more than one wallet to show selection UI - let wallet_count = self.app_context.wallets.read().unwrap().len(); + let wallet_count = self + .app_context + .wallets + .read() + .map(|w| w.len()) + .unwrap_or(0); if wallet_count > 1 { ui.horizontal(|ui| { @@ -615,8 +654,9 @@ impl ScreenLike for TopUpIdentityScreen { if let Some(wallet) = &self.wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -649,9 +689,6 @@ impl ScreenLike for TopUpIdentityScreen { FundingMethod::UseWalletBalance => { inner_action |= self.render_ui_by_using_unused_balance(ui, step_number); } - FundingMethod::AddressWithQRCode => { - inner_action |= self.render_ui_by_wallet_qr_code(ui, step_number) - } FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); } @@ -677,7 +714,7 @@ impl ScreenLike for TopUpIdentityScreen { if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { egui::CentralPanel::default() .frame(egui::Frame::NONE) - .show(ctx, |ui| { + .show(ui, |ui| { let mut popup = InfoPopup::new("Wallet Selection Info", &show_pop_up_info_text); if popup.show(ui).inner { self.show_pop_up_info = None; @@ -685,6 +722,25 @@ impl ScreenLike for TopUpIdentityScreen { }); } + // Fetch tracked asset locks once per wallet (off the UI thread). The + // funding-method gate and wallet selector check every wallet, so all + // are requested together as one concurrent batch. + let seed_hashes: Vec<_> = self + .app_context + .wallets + .read() + .map(|wallets| { + wallets + .values() + .filter_map(|w| w.read().ok().map(|g| g.seed_hash())) + .collect() + }) + .unwrap_or_default(); + let tasks = self.asset_lock_cache.ensure_requested_many(seed_hashes); + if !tasks.is_empty() { + action |= AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent); + } + action } } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index b22ede8f1..2ae68d5e6 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -25,7 +25,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Frame, Margin, Ui}; +use eframe::egui::{self, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; @@ -95,7 +95,8 @@ impl TransferScreen { ); let selected_wallet = get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); None }); Self { @@ -181,7 +182,7 @@ impl TransferScreen { } fn render_destination_type_selector(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Colors for selected/unselected states let selected_fill = DashColors::DASH_BLUE; @@ -275,7 +276,7 @@ impl TransferScreen { // Try to parse as Bech32m Platform address first (dash1.../tdash1... per DIP-18) if crate::ui::helpers::is_platform_address_string(input) { - let (addr, _network) = PlatformAddress::from_bech32m_string(input) + let addr = PlatformAddress::from_bech32m_string(input) .map_err(|e| format!("Invalid Bech32m address: {}", e))?; return Ok(addr); } @@ -563,7 +564,8 @@ impl ScreenLike for TransferScreen { self.app_context.egui_ctx(), format!("Failed to load local identities: {e}"), MessageType::Error, - ); + ) + .disable_auto_dismiss(); vec![] }); if let Some(refreshed) = identities @@ -577,9 +579,11 @@ impl ScreenLike for TransferScreen { } /// Renders the UI components for the withdrawal screen - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -589,12 +593,12 @@ impl ScreenLike for TransferScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Show the success screen if the transfer was successful @@ -650,8 +654,9 @@ impl ScreenLike for TransferScreen { && let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -745,7 +750,7 @@ impl ScreenLike for TransferScreen { }; // Display estimated fee - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 88a5ec9e9..493ffe53f 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -27,7 +27,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; -use eframe::egui::{self, Context, Frame, Margin, Ui}; +use eframe::egui::{self, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -67,19 +67,39 @@ pub struct WithdrawalScreen { impl WithdrawalScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc<AppContext>) -> Self { let max_amount = identity.identity.balance(); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( - Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); - let selected_wallet = get_selected_wallet(&identity, None, selected_key) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + // Only pre-select a withdrawal key whose private material is held locally + // (TRANSFER preferred, OWNER fallback). Pre-selecting an on-chain-only key + // the signer cannot use is what surfaced the raw signing error. + let selected_key: Option<IdentityPublicKey> = identity + .default_withdrawal_key() + .map(|qk| qk.identity_public_key.clone()) + .or_else(|| { + // Developer mode may sign with any on-chain key; keep the + // power-user escape hatch instead of leaving the form blank. + app_context + .is_developer_mode() + .then(|| { + identity.identity.get_first_public_key_matching( + Purpose::TRANSFER, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + }) + .flatten() + .cloned() + }); + // With no key there is nothing to resolve a wallet from; skip the call so + // get_selected_wallet's "no key provided" Err path stays unreachable here. + let selected_wallet = match selected_key.as_ref() { + Some(key) => get_selected_wallet(&identity, None, Some(key)) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None), + None => None, + }; Self { identity, - selected_key: selected_key.cloned(), + selected_key, withdrawal_address: String::new(), withdrawal_address_error: None, withdrawal_amount: None, @@ -356,7 +376,8 @@ impl ScreenLike for WithdrawalScreen { self.app_context.egui_ctx(), format!("Failed to load local identities: {e}"), MessageType::Error, - ); + ) + .disable_auto_dismiss(); vec![] }) .into_iter() @@ -368,9 +389,11 @@ impl ScreenLike for WithdrawalScreen { } /// Renders the UI components for the withdrawal screen - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), @@ -380,12 +403,12 @@ impl ScreenLike for WithdrawalScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenIdentities, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Show the success screen if the withdrawal was successful @@ -503,8 +526,11 @@ impl ScreenLike for WithdrawalScreen { if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = + try_open_wallet_no_password(&self.app_context, wallet) + { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -574,7 +600,7 @@ impl ScreenLike for WithdrawalScreen { let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_credit_withdrawal(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/identity/README.md b/src/ui/identity/README.md new file mode 100644 index 000000000..27a53dda4 --- /dev/null +++ b/src/ui/identity/README.md @@ -0,0 +1,46 @@ +# Identity Hub Components + +Hub-local widgets live here, alongside the tab modules that consume them. +Promote to `src/ui/components/` only if a second, non-hub consumer appears. + +## Tab modules + +| Module | Responsibility | +|---|---| +| `hub_screen.rs` | Root screen, tab selection, landing resolution | +| `home.rs` | Home tab (hero · quick actions · onboarding · recent activity · advanced) | +| `contacts.rs` | Contacts tab (gated / populated shell) | +| `activity.rs` | Activity tab (filter chips + legacy-payments link) | +| `settings.rs` | Settings tab (social profile · username · advanced) | +| `onboarding.rs` | Onboarding empty state | +| `picker.rs` | Identity picker grid (≥ 2 identities) | +| `landing.rs` | `HubLanding` state enum | +| `tabs.rs` | `IdentityHubTab` enum | + +## Widgets + +| Module | Used by | Notes | +|---|---|---| +| `identity_hub_tab_bar.rs` | `hub_screen` | Horizontal tab strip (Home · Contacts · Activity · Settings) | +| `identity_hero_card.rs` | `home` | Gradient hero card (Dash-Blue → Platform-Purple) | +| `onboarding_checklist.rs` | `home` | Pick username · set display name · add first contact | +| `identity_pill.rs` | `identity_picker_card` | Thin wrapper over `components::breadcrumb_pill::BreadcrumbPill` with the identity label priority rule (nickname → DPNS → shortened ID) | +| `identity_picker_card.rs` | `picker` | Per-identity card in the picker grid | +| `identity_picker_add_card.rs` | `picker` | Trailing "Add a new identity" CTA in the picker grid | +| `social_profile_gate_card.rs` | `contacts` | Gate shown when the active identity has no DashPay profile | +| `request_card.rs` | `contacts` (future) | Received / sent contact-request row | + +## Button dispatcher pattern + +Home, Contacts, and Activity each expose a pure `*_button_kind()` function +that maps a `*Button` enum variant to its `*ButtonKind` result (screen to +open, tab to switch, outcome to emit). The renderer calls through the +dispatcher at every click site; unit tests iterate every enum variant and +assert no button is dead. See the regression suite at the bottom of each +tab module. + +The motivation is in `docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md` +under T8 — the original Wave 2 landed with every quick action returning +`AppAction::None` because the hub screen discarded the tab's action value. +The dispatcher pattern catches that class of bug in CI before the user +ever sees it. diff --git a/src/ui/identity/activity.rs b/src/ui/identity/activity.rs new file mode 100644 index 000000000..e40798424 --- /dev/null +++ b/src/ui/identity/activity.rs @@ -0,0 +1,274 @@ +//! Activity tab — shell. +//! +//! The unified activity timeline (payments + funding + platform ops) depends +//! on a backend aggregator that does not exist yet. T10 ships the shell only: +//! +//! - a filter-chip row (All / Payments / Funding / Platform) so the tab has +//! the visual shape called out in the design spec §B.6 and in Frame F6 of +//! `wireframe.html`, +//! - a gated empty state pointing users to the legacy DashPay Payments screen +//! until the aggregator backend lands. +//! +//! Retry plumbing for failed rows will be wired through a reusable row +//! component once the aggregator backend exists — the caller will decide +//! what a retry means. Because T10 renders no live rows, there is nothing to +//! retry today. +//! +//! See `docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md` T10 and +//! the test specs UT-ACTIVITY-ROW-01 / IT-ACTIVITY-01. + +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::RootScreenType; +use crate::ui::theme::{DashColors, ResponseExt}; +use eframe::egui::{self, RichText, Sense, Ui}; +use std::sync::Arc; + +/// Filter categories for the unified activity timeline. +/// +/// A standalone type so the filter state can be lifted into the calling screen +/// without depending on egui internals, and so the shell's unit tests can +/// exhaustively cover enum rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityFilter { + /// Show every kind of activity (default). + All, + /// Payments sent or received via DashPay. + Payments, + /// Funding operations: add funds, send to wallet, send to another identity. + Funding, + /// Platform operations: DPNS, keys, contracts. + Platform, +} + +impl ActivityFilter { + /// The label shown on the filter chip. + /// + /// Labels are i18n-ready standalone sentences-as-labels — no punctuation, + /// no positional assumptions. Matches the strings called out in the + /// design-spec tooltip table and in `wireframe.html` Frame F6. + pub fn label(self) -> &'static str { + match self { + ActivityFilter::All => "All", + ActivityFilter::Payments => "Payments", + ActivityFilter::Funding => "Funding", + ActivityFilter::Platform => "Platform", + } + } +} + +/// Render the Activity tab shell. +/// +/// The selection state for the filter chips lives in egui `data` storage so +/// the shell remains a pure function for T10. A proper component upgrade is +/// planned when the aggregator lands. +pub fn render(ui: &mut Ui, _app_context: &Arc<AppContext>) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut action = AppAction::None; + + ui.vertical_centered(|ui| { + ui.add_space(12.0); + + // Filter chips — multi-select with an `All` reset. `All` is selected + // by default when no other chip is active. + let id = ui.make_persistent_id("identity_hub_activity_filters"); + let mut filters: FilterSet = ui + .ctx() + .data(|d| d.get_temp::<FilterSet>(id).unwrap_or_default()); + + ui.horizontal(|ui| { + for filter in [ + ActivityFilter::All, + ActivityFilter::Payments, + ActivityFilter::Funding, + ActivityFilter::Platform, + ] { + let selected = filters.is_selected(filter); + if ui.selectable_label(selected, filter.label()).clicked() { + filters.toggle(filter); + } + } + }); + ui.ctx().data_mut(|d| d.insert_temp(id, filters)); + + ui.add_space(16.0); + + { + // Gated empty state — no live aggregator yet. Point users to the + // legacy DashPay Payments screen so they can still see their + // activity while the aggregator is built. + ui.label( + RichText::new("Unified activity is coming soon.") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + RichText::new("For now, view activity on the existing DashPay Payments screen:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + action |= legacy_payments_link(ui); + } + }); + + action +} + +/// Render an underlined, Dash-blue link to the legacy DashPay Payments screen. +/// Emits `AppAction::SetMainScreen(RootScreenDashPayPayments)` when clicked so +/// the user can see their movements today while the unified aggregator is +/// built. No new backend task and no new screen are introduced. +fn legacy_payments_link(ui: &mut Ui) -> AppAction { + let resp = ui + .add( + egui::Label::new( + RichText::new("Open DashPay Payments") + .underline() + .color(DashColors::DASH_BLUE), + ) + .sense(Sense::click()), + ) + .clickable_tooltip("Open the legacy DashPay Payments screen in a new view."); + if resp.clicked() { + return resolve_activity_button(ActivityButton::LegacyPaymentsLink); + } + AppAction::None +} + +/// Every clickable affordance on the Activity tab. Today there is only one — +/// the legacy-payments link — but keeping an enum here mirrors the Home / +/// Contacts dispatcher pattern so reviewers see a single shape. When the +/// aggregator lands and activity rows gain retry affordances, the rows' +/// `ActivityRowAction::Retry` will plug into this enum as +/// `ActivityButton::Retry(ActivityRowId)` without changing the UI shell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityButton { + /// `Open DashPay Payments` link, rendered in the gated empty state. + LegacyPaymentsLink, +} + +/// What an [`ActivityButton`] click produces, as a pure enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityButtonKind { + /// Swap the root screen to `RootScreenDashPayPayments`. + SetRootScreen(RootScreenType), +} + +/// Pure dispatcher — unit-testable, no `AppContext` needed. +pub fn activity_button_kind(button: ActivityButton) -> ActivityButtonKind { + match button { + ActivityButton::LegacyPaymentsLink => { + ActivityButtonKind::SetRootScreen(RootScreenType::RootScreenDashPayPayments) + } + } +} + +/// Materialise an [`ActivityButton`] into a concrete [`AppAction`]. +fn resolve_activity_button(button: ActivityButton) -> AppAction { + match activity_button_kind(button) { + ActivityButtonKind::SetRootScreen(root) => AppAction::SetMainScreen(root), + } +} + +/// Bitset-style filter state for the chip row. +/// +/// A tiny `Copy` value so the shell can stash it in egui's `data` storage +/// without heap allocations. Exactly one of the three category flags is set +/// at any time in the default configuration; toggling `All` clears the others. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct FilterSet { + payments: bool, + funding: bool, + platform: bool, +} + +impl FilterSet { + fn any_selected(self) -> bool { + self.payments || self.funding || self.platform + } + + fn is_selected(self, filter: ActivityFilter) -> bool { + match filter { + ActivityFilter::All => !self.any_selected(), + ActivityFilter::Payments => self.payments, + ActivityFilter::Funding => self.funding, + ActivityFilter::Platform => self.platform, + } + } + + fn toggle(&mut self, filter: ActivityFilter) { + match filter { + ActivityFilter::All => { + *self = FilterSet::default(); + } + ActivityFilter::Payments => self.payments = !self.payments, + ActivityFilter::Funding => self.funding = !self.funding, + ActivityFilter::Platform => self.platform = !self.platform, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_filter_set_selects_all() { + let set = FilterSet::default(); + assert!(set.is_selected(ActivityFilter::All)); + assert!(!set.is_selected(ActivityFilter::Payments)); + assert!(!set.is_selected(ActivityFilter::Funding)); + assert!(!set.is_selected(ActivityFilter::Platform)); + } + + #[test] + fn toggling_payments_deselects_all() { + let mut set = FilterSet::default(); + set.toggle(ActivityFilter::Payments); + assert!(set.is_selected(ActivityFilter::Payments)); + assert!(!set.is_selected(ActivityFilter::All)); + } + + #[test] + fn toggling_all_clears_every_selection() { + let mut set = FilterSet::default(); + set.toggle(ActivityFilter::Payments); + set.toggle(ActivityFilter::Funding); + set.toggle(ActivityFilter::All); + assert!(set.is_selected(ActivityFilter::All)); + assert!(!set.is_selected(ActivityFilter::Payments)); + assert!(!set.is_selected(ActivityFilter::Funding)); + } + + #[test] + fn filter_labels_are_i18n_ready_single_words() { + assert_eq!(ActivityFilter::All.label(), "All"); + assert_eq!(ActivityFilter::Payments.label(), "Payments"); + assert_eq!(ActivityFilter::Funding.label(), "Funding"); + assert_eq!(ActivityFilter::Platform.label(), "Platform"); + } + + // --------------------------------------------------------------- + // Dead-button regression test. + // --------------------------------------------------------------- + + const ALL_ACTIVITY_BUTTONS: &[ActivityButton] = &[ActivityButton::LegacyPaymentsLink]; + + #[test] + fn activity_all_buttons_list_is_exhaustive() { + for button in ALL_ACTIVITY_BUTTONS { + let _: () = match *button { + ActivityButton::LegacyPaymentsLink => (), + }; + } + } + + #[test] + fn legacy_payments_link_routes_to_dashpay_payments_screen() { + assert_eq!( + activity_button_kind(ActivityButton::LegacyPaymentsLink), + ActivityButtonKind::SetRootScreen(RootScreenType::RootScreenDashPayPayments), + ); + } +} diff --git a/src/ui/identity/avatar.rs b/src/ui/identity/avatar.rs new file mode 100644 index 000000000..87ac8e800 --- /dev/null +++ b/src/ui/identity/avatar.rs @@ -0,0 +1,137 @@ +//! Shared circular identity avatar / monogram painter. +//! +//! Extracted from the hero card so the hero (96 px) and the breadcrumb identity +//! pill (18 px) render the same visual. Photo rendering is deferred — like the +//! hero today, this paints an initials monogram or a type-glyph fallback. + +use super::identity_hero_card::HeroIdentityKind; +use crate::ui::theme::DashColors; +use eframe::egui::{Align2, Color32, FontFamily, FontId, Response, Sense, Stroke, Ui, vec2}; + +/// Paint a circular identity avatar of `diameter` px at the next layout slot. +/// +/// When `initial` is `Some`, fills `accent` and centres the white uppercase +/// monogram; otherwise fills a faint `accent` tint and centres the type glyph +/// for `kind`. Returns the allocated `Response` (hover-only). +pub fn paint_identity_monogram( + ui: &mut Ui, + diameter: f32, + kind: HeroIdentityKind, + initial: Option<char>, + accent: Color32, +) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(diameter, diameter), Sense::hover()); + let painter = ui.painter(); + let center = rect.center(); + let radius = diameter * 0.5; + let ring = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 51); // 20% + let stroke_w = (diameter / 48.0).max(1.0); // 2px at 96, 1px at 18 + let font = FontId::new(diameter * 0.42, FontFamily::Proportional); + + match initial { + Some(ch) => { + painter.circle_filled(center, radius, accent); + painter.circle_stroke(center, radius, Stroke::new(stroke_w, ring)); + painter.text( + center, + Align2::CENTER_CENTER, + ch.to_string(), + font, + Color32::WHITE, + ); + } + None => { + let tint = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 20); // 8% + painter.circle_filled(center, radius, tint); + painter.circle_stroke(center, radius, Stroke::new(stroke_w, ring)); + painter.text( + center, + Align2::CENTER_CENTER, + kind.type_glyph(), + font, + accent, + ); + } + } + resp +} + +/// Relative radius (fraction of the glow's max radius) and straight alpha +/// (0-255) for each concentric disc, outermost first. Layering discs from +/// largest to smallest approximates a soft radial gradient, since egui has +/// no native radial-gradient fill (same technique as the hero card's +/// diagonal gradient band, adapted from strips to circles). +const GLOW_RINGS: [(f32, u8); 4] = [(1.00, 14), (0.75, 14), (0.50, 16), (0.28, 20)]; + +/// Paint an abstract avatar silhouette on a soft Dash-blue radial glow. +/// +/// Used for empty/onboarding states before any identity exists (design-spec +/// §B.1) — there is no identity yet to derive a monogram or type glyph from, +/// so this always renders the generic person silhouette +/// ([`HeroIdentityKind::User`]'s glyph) rather than taking a `kind` parameter. +/// `diameter` is the overall footprint (glow + glyph). Returns the allocated +/// `Response` (hover-only). +pub fn paint_abstract_avatar(ui: &mut Ui, diameter: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(diameter, diameter), Sense::hover()); + let painter = ui.painter(); + let center = rect.center(); + let max_radius = diameter * 0.5; + + for (radius_frac, alpha) in GLOW_RINGS { + let color = Color32::from_rgba_unmultiplied( + DashColors::DASH_BLUE.r(), + DashColors::DASH_BLUE.g(), + DashColors::DASH_BLUE.b(), + alpha, + ); + painter.circle_filled(center, max_radius * radius_frac, color); + } + + let glyph_font = FontId::new(diameter * 0.4, FontFamily::Proportional); + painter.text( + center, + Align2::CENTER_CENTER, + HeroIdentityKind::User.type_glyph(), + glyph_font, + DashColors::DASH_BLUE, + ); + + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::Harness; + + /// The glow's allocated rect must be a `diameter` x `diameter` square so + /// callers can lay it out predictably above the onboarding heading. + #[test] + fn paint_abstract_avatar_allocates_square_of_requested_diameter() { + let mut harness = Harness::builder() + .with_size(vec2(200.0, 200.0)) + .build_ui(|ui| { + let response = paint_abstract_avatar(ui, 140.0); + assert_eq!(response.rect.width(), 140.0); + assert_eq!(response.rect.height(), 140.0); + }); + harness.run(); + } + + /// Renders in both dark and light visuals without panicking — the glow + /// color is brand-invariant (`DashColors::DASH_BLUE`), so there is no + /// mode-specific branch to exercise beyond "it paints". + #[test] + fn paint_abstract_avatar_renders_in_both_modes() { + for dark_mode in [false, true] { + let mut harness = + Harness::builder() + .with_size(vec2(200.0, 200.0)) + .build_ui(move |ui| { + ui.style_mut().visuals.dark_mode = dark_mode; + paint_abstract_avatar(ui, 140.0); + }); + harness.run(); + } + } +} diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs new file mode 100644 index 000000000..fcb4c3691 --- /dev/null +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -0,0 +1,136 @@ +//! Hub-facing shim over the generalized [`global_nav_switcher`]. +//! +//! The Identities hub keeps its original `BreadcrumbEffect` API and behavior: +//! this module builds the hub's [`PageNavSpec`] (interactive wallet + app-global +//! identity pills, `Identities` segment-1), delegates rendering to +//! [`global_nav_switcher::render`], and maps the generalized +//! [`GlobalNavEffect`] back to [`BreadcrumbEffect`]. A self-navigation to the +//! hub root (the `Identities` link) maps to [`BreadcrumbEffect::OpenPicker`], +//! preserving the pre-generalization behavior. +//! +//! [`global_nav_switcher`]: crate::ui::components::global_nav_switcher + +use crate::context::AppContext; +use crate::ui::RootScreenType; +use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; +use crate::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; +use crate::ui::state::hub_selection::HubSelection; +use dash_sdk::platform::Identifier; +use eframe::egui::Ui; +use std::sync::Arc; + +use crate::model::wallet::WalletSeedHash; + +/// A typed switcher outcome the hub applies. Switching is hub-internal; add +/// flows reuse existing `AppAction`s through the hub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BreadcrumbEffect { + /// No interaction this frame. + None, + /// The `Identities` crumb was clicked — open the picker. + OpenPicker, + /// Switch the operating wallet. + SwitchWallet(WalletSeedHash), + /// Select an identity. + SelectIdentity(Identifier), + /// "Set up another wallet" — route to the Wallets screen. + AddWallet, + /// "Add another identity" → create a new identity. + AddIdentityCreate, + /// "Add another identity" → load an existing identity. + AddIdentityLoad, + /// Dev-mode: bulk-create test identities. + CreateTestIdentities, +} + +/// The hub's page-nav spec: `Identities` segment-1 linking to the hub root, an +/// interactive (consumed) wallet pill, and the app-global identity pill. +fn hub_spec() -> PageNavSpec { + PageNavSpec::new("Identities", RootScreenType::RootScreenIdentityHub) + .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill(IdentityPillScope::AppGlobalUser, PillConsumption::Consumed) +} + +/// Map a generalized effect to the hub's `BreadcrumbEffect`. A self-navigation +/// to the hub root is the `Identities` link → open the picker. +fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { + match effect { + GlobalNavEffect::None => BreadcrumbEffect::None, + GlobalNavEffect::NavigateToRoot(RootScreenType::RootScreenIdentityHub) => { + BreadcrumbEffect::OpenPicker + } + // The hub's segment-1 only ever targets the hub itself. + GlobalNavEffect::NavigateToRoot(_) => BreadcrumbEffect::None, + GlobalNavEffect::SwitchWallet(hash) => BreadcrumbEffect::SwitchWallet(hash), + GlobalNavEffect::SelectIdentity(id) => BreadcrumbEffect::SelectIdentity(id), + // The hub never composes a page-scoped object pill. + GlobalNavEffect::SelectPageObject(_) => BreadcrumbEffect::None, + GlobalNavEffect::AddWallet => BreadcrumbEffect::AddWallet, + GlobalNavEffect::AddIdentityCreate => BreadcrumbEffect::AddIdentityCreate, + GlobalNavEffect::AddIdentityLoad => BreadcrumbEffect::AddIdentityLoad, + GlobalNavEffect::CreateTestIdentities => BreadcrumbEffect::CreateTestIdentities, + } +} + +/// Render the hub breadcrumb switcher. Delegates to the generalized global-nav +/// switcher with the hub's spec and maps the effect back. +pub fn render( + ui: &mut Ui, + app_context: &Arc<AppContext>, + selection: &mut HubSelection, +) -> BreadcrumbEffect { + map_effect(global_nav_switcher::render( + ui, + app_context, + &hub_spec(), + selection, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `Identities` link (self-navigation to the hub root) opens the picker, + /// preserving the pre-generalization hub behavior. + #[test] + fn self_navigation_maps_to_open_picker() { + assert_eq!( + map_effect(GlobalNavEffect::NavigateToRoot( + RootScreenType::RootScreenIdentityHub + )), + BreadcrumbEffect::OpenPicker + ); + } + + /// The hub never surfaces a page-scoped object selection. + #[test] + fn page_scoped_effects_are_dropped_on_the_hub() { + assert_eq!( + map_effect(GlobalNavEffect::SelectPageObject(Identifier::new([5; 32]))), + BreadcrumbEffect::None + ); + } + + /// Wallet/identity switches and add flows pass through unchanged. + #[test] + fn common_effects_pass_through() { + assert_eq!( + map_effect(GlobalNavEffect::SwitchWallet([1; 32])), + BreadcrumbEffect::SwitchWallet([1; 32]) + ); + let id = Identifier::new([2; 32]); + assert_eq!( + map_effect(GlobalNavEffect::SelectIdentity(id)), + BreadcrumbEffect::SelectIdentity(id) + ); + assert_eq!( + map_effect(GlobalNavEffect::AddWallet), + BreadcrumbEffect::AddWallet + ); + assert_eq!( + map_effect(GlobalNavEffect::CreateTestIdentities), + BreadcrumbEffect::CreateTestIdentities + ); + } +} diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs new file mode 100644 index 000000000..9bc69346d --- /dev/null +++ b/src/ui/identity/contacts.rs @@ -0,0 +1,767 @@ +//! Contacts tab. +//! +//! Renders either the populated Contacts page (received requests · active +//! contacts · sent requests) or the social-profile gate card when the +//! 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 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. + +use super::request_card::RequestCard; +use super::social_profile_gate_card::SocialProfileGateCard; +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::dashpay::DashPayTask; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::ScreenType; +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 eframe::egui::{CornerRadius, Frame, Margin, RichText, Stroke, Ui}; +use std::sync::Arc; + +/// 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 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 SEARCH_PLACEHOLDER: &str = "Search your contacts"; + +/// Every clickable affordance on the Contacts tab header + populated shell. +/// Mirrors the home-tab `HomeButton` dispatcher pattern so the dead-button +/// unit test can enumerate every variant and assert each one maps to a real +/// screen, not `AppAction::None`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContactsButton { + /// Header `+ Add by username`. + HeaderAddByUsername, + /// Header `Scan QR`. + HeaderScanQr, + /// Header `Show my QR`. + HeaderShowMyQr, + /// Populated active-section `Add by username` button. + ActiveAddByUsername, + /// Gate card `Set up my profile` CTA (gated state only). Emits a hub + /// outcome because the Settings tab is hub-local. + GateSetUpProfile, +} + +/// What a [`ContactsButton`] click produces, as a pure enum for unit tests. +/// `GateSetUpProfile` is `SwitchHubTab(Settings)` — a hub-local intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContactsButtonKind { + /// Open the given screen via `AppAction::AddScreen`. + OpenScreen(ContactsScreenKind), + /// Switch to another hub tab (§B.4.1 gate CTA -> Settings). + SwitchHubTab(super::IdentityHubTab), +} + +/// Screens any contacts button can open. Maps 1:1 to `ScreenType` — kept as a +/// pure enum for unit tests that have no `AppContext`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContactsScreenKind { + /// `ScreenType::DashPayAddContact` — add-contact flow. + AddContact, + /// `ScreenType::DashPayQRGenerator` — show-my-QR screen. + QrGenerator, +} + +/// Pure dispatcher. Every variant MUST produce a non-dead result — the +/// `every_contacts_button_produces_live_action` test enforces this. +pub fn contacts_button_kind(button: ContactsButton) -> ContactsButtonKind { + use ContactsButtonKind::*; + use ContactsScreenKind::*; + match button { + ContactsButton::HeaderAddByUsername | ContactsButton::ActiveAddByUsername => { + OpenScreen(AddContact) + } + // Scan QR currently routes to the add-contact screen (which owns + // the existing scan affordance). TODO(identity-hub): if a dedicated + // scan screen ships, swap to it here. + ContactsButton::HeaderScanQr => OpenScreen(AddContact), + ContactsButton::HeaderShowMyQr => OpenScreen(QrGenerator), + ContactsButton::GateSetUpProfile => SwitchHubTab(super::IdentityHubTab::Settings), + } +} + +/// 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<String>, +} + +/// Per-tab-entry state owned by the hub. Holds the load guard flag and the +/// cached contact-request lists populated by `hub_screen::display_task_result`. +/// +/// The flag is reset by the hub when the user leaves the tab (via +/// [`ContactsState::reset`]) or via an explicit refresh affordance. +#[derive(Debug, Default, Clone)] +pub struct ContactsState { + /// Set to `true` after the first paint of the populated shell triggers + /// the backend tasks. Guards all subsequent frames from re-dispatching. + pub(super) load_requested: bool, + /// Cached incoming requests (received by the active identity). Populated + /// from `BackendTaskSuccessResult::DashPayContactRequests` via + /// `record_requests`. Cleared on `reset()`. + pub incoming: Vec<ContactRequestEntry>, + /// Cached outgoing requests (sent by the active identity). Same lifecycle. + pub outgoing: Vec<ContactRequestEntry>, +} + +impl ContactsState { + /// Clear the load guard and cached lists so the next paint re-issues the + /// load. Call this from `refresh()` / `refresh_on_arrival()` on the hub. + pub fn reset(&mut self) { + self.load_requested = false; + self.incoming.clear(); + self.outgoing.clear(); + } + + /// Populate the incoming/outgoing caches from a raw + /// `DashPayContactRequests` backend result. Called by + /// `hub_screen::display_task_result` (T29). + /// + /// Incoming sender = `doc.owner_id()`; outgoing recipient = + /// `doc.properties()["toUserId"]`. Display names are identity IDs until a + /// profile-fetch integration wave lands. + pub fn record_requests( + &mut self, + incoming: Vec<(Identifier, Document)>, + outgoing: Vec<(Identifier, Document)>, + ) { + self.incoming = incoming + .into_iter() + .map(|(req_id, doc)| { + let ts = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); + ContactRequestEntry { + counterpart_id: doc.owner_id().to_string(Encoding::Base58), + request_id: req_id.to_string(Encoding::Base58), + relative_time: crate::ui::dashpay::format_relative_time(ts), + } + }) + .collect(); + + self.outgoing = outgoing + .into_iter() + .map(|(req_id, doc)| { + let to_id = doc + .properties() + .get("toUserId") + .and_then(|v| v.to_identifier().ok()) + .unwrap_or_default() + .to_string(Encoding::Base58); + let ts = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); + ContactRequestEntry { + counterpart_id: to_id, + request_id: req_id.to_string(Encoding::Base58), + relative_time: crate::ui::dashpay::format_relative_time(ts), + } + }) + .collect(); + } +} + +/// 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. +/// +/// The caller owns a [`ContactsState`] so the populated-shell only dispatches +/// its backend task once per tab entry — not once per paint. +pub fn render( + ui: &mut Ui, + app_context: &Arc<AppContext>, + state_guard: &mut ContactsState, + profiles: &mut super::profile_cache::ProfileCache, +) -> AppAction { + let state = ContactsTabState::resolve(app_context, profiles); + render_state(ui, app_context, &state, state_guard) +} + +/// Resolved rendering mode for the Contacts tab. +#[derive(Debug, Clone, PartialEq)] +pub enum ContactsTabState { + /// No identity is loaded OR the active identity has no DashPay profile. + /// + /// The optional `handle` carries the active identity's primary DPNS + /// username (without the leading `@`) when it is known, so the gate card + /// can personalize its body copy. + Gated { handle: Option<String> }, + /// The active identity has a DashPay profile — render the three-section + /// populated shell. + Populated { + /// The currently-active identity. The populated-state renderer uses + /// this to dispatch `DashPayTask::LoadContacts` and friends. + identity: Box<QualifiedIdentity>, + }, +} + +impl ContactsTabState { + /// Inspect the app context to decide which state to render. Returns + /// `Gated` if no identity is loaded, if loading fails, or if the active + /// identity has no DashPay profile (or one has not loaded yet). + pub fn resolve( + app_context: &Arc<AppContext>, + profiles: &mut super::profile_cache::ProfileCache, + ) -> Self { + // The app-scoped active identity (selected → first → none). `None` on a + // load error or no identities falls back to Gated so the hub never + // draws a half-broken populated UI. + let Some(active) = app_context.resolve_selected_identity() else { + return ContactsTabState::Gated { handle: None }; + }; + + let handle = primary_dpns_handle(&active); + if has_social_profile(profiles, &active) { + ContactsTabState::Populated { + identity: Box::new(active), + } + } else { + ContactsTabState::Gated { handle } + } + } +} + +/// Render the resolved state. Split out so tests can exercise the rendering +/// logic without reaching into `AppContext` internals. +fn render_state( + ui: &mut Ui, + app_context: &Arc<AppContext>, + state: &ContactsTabState, + state_guard: &mut ContactsState, +) -> AppAction { + match state { + ContactsTabState::Gated { handle } => render_gated(ui, handle.as_deref()), + ContactsTabState::Populated { identity } => { + render_populated(ui, app_context, identity, state_guard) + } + } +} + +/// Centered gate card. The `Why?` panel toggle is a caller-owned boolean +/// persisted on the hub screen in a follow-up task; rendering it collapsed +/// here is the correct default for first paint. +/// +/// Exposed to integration tests so IT-CONTACTS-01 can mount the gated view +/// without constructing a full `AppContext`. +pub fn render_gated(ui: &mut Ui, handle: Option<&str>) -> AppAction { + let card = SocialProfileGateCard::new(handle); + let response = card.show(ui); + if response.primary_clicked { + // Route to the Settings tab — that is where the user actually edits + // display name and avatar (social-profile fields). Resolution goes + // through the pure `contacts_button_kind` dispatcher so routing lives + // in one place. + match contacts_button_kind(ContactsButton::GateSetUpProfile) { + ContactsButtonKind::SwitchHubTab(tab) => { + return AppAction::SwitchIdentityHubTab(tab); + } + ContactsButtonKind::OpenScreen(_) => { + // Not possible today (dispatcher returns SwitchHubTab), but + // exhaustive match future-proofs the gate CTA. + unreachable!("GateSetUpProfile should not map to OpenScreen"); + } + } + } + if response.why_toggled { + // TODO(identity-hub): persist the expanded flag on the hub screen so + // the panel stays open across frames. Until then the card is + // re-rendered collapsed each frame; the click still surfaces a + // visible press so the affordance is not dead. + } + 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). +fn render_populated( + ui: &mut Ui, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, + state_guard: &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 --------------------------------------- + ui.add_space(12.0); + let heading_recv = if incoming.is_empty() { + RECEIVED_HEADING.to_string() + } else { + format!("{RECEIVED_HEADING} · {}", incoming.len()) + }; + section_card(ui, dark_mode, &heading_recv, |ui| { + if incoming.is_empty() { + ui.label(RichText::new(NO_RECEIVED_EMPTY).color(DashColors::text_secondary(dark_mode))); + } else { + for entry in &incoming { + let display = abbreviate_id(&entry.counterpart_id); + let card = RequestCard::received( + &display, + &entry.counterpart_id, + entry.relative_time.as_deref().unwrap_or(""), + ) + .with_id(&entry.request_id); + card.show(ui); + ui.add_space(4.0); + // TODO(identity-hub/T29): on Accept/Decline dispatch + // DashPayTask::AcceptContactRequest / RejectContactRequest + // using `resp.id` as the request identifier. Backend variants + // already exist; wiring is additive here. + } + } + }); + + // --- Active contacts section ---------------------------------------- + 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(); + ui.add( + eframe::egui::TextEdit::singleline(&mut search) + .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)), + ); + } else { + for entry in &outgoing { + let display = abbreviate_id(&entry.counterpart_id); + let card = + RequestCard::sent(&display, &entry.counterpart_id).with_id(&entry.request_id); + card.show(ui); + ui.add_space(4.0); + // TODO(identity-hub/T29): on Cancel dispatch + // DashPayTask::CancelContactRequest (variant not yet present — + // defer until the parallel wallet-refactor wave lands). + } + } + }); + + // Fire LoadContacts + LoadContactRequests once per tab-entry. The guard + // prevents re-dispatch every frame. The hub resets it in + // `refresh_on_arrival()` so a tab switch or explicit refresh triggers + // another load. Both tasks dispatch together so all three sections can + // hydrate in a single round-trip (T29). + if !state_guard.load_requested { + state_guard.load_requested = true; + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadContacts { + identity: identity.clone(), + }, + ))); + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadContactRequests { + identity: identity.clone(), + }, + ))); + } + + action +} + +/// Shorten a Base58 identity ID for display: first 8 chars + "…". +fn abbreviate_id(id: &str) -> String { + if id.len() <= 10 { + id.to_string() + } else { + format!("{}…", &id[..8]) + } +} + +/// Header row: title on the left, three action buttons right-aligned. +/// +/// Returns any `AppAction` generated by clicks on the three header buttons +/// (`Add by username`, `Scan QR`, `Show my QR`). Each is routed through the +/// pure [`contacts_button_kind`] dispatcher so the same mapping is used by +/// the unit tests and by the renderer. +fn header_row(ui: &mut Ui, app_context: &Arc<AppContext>, dark_mode: bool) -> AppAction { + let mut action = AppAction::None; + ui.horizontal(|ui| { + ui.label( + RichText::new("Contacts") + .strong() + .size(22.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + // `Show my QR` — opens the existing QR generator screen. + let show_qr = ui + .add(ComponentStyles::secondary_button( + SHOW_MY_QR_LABEL, + dark_mode, + )) + .clickable_tooltip("Show a QR code so someone nearby can add you or pay you."); + if show_qr.clicked() { + action |= resolve_contacts_button(ContactsButton::HeaderShowMyQr, app_context); + } + ui.add_space(8.0); + + // `Scan QR` — routes to the add-contact screen, which owns + // the existing scan affordance; no new scan-only entry point + // is introduced. TODO(identity-hub): swap to a dedicated QR + // scanner screen if/when one ships. + let scan = ui + .add(ComponentStyles::secondary_button(SCAN_QR_LABEL, dark_mode)) + .clickable_tooltip("Use a camera or paste a QR image to add a contact."); + if scan.clicked() { + action |= resolve_contacts_button(ContactsButton::HeaderScanQr, app_context); + } + ui.add_space(8.0); + + // `Add by username` — primary CTA routes to the existing + // Add-contact screen (username-first input). + 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::HeaderAddByUsername, app_context); + } + }, + ); + }); + action +} + +/// Materialise a [`ContactsButton`] into a concrete [`AppAction`] using the +/// provided `AppContext`. Thin adapter over [`contacts_button_kind`] so the +/// renderer keeps using the pure dispatcher and tests can exercise the +/// decision logic without an `AppContext`. +fn resolve_contacts_button(button: ContactsButton, app_context: &Arc<AppContext>) -> AppAction { + match contacts_button_kind(button) { + ContactsButtonKind::OpenScreen(kind) => { + let screen = match kind { + ContactsScreenKind::AddContact => ScreenType::DashPayAddContact, + ContactsScreenKind::QrGenerator => ScreenType::DashPayQRGenerator, + }; + AppAction::AddScreen(screen.create_screen(app_context)) + } + ContactsButtonKind::SwitchHubTab(tab) => AppAction::SwitchIdentityHubTab(tab), + } +} + +/// Render a bordered section card with a heading and caller-supplied body. +fn section_card(ui: &mut Ui, dark_mode: bool, heading: &str, body: impl FnOnce(&mut Ui)) { + let frame = Frame::new() + .fill(DashColors::surface_elevated(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .inner_margin(Margin::symmetric(16, 12)); + frame.show(ui, |ui| { + ui.label( + RichText::new(heading) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + body(ui); + }); +} + +/// Primary DPNS handle for an identity: the first registered DPNS name, when +/// available. Returns the bare handle without the leading `@`. +fn primary_dpns_handle(identity: &QualifiedIdentity) -> Option<String> { + identity + .dpns_names + .first() + .map(|n| n.name.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Detect whether the identity has a DashPay social profile. Reads the hub's +/// async profile cache; an entry that is absent (not loaded yet) or holds no +/// profile yields `false`, so the gate is shown and the user is not blocked +/// until a profile is confirmed to exist. +fn has_social_profile( + profiles: &mut super::profile_cache::ProfileCache, + identity: &QualifiedIdentity, +) -> bool { + matches!(profiles.get_or_request(identity), Some(Some(_))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gated_state_variant_preserves_handle() { + let s = ContactsTabState::Gated { + handle: Some("alex.dash".to_string()), + }; + match s { + ContactsTabState::Gated { handle } => { + assert_eq!(handle.as_deref(), Some("alex.dash")); + } + _ => panic!("expected Gated"), + } + } + + #[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}`. + assert_eq!( + format!("{ACTIVE_HEADING_PREFIX} · 0"), + "Active contacts · 0" + ); + } + + #[test] + fn copy_constants_are_complete_sentences() { + for line in [NO_RECEIVED_EMPTY, NO_ACTIVE_EMPTY] { + assert!( + line.ends_with('.'), + "empty-state copy '{line}' must end with a period" + ); + assert!( + line.chars().next().unwrap().is_ascii_uppercase(), + "empty-state copy '{line}' must start with an uppercase letter" + ); + } + } + + // --------------------------------------------------------------- + // 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. + // --------------------------------------------------------------- + + const ALL_CONTACTS_BUTTONS: &[ContactsButton] = &[ + ContactsButton::HeaderAddByUsername, + ContactsButton::HeaderScanQr, + ContactsButton::HeaderShowMyQr, + ContactsButton::ActiveAddByUsername, + 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(...)`. + match kind { + ContactsButtonKind::OpenScreen(_) | ContactsButtonKind::SwitchHubTab(_) => {} + } + } + } + + #[test] + fn add_by_username_and_scan_qr_open_add_contact_screen() { + assert_eq!( + contacts_button_kind(ContactsButton::HeaderAddByUsername), + ContactsButtonKind::OpenScreen(ContactsScreenKind::AddContact), + ); + assert_eq!( + contacts_button_kind(ContactsButton::ActiveAddByUsername), + ContactsButtonKind::OpenScreen(ContactsScreenKind::AddContact), + ); + assert_eq!( + contacts_button_kind(ContactsButton::HeaderScanQr), + ContactsButtonKind::OpenScreen(ContactsScreenKind::AddContact), + ); + } + + #[test] + fn show_my_qr_opens_qr_generator_screen() { + assert_eq!( + contacts_button_kind(ContactsButton::HeaderShowMyQr), + ContactsButtonKind::OpenScreen(ContactsScreenKind::QrGenerator), + ); + } + + #[test] + fn gate_cta_switches_to_settings_tab() { + assert_eq!( + contacts_button_kind(ContactsButton::GateSetUpProfile), + ContactsButtonKind::SwitchHubTab(super::super::IdentityHubTab::Settings), + ); + } + + /// T28 regression guard — `ContactsState::reset()` must clear the load guard + /// so the next render re-dispatches `LoadContacts`. This is the low-level + /// assertion beneath the `change_context` → `refresh` → `contacts_state.reset()` + /// chain that prevents stale contacts from a previous network/identity being + /// served after a context switch. + #[test] + fn t28_reset_clears_load_guard() { + let mut state = ContactsState { + load_requested: true, + ..Default::default() + }; + state.reset(); + + assert!( + !state.load_requested, + "reset() must clear load_requested so the next render re-fires LoadContacts" + ); + } + + // --------------------------------------------------------------- + // ContactsState cache wiring + // --------------------------------------------------------------- + + /// T28 regression guard (extended): reset() must clear the load guard AND + /// the request caches so a refresh or network-switch re-fires the load + /// and the Received/Sent sections don't show stale data. + #[test] + fn t28_reset_clears_load_guard_and_caches() { + let mut state = ContactsState { + load_requested: true, + incoming: vec![ContactRequestEntry { + counterpart_id: "AAA".into(), + request_id: "RRR".into(), + relative_time: None, + }], + outgoing: vec![ContactRequestEntry { + counterpart_id: "BBB".into(), + request_id: "SSS".into(), + relative_time: None, + }], + }; + state.reset(); + assert!( + !state.load_requested, + "reset() must clear the load guard (T28)" + ); + assert!( + state.incoming.is_empty(), + "reset() must clear incoming cache" + ); + assert!( + state.outgoing.is_empty(), + "reset() must clear outgoing cache" + ); + } + + /// 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), ""); + } + + /// Received and sent section headings count the entries. + #[test] + fn section_headings_include_count_when_populated() { + let mut state = ContactsState::default(); + state.incoming.push(ContactRequestEntry { + counterpart_id: "AAA".into(), + request_id: "RRR".into(), + relative_time: Some("1 minute ago".into()), + }); + assert_eq!( + format!("{RECEIVED_HEADING} · {}", state.incoming.len()), + "Received requests · 1" + ); + assert_eq!(RECEIVED_HEADING, "Received requests"); + } +} diff --git a/src/ui/identity/home.rs b/src/ui/identity/home.rs new file mode 100644 index 000000000..d988f19e7 --- /dev/null +++ b/src/ui/identity/home.rs @@ -0,0 +1,1025 @@ +//! Identity Home tab. +//! +//! See design-spec §B.2 / §B.3. The Home tab is the default landing inside the +//! Identities hub once at least one identity exists. It stacks: +//! +//! 1. Hero identity card (gradient surface) — display name, handle, balance, +//! identity-type badge, network pill. Two variants: social profile set +//! (`IdentityHeroCard` with display name) and no social profile (type-glyph +//! monogram + inline `Set up your social profile` card below the hero). +//! 2. Quick-actions row: **Send**, **Receive**, **Add contact**. `Add contact` +//! is gated behind a social profile (see §B.3). +//! 3. Secondary actions row (ghost buttons): `Add funds`, `Send to wallet`, +//! `Send to another identity`. All three visible for all personas (§B.2). +//! 4. Onboarding checklist strip (until all three steps are complete or the +//! user dismisses it). +//! 5. Recent activity preview (up to 5 rows), currently an empty-state +//! preview; the `See all activity` link routes to the Activity tab. +//! Richer content is parked until the activity aggregator lands. +//! 6. Advanced details expander (raw Identity ID, revision, last updated). +//! +//! Strings are taken verbatim from §B.2 / §B.3 and the wording audit in §C. +//! +//! This module is state-less per-frame: the only persisted state is the +//! `dismissed_checklist` flag owned by the calling hub screen and passed in +//! via [`HomeState`]. Everything else is recomputed from `AppContext`. + +use super::identity_hero_card::{HeroIdentityKind, IdentityHeroCard}; +use super::onboarding_checklist::{ChecklistAction, ChecklistStep, OnboardingChecklist}; +use crate::app::AppAction; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::ScreenType; +use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; +use crate::ui::identity::tabs::IdentityHubTab; +use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing, network_label}; +#[cfg(test)] +use dash_sdk::dashcore_rpc::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, CornerRadius, Frame, Margin, RichText, Stroke, Ui}; +use std::sync::Arc; + +/// Mutable state owned by the hub screen and passed by reference to the Home +/// tab each frame. Kept tiny so the hub stays the single source of truth. +#[derive(Debug, Default, Clone)] +pub struct HomeState { + /// Whether the user has dismissed the onboarding checklist for this + /// session. Dismissal is intentionally ephemeral for now (no DB schema + /// change); if this needs to persist across restarts, the follow-up is + /// an additive `Settings` DB column with `DEFAULT false`. + pub dismissed_checklist: bool, + /// Whether the user has dismissed the inline social profile card. Same + /// ephemeral rationale as `dismissed_checklist` — honoured only for the + /// current session. + pub skipped_social_profile: bool, + /// Whether the Advanced expander on Home is open. Persisted in memory so + /// toggling state survives tab switches without a DB write. + pub advanced_open: bool, +} + +/// Intent returned by the Home tab so the hub screen can act on it without +/// needing deep knowledge of which button the user pressed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HomeOutcome { + /// Nothing happened this frame. + None, + /// User wants to switch to the Activity tab. + GoToActivity, + /// User wants to switch to the Contacts tab (via `Add contact`). + GoToContacts, + /// User wants to switch to the Settings tab — used by the inline + /// "Set up your social profile" card and the onboarding "Set a display + /// name" step, since DashPay social profile editing lives in §B.8 (the + /// Settings tab), not in the DPNS register-a-username flow. + GoToSettings, + /// User dismissed the onboarding checklist. + DismissChecklist, + /// User dismissed the inline social profile card. + SkipSocialProfile, + /// User toggled the Advanced expander. + ToggleAdvanced, +} + +/// Every clickable affordance on the Home tab. +/// +/// Pairing button identities with this enum lets us unit-test dispatch without +/// a UI harness: the test iterates every variant and asserts the resulting +/// action is **not** `AppAction::None` / `HomeOutcome::None`. Invariant: every +/// `HomeButton` must resolve to a non-no-op action — if the test passes, +/// every button produces a real side effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HomeButton { + /// Quick-action `Send`. + Send, + /// Quick-action `Receive`. + Receive, + /// Quick-action `Add contact` (enabled path — gated callers do not reach + /// the dispatcher). + AddContact, + /// Secondary action `Add funds`. + AddFunds, + /// Secondary action `Send to wallet`. + SendToWallet, + /// Secondary action `Send to another identity`. + SendToAnotherIdentity, + /// Inline social profile card `Add a display name` CTA. The hero card's + /// separate `Pick a username` CTA (when no DPNS handle exists) maps to + /// [`PickUsernameHero`](HomeButton::PickUsernameHero). + SetUpSocialProfile, + /// Hero card `Pick a username` CTA — only rendered when the hero has no + /// DPNS handle. + PickUsernameHero, + /// `See all activity` link in the recent-activity preview. + SeeAllActivity, + /// Onboarding checklist: "Pick a username" step. + ChecklistPickUsername, + /// Onboarding checklist: "Set a display name" step. + ChecklistSetDisplayName, + /// Onboarding checklist: "Add your first contact" step. + ChecklistAddFirstContact, + /// Onboarding checklist dismiss (X). + DismissChecklist, + /// Advanced expander header toggle. + ToggleAdvanced, +} + +/// What a Home-tab button press produces, as a pure discriminant — independent +/// of any `AppContext` so it is unit-testable without fixtures. The render +/// function maps this back to a concrete [`AppAction`] / [`HomeOutcome`] using +/// [`home_button_action`]. +/// +/// Variant summary: +/// - `ScreenType(ScreenKind)` — push a screen via `AppAction::AddScreen`. +/// - `Outcome(HomeOutcome)` — hub-local intent (tab switch, dismiss, toggle). +/// +/// The test harness uses [`HomeButtonKind::is_dead`] to flag any button that +/// resolves to a no-op. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HomeButtonKind { + /// Open a screen of the given kind for the hero identity. + OpenScreen(HomeScreenKind), + /// A hub-local outcome (checklist dismiss, tab switch, etc). + Outcome(HomeOutcome), +} + +/// The set of screens any Home-tab button can push. Each variant maps 1:1 to +/// a [`ScreenType`] constructor via [`HomeScreenKind::into_screen_type`] — +/// kept as a pure enum so unit tests do not need `ScreenType::create_screen`, +/// which requires an `AppContext`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HomeScreenKind { + /// `Screen::TransferScreen` — transfer credits between identities. + Transfer, + /// `Screen::TopUpIdentityScreen` — move wallet Dash into the identity. + TopUp, + /// `Screen::WithdrawalScreen` — move identity credits back to the wallet. + Withdrawal, + /// `Screen::RegisterDpnsNameScreen` — register a DPNS name for the identity. + RegisterDpnsName, +} + +impl HomeButtonKind { + /// A button is "dead" when pressing it would produce neither an + /// `AppAction::AddScreen` nor a meaningful [`HomeOutcome`]. This is the + /// invariant checked by the dead-button unit test. + pub fn is_dead(self) -> bool { + matches!(self, HomeButtonKind::Outcome(HomeOutcome::None)) + } +} + +/// Pure dispatcher: maps a [`HomeButton`] to what it *should* produce, with +/// no `AppContext` dependency. Unit-testable. +pub fn home_button_kind(button: HomeButton) -> HomeButtonKind { + use HomeButtonKind::{OpenScreen, Outcome}; + use HomeScreenKind::*; + match button { + HomeButton::Send | HomeButton::SendToAnotherIdentity => OpenScreen(Transfer), + HomeButton::Receive | HomeButton::AddFunds => OpenScreen(TopUp), + HomeButton::SendToWallet => OpenScreen(Withdrawal), + HomeButton::PickUsernameHero | HomeButton::ChecklistPickUsername => { + OpenScreen(RegisterDpnsName) + } + HomeButton::AddContact | HomeButton::ChecklistAddFirstContact => { + Outcome(HomeOutcome::GoToContacts) + } + HomeButton::SetUpSocialProfile | HomeButton::ChecklistSetDisplayName => { + Outcome(HomeOutcome::GoToSettings) + } + HomeButton::SeeAllActivity => Outcome(HomeOutcome::GoToActivity), + HomeButton::DismissChecklist => Outcome(HomeOutcome::DismissChecklist), + HomeButton::ToggleAdvanced => Outcome(HomeOutcome::ToggleAdvanced), + } +} + +/// Convert a [`HomeScreenKind`] to the matching [`ScreenType`] bound to the +/// given identity. Small helper so the render loop stays readable and the +/// same mapping is used everywhere. +fn home_screen_kind_to_screen_type( + kind: HomeScreenKind, + identity: &QualifiedIdentity, +) -> ScreenType { + match kind { + HomeScreenKind::Transfer => ScreenType::TransferScreen(identity.clone()), + HomeScreenKind::TopUp => ScreenType::TopUpIdentity(identity.clone()), + HomeScreenKind::Withdrawal => ScreenType::WithdrawalScreen(identity.clone()), + HomeScreenKind::RegisterDpnsName => { + ScreenType::RegisterDpnsName(RegisterDpnsNameSource::Identities) + } + } +} + +/// Result of a Home-tab button press as consumed by the renderer: the +/// `AppAction` to forward upstream plus the hub outcome (tab switch, etc). +/// Either may be `None`; `is_noop` returns `true` only when both are. +/// +/// Does not derive `Clone` because `AppAction` is not `Clone`. The struct is +/// produced, merged into the parent accumulator, and dropped within a single +/// frame. +#[derive(Debug)] +pub struct HomeButtonAction { + pub app_action: AppAction, + pub outcome: HomeOutcome, +} + +impl HomeButtonAction { + /// Whether the resolved action does nothing — both `AppAction::None` and + /// `HomeOutcome::None`. Used by the render code to silently drop no-ops. + pub fn is_noop(&self) -> bool { + matches!(self.app_action, AppAction::None) && self.outcome == HomeOutcome::None + } +} + +/// Dispatch a button to its concrete `(AppAction, HomeOutcome)` pair, using +/// [`home_button_kind`] for the pure decision and the current `AppContext` +/// only to materialise the target screen when one is needed. +pub fn home_button_action( + button: HomeButton, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, +) -> HomeButtonAction { + match home_button_kind(button) { + HomeButtonKind::OpenScreen(kind) => HomeButtonAction { + app_action: AppAction::AddScreen( + home_screen_kind_to_screen_type(kind, identity).create_screen(app_context), + ), + outcome: HomeOutcome::None, + }, + HomeButtonKind::Outcome(outcome) => HomeButtonAction { + app_action: AppAction::None, + outcome, + }, + } +} + +/// Render the Home tab. +/// +/// Returns a pair: `(AppAction, HomeOutcome)`. Most screens in the codebase +/// return only `AppAction`, but the Home tab also needs to report hub-local +/// intents (tab switch, dismiss checklist, toggle expander) that the hub +/// screen owns — hence the extra channel. +pub fn render( + ui: &mut Ui, + app_context: &Arc<AppContext>, + state: &HomeState, + profiles: &mut super::profile_cache::ProfileCache, +) -> (AppAction, HomeOutcome) { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut action = AppAction::None; + let mut outcome = HomeOutcome::None; + + // Render the app-scoped active identity (selected → first → none). The + // breadcrumb switcher and picker write the selection; this reads it. + let identity = match app_context.resolve_selected_identity() { + Some(qi) => qi, + None => { + render_empty(ui, dark_mode); + return (action, outcome); + } + }; + + // A tiny local closure that dispatches via the pure + // `home_button_action` function and merges the result into the + // `(action, outcome)` pair. Using this at every click site keeps the UI + // code a single place to search for dead buttons. + let mut apply = |button: HomeButton| { + let dispatched = home_button_action(button, app_context, &identity); + action |= dispatched.app_action; + if dispatched.outcome != HomeOutcome::None { + outcome = dispatched.outcome; + } + }; + + // --- Hero card ---------------------------------------------------- + let hero = build_hero(app_context, &identity, profiles); + let hero_has_social_profile = hero.has_social_profile(); + let hero_response = hero.show(ui); + if hero_response.pick_username_clicked() { + apply(HomeButton::PickUsernameHero); + } + + // --- Inline "Set up your social profile" card (no-profile variant) - + // + // Per wireframe §B.3 this prompt belongs immediately below the hero for + // the no-profile variant (V2 visual fix). It was previously rendered after + // the secondary-actions row, leaving the hero visually empty and the card + // buried. Moved here so the two form a single compact visual group with no + // gap between them. With V1 applied (hero sized to content, no min-height + // floor) the hero is already compact — no extra space is added before this + // card, and the standard Spacing::MD below separates both from the actions. + // + // V2/V3 conflict: the onboarding checklist already contains a + // "Set a display name" step that routes to Settings — the same action as + // this card. When the checklist is visible (not dismissed), suppress this + // card so the user sees exactly one prompt for the action. + let checklist_covers_profile = !hero_has_social_profile && !state.dismissed_checklist; + if !hero_has_social_profile + && !state.skipped_social_profile + && !checklist_covers_profile + && paint_social_profile_card(ui, dark_mode) + { + apply(HomeButton::SetUpSocialProfile); + } + ui.add_space(Spacing::MD); + + // --- Quick actions row -------------------------------------------- + ui.horizontal(|ui| { + // "Send" routes to the identity Transfer screen (identity→identity + // credit transfer). The tooltip therefore describes that action, not + // a wallet-Dash send (T30). + if primary_quick_action( + ui, + "Send", + "Transfer credits from this identity to another identity.", + ) + .clicked() + { + apply(HomeButton::Send); + } + ui.add_space(Spacing::SM); + // "Receive" routes to TopUpIdentity (wallet→identity credits). The + // tooltip therefore describes adding funds from the wallet, not showing + // a QR code for inbound Dash (T30). + if primary_quick_action( + ui, + "Receive", + "Move Dash from your wallet into this identity.", + ) + .clicked() + { + apply(HomeButton::Receive); + } + ui.add_space(Spacing::SM); + + // Add contact is gated behind a social profile per §B.3. + let add_contact = egui::Button::new( + RichText::new("Add contact") + .strong() + .color(DashColors::text_primary(dark_mode)), + ) + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border(dark_mode), + )) + .min_size(egui::vec2(160.0, 40.0)); + if hero_has_social_profile { + let resp = ui + .add(add_contact) + .clickable_tooltip("Find someone by username and add them to your contacts."); + if resp.clicked() { + apply(HomeButton::AddContact); + } + } else { + ui.add_enabled(false, add_contact).disabled_tooltip( + "Set up a social profile first. Contacts need a display name and avatar \ + so people can find you.", + ); + } + }); + ui.add_space(Spacing::MD); + + // --- Secondary actions row ---------------------------------------- + ui.horizontal(|ui| { + if ghost_action( + ui, + "Add funds", + "Move Dash from your wallet into this identity.", + dark_mode, + ) + .clicked() + { + apply(HomeButton::AddFunds); + } + ui.add_space(Spacing::SM); + if ghost_action( + ui, + "Send to wallet", + "Convert your identity balance back to spendable Dash in your wallet.", + dark_mode, + ) + .clicked() + { + apply(HomeButton::SendToWallet); + } + ui.add_space(Spacing::SM); + if ghost_action( + ui, + "Send to another identity", + "Transfer Dash directly from this identity to another identity.", + dark_mode, + ) + .clicked() + { + apply(HomeButton::SendToAnotherIdentity); + } + }); + ui.add_space(Spacing::MD); + + // --- Onboarding checklist ----------------------------------------- + if !state.dismissed_checklist { + // Extract the primary DPNS handle for the done-subtext ("You are + // @{handle}.") — passed into the checklist as optional context. + let primary_handle = identity + .dpns_names + .first() + .map(|n| n.name.trim().to_string()) + .filter(|s| !s.is_empty()); + + let mut checklist = OnboardingChecklist::new(); + if let Some(h) = &primary_handle { + checklist = checklist.with_handle(h); + } + if primary_handle.is_some() { + checklist = checklist.mark_complete(ChecklistStep::PickUsername); + } + if hero_has_social_profile { + checklist = checklist.mark_complete(ChecklistStep::SetDisplayName); + } else if state.skipped_social_profile { + checklist = checklist.hide(ChecklistStep::SetDisplayName); + } + // We don't yet know if the user has contacts without a DashPay load; + // leave `AddFirstContact` in its default (pending) state. The checklist + // honours mark_complete calls as they come from the contacts pipeline + // once T9 wires contact counts into the hub state. + + if !checklist.all_complete() { + let resp = checklist.show(ui); + match resp.action() { + Some(ChecklistAction::Dismissed) => { + apply(HomeButton::DismissChecklist); + } + Some(ChecklistAction::Activated(step)) => match step { + ChecklistStep::PickUsername => apply(HomeButton::ChecklistPickUsername), + ChecklistStep::SetDisplayName => apply(HomeButton::ChecklistSetDisplayName), + ChecklistStep::AddFirstContact => apply(HomeButton::ChecklistAddFirstContact), + }, + None => {} + } + ui.add_space(Spacing::MD); + } + } + + // --- Recent activity preview -------------------------------------- + ui.push_id("home_recent_activity", |ui| { + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border_light(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .inner_margin(Margin::same(Spacing::MD as i8)); + frame.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Recent activity") + .size(16.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + ui.add_space(Spacing::SM); + + // Empty-state preview — wiring to real data is parked until the + // unified activity aggregator lands. We render the design-spec + // empty-state sentence so the copy is visible. + ui.label( + RichText::new( + "No activity yet. When you send or receive Dash, it will show up here.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(Spacing::SM); + if ui + .add( + egui::Label::new( + RichText::new("See all activity") + .underline() + .color(DashColors::DASH_BLUE), + ) + .sense(egui::Sense::click()), + ) + .clickable_tooltip("Open the unified activity timeline for this identity.") + .clicked() + { + apply(HomeButton::SeeAllActivity); + } + }); + }); + + ui.add_space(Spacing::MD); + + // --- Advanced expander -------------------------------------------- + let advanced_header = if state.advanced_open { + "▾ Advanced details" + } else { + "▸ Advanced details" + }; + let header_resp = ui + .add( + egui::Label::new( + RichText::new(advanced_header).color(DashColors::text_secondary(dark_mode)), + ) + .sense(egui::Sense::click()), + ) + .clickable_tooltip("Show technical details like raw IDs, keys, and revision numbers."); + if header_resp.clicked() { + apply(HomeButton::ToggleAdvanced); + } + if state.advanced_open { + ui.add_space(Spacing::XS); + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border_light(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_SM)) + .inner_margin(Margin::same(Spacing::SM as i8)); + frame.show(ui, |ui| { + let id_str = identity.identity.id().to_string(Encoding::Base58); + ui.horizontal(|ui| { + ui.label( + RichText::new("Identity ID:").color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(Spacing::XS); + ui.add( + egui::Label::new( + RichText::new(&id_str) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ) + .selectable(true), + ); + }); + ui.horizontal(|ui| { + ui.label(RichText::new("Version:").color(DashColors::text_secondary(dark_mode))); + ui.add_space(Spacing::XS); + ui.label( + RichText::new(identity.identity.revision().to_string()) + .color(DashColors::text_primary(dark_mode)), + ); + }); + ui.horizontal(|ui| { + ui.label(RichText::new("Keys:").color(DashColors::text_secondary(dark_mode))); + ui.add_space(Spacing::XS); + ui.label( + RichText::new(identity.identity.public_keys().len().to_string()) + .color(DashColors::text_primary(dark_mode)), + ); + }); + }); + } + + (action, outcome) +} + +/// Convenience for callers: apply the `HomeOutcome` to the hub state. +pub fn apply_outcome(state: &mut HomeState, outcome: HomeOutcome) -> Option<IdentityHubTab> { + match outcome { + HomeOutcome::None => None, + HomeOutcome::GoToActivity => Some(IdentityHubTab::Activity), + HomeOutcome::GoToContacts => Some(IdentityHubTab::Contacts), + HomeOutcome::GoToSettings => Some(IdentityHubTab::Settings), + HomeOutcome::DismissChecklist => { + state.dismissed_checklist = true; + None + } + HomeOutcome::SkipSocialProfile => { + state.skipped_social_profile = true; + None + } + HomeOutcome::ToggleAdvanced => { + state.advanced_open = !state.advanced_open; + None + } + } +} + +/// Pick an identity to render in the hero. Returns the first loaded +/// qualified identity on the active network, or `None` if the load fails or +/// the user has no identities. The hub already handles the latter via +/// `HubLanding::Onboarding`, so this is only reached when at least one +/// identity exists. +/// Build the [`IdentityHeroCard`] from a qualified identity. Keeps the +/// rendering code in `render` readable. +fn build_hero( + app_context: &Arc<AppContext>, + qi: &QualifiedIdentity, + profiles: &mut super::profile_cache::ProfileCache, +) -> IdentityHeroCard { + let kind: HeroIdentityKind = qi.identity_type.into(); + let balance_dash = format_credits_short(qi.identity.balance()); + let handle = qi + .dpns_names + .first() + .map(|n| n.name.clone()) + .filter(|n| !n.trim().is_empty()); + + // Best-effort DashPay display name. The local profile cache was removed in + // the platform-wallet migration; the hub loads profiles asynchronously, so + // this is empty until the first load completes and the hero re-renders. + let display_name = load_display_name_opt(profiles, qi); + + let mut card = IdentityHeroCard::new(kind, balance_dash); + if let Some(handle) = handle { + card = card.with_dpns_handle(handle); + } + if let Some(name) = display_name { + card = card.with_display_name(name); + } + let network_label = network_label(app_context.network()); + card = card + .with_network_label(network_label) + .with_network_tooltip(format!( + "You are on {network_label}. Identities and balances are separate per network.", + )); + card +} + +fn load_display_name_opt( + profiles: &mut super::profile_cache::ProfileCache, + qi: &QualifiedIdentity, +) -> Option<String> { + profiles + .get_or_request(qi) + .and_then(|p| p.as_ref()) + .and_then(|fields| fields.display_name_opt().map(str::to_owned)) +} + +/// Format a credit balance (u64, Platform credits) as a bare DASH amount +/// string with four decimal places and no unit suffix, for embedding in a +/// `"{amount} DASH"` template. Distinct from +/// [`crate::model::fee_estimation::format_credits_as_dash`], which returns a +/// trimmed amount with the unit already appended. +fn format_credits_short(credits: u64) -> String { + format!( + "{:.4}", + crate::model::amount::Amount::dash_from_credits(credits).to_f64() + ) +} + +/// Render the empty-state placeholder shown when no loaded identity can be +/// resolved. In practice the hub routes to `HubLanding::Onboarding` in that +/// situation — this path is defensive and only triggers on a mid-frame load +/// failure. +fn render_empty(ui: &mut Ui, dark_mode: bool) { + ui.vertical_centered(|ui| { + ui.add_space(Spacing::LG); + ui.label( + RichText::new("No identity selected.").color(DashColors::text_secondary(dark_mode)), + ); + }); +} + +/// Build a primary (filled, Dash-blue) button used in the quick-actions row. +/// Returns the `Response` so the caller can attach click handling inline. +fn primary_quick_action(ui: &mut Ui, label: &str, tooltip: &str) -> egui::Response { + let btn = egui::Button::new(RichText::new(label).strong().color(Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(140.0, 40.0)); + ui.add(btn).clickable_tooltip(tooltip) +} + +/// Build a ghost (outlined) button used in the secondary-actions row. +fn ghost_action(ui: &mut Ui, label: &str, tooltip: &str, dark_mode: bool) -> egui::Response { + let btn = egui::Button::new(RichText::new(label).color(DashColors::text_primary(dark_mode))) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border(dark_mode), + )) + .min_size(egui::vec2(160.0, 36.0)); + ui.add(btn).clickable_tooltip(tooltip) +} + +/// Render the inline social profile card shown below the hero when the user +/// has no display name. Returns `true` when the primary `Add a display name` +/// button is clicked. +fn paint_social_profile_card(ui: &mut Ui, dark_mode: bool) -> bool { + let mut clicked = false; + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border_light(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .inner_margin(Margin::same(Spacing::MD as i8)); + frame.show(ui, |ui| { + ui.label( + RichText::new("Set up your social profile") + .size(18.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(Spacing::XS); + ui.label( + RichText::new( + "Add a display name, bio, and avatar so people can find you on DashPay. \ + This is optional — you can still use every other feature without it.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(Spacing::SM); + let btn = egui::Button::new( + RichText::new("Add a display name") + .strong() + .color(Color32::WHITE), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(200.0, 36.0)); + // Profile editing currently lives in the legacy Dashpay section. + // Click is wired through the caller's `action` channel via the + // return value; we don't route the user from inside the helper so + // the caller retains full control of navigation. + if ui + .add(btn) + .clickable_tooltip("Open the profile editor to pick a display name, bio, and avatar.") + .clicked() + { + clicked = true; + } + }); + clicked +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_credits_emits_four_decimals() { + assert_eq!(format_credits_short(0), "0.0000"); + // 1.2345 DASH = 123_450_000_000 credits. + assert_eq!(format_credits_short(123_450_000_000), "1.2345"); + } + + #[test] + fn home_state_default_flags_are_false() { + let state = HomeState::default(); + assert!(!state.advanced_open); + assert!(!state.dismissed_checklist); + assert!(!state.skipped_social_profile); + } + + #[test] + fn apply_outcome_none_returns_none() { + let mut state = HomeState::default(); + assert_eq!(apply_outcome(&mut state, HomeOutcome::None), None); + assert!(!state.dismissed_checklist); + } + + #[test] + fn apply_outcome_dismiss_checklist_sets_flag() { + let mut state = HomeState::default(); + assert_eq!( + apply_outcome(&mut state, HomeOutcome::DismissChecklist), + None, + ); + assert!(state.dismissed_checklist); + } + + #[test] + fn apply_outcome_toggle_advanced_flips_state() { + let mut state = HomeState::default(); + apply_outcome(&mut state, HomeOutcome::ToggleAdvanced); + assert!(state.advanced_open); + apply_outcome(&mut state, HomeOutcome::ToggleAdvanced); + assert!(!state.advanced_open); + } + + #[test] + fn apply_outcome_go_to_tabs_returns_tab() { + let mut state = HomeState::default(); + assert_eq!( + apply_outcome(&mut state, HomeOutcome::GoToActivity), + Some(IdentityHubTab::Activity), + ); + assert_eq!( + apply_outcome(&mut state, HomeOutcome::GoToContacts), + Some(IdentityHubTab::Contacts), + ); + } + + #[test] + fn network_label_returns_stable_strings() { + assert_eq!(network_label(Network::Mainnet), "Mainnet"); + assert_eq!(network_label(Network::Testnet), "Testnet"); + assert_eq!(network_label(Network::Devnet), "Devnet"); + assert_eq!(network_label(Network::Regtest), "Regtest"); + } + + // ----------------------------------------------------------------- + // Dead-button regression tests. + // + // Invariant: every `HomeButton` variant must produce a non-dead + // `HomeButtonKind` (neither `Outcome(HomeOutcome::None)` nor some + // future catch-all). The enum is non-`Default`-constructible and + // every variant is enumerated in `ALL_HOME_BUTTONS` — if you add a + // new button, you MUST extend this list or the "coverage" test + // fails. Guards against #842, where the hub screen discarded the + // tab's action value and every quick/secondary action silently + // returned `AppAction::None`. + // ----------------------------------------------------------------- + + /// Every `HomeButton` variant. The list is hand-maintained so that + /// the compiler catches missing entries via `ALL_BUTTONS_IS_EXHAUSTIVE` + /// below — adding a variant without updating this list is a compile + /// error. + const ALL_HOME_BUTTONS: &[HomeButton] = &[ + HomeButton::Send, + HomeButton::Receive, + HomeButton::AddContact, + HomeButton::AddFunds, + HomeButton::SendToWallet, + HomeButton::SendToAnotherIdentity, + HomeButton::SetUpSocialProfile, + HomeButton::PickUsernameHero, + HomeButton::SeeAllActivity, + HomeButton::ChecklistPickUsername, + HomeButton::ChecklistSetDisplayName, + HomeButton::ChecklistAddFirstContact, + HomeButton::DismissChecklist, + HomeButton::ToggleAdvanced, + ]; + + /// Exhaustiveness check: if a new `HomeButton` variant is added + /// without updating `ALL_HOME_BUTTONS`, this match will fail to + /// compile because the new variant has no arm. + #[test] + fn all_buttons_list_is_exhaustive() { + for button in ALL_HOME_BUTTONS { + #[allow(unreachable_patterns)] + let _: () = match *button { + HomeButton::Send => (), + HomeButton::Receive => (), + HomeButton::AddContact => (), + HomeButton::AddFunds => (), + HomeButton::SendToWallet => (), + HomeButton::SendToAnotherIdentity => (), + HomeButton::SetUpSocialProfile => (), + HomeButton::PickUsernameHero => (), + HomeButton::SeeAllActivity => (), + HomeButton::ChecklistPickUsername => (), + HomeButton::ChecklistSetDisplayName => (), + HomeButton::ChecklistAddFirstContact => (), + HomeButton::DismissChecklist => (), + HomeButton::ToggleAdvanced => (), + }; + } + } + + #[test] + fn every_home_button_produces_live_action() { + for button in ALL_HOME_BUTTONS { + let kind = home_button_kind(*button); + assert!( + !kind.is_dead(), + "HomeButton::{button:?} is dead — produced {kind:?}. Either the button is \ + supposed to be disabled (then do not render it as enabled), or the dispatcher \ + lost its arm.", + ); + } + } + + /// Pin the specific mapping for the most-clicked surfaces so a future + /// rename or swap (e.g. routing `Send` to `TopUp`) is caught. + #[test] + fn primary_send_receive_mappings_are_stable() { + assert_eq!( + home_button_kind(HomeButton::Send), + HomeButtonKind::OpenScreen(HomeScreenKind::Transfer), + ); + assert_eq!( + home_button_kind(HomeButton::Receive), + HomeButtonKind::OpenScreen(HomeScreenKind::TopUp), + ); + assert_eq!( + home_button_kind(HomeButton::AddFunds), + HomeButtonKind::OpenScreen(HomeScreenKind::TopUp), + ); + assert_eq!( + home_button_kind(HomeButton::SendToWallet), + HomeButtonKind::OpenScreen(HomeScreenKind::Withdrawal), + ); + assert_eq!( + home_button_kind(HomeButton::SendToAnotherIdentity), + HomeButtonKind::OpenScreen(HomeScreenKind::Transfer), + ); + } + + #[test] + fn social_profile_ctas_route_to_settings_not_dpns() { + // Design-spec §B.8: DashPay profile (display name / bio / avatar) is + // edited on the Settings tab, not in RegisterDpnsName. + assert_eq!( + home_button_kind(HomeButton::SetUpSocialProfile), + HomeButtonKind::Outcome(HomeOutcome::GoToSettings), + ); + assert_eq!( + home_button_kind(HomeButton::ChecklistSetDisplayName), + HomeButtonKind::Outcome(HomeOutcome::GoToSettings), + ); + } + + #[test] + fn pick_a_username_routes_to_dpns_flow() { + assert_eq!( + home_button_kind(HomeButton::PickUsernameHero), + HomeButtonKind::OpenScreen(HomeScreenKind::RegisterDpnsName), + ); + assert_eq!( + home_button_kind(HomeButton::ChecklistPickUsername), + HomeButtonKind::OpenScreen(HomeScreenKind::RegisterDpnsName), + ); + } + + #[test] + fn see_all_activity_switches_to_activity_tab() { + assert_eq!( + home_button_kind(HomeButton::SeeAllActivity), + HomeButtonKind::Outcome(HomeOutcome::GoToActivity), + ); + } + + #[test] + fn add_contact_and_first_contact_step_switch_to_contacts_tab() { + assert_eq!( + home_button_kind(HomeButton::AddContact), + HomeButtonKind::Outcome(HomeOutcome::GoToContacts), + ); + assert_eq!( + home_button_kind(HomeButton::ChecklistAddFirstContact), + HomeButtonKind::Outcome(HomeOutcome::GoToContacts), + ); + } + + #[test] + fn dismiss_and_toggle_are_pure_hub_outcomes() { + assert_eq!( + home_button_kind(HomeButton::DismissChecklist), + HomeButtonKind::Outcome(HomeOutcome::DismissChecklist), + ); + assert_eq!( + home_button_kind(HomeButton::ToggleAdvanced), + HomeButtonKind::Outcome(HomeOutcome::ToggleAdvanced), + ); + } + + #[test] + fn apply_outcome_go_to_settings_returns_settings_tab() { + // `GoToSettings` must be wired into `apply_outcome`; a missing + // match arm would silently drop the outcome. + let mut state = HomeState::default(); + assert_eq!( + apply_outcome(&mut state, HomeOutcome::GoToSettings), + Some(IdentityHubTab::Settings), + ); + } + + #[test] + fn home_button_action_wraps_kind_into_appaction() { + // No `AppContext` available in this unit test, so we only inspect + // the `is_noop` invariant after resolving — mirrors what the + // renderer cares about. + for button in ALL_HOME_BUTTONS { + let kind = home_button_kind(*button); + // Every kind must be "live" (non-dead); the renderer wraps + // `OpenScreen` into `AppAction::AddScreen` (non-None) and + // `Outcome(x)` into `HomeButtonAction.outcome = x`. Either + // way the result is a non-noop. + let mock = match kind { + HomeButtonKind::OpenScreen(_) => HomeButtonAction { + // The render code wraps this into AddScreen(...) + // — we assert non-noop via a placeholder so the test + // doesn't need `AppContext`. + app_action: AppAction::None, + outcome: HomeOutcome::None, + }, + HomeButtonKind::Outcome(o) => HomeButtonAction { + app_action: AppAction::None, + outcome: o, + }, + }; + // For `OpenScreen` variants we can only assert via the kind, + // not via HomeButtonAction.is_noop (see above). For `Outcome` + // variants we CAN check the materialised struct. + match kind { + HomeButtonKind::OpenScreen(_) => { + assert!( + !kind.is_dead(), + "OpenScreen kind for {button:?} is not dead", + ); + } + HomeButtonKind::Outcome(_) => { + assert!( + !mock.is_noop(), + "Outcome for {button:?} resolves to noop — {mock:?}", + ); + } + } + } + } +} diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs new file mode 100644 index 000000000..2f5f58113 --- /dev/null +++ b/src/ui/identity/hub_screen.rs @@ -0,0 +1,416 @@ +//! Root screen for the unified Identities hub. +//! +//! Holds the currently selected tab and the cached landing state; each frame it +//! recomputes the landing from the active-network identity count and dispatches +//! rendering to the appropriate submodule. +//! +//! See `docs/ai-design/2026-04-23-identity-hub-impl/04-dev-plan.md`. + +use super::breadcrumb_switcher::{self, BreadcrumbEffect}; +use super::identity_hub_tab_bar::IdentityHubTabBar; +use crate::app::AppAction; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel_with_breadcrumb; +use crate::ui::state::hub_selection::{HubSelection, HubView, effective_view}; +use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, Context}; +use std::sync::Arc; + +use super::home::HomeState; +use super::landing::HubLanding; +use super::settings::SettingsTab; +use super::tabs::IdentityHubTab; + +/// Top-level screen for the new unified Identities hub. +/// +/// Owns the selected tab, per-tab state, and the cached landing state; tab +/// submodules render into this via the shared `AppContext`. +pub struct IdentityHubScreen { + pub app_context: Arc<AppContext>, + /// The currently selected tab. Persisted only in memory for now — start tab + /// persistence across restarts is a follow-up. + selected_tab: IdentityHubTab, + /// Cached landing-load error banner. Set by `landing()` when identity + /// loading fails; cleared on `refresh`. Separating this from a real + /// zero-identity state avoids inviting users to create identities when the + /// real problem is a database / context error. + load_error_banner: Option<BannerHandle>, + /// Remembered landing state from the most recent successful load. Falls + /// back to `HubLanding::Onboarding` on first render so the UI has + /// something to draw until the first load attempt completes. + last_good_landing: HubLanding, + /// Home-tab state (dismissed checklist flag, advanced expander, etc). + /// Owned here so tab switches do not wipe it. + home_state: HomeState, + /// Settings-tab state. Held on the hub so edit fields, unsaved drafts, + /// and modal state persist across frames. + settings_tab: SettingsTab, + /// Contacts-tab state. Owned here to debounce + /// [`crate::backend_task::dashpay::DashPayTask::LoadContacts`] to a + /// single dispatch per tab entry, instead of firing every frame. + contacts_state: super::contacts::ContactsState, + /// DashPay profiles, loaded asynchronously (the local profile cache was + /// removed in the platform-wallet migration). Read by the Home, Contacts, + /// and Settings tabs; loads are dispatched after rendering each frame. + profile_cache: super::profile_cache::ProfileCache, + /// Breadcrumb-switcher view state (picker override + dropdown search + /// buffers). The active identity itself is app-scoped on `AppContext`. + selection: HubSelection, +} + +impl IdentityHubScreen { + /// Construct a new hub screen. Follows the project convention: constructors + /// handle errors internally via `MessageBanner` and return `Self`. The + /// scaffold has nothing to fail on yet. + pub fn new(app_context: &Arc<AppContext>) -> Self { + Self { + app_context: app_context.clone(), + selected_tab: IdentityHubTab::default(), + load_error_banner: None, + last_good_landing: HubLanding::Onboarding, + home_state: HomeState::default(), + settings_tab: SettingsTab::new(), + contacts_state: super::contacts::ContactsState::default(), + profile_cache: super::profile_cache::ProfileCache::default(), + selection: HubSelection::default(), + } + } + + /// Resolve the current landing state from the active-network identity + /// count. On load failure, surface a calm error banner (technical details + /// attached separately) and reuse the last-known-good landing instead of + /// silently routing the user to onboarding. + pub(crate) fn landing(&mut self, ctx: &Context) -> HubLanding { + // FR-6: the Identities hub is an everyday-user surface — its landing + // count and picker list User identities only, never masternode/evonode. + match self.app_context.load_local_user_identities() { + Ok(identities) => { + // Clear any previously-shown error banner now that loading works. + self.load_error_banner.take_and_clear(); + let landing = HubLanding::from_identity_count(identities.len()); + self.last_good_landing = landing; + landing + } + Err(e) => { + // Idempotent: set_global de-duplicates by text, so repainting + // this frame after frame does not spam banners. + let handle = MessageBanner::set_global( + ctx, + "Could not load your identities from this device. Try refreshing or \ + reopening the app.", + MessageType::Error, + ); + handle.with_details(&e); + handle.disable_auto_dismiss(); + self.load_error_banner = Some(handle); + self.last_good_landing + } + } + } + + /// Currently selected tab. + pub fn selected_tab(&self) -> IdentityHubTab { + self.selected_tab + } + + /// Select a different tab (used by tab-bar click handling + in-screen deep links). + pub fn select_tab(&mut self, tab: IdentityHubTab) { + self.selected_tab = tab; + } + + /// 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. + fn apply_breadcrumb_effect(&mut self, effect: BreadcrumbEffect) -> AppAction { + match effect { + BreadcrumbEffect::None => AppAction::None, + BreadcrumbEffect::OpenPicker => { + self.selection.open_picker(); + AppAction::None + } + BreadcrumbEffect::SwitchWallet(hash) => { + self.app_context.set_selected_hd_wallet(Some(hash)); + self.selection.clear_picker_override(); + self.contacts_state.reset(); + self.profile_cache.reset(); + AppAction::None + } + BreadcrumbEffect::SelectIdentity(id) => { + self.app_context.set_selected_identity(Some(id)); + self.selection.clear_picker_override(); + self.contacts_state.reset(); + self.profile_cache.reset(); + AppAction::None + } + BreadcrumbEffect::AddWallet => { + AppAction::SetMainScreen(RootScreenType::RootScreenWalletsBalances) + } + // The bulk-create flow is not wired yet; route to the single-create + // screen so the dev entry is functional in the interim. + BreadcrumbEffect::AddIdentityCreate | BreadcrumbEffect::CreateTestIdentities => { + AppAction::AddScreen(ScreenType::AddNewIdentity.create_screen(&self.app_context)) + } + BreadcrumbEffect::AddIdentityLoad => AppAction::AddScreen( + ScreenType::AddExistingIdentity.create_screen(&self.app_context), + ), + } + } +} + +impl ScreenLike for IdentityHubScreen { + fn refresh(&mut self) { + // Clear the stale load-error banner so the next `landing()` call can + // try again cleanly, and reset per-tab load guards so a refresh + // re-fires the Contacts load. + self.load_error_banner.take_and_clear(); + self.contacts_state.reset(); + self.profile_cache.reset(); + self.selection.clear_searches(); + } + + fn refresh_on_arrival(&mut self) { + self.refresh(); + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; + let mut action = AppAction::None; + + // Top panel hosts the three-segment breadcrumb switcher on every + // landing. The switcher is a pure component returning a typed effect; + // the hub applies it below. + let app_context = self.app_context.clone(); + let selection = &mut self.selection; + let mut breadcrumb_effect = BreadcrumbEffect::None; + action |= add_top_panel_with_breadcrumb( + ui, + &self.app_context, + |ui| { + breadcrumb_effect = breadcrumb_switcher::render(ui, &app_context, selection); + AppAction::None + }, + vec![], + ); + + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenIdentityHub); + + // Resolve the surface: onboarding (no identities) / picker (choose one) + // / home (the resolved active identity). `landing()` keeps the + // load-error banner behaviour and a last-known-good fallback. + let landing = self.landing(ctx); + // Load the identity list once per frame and reuse it for the view + // computation AND the Picker arm (the Onboarding surface needs no list). + // TODO(IDH-003 follow-up): also fold `landing()`'s load and the + // breadcrumb switcher's per-frame loads into a single shared snapshot. + let frame_identities = if matches!(landing, HubLanding::Onboarding) { + Vec::new() + } else { + // FR-6: User identities only — the picker grid never lists MN/Evonode. + self.app_context + .load_local_user_identities() + .unwrap_or_default() + }; + let view = if matches!(landing, HubLanding::Onboarding) { + HubView::Onboarding + } else { + let active = self.app_context.selected_identity_id(); + let has_explicit = + active.is_some_and(|id| frame_identities.iter().any(|qi| qi.identity.id() == id)); + effective_view( + frame_identities.len(), + has_explicit, + self.selection.picker_override(), + ) + }; + + let mut picked_identity: Option<String> = None; + action |= island_central_panel(ui, |ui| { + // Claim the island's full width so its bordered panel reaches the + // window edges; the content below stays centered. + ui.set_min_width(ui.available_width()); + match view { + HubView::Onboarding => super::onboarding::render(ui, &self.app_context), + HubView::Picker => super::picker::render( + ui, + &self.app_context, + &frame_identities, + Some(&mut picked_identity), + ), + HubView::Home => { + // `ui.vertical_centered(...).inner` carries the tab's + // `AppAction` back out. + let inner = ui.vertical_centered(|ui| { + // Hub tab bar; selection state lives on the screen so + // refreshes and deep links can update it from outside. + let tab_response = IdentityHubTabBar::new(self.selected_tab).show(ui); + if let Some(clicked) = tab_response.clicked() { + if clicked != self.selected_tab { + // Tab-entry transition: reset per-tab load guards + // so the incoming tab re-dispatches once. + self.contacts_state.reset(); + } + self.selected_tab = clicked; + } + ui.add_space(16.0); + match self.selected_tab { + IdentityHubTab::Home => { + let (home_action, outcome) = super::home::render( + ui, + &self.app_context, + &self.home_state, + &mut self.profile_cache, + ); + if let Some(next_tab) = + super::home::apply_outcome(&mut self.home_state, outcome) + { + if next_tab != self.selected_tab { + self.contacts_state.reset(); + } + self.selected_tab = next_tab; + } + home_action + } + IdentityHubTab::Contacts => super::contacts::render( + ui, + &self.app_context, + &mut self.contacts_state, + &mut self.profile_cache, + ), + IdentityHubTab::Activity => { + super::activity::render(ui, &self.app_context) + } + IdentityHubTab::Settings => self.settings_tab.render( + ui, + &self.app_context, + &mut self.profile_cache, + ), + } + }); + inner.inner + } + } + }); + + // A picker card click sets the active identity and routes to Home. + if let Some(id_str) = picked_identity + && let Ok(id) = Identifier::from_string_unknown_encoding(&id_str) + { + self.app_context.set_selected_identity(Some(id)); + self.selection.clear_picker_override(); + self.contacts_state.reset(); + self.profile_cache.reset(); + } + + action |= self.apply_breadcrumb_effect(breadcrumb_effect); + + // Dispatch any profile load a tab requested this frame (single load + // in flight; the local profile cache was removed in the platform-wallet + // migration, so profiles resolve asynchronously via the backend). + action |= self.profile_cache.dispatch_pending(); + + action + } + + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // AppState sets the global banner centrally; the hub itself owns no + // in-flight task banners. Sub-tab content overrides its own lifecycle. + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + // Feed an async DashPay profile load back into the cache the tabs read. + self.profile_cache.record_result(&result); + + match &result { + // A confirmed profile-save success: commit the edit baseline on the + // Settings tab so the Save button re-enables only after the next + // edit. Guard by identity ID to reject stale results. + BackendTaskSuccessResult::DashPayProfileUpdated(saved_id) => { + let matches = self + .settings_tab + .selected_identity() + .is_some_and(|qi| qi.identity.id() == saved_id); + if matches { + self.settings_tab.on_profile_saved(); + } + } + // Populate the Received/Sent request caches so the Contacts tab + // 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()); + } + _ => {} + } + } + + fn display_task_error(&mut self, _error: &TaskError) -> bool { + // 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 + // otherwise commit the stale submitted values as the new baseline. + // Clearing on any error is safe: pending_save is None most of the time, + // and clearing it while it's None is a no-op. + self.settings_tab.clear_pending_save(); + + // Let AppState render the default error banner — the hub has no + // other special-case error handling of its own yet. + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Unit tests for the screen's pure state manipulation. Rendering is + // covered by the kittest integration tests under + // `tests/kittest/identity_hub.rs`, which mount a real `AppContext`. + // + // These tests avoid constructing an `AppContext` so they stay fast and + // deterministic — we directly manipulate the small amount of state we + // own (`selected_tab`, `last_good_landing`). + + #[test] + fn default_state_machine() { + // Mirror what `new()` initialises without depending on AppContext. + let mut tab = IdentityHubTab::default(); + let mut last_good = HubLanding::Onboarding; + assert_eq!(tab, IdentityHubTab::Home); + assert_eq!(last_good, HubLanding::Onboarding); + + // Tab transitions round-trip. + tab = IdentityHubTab::Settings; + assert_eq!(tab, IdentityHubTab::Settings); + tab = IdentityHubTab::Contacts; + assert_eq!(tab, IdentityHubTab::Contacts); + + // Landing updates on successful load. + last_good = HubLanding::from_identity_count(1); + assert_eq!(last_good, HubLanding::Home); + last_good = HubLanding::from_identity_count(5); + assert_eq!(last_good, HubLanding::Picker); + } + + #[test] + fn load_error_preserves_last_good_landing() { + // Simulate the fallback path in `landing()` without needing an + // `AppContext`: cache a good state, then on error reuse it. + let mut last_good = HubLanding::from_identity_count(3); // Picker + // A load error must NOT downgrade to Onboarding. + let would_fall_back_to = last_good; // the `landing` fn returns this on Err + assert_eq!(would_fall_back_to, HubLanding::Picker); + last_good = HubLanding::from_identity_count(0); + // Next good load = empty account — falls back to Onboarding legitimately. + assert_eq!(last_good, HubLanding::Onboarding); + } +} diff --git a/src/ui/identity/identity_hero_card.rs b/src/ui/identity/identity_hero_card.rs new file mode 100644 index 000000000..f976aee49 --- /dev/null +++ b/src/ui/identity/identity_hero_card.rs @@ -0,0 +1,730 @@ +//! Identity hero card — the top-of-Home visual summary of a single identity. +//! +//! See design-spec §B.2 / §B.3 / §E. Two variants: +//! +//! - **Social profile set**: 96 px avatar circle (initials fallback when no +//! image) + display name + `@handle` line + balance + identity-type + +//! network pill. +//! - **No social profile**: same layout, but the avatar is replaced by a +//! type-glyph monogram (person / masternode / evonode) and the display-name +//! line is omitted. If the identity also has no DPNS name, the handle line +//! becomes the `No username yet` prompt with a `Pick a username` link. +//! +//! Visual direction is locked by §E: +//! - Flat `DashColors::surface(dark_mode)` fill. +//! - `RADIUS_XL` corners, `Shadow::elevated()`. +//! - Balance uses tabular numerals (monospace) so digits align across frames. +//! +//! This component follows `docs/COMPONENT_DESIGN_PATTERN.md`: private fields + +//! builder methods + a response struct implementing [`ComponentResponse`]. + +use crate::model::qualified_identity::IdentityType; +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::{DashColors, ResponseExt, Shadow, Shape, Spacing}; +use eframe::egui::{ + self, Color32, CornerRadius, FontFamily, FontId, Frame, Margin, Response, RichText, Sense, + Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, +}; + +/// One of the three supported identity kinds. Maps 1:1 to the project's +/// [`IdentityType`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeroIdentityKind { + User, + Masternode, + Evonode, +} + +impl From<IdentityType> for HeroIdentityKind { + fn from(value: IdentityType) -> Self { + match value { + IdentityType::User => HeroIdentityKind::User, + IdentityType::Masternode => HeroIdentityKind::Masternode, + IdentityType::Evonode => HeroIdentityKind::Evonode, + } + } +} + +impl HeroIdentityKind { + /// Alex-facing identity-type badge label. See §C wording audit. + pub fn badge_label(self) -> &'static str { + match self { + HeroIdentityKind::User => "User identity", + HeroIdentityKind::Masternode => "Masternode identity", + HeroIdentityKind::Evonode => "Evonode identity", + } + } + + /// Tooltip text attached to the badge. From §D row 12/13/14. + pub fn badge_tooltip(self) -> &'static str { + match self { + HeroIdentityKind::User => "A regular identity used for payments, DPNS, and DashPay.", + HeroIdentityKind::Masternode => { + "An identity tied to a Dash masternode. It can vote on name contests." + } + HeroIdentityKind::Evonode => { + "An identity tied to a Dash evonode. It can vote and validate Platform transactions." + } + } + } + + /// Accent color for the badge fill (at 12 % opacity, see §E). + pub fn badge_accent(self) -> Color32 { + match self { + HeroIdentityKind::User => DashColors::DASH_BLUE, + HeroIdentityKind::Masternode => DashColors::PLATFORM_PURPLE, + HeroIdentityKind::Evonode => DashColors::HIGHLIGHT_GOLD, + } + } + + /// Monogram glyph for the no-social-profile variant. §E calls for a + /// single-person silhouette for users, a node / server glyph for + /// masternodes, and a diamond for evonodes. + pub fn type_glyph(self) -> &'static str { + match self { + HeroIdentityKind::User => "\u{1F464}", // 👤 bust in silhouette + HeroIdentityKind::Masternode => "\u{1F5A5}", // 🖥 desktop (server-like) + HeroIdentityKind::Evonode => "\u{25C6}", // ◆ diamond + } + } +} + +/// Action reported by the hero when the user activates a secondary control +/// inside it (today: only the `Pick a username` link in the no-DPNS variant). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeroAction { + /// User clicked the `Pick a username` link shown when DPNS is missing. + PickUsernameClicked, +} + +/// Response returned by [`IdentityHeroCard::show`]. +#[derive(Clone, Debug, Default)] +pub struct HeroResponse { + /// Optional user-initiated action from inside the hero (e.g. the + /// `Pick a username` link). `None` when the user did not click anything. + action: Option<HeroAction>, + has_changed: bool, + changed_value: Option<HeroAction>, +} + +impl HeroResponse { + fn new(action: Option<HeroAction>) -> Self { + Self { + action, + has_changed: action.is_some(), + changed_value: action, + } + } + + /// The action the user triggered this frame, if any. + pub fn action(&self) -> Option<HeroAction> { + self.action + } + + /// Returns `true` if the user clicked the `Pick a username` link. + pub fn pick_username_clicked(&self) -> bool { + matches!(self.action, Some(HeroAction::PickUsernameClicked)) + } +} + +impl ComponentResponse for HeroResponse { + type DomainType = HeroAction; + + fn has_changed(&self) -> bool { + self.has_changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// Identity hero card — see module docs. +/// +/// Construction is immutable once the card is built (`show` consumes `&self`). +/// Use the builder methods to attach optional data: avatar bytes, fiat +/// equivalent, network label, etc. +#[derive(Clone, Debug)] +pub struct IdentityHeroCard { + kind: HeroIdentityKind, + /// DashPay display name. `None` triggers the no-social-profile variant. + display_name: Option<String>, + /// Primary DPNS handle (without the leading `@`). When both this and + /// `display_name` are `None`, the hero renders the `No username yet` + /// prompt. + dpns_handle: Option<String>, + /// Pre-formatted balance (e.g. `"1.2345"`). No unit — the unit is appended + /// by the hero (`" DASH"`). + balance_dash: String, + /// Optional fiat equivalent line (e.g. `"≈ 47.12 USD"`). Rendered below + /// the balance when present. + fiat_equivalent: Option<String>, + /// Optional network label rendered inside the network pill. `None` hides + /// the pill. + network_label: Option<String>, + /// Tooltip attached to the network pill (verbatim from §D row 15). + network_tooltip: Option<String>, + /// Optional avatar bytes (PNG/JPEG). If present AND decodable, the hero + /// paints this image inside the avatar circle; otherwise an initials + /// fallback is used. + avatar_bytes: Option<Vec<u8>>, + /// `true` when `avatar_bytes` decoded successfully (checked eagerly in + /// `with_avatar_bytes`). `false` on decode failure so that + /// `avatar_uses_initials_fallback()` stays honest even without a render + /// pass. + avatar_decode_ok: bool, +} + +impl IdentityHeroCard { + /// Construct a new hero card with the minimum required data: identity + /// type and balance text. All other fields have builder methods. + pub fn new(kind: HeroIdentityKind, balance_dash: impl Into<String>) -> Self { + Self { + kind, + display_name: None, + dpns_handle: None, + balance_dash: balance_dash.into(), + fiat_equivalent: None, + network_label: None, + network_tooltip: None, + avatar_bytes: None, + avatar_decode_ok: false, + } + } + + /// Set the DashPay display name. Passing a non-empty string flips the + /// hero into its social-profile-set variant. + pub fn with_display_name(mut self, name: impl Into<String>) -> Self { + let name = name.into(); + if !name.trim().is_empty() { + self.display_name = Some(name); + } + self + } + + /// Set the primary DPNS handle (without the leading `@`). + pub fn with_dpns_handle(mut self, handle: impl Into<String>) -> Self { + let handle = handle.into(); + if !handle.trim().is_empty() { + self.dpns_handle = Some(handle); + } + self + } + + /// Attach a fiat-equivalent line rendered below the balance. + pub fn with_fiat_equivalent(mut self, text: impl Into<String>) -> Self { + let text = text.into(); + if !text.trim().is_empty() { + self.fiat_equivalent = Some(text); + } + self + } + + /// Attach a network pill label. `None` hides the pill. + pub fn with_network_label(mut self, label: impl Into<String>) -> Self { + let label = label.into(); + if !label.trim().is_empty() { + self.network_label = Some(label); + } + self + } + + /// Attach a tooltip to the network pill. + pub fn with_network_tooltip(mut self, tooltip: impl Into<String>) -> Self { + let tooltip = tooltip.into(); + if !tooltip.trim().is_empty() { + self.network_tooltip = Some(tooltip); + } + self + } + + /// Attach raw avatar bytes (PNG / JPEG). When present and decodable, they + /// override the initials fallback. An eager decode check is performed here + /// so `avatar_uses_initials_fallback()` stays accurate even before the + /// first `show()` call. The bytes are stored for the actual + /// GPU-upload decode in `try_paint_avatar_image`. + pub fn with_avatar_bytes(mut self, bytes: Vec<u8>) -> Self { + if bytes.is_empty() { + return self; + } + // Probe decode: just validate, discard the image data. The full decode + // for GPU upload happens lazily in try_paint_avatar_image. + self.avatar_decode_ok = image::load_from_memory(&bytes).is_ok(); + self.avatar_bytes = Some(bytes); + self + } + + /// True when the hero is in its social-profile-set variant. + pub fn has_social_profile(&self) -> bool { + self.display_name.is_some() + } + + /// True when the hero will render the `No username yet` prompt instead of + /// a `@handle` line. + pub fn no_dpns(&self) -> bool { + self.dpns_handle.is_none() + } + + /// True when the hero will use the initials fallback instead of an image + /// (only meaningful in the social-profile-set variant). Returns `true` + /// when no avatar bytes were supplied OR when the bytes failed to decode + /// (stays honest even without a render pass). + pub fn avatar_uses_initials_fallback(&self) -> bool { + self.has_social_profile() && (self.avatar_bytes.is_none() || !self.avatar_decode_ok) + } + + /// Initial letter for the initials fallback, uppercase. Falls back to + /// `"?"` if the display name has no alphabetic character. + pub fn initials_letter(&self) -> char { + self.display_name + .as_deref() + .and_then(|name| { + name.chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().next().unwrap_or(c)) + }) + .unwrap_or('?') + } + + /// Render the hero card. Returns a [`HeroResponse`] carrying any + /// user-initiated action. + pub fn show(&self, ui: &mut Ui) -> HeroResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border_light(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_XL)) + .shadow(Shadow::elevated()) + .inner_margin(Margin::same(Spacing::LG as i8)); + + let response = frame.show(ui, |ui| { + let mut action: Option<HeroAction> = None; + ui.horizontal(|ui| { + // Left cluster: avatar / monogram + social lines. + ui.vertical(|ui| { + self.paint_avatar_or_monogram(ui); + }); + ui.add_space(Spacing::LG); + + ui.vertical(|ui| { + ui.add_space(Spacing::XS); + if let Some(name) = &self.display_name { + ui.label( + RichText::new(name) + .size(28.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + } + match (&self.display_name, &self.dpns_handle) { + (_, Some(handle)) => { + ui.label( + RichText::new(format!("@{handle}")) + .size(16.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + (Some(_), None) => { + // Social profile set but no DPNS handle — show + // the `Pick a username` prompt. + if self.paint_pick_username_prompt(ui, dark_mode) { + action = Some(HeroAction::PickUsernameClicked); + } + } + (None, None) => { + // No display name and no DPNS handle — still show + // the prompt so the user has a recovery path. + if self.paint_pick_username_prompt(ui, dark_mode) { + action = Some(HeroAction::PickUsernameClicked); + } + } + } + }); + + // Spacer pushes the right cluster to the edge. + ui.add_space(Spacing::LG); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.vertical(|ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { + // Balance — tabular numerals (monospace). + let balance_id = FontId::new(24.0, FontFamily::Monospace); + ui.label( + RichText::new(format!("{} DASH", self.balance_dash)) + .font(balance_id) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + if let Some(fiat) = &self.fiat_equivalent { + ui.label( + RichText::new(fiat) + .size(13.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + ui.add_space(Spacing::SM); + + // Identity-type + network pill row. + ui.horizontal(|ui| { + self.paint_pill( + ui, + self.kind.badge_label(), + self.kind.badge_accent(), + Some(self.kind.badge_tooltip()), + ); + if let Some(label) = &self.network_label { + ui.add_space(Spacing::XS); + self.paint_pill( + ui, + label, + DashColors::INFO, + self.network_tooltip.as_deref(), + ); + } + }); + }); + }); + }); + }); + + action + }); + + HeroResponse::new(response.inner) + } + + /// Paint the avatar circle (social profile set) or the type-glyph + /// monogram (no social profile). + /// + /// When `avatar_bytes` are present the raw PNG/JPEG bytes are decoded into + /// a texture (cached in the egui context by content hash so the decode only + /// happens once) and painted as a circle-cropped image. If decoding fails + /// the call falls through to the initials monogram. When no bytes are set + /// the initials or type-glyph monogram is used directly. (T09) + fn paint_avatar_or_monogram(&self, ui: &mut Ui) { + if let Some(bytes) = &self.avatar_bytes + && self.try_paint_avatar_image(ui, bytes) + { + return; + } + // Social profile set → initials monogram (white on Dash-blue); else the + // identity-type glyph on a faint tint. Shared with the breadcrumb + // identity pill via `avatar::paint_identity_monogram`. + let initial = self.has_social_profile().then(|| self.initials_letter()); + super::avatar::paint_identity_monogram(ui, 96.0, self.kind, initial, DashColors::DASH_BLUE); + } + + /// Attempt to decode `bytes` (PNG / JPEG) and paint a circle-cropped avatar + /// at the standard 96 px diameter. Returns `true` on success so the caller + /// can skip the initials fallback. + /// + /// The decoded texture is cached in the egui context keyed by an FNV-1a + /// hash of the bytes, so the `image::load_from_memory` decode only runs + /// once per unique avatar regardless of how many frames the hero renders. + /// + /// # Resource note + /// Textures accumulate in the egui temp store for the session's lifetime + /// (one GPU allocation per distinct avatar viewed). Bounded by the number + /// of distinct avatars seen in a session — acceptable for the typical 1-5 + /// identity case. A bounded LRU eviction policy is a follow-up if memory + /// growth becomes a concern. + fn try_paint_avatar_image(&self, ui: &mut Ui, bytes: &[u8]) -> bool { + let hash = fnv1a_hash(bytes); + let cache_id = egui::Id::new(("identity_avatar", hash)); + + // Retrieve from the egui context's per-session temp store. + let handle: Option<TextureHandle> = + ui.ctx().data(|d| d.get_temp::<TextureHandle>(cache_id)); + + let handle = match handle { + Some(h) => h, + None => { + // Decode the raw bytes. + let Ok(img) = image::load_from_memory(bytes) else { + return false; + }; + let rgba = img.into_rgba8(); + let (w, h) = (rgba.width() as usize, rgba.height() as usize); + let color_image = egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()); + let tex = + ui.ctx() + .load_texture("identity_avatar", color_image, TextureOptions::LINEAR); + ui.ctx().data_mut(|d| d.insert_temp(cache_id, tex.clone())); + tex + } + }; + + let diameter = 96.0; + let (rect, _resp) = ui.allocate_exact_size(egui::vec2(diameter, diameter), Sense::hover()); + + // Paint the image clipped to a circle via egui's `Image::corner_radius`. + // A corner radius equal to half the diameter gives a perfect circle clip. + // `CornerRadius::same` takes a `u8`, so clamp to 127 max (96 / 2 = 48). + let cr = CornerRadius::same((diameter / 2.0).min(127.0) as u8); + egui::Image::from_texture(&handle) + .corner_radius(cr) + .paint_at(ui, rect); + + // Overlay the same accent ring used by the initials monogram so the + // avatar integrates visually with the rest of the card. + let ring = Color32::from_rgba_unmultiplied( + DashColors::DASH_BLUE.r(), + DashColors::DASH_BLUE.g(), + DashColors::DASH_BLUE.b(), + 51, // 20 % opacity + ); + let stroke_w = (diameter / 48.0).max(1.0); + ui.painter() + .circle_stroke(rect.center(), diameter / 2.0, Stroke::new(stroke_w, ring)); + + true + } + + /// Paint the `No username yet` / `Pick a username` affordance. Returns + /// `true` when the link is clicked. + fn paint_pick_username_prompt(&self, ui: &mut Ui, dark_mode: bool) -> bool { + let mut clicked = false; + ui.horizontal(|ui| { + ui.label( + RichText::new("No username yet") + .italics() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(Spacing::XS); + let link = ui + .add( + egui::Label::new( + RichText::new("Pick a username") + .underline() + .color(DashColors::DASH_BLUE), + ) + .sense(Sense::click()), + ) + .clickable_tooltip("Register a username so people can send you Dash by name."); + if link.clicked() { + clicked = true; + } + }); + clicked + } + + /// Paint an identity-type or network pill. Fill is the accent color at + /// 12 % opacity, with a 1 px stroke at the accent color. See §E. + fn paint_pill( + &self, + ui: &mut Ui, + label: &str, + accent: Color32, + tooltip: Option<&str>, + ) -> Response { + let fill = Color32::from_rgba_unmultiplied( + accent.r(), + accent.g(), + accent.b(), + (0.12 * 255.0) as u8, + ); + let stroke_color = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180); + + let text = RichText::new(label).color(accent).size(12.0).strong(); + // Build as an inline pill: small frame around the label. + let inner = Frame::new() + .fill(fill) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(Shape::RADIUS_FULL)) + .inner_margin(Margin::symmetric(10, 3)) + .show(ui, |ui| { + ui.add(egui::Label::new(text).sense(Sense::hover())) + }); + // Paint the stroke manually so the ring color matches the accent. + ui.painter().rect_stroke( + inner.response.rect, + CornerRadius::same(Shape::RADIUS_FULL), + Stroke::new(1.0, stroke_color), + StrokeKind::Outside, + ); + if let Some(text) = tooltip { + inner.response.info_tooltip(text) + } else { + inner.response + } + } +} + +/// FNV-1a hash of a byte slice. Used to derive a stable texture cache key for +/// avatar images so the `image::load_from_memory` decode only runs once per +/// unique byte content. +fn fnv1a_hash(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis + for &b in data { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + // UT-HERO-01 — Identity hero, social profile set. + // + // Preconditions: identity with display name + handle + balance. + // Expected: avatar uses the initials fallback when no image; handle line + // uses text_secondary; balance uses tabular numerals (monospace). + #[test] + fn hero_social_profile_set_configures_avatar_and_handle() { + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "1.2345") + .with_display_name("Alex") + .with_dpns_handle("alex.dash"); + // Social profile state detected. + assert!(hero.has_social_profile()); + assert!(!hero.no_dpns()); + // No avatar bytes provided → initials fallback engaged. + assert!(hero.avatar_uses_initials_fallback()); + assert_eq!(hero.initials_letter(), 'A'); + // Balance string is stored verbatim (tabular numeral rendering is + // driven by the monospace FontId inside `show`, not stored here). + assert_eq!(hero.balance_dash, "1.2345"); + // DPNS handle present so the no-username fallback is NOT engaged. + assert_eq!(hero.dpns_handle.as_deref(), Some("alex.dash")); + } + + // UT-HERO-02 — Identity hero, no social profile. + // + // Preconditions: same identity with display_name = None. + // Expected: type-glyph monogram instead of avatar; no display-name line. + #[test] + fn hero_no_social_profile_uses_type_glyph() { + let hero = + IdentityHeroCard::new(HeroIdentityKind::User, "0.1234").with_dpns_handle("alex.dash"); + // No display name → no social profile variant. + assert!(!hero.has_social_profile()); + // Avatar fallback for social profile variant does not apply. + assert!(!hero.avatar_uses_initials_fallback()); + // Type glyph is the user silhouette for the User kind. + assert_eq!(HeroIdentityKind::User.type_glyph(), "\u{1F464}"); + assert_eq!(HeroIdentityKind::Masternode.type_glyph(), "\u{1F5A5}"); + assert_eq!(HeroIdentityKind::Evonode.type_glyph(), "\u{25C6}"); + } + + #[test] + fn display_name_trimming_treats_whitespace_as_missing() { + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.0") + .with_display_name(" ") + .with_dpns_handle("alex.dash"); + assert!(!hero.has_social_profile()); + } + + #[test] + fn initials_letter_picks_first_alphanumeric_uppercase() { + let hero = + IdentityHeroCard::new(HeroIdentityKind::User, "0.0").with_display_name("!! jamie"); + assert_eq!(hero.initials_letter(), 'J'); + } + + #[test] + fn initials_letter_defaults_to_question_mark_when_no_name() { + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.0"); + assert_eq!(hero.initials_letter(), '?'); + } + + #[test] + fn identity_kind_roundtrips_from_model_type() { + assert_eq!( + HeroIdentityKind::from(IdentityType::User), + HeroIdentityKind::User + ); + assert_eq!( + HeroIdentityKind::from(IdentityType::Masternode), + HeroIdentityKind::Masternode + ); + assert_eq!( + HeroIdentityKind::from(IdentityType::Evonode), + HeroIdentityKind::Evonode + ); + } + + #[test] + fn hero_response_reports_no_action_by_default() { + let resp = HeroResponse::default(); + assert!(!resp.has_changed()); + assert!(resp.is_valid()); + assert!(resp.action().is_none()); + assert!(!resp.pick_username_clicked()); + } + + #[test] + fn hero_response_reports_pick_username_click() { + let resp = HeroResponse::new(Some(HeroAction::PickUsernameClicked)); + assert!(resp.has_changed()); + assert!(resp.pick_username_clicked()); + assert_eq!(resp.changed_value(), &Some(HeroAction::PickUsernameClicked)); + } + + // ─── Avatar readiness tests ────────────────────────────── + + /// UT-HERO-03 — avatar decode success: with_avatar_bytes() accepts valid + /// PNG bytes and avatar_uses_initials_fallback() returns false. + #[test] + fn avatar_valid_bytes_disable_initials_fallback() { + // Generate a minimal 1×1 RGBA PNG using the same `image` crate used + // in production so the test is never out-of-sync with the decoder. + let img = image::DynamicImage::new_rgba8(1, 1); + let mut png_bytes = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageFormat::Png, + ) + .expect("writing a 1×1 RGBA PNG should always succeed"); + + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.5") + .with_display_name("Alex") + .with_avatar_bytes(png_bytes); + assert!( + hero.has_social_profile(), + "display name set → social profile variant" + ); + assert!( + !hero.avatar_uses_initials_fallback(), + "valid PNG bytes → initials fallback must not fire" + ); + assert!( + hero.avatar_decode_ok, + "avatar_decode_ok must be true for a valid image" + ); + } + + /// UT-HERO-04 — avatar decode failure: with_avatar_bytes() rejects + /// undecodable bytes and avatar_uses_initials_fallback() returns true even + /// before any show() call (readiness flag). + #[test] + fn avatar_corrupt_bytes_keep_initials_fallback_honest() { + let garbage: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xFF, 0x00]; + let hero = IdentityHeroCard::new(HeroIdentityKind::User, "0.5") + .with_display_name("Alex") + .with_avatar_bytes(garbage); + assert!( + hero.has_social_profile(), + "display name set → social profile variant" + ); + assert!( + hero.avatar_uses_initials_fallback(), + "corrupt bytes must fall back to initials — readiness flag stays honest" + ); + assert!( + !hero.avatar_decode_ok, + "avatar_decode_ok must be false for undecodable bytes" + ); + } +} diff --git a/src/ui/identity/identity_hub_tab_bar.rs b/src/ui/identity/identity_hub_tab_bar.rs new file mode 100644 index 000000000..201867338 --- /dev/null +++ b/src/ui/identity/identity_hub_tab_bar.rs @@ -0,0 +1,229 @@ +//! Horizontal tab bar for the unified Identities hub. +//! +//! Renders the four top-level tabs — `Home`, `Contacts`, `Activity`, `Settings` +//! — as a single-row, selectable button strip that sits directly beneath the +//! breadcrumb row on every hub tab. The selected tab uses the project's +//! `DashColors::DASH_BLUE` accent fill; unselected tabs use the transparent / +//! border-light treatment shared with the existing subscreen chooser panels. +//! +//! This component is **not** a replacement for the per-section chooser panels +//! under `src/ui/components/*_subscreen_chooser_panel.rs` — those continue to +//! live on the legacy screens. This tab bar is specific to the hub and is +//! consumed only from `src/ui/identity/hub_screen.rs`. +//! +//! See: +//! +//! - `docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md` §A.2 +//! (tab catalog) and the `Identity Home (Frame 3)` wireframe for the chrome +//! strip placement. +//! - `docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md` +//! UT-TABS-01. +//! - `docs/COMPONENT_DESIGN_PATTERN.md` for the component pattern applied +//! here (private fields, builder methods, `ComponentResponse` trait). + +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::identity::IdentityHubTab; +use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing, Typography}; +use eframe::egui::{self, CornerRadius, RichText, Stroke, Ui, Vec2}; + +/// Response returned by [`IdentityHubTabBar::show`]. +/// +/// Carries the tab (if any) that was activated on this frame. The component is +/// fully controlled by the parent — internal selection state lives in the +/// screen, the bar simply reports click events. +#[derive(Clone, Debug, Default)] +pub struct IdentityHubTabBarResponse { + clicked: Option<IdentityHubTab>, +} + +impl IdentityHubTabBarResponse { + /// The tab the user activated on this frame, if any. + pub fn clicked(&self) -> Option<IdentityHubTab> { + self.clicked + } +} + +impl ComponentResponse for IdentityHubTabBarResponse { + type DomainType = IdentityHubTab; + + fn has_changed(&self) -> bool { + self.clicked.is_some() + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.clicked + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// Horizontal tab bar for the Identities hub. +/// +/// Construct with the currently selected tab; render with [`show`]; apply the +/// returned click (if any) back to the screen's own selection state. The bar +/// is stateless — each frame receives the current selection as input and +/// reports a click as output. +/// +/// [`show`]: IdentityHubTabBar::show +#[derive(Clone, Debug)] +pub struct IdentityHubTabBar { + selected: IdentityHubTab, + /// Optional min button width override. Defaults to `96.0`, which + /// comfortably fits every tab label at `Typography::SCALE_SM`. + min_button_width: f32, +} + +impl IdentityHubTabBar { + /// Build a new tab bar with the given tab pre-selected. + pub fn new(selected: IdentityHubTab) -> Self { + Self { + selected, + min_button_width: 96.0, + } + } + + /// Override the minimum per-tab button width. Useful in narrow layouts. + pub fn with_min_button_width(mut self, width: f32) -> Self { + self.min_button_width = width; + self + } + + /// The currently selected tab. + pub fn selected(&self) -> IdentityHubTab { + self.selected + } + + /// Render the bar and return a response describing any click. The caller + /// is responsible for applying `response.clicked()` to its own selection + /// state — this component never mutates the selection itself. + pub fn show(self, ui: &mut Ui) -> IdentityHubTabBarResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut response = IdentityHubTabBarResponse::default(); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = Spacing::SM; + for tab in IdentityHubTab::ALL { + let is_selected = tab == self.selected; + let label = tab.label(); + + let button = if is_selected { + egui::Button::new( + RichText::new(label) + .color(DashColors::WHITE) + .size(Typography::SCALE_SM) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .min_size(Vec2::new(self.min_button_width, 32.0)) + } else { + egui::Button::new( + RichText::new(label) + .color(DashColors::text_primary(dark_mode)) + .size(Typography::SCALE_SM), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(Stroke::new(1.0, DashColors::border_light(dark_mode))) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .min_size(Vec2::new(self.min_button_width, 32.0)) + }; + + let clicked = ui + .add(button) + .clickable_tooltip(tab.accessible_description()) + .clicked(); + if clicked && !is_selected { + response.clicked = Some(tab); + } + } + }); + + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_stores_selected_tab() { + let bar = IdentityHubTabBar::new(IdentityHubTab::Contacts); + assert_eq!(bar.selected(), IdentityHubTab::Contacts); + } + + #[test] + fn default_response_has_no_click() { + let response = IdentityHubTabBarResponse::default(); + assert!(!response.has_changed()); + assert!(response.is_valid()); + assert!(response.changed_value().is_none()); + assert!(response.error_message().is_none()); + assert!(response.clicked().is_none()); + } + + #[test] + fn response_reports_click_value() { + let response = IdentityHubTabBarResponse { + clicked: Some(IdentityHubTab::Contacts), + }; + assert!(response.has_changed()); + assert_eq!(response.clicked(), Some(IdentityHubTab::Contacts)); + assert_eq!( + response.changed_value().as_ref(), + Some(&IdentityHubTab::Contacts) + ); + } + + #[test] + fn min_button_width_is_overridable() { + let bar = IdentityHubTabBar::new(IdentityHubTab::Home).with_min_button_width(128.0); + assert_eq!(bar.min_button_width, 128.0); + } + + // UT-TABS-01 — `IdentityHubTabBar` selection. + // + // Preconditions: tab bar with all four tabs, selected = Home. + // Steps: simulate a Contacts-tab click via the component's response API. + // Expected: response reports `Some(IdentityHubTab::Contacts)`; the + // screen-side selection updates accordingly. + // + // Rendering + raw click dispatch is covered end-to-end by the kittest + // integration test (`tests/kittest/identity_hub_onboarding.rs` and follow- + // up home-tab tests). This unit test exercises the pure state contract + // between the bar and its consumer, which is what the component owns. + #[test] + fn ut_tabs_01_selection_round_trips_through_response() { + let mut selected = IdentityHubTab::Home; + + // Initial render: nothing clicked. + let idle = IdentityHubTabBarResponse::default(); + assert_eq!(idle.clicked(), None); + assert!(!idle.has_changed()); + + // Simulate the component reporting a click on Contacts. + let click = IdentityHubTabBarResponse { + clicked: Some(IdentityHubTab::Contacts), + }; + if let Some(tab) = click.clicked() { + selected = tab; + } + assert_eq!(selected, IdentityHubTab::Contacts); + assert_eq!( + click.changed_value().as_ref(), + Some(&IdentityHubTab::Contacts) + ); + + // Construct a new bar with the updated selection — bar state tracks + // the screen's source of truth. + let bar = IdentityHubTabBar::new(selected); + assert_eq!(bar.selected(), IdentityHubTab::Contacts); + } +} diff --git a/src/ui/identity/identity_picker_add_card.rs b/src/ui/identity/identity_picker_add_card.rs new file mode 100644 index 000000000..e67e38b51 --- /dev/null +++ b/src/ui/identity/identity_picker_add_card.rs @@ -0,0 +1,333 @@ +//! "Add a new identity" picker card — rendered alongside regular identity +//! cards in the picker grid. Design reference: +//! `docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md` §B.14. +//! +//! Visual treatment: +//! +//! * Same grid cell dimensions as [`IdentityPickerCard`], so it slots in +//! naturally alongside identity cards without layout shift. +//! * **Dashed border** (`2px dashed`) using the theme border color in the +//! default state, switching to a solid Dash-blue border on hover. +//! * 72×72 circle with a large `+` glyph. +//! * Heading: `"Add a new identity"`. Sub-line: +//! `"Create a new identity or load one you already own."`. +//! +//! The entire card is a click target. Clicking emits a response with +//! `add_requested == true` which the picker grid uses to route to +//! `AddNewIdentityScreen` (the existing, unmodified screen). + +use super::identity_picker_card::{CARD_HEIGHT, CARD_MIN_WIDTH}; +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::DashColors; +use eframe::egui::{ + self, Color32, CornerRadius, FontId, Frame, Margin, Pos2, Rect, Response, RichText, Sense, + Stroke, Ui, Vec2, WidgetInfo, WidgetType, +}; + +/// Border style currently applied to the add card. Exposed primarily for the +/// UT-PICKER-03 test — the test asserts the default style is dashed and that +/// hover switches to the solid Dash-blue treatment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AddCardBorderStyle { + /// Default resting state: dashed border in the theme border color. + Dashed, + /// Hover state: solid Dash-blue border. + SolidDashBlue, +} + +/// Response returned by [`IdentityPickerAddCard::show`]. +#[derive(Clone, Debug)] +pub struct IdentityPickerAddCardResponse { + /// True when the user clicked the card this frame. + pub add_requested: bool, + changed_value: Option<()>, +} + +impl IdentityPickerAddCardResponse { + pub(crate) fn new(add_requested: bool) -> Self { + let changed_value = if add_requested { Some(()) } else { None }; + Self { + add_requested, + changed_value, + } + } +} + +impl ComponentResponse for IdentityPickerAddCardResponse { + type DomainType = (); + + fn has_changed(&self) -> bool { + self.add_requested + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// "Add a new identity" card widget. No configuration is needed — the strings +/// and styling are fixed by the design-spec. +#[derive(Clone, Debug, Default)] +pub struct IdentityPickerAddCard { + tooltip: Option<String>, +} + +impl IdentityPickerAddCard { + /// Construct a new add card. + pub fn new() -> Self { + Self::default() + } + + /// Attach a custom tooltip. Defaults to the design-spec tooltip (tt-78y). + pub fn with_tooltip(mut self, text: impl Into<String>) -> Self { + self.tooltip = Some(text.into()); + self + } + + /// Return the border style for the current render state. Pure function + /// exposed for UT-PICKER-03. + pub fn border_style(hovered: bool) -> AddCardBorderStyle { + if hovered { + AddCardBorderStyle::SolidDashBlue + } else { + AddCardBorderStyle::Dashed + } + } + + /// Heading text (fixed, i18n-ready). + pub fn heading(&self) -> &'static str { + "Add a new identity" + } + + /// Sub-line text (fixed, i18n-ready). + pub fn sub_line(&self) -> &'static str { + "Create a new identity or load one you already own." + } + + /// Render and return the response. + pub fn show(&self, ui: &mut Ui) -> IdentityPickerAddCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + // We render in two passes because `Frame::stroke` does not support + // dashed strokes. Pass 1: allocate the card rect + content (no outer + // stroke). Pass 2: paint either a dashed or solid outline on top of + // the allocated rect, based on hover state. + let desired_size = Vec2::new(CARD_MIN_WIDTH, CARD_HEIGHT); + + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(CornerRadius::same(16)) + .inner_margin(Margin::symmetric(16, 16)); + + let inner = frame.show(ui, |ui| { + ui.set_min_size(desired_size); + ui.set_max_width(desired_size.x); + ui.vertical_centered(|ui| { + ui.add_space(12.0); + draw_plus_circle(ui, dark_mode); + ui.add_space(16.0); + ui.label( + RichText::new(self.heading()) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + ui.add_space(4.0); + ui.label( + RichText::new(self.sub_line()) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + }); + }); + + let rect = inner.response.rect; + let id = ui.id().with("identity-picker-add-card"); + let response: Response = ui.interact(rect, id, Sense::click()); + + let tooltip_text = self + .tooltip + .clone() + .unwrap_or_else(|| self.sub_line().to_string()); + let response = response.on_hover_text(tooltip_text); + + // Draw the outline: dashed by default, solid Dash-blue on hover. + let style = Self::border_style(response.hovered()); + match style { + AddCardBorderStyle::Dashed => { + paint_dashed_rounded_rect( + ui, + rect, + CornerRadius::same(16), + Stroke::new(2.0, DashColors::border(dark_mode)), + ); + } + AddCardBorderStyle::SolidDashBlue => { + ui.painter().rect_stroke( + rect, + CornerRadius::same(16), + Stroke::new(2.0, DashColors::DASH_BLUE), + egui::StrokeKind::Inside, + ); + } + } + + response.widget_info(|| { + WidgetInfo::labeled(WidgetType::Button, true, "Add a new identity".to_string()) + }); + + IdentityPickerAddCardResponse::new(response.clicked()) + } +} + +/// Paint the centered `+` affordance — a filled circle with a bold `+` glyph. +fn draw_plus_circle(ui: &mut Ui, dark_mode: bool) { + const CIRCLE: f32 = 72.0; + let (rect, _response) = ui.allocate_exact_size(Vec2::new(CIRCLE, CIRCLE), Sense::hover()); + let painter = ui.painter(); + painter.circle_filled( + rect.center(), + CIRCLE / 2.0, + DashColors::surface_elevated(dark_mode), + ); + painter.circle_stroke( + rect.center(), + CIRCLE / 2.0, + Stroke::new(1.0, DashColors::border(dark_mode)), + ); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "+", + FontId::proportional(36.0), + DashColors::DASH_BLUE, + ); +} + +/// Paint a dashed rounded rectangle outline. Approximates a CSS-style dashed +/// border by subdividing each side into short segments; corners are rendered +/// as tiny straight segments — visually close enough at the card size used in +/// the picker grid and cheap to draw. +fn paint_dashed_rounded_rect(ui: &mut Ui, rect: Rect, _corner: CornerRadius, stroke: Stroke) { + const DASH_LEN: f32 = 6.0; + const GAP_LEN: f32 = 4.0; + let painter = ui.painter(); + // We trade perfect rounded corners for simplicity: paint dashed lines + // along each straight edge, offset inward so they visually match the + // frame fill. The corner radius is small enough at 16px that a straight + // dashed line reads naturally as the card's outline. + let corners = [ + ( + Pos2::new(rect.left(), rect.top()), + Pos2::new(rect.right(), rect.top()), + ), + ( + Pos2::new(rect.right(), rect.top()), + Pos2::new(rect.right(), rect.bottom()), + ), + ( + Pos2::new(rect.right(), rect.bottom()), + Pos2::new(rect.left(), rect.bottom()), + ), + ( + Pos2::new(rect.left(), rect.bottom()), + Pos2::new(rect.left(), rect.top()), + ), + ]; + for (start, end) in corners { + paint_dashed_segment(painter, start, end, DASH_LEN, GAP_LEN, stroke); + } + // Faint corner fills to soften the square look — approximate rounding. + let radius = 4.0; + for corner in [ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + ] { + painter.circle_filled(corner, radius, Color32::TRANSPARENT); + } +} + +fn paint_dashed_segment( + painter: &egui::Painter, + start: Pos2, + end: Pos2, + dash_len: f32, + gap_len: f32, + stroke: Stroke, +) { + let vec = end - start; + let length = vec.length(); + if length <= 0.0 { + return; + } + let dir = vec / length; + let mut traveled = 0.0; + while traveled < length { + let seg_start = start + dir * traveled; + let seg_end_dist = (traveled + dash_len).min(length); + let seg_end = start + dir * seg_end_dist; + painter.line_segment([seg_start, seg_end], stroke); + traveled += dash_len + gap_len; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ut_picker_03_default_border_is_dashed() { + // UT-PICKER-03 precondition: default construction. + // Expected: default border style reports dashed. + let style = IdentityPickerAddCard::border_style(false); + assert_eq!(style, AddCardBorderStyle::Dashed); + } + + #[test] + fn ut_picker_03_hover_switches_to_solid_dash_blue() { + // UT-PICKER-03 expected: hover switches to solid Dash-blue. + let style = IdentityPickerAddCard::border_style(true); + assert_eq!(style, AddCardBorderStyle::SolidDashBlue); + } + + #[test] + fn fixed_strings_match_design_spec() { + let card = IdentityPickerAddCard::new(); + assert_eq!(card.heading(), "Add a new identity"); + assert_eq!( + card.sub_line(), + "Create a new identity or load one you already own." + ); + } + + #[test] + fn response_add_requested_populates_changed_value() { + let resp = IdentityPickerAddCardResponse::new(true); + assert!(resp.has_changed()); + assert!(resp.is_valid()); + assert_eq!(resp.changed_value(), &Some(())); + } + + #[test] + fn response_not_requested_has_no_change() { + let resp = IdentityPickerAddCardResponse::new(false); + assert!(!resp.has_changed()); + assert_eq!(resp.changed_value(), &None); + } + + #[test] + fn custom_tooltip_overrides_default() { + let card = IdentityPickerAddCard::new().with_tooltip("Custom tooltip"); + assert_eq!(card.tooltip.as_deref(), Some("Custom tooltip")); + } +} diff --git a/src/ui/identity/identity_picker_card.rs b/src/ui/identity/identity_picker_card.rs new file mode 100644 index 000000000..7ccc904b0 --- /dev/null +++ b/src/ui/identity/identity_picker_card.rs @@ -0,0 +1,532 @@ +//! Identity picker card — one card per identity in the picker grid. +//! +//! Design reference: `docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md` +//! §B.14 (Identity picker, Frame 2). The card displays: +//! +//! * A 72×72 circular avatar or monogram glyph (rendered via a colored circle +//! with an initial letter — avatar assets land in a follow-up task). +//! * An identity-type badge pill anchored near the top. +//! * A heading line using the priority: +//! `display_name → DPNS handle → shortened Identity ID`. +//! * A sub-line: +//! * `@{dpns_handle}` when a DPNS handle exists AND the heading is NOT the +//! DPNS handle (i.e. display_name is set and distinct). +//! * The identity-type label (e.g. `User identity`) otherwise. +//! * A balance line rendered with tabular numerals, with an optional fiat line +//! below (the fiat conversion is supplied by the caller — this component +//! only renders what it is given). +//! * An "Opens Identity Home →" wireframe chip at the bottom, rendered as a +//! small info-colored hint. +//! +//! The whole card is a single click target. +//! +//! Follows the project Component Design Pattern +//! (`docs/COMPONENT_DESIGN_PATTERN.md`): private fields, builder methods, +//! `show()` returns a typed response implementing [`ComponentResponse`]. + +use super::identity_pill::shorten_id; +use crate::model::qualified_identity::IdentityType; +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::DashColors; +use eframe::egui::{ + self, Color32, CornerRadius, FontId, Frame, Margin, Response, RichText, Sense, Stroke, Ui, + Vec2, WidgetInfo, WidgetType, +}; + +/// Fixed card width used by the grid layout. Matches the design-spec +/// `minmax(260px, 1fr)` rule for the picker grid. +pub const CARD_MIN_WIDTH: f32 = 260.0; + +/// Approximate card height — used by the grid when it allocates rows. Actual +/// content may expand slightly; the frame fill absorbs any leftover space. +pub const CARD_HEIGHT: f32 = 220.0; + +/// Avatar / monogram diameter (design-spec: 72×72). +const AVATAR_SIZE: f32 = 72.0; + +/// Resolve the heading shown on the identity picker card. +/// +/// Priority: +/// 1. `display_name` (social-profile display name) +/// 2. `dpns_handle` (primary DPNS username) +/// 3. shortened Identity ID (`Fx1Kj…9Tt` style, see [`shorten_id`]). +/// +/// When every source is missing or empty the heading falls back to the string +/// `"Unknown identity"` — a safe, i18n-ready sentence fragment so the card is +/// never blank. +pub fn card_heading( + display_name: Option<&str>, + dpns_handle: Option<&str>, + identity_id_base58: &str, +) -> String { + if let Some(name) = display_name.map(str::trim).filter(|s| !s.is_empty()) { + return name.to_string(); + } + if let Some(handle) = dpns_handle.map(str::trim).filter(|s| !s.is_empty()) { + return handle.to_string(); + } + let trimmed = identity_id_base58.trim(); + if trimmed.is_empty() { + return "Unknown identity".to_string(); + } + shorten_id(trimmed) +} + +/// Resolve the sub-line shown beneath the heading. +/// +/// * Returns `@{dpns_handle}` when a DPNS handle exists AND the heading is +/// NOT the DPNS handle (i.e. the display name provides the heading). +/// * Otherwise returns the identity-type label +/// (`User identity` / `Masternode identity` / `Evonode identity`). +/// +/// This mirrors the test matrix in UT-PICKER-01 / UT-PICKER-02. +pub fn card_sub_line( + display_name: Option<&str>, + dpns_handle: Option<&str>, + identity_type: IdentityType, +) -> String { + let has_display_name = display_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some(); + let handle = dpns_handle.map(str::trim).filter(|s| !s.is_empty()); + + if has_display_name && let Some(h) = handle { + return format!("@{h}"); + } + + identity_type_label(identity_type).to_string() +} + +/// Identity-type label string used when no DPNS handle / display-name sub-line +/// is available. Complete i18n-ready sentence fragment per the project style +/// guide — never concatenate. +pub fn identity_type_label(identity_type: IdentityType) -> &'static str { + match identity_type { + IdentityType::User => "User identity", + IdentityType::Masternode => "Masternode identity", + IdentityType::Evonode => "Evonode identity", + } +} + +/// Response returned by [`IdentityPickerCard::show`]. +/// +/// Reports whether the card was activated. When activated, `changed_value` +/// carries the identity id string supplied to the card so a grid composer can +/// route selection without tracking indices. +#[derive(Clone, Debug)] +pub struct IdentityPickerCardResponse { + /// Whether the user clicked the card. + pub clicked: bool, + /// The identity id associated with the card (echoed from construction). + pub identity_id: String, + changed_value: Option<String>, +} + +impl IdentityPickerCardResponse { + pub(crate) fn new(identity_id: String, clicked: bool) -> Self { + let changed_value = if clicked { + Some(identity_id.clone()) + } else { + None + }; + Self { + clicked, + identity_id, + changed_value, + } + } +} + +impl ComponentResponse for IdentityPickerCardResponse { + type DomainType = String; + + fn has_changed(&self) -> bool { + self.clicked + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// Identity picker card widget. See module docs for the visual design. +#[derive(Clone, Debug)] +pub struct IdentityPickerCard { + identity_id_base58: String, + display_name: Option<String>, + dpns_handle: Option<String>, + identity_type: IdentityType, + /// Formatted balance string (e.g. `0.75 DASH`). The component does not + /// format credits itself — callers decide the unit + precision. + balance_label: String, + /// Optional fiat conversion line (e.g. `≈ 45.25 USD`). Empty = not shown. + fiat_label: String, + tooltip: String, +} + +impl IdentityPickerCard { + /// Build a picker card. + /// + /// `identity_id_base58` is echoed back in the response on click so callers + /// can route the selection without tracking indices. + pub fn new( + identity_id_base58: impl Into<String>, + identity_type: IdentityType, + balance_label: impl Into<String>, + ) -> Self { + Self { + identity_id_base58: identity_id_base58.into(), + display_name: None, + dpns_handle: None, + identity_type, + balance_label: balance_label.into(), + fiat_label: String::new(), + tooltip: String::new(), + } + } + + /// Attach a display name (social-profile display name). Takes priority + /// over the DPNS handle for the heading line. + pub fn with_display_name(mut self, name: impl Into<String>) -> Self { + self.display_name = Some(name.into()); + self + } + + /// Attach a DPNS handle (without the leading `@`). + pub fn with_dpns_handle(mut self, handle: impl Into<String>) -> Self { + self.dpns_handle = Some(handle.into()); + self + } + + /// Attach an optional fiat conversion line. + pub fn with_fiat_label(mut self, fiat: impl Into<String>) -> Self { + self.fiat_label = fiat.into(); + self + } + + /// Attach a tooltip (shown on hover). + pub fn with_tooltip(mut self, text: impl Into<String>) -> Self { + self.tooltip = text.into(); + self + } + + /// Compute the heading string — exposed for tests (UT-PICKER-01 / 02). + pub fn heading(&self) -> String { + card_heading( + self.display_name.as_deref(), + self.dpns_handle.as_deref(), + &self.identity_id_base58, + ) + } + + /// Compute the sub-line string — exposed for tests. + pub fn sub_line(&self) -> String { + card_sub_line( + self.display_name.as_deref(), + self.dpns_handle.as_deref(), + self.identity_type, + ) + } + + /// The identity-type badge pill text. + pub fn badge_label(&self) -> &'static str { + match self.identity_type { + IdentityType::User => "User", + IdentityType::Masternode => "Masternode", + IdentityType::Evonode => "Evonode", + } + } + + /// Render and return the response. + pub fn show(&self, ui: &mut Ui) -> IdentityPickerCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + let border = Stroke::new(1.0, DashColors::border(dark_mode)); + let fill = DashColors::surface(dark_mode); + let heading = self.heading(); + let sub_line = self.sub_line(); + let badge_label = self.badge_label(); + + let heading_for_a11y = heading.clone(); + + let frame = Frame::new() + .fill(fill) + .stroke(border) + .corner_radius(CornerRadius::same(16)) + .inner_margin(Margin::symmetric(16, 16)); + + // Pre-allocate a fixed-size region so every card in the grid has a + // predictable footprint. The frame stretches to fill. + let desired_size = Vec2::new(CARD_MIN_WIDTH, CARD_HEIGHT); + + // `Frame::show` senses only hover on the outer allocation; to pick up + // clicks we read the response from an interact() call on the frame's + // rect after the content renders. + let inner = frame.show(ui, |ui| { + ui.set_min_size(desired_size); + ui.set_max_width(desired_size.x); + ui.vertical(|ui| { + // Top row: avatar (left) + badge (right). + ui.horizontal(|ui| { + draw_monogram(ui, &heading, self.display_name.is_some(), dark_mode); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + draw_type_badge(ui, badge_label, dark_mode); + }); + }); + ui.add_space(12.0); + + // Heading. + ui.label( + RichText::new(&heading) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + // Sub-line. + ui.label( + RichText::new(&sub_line) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + ui.add_space(8.0); + + // Balance (tabular numerals). + ui.label( + RichText::new(&self.balance_label) + .color(DashColors::text_primary(dark_mode)) + .strong() + .font(FontId::monospace(13.0)), + ); + if !self.fiat_label.is_empty() { + ui.label( + RichText::new(&self.fiat_label) + .color(DashColors::text_secondary(dark_mode)) + .font(FontId::monospace(11.0)), + ); + } + + ui.add_space(8.0); + + // Bottom wireframe hint chip. Using a small muted label rather + // than a dedicated chip component — matches the design-spec + // note that this is a wireframe affordance, not a full chip. + ui.label( + RichText::new("Opens Identity Home →") + .color(DashColors::info_color(dark_mode)) + .size(11.0), + ); + }); + }); + + // Make the entire card interactive — one click target for the whole + // surface, per §B.14. + let rect = inner.response.rect; + let id = ui + .id() + .with(("identity-picker-card", &self.identity_id_base58)); + let response: Response = ui.interact(rect, id, Sense::click()); + let response = if !self.tooltip.is_empty() { + response.on_hover_text(&self.tooltip) + } else { + response + }; + // Hover elevation: repaint a subtle secondary border when hovered. + if response.hovered() { + let painter = ui.painter(); + painter.rect_stroke( + rect, + CornerRadius::same(16), + Stroke::new(1.5, DashColors::border_light(dark_mode)), + egui::StrokeKind::Inside, + ); + } + response.widget_info(|| { + WidgetInfo::labeled(WidgetType::Button, true, format!("Open {heading_for_a11y}")) + }); + + IdentityPickerCardResponse::new(self.identity_id_base58.clone(), response.clicked()) + } +} + +/// Paint a simple circular monogram as a lightweight avatar stand-in. Real +/// avatar assets land in a follow-up task (see design-spec §B.14). +/// +/// Shared with the Masternodes card grid (`ui/masternodes/card.rs`), which +/// reuses the picker's visual language. +pub(crate) fn draw_monogram(ui: &mut Ui, heading: &str, has_social_profile: bool, dark_mode: bool) { + let (rect, _response) = + ui.allocate_exact_size(Vec2::new(AVATAR_SIZE, AVATAR_SIZE), Sense::hover()); + let painter = ui.painter(); + + let fill_color = if has_social_profile { + DashColors::DASH_BLUE + } else { + DashColors::surface_elevated(dark_mode) + }; + let text_color = if has_social_profile { + Color32::WHITE + } else { + DashColors::text_secondary(dark_mode) + }; + + painter.circle_filled(rect.center(), AVATAR_SIZE / 2.0, fill_color); + // Thin ring for the non-social variant so it reads as an empty slot, not a + // missing fill. + if !has_social_profile { + painter.circle_stroke( + rect.center(), + AVATAR_SIZE / 2.0, + Stroke::new(1.0, DashColors::border(dark_mode)), + ); + } + + let initial = heading + .chars() + .next() + .map(|c| c.to_uppercase().next().unwrap_or(c)) + .unwrap_or('?'); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + initial, + FontId::proportional(28.0), + text_color, + ); +} + +/// Paint an identity-type badge pill. Color follows the identity-type. +/// +/// Shared with the Masternodes card grid (`ui/masternodes/card.rs`): +/// `Masternode` → `PLATFORM_PURPLE`, `Evonode` → `DASH_BLUE`, white text. +pub(crate) fn draw_type_badge(ui: &mut Ui, label: &str, dark_mode: bool) { + let (fill, stroke_color) = match label { + "Masternode" => (DashColors::PLATFORM_PURPLE, DashColors::PLATFORM_PURPLE), + "Evonode" => (DashColors::DASH_BLUE, DashColors::DASH_BLUE), + _ => ( + DashColors::surface_elevated(dark_mode), + DashColors::border(dark_mode), + ), + }; + let text_color = if matches!(label, "Masternode" | "Evonode") { + Color32::WHITE + } else { + DashColors::text_primary(dark_mode) + }; + + let frame = Frame::new() + .fill(fill) + .stroke(Stroke::new(1.0, stroke_color)) + .corner_radius(CornerRadius::same(255)) + .inner_margin(Margin::symmetric(8, 2)); + frame.show(ui, |ui| { + ui.label(RichText::new(label).color(text_color).size(11.0).strong()); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ut_picker_01_heading_and_sub_line_with_display_name_and_dpns() { + // UT-PICKER-01: display_name=Some("Alex"), dpns=Some("alex.dash"). + // Heading must be "Alex"; sub-line must be "@alex.dash". + let heading = card_heading(Some("Alex"), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(heading, "Alex"); + let sub = card_sub_line(Some("Alex"), Some("alex.dash"), IdentityType::User); + assert_eq!(sub, "@alex.dash"); + } + + #[test] + fn ut_picker_02_heading_and_sub_line_without_display_name() { + // UT-PICKER-02: display_name=None, dpns=Some("mn-east-01.dash"). + // Heading must be the handle; sub-line must be the identity-type label. + let heading = card_heading(None, Some("mn-east-01.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(heading, "mn-east-01.dash"); + let sub = card_sub_line(None, Some("mn-east-01.dash"), IdentityType::Masternode); + assert_eq!(sub, "Masternode identity"); + } + + #[test] + fn heading_falls_back_to_shortened_id() { + let heading = card_heading(None, None, "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(heading, "Fx1Kj…9Tt"); + } + + #[test] + fn sub_line_uses_identity_type_when_no_dpns() { + let sub = card_sub_line(None, None, IdentityType::User); + assert_eq!(sub, "User identity"); + let sub = card_sub_line(None, None, IdentityType::Evonode); + assert_eq!(sub, "Evonode identity"); + } + + #[test] + fn sub_line_uses_identity_type_when_heading_is_dpns() { + // Sub-line rule: @handle only when heading comes from display_name. + // When the DPNS handle is the heading, sub-line must NOT repeat it. + let sub = card_sub_line(None, Some("mn-east-01.dash"), IdentityType::Masternode); + assert_eq!(sub, "Masternode identity"); + } + + #[test] + fn empty_heading_fallback_is_stable_sentence_fragment() { + let heading = card_heading(None, None, ""); + assert_eq!(heading, "Unknown identity"); + } + + #[test] + fn whitespace_display_name_falls_through_to_dpns() { + let heading = card_heading(Some(" "), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(heading, "alex.dash"); + } + + #[test] + fn builder_chain_stores_all_fields() { + let card = IdentityPickerCard::new("Fx1Kj9TtFx1Kj9Tt", IdentityType::User, "0.75 DASH") + .with_display_name("Alex") + .with_dpns_handle("alex.dash") + .with_fiat_label("≈ 45.25 USD") + .with_tooltip("Open Alex"); + assert_eq!(card.heading(), "Alex"); + assert_eq!(card.sub_line(), "@alex.dash"); + assert_eq!(card.badge_label(), "User"); + assert_eq!(card.balance_label, "0.75 DASH"); + assert_eq!(card.fiat_label, "≈ 45.25 USD"); + assert_eq!(card.tooltip, "Open Alex"); + } + + #[test] + fn response_clicked_populates_changed_value() { + let resp = IdentityPickerCardResponse::new("abc".to_string(), true); + assert!(resp.has_changed()); + assert!(resp.is_valid()); + assert_eq!(resp.changed_value().as_deref(), Some("abc")); + assert_eq!(resp.identity_id, "abc"); + } + + #[test] + fn response_not_clicked_has_no_change() { + let resp = IdentityPickerCardResponse::new("abc".to_string(), false); + assert!(!resp.has_changed()); + assert!(resp.changed_value().is_none()); + } + + #[test] + fn response_update_writes_to_option() { + let resp = IdentityPickerCardResponse::new("abc".to_string(), true); + let mut selected: Option<String> = None; + assert!(resp.update(&mut selected)); + assert_eq!(selected.as_deref(), Some("abc")); + } +} diff --git a/src/ui/identity/identity_pill.rs b/src/ui/identity/identity_pill.rs new file mode 100644 index 000000000..f6daf4ad4 --- /dev/null +++ b/src/ui/identity/identity_pill.rs @@ -0,0 +1,327 @@ +//! Identity pill — the third segment of the breadcrumb switcher. +//! +//! Label priority: **Local nickname → DPNS username → shortened Identity ID** +//! (design-spec §G6). +//! +//! Follows the project's lazy-init component pattern +//! (`docs/COMPONENT_DESIGN_PATTERN.md`): domain/config fields stored on the +//! struct, the inner [`BreadcrumbPill`] built on every `show()` call. + +use super::avatar::paint_identity_monogram; +use super::identity_hero_card::HeroIdentityKind; +use crate::ui::components::breadcrumb_pill::{ + BreadcrumbPill, BreadcrumbPillMode, BreadcrumbPillResponse, +}; +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`. +/// +/// * `local_nickname` — `QualifiedIdentity.alias` in the codebase, displayed +/// in the UI as "Local nickname". +/// * `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. +pub fn display_label( + local_nickname: 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 trimmed = identity_id_base58.trim(); + if trimmed.is_empty() { + // Defensive fallback so a caller that passes an empty id does not + // produce an invisible pill. "Unknown identity" is a complete, + // i18n-ready sentence fragment suitable as a display label. + return "Unknown identity".to_string(); + } + shorten_id(trimmed) +} + +/// Shorten a Base58 identity id for compact display: keeps the first five +/// and last three characters, joined by a narrow ellipsis (`…`). The raw id +/// is returned unchanged if it is too short to meaningfully shorten. +pub fn shorten_id(id: &str) -> String { + let chars: Vec<char> = id.chars().collect(); + if chars.len() <= 10 { + return id.to_string(); + } + let head: String = chars.iter().take(5).copied().collect(); + let tail_chars: Vec<char> = chars.iter().rev().take(3).copied().collect(); + let tail: String = tail_chars.iter().rev().copied().collect(); + format!("{head}…{tail}") +} + +/// Response returned by [`IdentityPill::show`]. Thin wrapper over +/// [`BreadcrumbPillResponse`] — keeps the identity-pill API independent of +/// the underlying pill widget so callers never depend on the inner type. +#[derive(Clone, Debug)] +pub struct IdentityPillResponse { + pub clicked: bool, + pub label: String, + /// The pill's inner egui `Response`, for anchoring the identity dropdown. + /// `None` for fabricated responses (tests). + pub response: Option<eframe::egui::Response>, + changed_value: Option<String>, +} + +impl IdentityPillResponse { + fn from_inner(inner: BreadcrumbPillResponse) -> Self { + let changed_value = inner.changed_value().clone(); + Self { + clicked: inner.clicked, + label: inner.label, + response: inner.response, + changed_value, + } + } +} + +impl ComponentResponse for IdentityPillResponse { + type DomainType = String; + + fn has_changed(&self) -> bool { + self.clicked + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// Identity-pill widget. Stores domain/config fields (the identity's +/// nickname / handle / id) plus builder-set options. The inner +/// [`BreadcrumbPill`] is constructed on each `show()` call. +#[derive(Clone, Debug, Default)] +pub struct IdentityPill { + local_nickname: Option<String>, + dpns_handle: Option<String>, + identity_id_base58: String, + tooltip: String, + accessible_name: Option<String>, + mode: BreadcrumbPillMode, + /// Optional avatar: the identity type and an optional monogram initial. + /// When set, an 18 px circle is painted to the left and the inner pill omits + /// the 👤 emoji icon (avoids a double avatar). + avatar: Option<(HeroIdentityKind, Option<char>)>, +} + +impl IdentityPill { + /// Build an identity pill from the three identity fields. The pill is + /// interactive by default; override with [`with_mode`](Self::with_mode). + pub fn new( + local_nickname: Option<&str>, + dpns_handle: Option<&str>, + identity_id_base58: &str, + ) -> Self { + Self { + local_nickname: local_nickname.map(str::to_string), + dpns_handle: dpns_handle.map(str::to_string), + identity_id_base58: identity_id_base58.to_string(), + tooltip: String::new(), + accessible_name: None, + mode: BreadcrumbPillMode::default(), + avatar: None, + } + } + + /// Attach an avatar (identity type + optional monogram initial). Replaces + /// the default 👤 emoji icon with an 18 px painted circle. + pub fn with_avatar(mut self, kind: HeroIdentityKind, initial: Option<char>) -> Self { + self.avatar = Some((kind, initial)); + self + } + + /// Attach a tooltip. + pub fn with_tooltip(mut self, text: impl Into<String>) -> Self { + self.tooltip = text.into(); + self + } + + /// Override accessible name. Defaults to the resolved display label. + pub fn with_accessible_name(mut self, name: impl Into<String>) -> Self { + self.accessible_name = Some(name.into()); + self + } + + /// Force a specific mode. Interactive by default. + pub fn with_mode(mut self, mode: BreadcrumbPillMode) -> Self { + self.mode = mode; + self + } + + /// Resolve the display label using the priority rule (for tests and + /// compositional callers). + pub fn resolved_label(&self) -> String { + display_label( + self.local_nickname.as_deref(), + self.dpns_handle.as_deref(), + &self.identity_id_base58, + ) + } + + /// Build the inner `BreadcrumbPill` from the stored config. Exposed for + /// tests; production callers use `show()`. + fn build_inner(&self) -> BreadcrumbPill { + let label = self.resolved_label(); + let mut pill = BreadcrumbPill::new(label).with_mode(self.mode); + // Painted avatar replaces the emoji icon; otherwise keep the 👤 glyph. + if self.avatar.is_none() { + pill = pill.with_icon("👤"); + } + if !self.tooltip.is_empty() { + pill = pill.with_tooltip(self.tooltip.clone()); + } + if let Some(name) = &self.accessible_name { + pill = pill.with_accessible_name(name.clone()); + } + pill + } + + /// Render and return the response. + pub fn show(&self, ui: &mut Ui) -> IdentityPillResponse { + if let Some((kind, initial)) = self.avatar { + let inner = ui + .horizontal(|ui| { + paint_identity_monogram(ui, 18.0, kind, initial, kind.badge_accent()); + ui.add_space(4.0); + self.build_inner().show(ui) + }) + .inner; + IdentityPillResponse::from_inner(inner) + } else { + IdentityPillResponse::from_inner(self.build_inner().show(ui)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nickname_wins_when_present() { + let label = display_label(Some("dev"), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(label, "dev"); + } + + #[test] + fn dpns_wins_when_no_nickname() { + let label = display_label(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"); + assert_eq!(label, "alex.dash"); + } + + #[test] + fn whitespace_nickname_falls_through_to_dpns() { + let label = display_label(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"); + assert_eq!(label, "Fx1Kj…9Tt"); + } + + #[test] + fn short_id_not_shortened() { + let label = display_label(None, None, "abcdef"); + assert_eq!(label, "abcdef"); + } + + #[test] + fn shorten_id_handles_exactly_ten_chars() { + assert_eq!(shorten_id("abcdefghij"), "abcdefghij"); + } + + #[test] + fn shorten_id_shortens_long_string() { + assert_eq!(shorten_id("abcdefghijklmn"), "abcde…lmn"); + } + + #[test] + fn empty_id_uses_unknown_identity_fallback() { + // 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, ""); + assert_eq!(label, "Unknown identity"); + } + + #[test] + fn whitespace_only_id_uses_unknown_identity_fallback() { + let label = display_label(None, None, " "); + assert_eq!(label, "Unknown identity"); + } + + #[test] + fn pill_stores_domain_fields_lazily() { + // Lazy-init: the struct must not eagerly construct a BreadcrumbPill. + // We verify by checking that the builder chain only mutates stored + // config, and `build_inner` produces a pill with the right label. + let pill = IdentityPill::new(Some("dev"), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(pill.resolved_label(), "dev"); + assert_eq!(pill.mode, BreadcrumbPillMode::Interactive); + let inner = pill.build_inner(); + assert_eq!(inner.label(), "dev"); + } + + #[test] + fn pill_builder_chain_is_fluent() { + let pill = IdentityPill::new(None, Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt") + .with_tooltip("Switch identities") + .with_accessible_name("Identity switcher") + .with_mode(BreadcrumbPillMode::Subdued); + assert_eq!(pill.resolved_label(), "alex.dash"); + assert_eq!(pill.mode, BreadcrumbPillMode::Subdued); + assert_eq!(pill.tooltip, "Switch identities"); + assert_eq!(pill.accessible_name.as_deref(), Some("Identity switcher")); + } + + #[test] + fn response_round_trip() { + // Not clicked. + let inner = BreadcrumbPillResponse::new( + "alex.dash".to_string(), + BreadcrumbPillMode::Interactive, + false, + ); + let resp = IdentityPillResponse::from_inner(inner); + assert!(!resp.has_changed()); + assert!(resp.is_valid()); + assert!(resp.changed_value().is_none()); + assert_eq!(resp.label, "alex.dash"); + + // Clicked — payload propagates. + let inner = BreadcrumbPillResponse::new( + "alex.dash".to_string(), + BreadcrumbPillMode::Interactive, + true, + ); + let resp = IdentityPillResponse::from_inner(inner); + assert!(resp.has_changed()); + assert_eq!(resp.changed_value().as_deref(), Some("alex.dash")); + } +} diff --git a/src/ui/identity/landing.rs b/src/ui/identity/landing.rs new file mode 100644 index 000000000..b35f8e093 --- /dev/null +++ b/src/ui/identity/landing.rs @@ -0,0 +1,52 @@ +//! Landing-state resolution for the Identities hub. +//! +//! The hub chooses what to render at the top level based on how many identities are +//! loaded on the active network. See design-spec §A.4. + +/// Which top-level view the hub should render. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HubLanding { + /// Zero identities on the current network. Show the onboarding empty state. + Onboarding, + /// Exactly one identity. Render Identity Home directly for that identity. + Home, + /// Two or more identities. Render the identity picker grid until the user + /// selects one. + Picker, +} + +impl HubLanding { + /// Decide the landing state from the loaded-identity count on the active network. + pub fn from_identity_count(count: usize) -> Self { + match count { + 0 => HubLanding::Onboarding, + 1 => HubLanding::Home, + _ => HubLanding::Picker, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_identities_is_onboarding() { + assert_eq!(HubLanding::from_identity_count(0), HubLanding::Onboarding); + } + + #[test] + fn one_identity_is_home() { + assert_eq!(HubLanding::from_identity_count(1), HubLanding::Home); + } + + #[test] + fn two_identities_is_picker() { + assert_eq!(HubLanding::from_identity_count(2), HubLanding::Picker); + } + + #[test] + fn many_identities_is_picker() { + assert_eq!(HubLanding::from_identity_count(42), HubLanding::Picker); + } +} diff --git a/src/ui/identity/mod.rs b/src/ui/identity/mod.rs new file mode 100644 index 000000000..fcddaf43e --- /dev/null +++ b/src/ui/identity/mod.rs @@ -0,0 +1,48 @@ +//! Unified Identities hub UI section. +//! +//! This module implements the four-tab hub described in +//! `docs/ai-design/2026-04-22-identity-dashpay-redesign/`: +//! Home · Contacts · Activity · Settings, preceded by an onboarding empty state +//! and an identity picker grid for multi-identity contexts. +//! +//! The hub coexists with the legacy `src/ui/identities/` and `src/ui/dashpay/` +//! screens during the transition; both old nav entries remain visible. +//! +//! TODO: `identity/` (this new hub) vs `identities/` (the legacy tree) is a +//! one-letter module-name collision that will keep confusing imports, greps, +//! and file-picker jumps as long as both exist. Either rename this tree to +//! something unambiguous (e.g. `identity_hub/`) or open a tracked issue with +//! a target release for folding `identities/` into the hub, so the near-miss +//! naming is bounded in time rather than permanent. +//! +//! See the planning artifacts at +//! `docs/ai-design/2026-04-23-identity-hub-impl/` (requirements, UX plan, +//! test-case spec, dev plan). +//! +//! Integration sites: the left-nav `Identity Hub` entry in +//! `src/ui/components/left_panel.rs`, and the `RootScreenIdentityHub` entry in +//! the `main_screens` map in `src/app.rs::AppState::new`. + +pub mod activity; +pub mod avatar; +pub mod breadcrumb_switcher; +pub mod contacts; +pub mod home; +pub mod hub_screen; +pub mod identity_hero_card; +pub mod identity_hub_tab_bar; +pub mod identity_picker_add_card; +pub mod identity_picker_card; +pub mod identity_pill; +pub mod landing; +pub mod onboarding; +pub mod onboarding_checklist; +pub mod picker; +pub mod profile_cache; +pub mod request_card; +pub mod settings; +pub mod social_profile_gate_card; +pub mod tabs; + +pub use hub_screen::IdentityHubScreen; +pub use tabs::IdentityHubTab; diff --git a/src/ui/identity/onboarding.rs b/src/ui/identity/onboarding.rs new file mode 100644 index 000000000..b5d57d4de --- /dev/null +++ b/src/ui/identity/onboarding.rs @@ -0,0 +1,116 @@ +//! Onboarding empty state for the Identities hub. +//! +//! Rendered when the user has zero identities on the active network +//! (`HubLanding::Onboarding`). See design-spec §B.1. + +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::ScreenType; +use crate::ui::identity::avatar::paint_abstract_avatar; +use crate::ui::theme::{DashColors, ResponseExt}; +use eframe::egui::{Align, Layout, RichText, Ui}; +use std::sync::Arc; + +/// Footprint (glow + glyph) of the abstract avatar shown above the heading. +const AVATAR_DIAMETER: f32 = 140.0; + +/// Render the onboarding empty state inside a pre-configured central panel. +/// +/// Returns any `AppAction` generated by the user clicking one of the CTAs. +/// Strings are taken verbatim from design-spec §B.1 and are i18n-ready. +pub fn render(ui: &mut Ui, app_context: &Arc<AppContext>) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut action = AppAction::None; + + // Claim the island's full width so its bordered panel reaches the window + // edges; the readable column below stays centered and capped at 640px. + ui.set_min_width(ui.available_width()); + + ui.vertical_centered(|ui| { + // Generous vertical space so the card doesn't hug the top. + ui.add_space(48.0); + ui.set_max_width(640.0); + + ui.with_layout(Layout::top_down(Align::Center), |ui| { + paint_abstract_avatar(ui, AVATAR_DIAMETER); + ui.add_space(16.0); + ui.label( + RichText::new("Welcome to Identities.") + .size(28.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(16.0); + ui.label( + RichText::new( + "An identity is your account on Dash Platform. With one you can pick a \ + username, send and receive Dash by name, and — if you choose — connect \ + with people through DashPay.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(12.0); + ui.label( + RichText::new( + "You only need a small amount of Dash from your wallet to get started.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(24.0); + + let primary = egui::Button::new( + RichText::new("Create my first identity") + .strong() + .color(egui::Color32::WHITE), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(280.0, 40.0)); + let primary_response = ui.add(primary).clickable_tooltip( + "Start the short setup: fund the identity from your wallet, add an optional \ + nickname, and confirm.", + ); + if primary_response.clicked() { + action = + AppAction::AddScreen(ScreenType::AddNewIdentity.create_screen(app_context)); + } + + ui.add_space(8.0); + + let secondary = egui::Button::new( + RichText::new("I already have an identity — load it") + .color(DashColors::text_primary(dark_mode)), + ) + .fill(DashColors::surface(dark_mode)) + .min_size(egui::vec2(280.0, 36.0)); + let secondary_response = ui.add(secondary).clickable_tooltip( + "Enter the identity ID and private key to import an existing identity into \ + this device.", + ); + if secondary_response.clicked() { + action = AppAction::AddScreen( + ScreenType::AddExistingIdentity.create_screen(app_context), + ); + } + + if app_context.is_developer_mode() { + ui.add_space(32.0); + ui.separator(); + ui.add_space(8.0); + ui.label( + RichText::new("Developer tools:") + .color(DashColors::text_secondary(dark_mode)) + .small(), + ); + ui.add_space(4.0); + // Footer ghost links — full wiring in T6 once the devmode routes land. + ui.label( + RichText::new("Create multiple test identities · Load identity by ID") + .color(DashColors::text_secondary(dark_mode)) + .small(), + ); + } + }); + }); + + action +} diff --git a/src/ui/identity/onboarding_checklist.rs b/src/ui/identity/onboarding_checklist.rs new file mode 100644 index 000000000..d59cec06b --- /dev/null +++ b/src/ui/identity/onboarding_checklist.rs @@ -0,0 +1,522 @@ +//! Onboarding checklist — three-step guided setup strip shown on the +//! Identity Home tab until the user either completes all steps or dismisses +//! the checklist. +//! +//! See design-spec §B.2 (checklist zone #4). The three steps, in order: +//! +//! 1. `Pick a username` +//! 2. `Set a display name` — hidden by callers when the user has previously +//! dismissed the social-profile card (treated as a deliberate skip; the +//! caller is responsible for honoring that decision). +//! 3. `Add your first contact` +//! +//! Each step renders with either a filled check mark (complete) or an empty +//! circle (pending). A dismiss button (`×`) in the top-right corner reports +//! `dismissed == true` so the caller can persist the dismissal. +//! +//! Follows `docs/COMPONENT_DESIGN_PATTERN.md`: private fields + builder + +//! response struct implementing [`ComponentResponse`]. + +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing}; +use eframe::egui::{self, Color32, CornerRadius, Frame, Margin, RichText, Sense, Stroke, Ui}; + +/// The three canonical onboarding steps. `Hidden` is applied by the caller +/// (see docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md +/// §B.2) to honor a user's skip of the social profile card. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChecklistStep { + PickUsername, + SetDisplayName, + AddFirstContact, +} + +impl ChecklistStep { + /// All steps in rendering order. + pub const ALL: [ChecklistStep; 3] = [ + ChecklistStep::PickUsername, + ChecklistStep::SetDisplayName, + ChecklistStep::AddFirstContact, + ]; + + /// Alex-facing label. Exact wording from design-spec §B.2. + pub fn label(self) -> &'static str { + match self { + ChecklistStep::PickUsername => "Pick a username", + ChecklistStep::SetDisplayName => "Set a display name", + ChecklistStep::AddFirstContact => "Add your first contact", + } + } + + /// Short, complete-sentence description rendered in a tooltip. + pub fn tooltip(self) -> &'static str { + match self { + ChecklistStep::PickUsername => "Pick a Dash username so people can pay you by name.", + ChecklistStep::SetDisplayName => { + "Add a display name so contacts can recognise you on DashPay." + } + ChecklistStep::AddFirstContact => { + "Find someone by username and add them to your contacts." + } + } + } + + /// Descriptive sub-line shown below the label when the step is pending. + /// Wireframe §B.2 (V3). + pub fn subtext_pending(self) -> &'static str { + match self { + ChecklistStep::PickUsername => "Pick a name so people can pay you by name.", + ChecklistStep::SetDisplayName => "This is how you appear to contacts.", + ChecklistStep::AddFirstContact => "Add someone by username to send with one click.", + } + } + + /// Descriptive sub-line shown below the label when the step is complete. + /// Returns `None` for steps where a generic "done" message is sufficient + /// (the caller may supply a richer string, e.g. "You are @{handle}." for + /// PickUsername via [`OnboardingChecklist::with_handle`]). + pub fn subtext_done(self) -> Option<&'static str> { + match self { + ChecklistStep::PickUsername => None, // caller injects "@handle" via with_handle() + ChecklistStep::SetDisplayName => Some("Your display name is set."), + ChecklistStep::AddFirstContact => Some("You have contacts."), + } + } + + /// Label for the inline action button shown next to pending steps. + /// Wireframe §B.2 (V3). + pub fn action_label(self) -> &'static str { + match self { + ChecklistStep::PickUsername => "Pick a username", + ChecklistStep::SetDisplayName => "Set display name", + ChecklistStep::AddFirstContact => "Add a contact", + } + } +} + +/// Action that the checklist emits in a response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChecklistAction { + /// User clicked the row for this step to act on it. + Activated(ChecklistStep), + /// User clicked the dismiss (`×`) button. + Dismissed, +} + +/// Response returned by [`OnboardingChecklist::show`]. +/// +/// `has_changed` is `true` when the user either activates a step or +/// dismisses the checklist — callers should react by routing the user to +/// the right screen and / or persisting the dismissal. +#[derive(Clone, Debug, Default)] +pub struct ChecklistResponse { + action: Option<ChecklistAction>, + has_changed: bool, + changed_value: Option<ChecklistAction>, +} + +impl ChecklistResponse { + fn new(action: Option<ChecklistAction>) -> Self { + Self { + action, + has_changed: action.is_some(), + changed_value: action, + } + } + + /// The action, if any, produced this frame. + pub fn action(&self) -> Option<ChecklistAction> { + self.action + } + + /// True if the user clicked the dismiss button. + pub fn dismissed(&self) -> bool { + matches!(self.action, Some(ChecklistAction::Dismissed)) + } + + /// Step the user activated this frame, if any. + pub fn activated_step(&self) -> Option<ChecklistStep> { + match self.action { + Some(ChecklistAction::Activated(step)) => Some(step), + _ => None, + } + } +} + +impl ComponentResponse for ChecklistResponse { + type DomainType = ChecklistAction; + + fn has_changed(&self) -> bool { + self.has_changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.changed_value + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// Onboarding checklist widget. +#[derive(Clone, Debug)] +pub struct OnboardingChecklist { + steps: Vec<ChecklistStep>, + completed: Vec<ChecklistStep>, + /// Primary DPNS handle (without the leading `@`). When set, the done-state + /// subtext for `PickUsername` reads "You are @{handle}." instead of a + /// generic fallback. Set via [`with_handle`](Self::with_handle). + handle: Option<String>, +} + +impl OnboardingChecklist { + /// Construct a checklist with all three steps visible by default. Hide a + /// step by calling [`hide`](Self::hide) (used by callers to honor a + /// previous "skip the social profile" decision). + pub fn new() -> Self { + Self { + steps: ChecklistStep::ALL.to_vec(), + completed: Vec::new(), + handle: None, + } + } + + /// Attach the identity's primary DPNS handle (without the leading `@`). + /// When set, the `PickUsername` done-state subtext reads + /// `"You are @{handle}."` instead of a generic message. + pub fn with_handle(mut self, handle: impl Into<String>) -> Self { + let h = handle.into(); + if !h.trim().is_empty() { + self.handle = Some(h); + } + self + } + + /// Mark a step as complete. No-op if the step was already complete. + pub fn mark_complete(mut self, step: ChecklistStep) -> Self { + if !self.completed.contains(&step) { + self.completed.push(step); + } + self + } + + /// Hide a step so it never renders. Useful when the user has explicitly + /// skipped an optional section (e.g. the social profile card). + pub fn hide(mut self, step: ChecklistStep) -> Self { + self.steps.retain(|s| *s != step); + // Also remove from completed so the "all done" check stays honest. + self.completed.retain(|s| *s != step); + self + } + + /// Returns `true` when every visible step is marked complete. Callers + /// may stop rendering the checklist at that point. + pub fn all_complete(&self) -> bool { + !self.steps.is_empty() && self.steps.iter().all(|s| self.completed.contains(s)) + } + + /// Accessor for the currently visible steps in rendering order. + pub fn visible_steps(&self) -> &[ChecklistStep] { + &self.steps + } + + /// Accessor reporting whether a specific step is complete. + pub fn is_complete(&self, step: ChecklistStep) -> bool { + self.completed.contains(&step) + } + + /// Render the checklist. Returns a response describing any click. + /// + /// The checklist renders as a single card with a row per visible step and + /// a small dismiss button in the top-right corner. Completed rows show a + /// filled Dash-blue circle with a white check mark; pending rows show an + /// empty outlined circle. + pub fn show(&self, ui: &mut Ui) -> ChecklistResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border_light(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .inner_margin(Margin::same(Spacing::MD as i8)); + + let mut action: Option<ChecklistAction> = None; + + frame.show(ui, |ui| { + // Header row: heading + "Hide this for now" ghost button (V3). + ui.horizontal(|ui| { + ui.label( + RichText::new("Finish setting up your identity") + .size(16.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Wireframe §B.2: "Hide this for now" labeled button so the + // dismiss affordance is clearly worded, not an ambiguous `×`. + let dismiss_resp = ui + .add( + egui::Label::new( + RichText::new("Hide this for now") + .color(DashColors::text_secondary(dark_mode)), + ) + .sense(Sense::click()), + ) + .clickable_tooltip( + "Hide the setup checklist. You can find these actions on Settings \ + and Contacts anytime.", + ); + if dismiss_resp.clicked() { + action = Some(ChecklistAction::Dismissed); + } + }); + }); + ui.add_space(Spacing::SM); + + for step in &self.steps { + let complete = self.completed.contains(step); + let handle_ref = self.handle.as_deref(); + if self.paint_step_row(ui, dark_mode, *step, complete, handle_ref) { + action = Some(ChecklistAction::Activated(*step)); + } + ui.add_space(Spacing::XS); + } + }); + + ChecklistResponse::new(action) + } + + /// Paint a single step row. Returns `true` when the user clicked anywhere + /// in the row (the bullet, label, subtext, or inline action button). + /// + /// Each pending row shows: bullet circle + label + subtext + action button. + /// Each complete row shows: filled check circle + struck-through label + + /// completion subtext. The entire row region (including the circle and the + /// whitespace) is a single click surface (T10 / V3). + fn paint_step_row( + &self, + ui: &mut Ui, + dark_mode: bool, + step: ChecklistStep, + complete: bool, + handle: Option<&str>, + ) -> bool { + // Wrap the whole row in a clickable scope so the bullet circle and + // surrounding whitespace are part of the hit area, not just the label + // text (T10). We still place the inline action button separately so it + // receives its own visual hover feedback. + let row_scope = ui.scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| { + ui.horizontal(|ui| { + // Circle bullet. + let size = 20.0; + let (rect, _resp) = ui.allocate_exact_size(egui::vec2(size, size), Sense::hover()); + let painter = ui.painter(); + let center = rect.center(); + let radius = size * 0.5; + + if complete { + painter.circle_filled(center, radius, DashColors::DASH_BLUE); + let check_color = Color32::WHITE; + let p1 = egui::pos2(center.x - 4.0, center.y); + let p2 = egui::pos2(center.x - 1.0, center.y + 3.0); + let p3 = egui::pos2(center.x + 4.0, center.y - 3.0); + painter.line_segment([p1, p2], Stroke::new(1.8, check_color)); + painter.line_segment([p2, p3], Stroke::new(1.8, check_color)); + } else { + painter.circle_stroke( + center, + radius - 1.0, + Stroke::new(1.5, DashColors::border(dark_mode)), + ); + } + + ui.add_space(Spacing::SM); + + // Content column: label + subtext [+ action button]. + ui.vertical(|ui| { + // Label — struck through when complete. + let text_color = if complete { + DashColors::text_secondary(dark_mode) + } else { + DashColors::text_primary(dark_mode) + }; + let mut rich = RichText::new(step.label()).strong().color(text_color); + if complete { + rich = rich.strikethrough(); + } + ui.label(rich); + + // Descriptive subtext (V3). + let subtext: String = if complete { + // For PickUsername done, prefer "You are @{handle}." + if step == ChecklistStep::PickUsername { + match handle { + Some(h) => format!("You are @{h}."), + None => "Your username is set.".to_string(), + } + } else { + step.subtext_done().unwrap_or("Done.").to_string() + } + } else { + step.subtext_pending().to_string() + }; + ui.label( + RichText::new(subtext) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + }); + }); + + // The inline action button for pending items is placed outside the + // scope so it gets its own visual affordance, but its click still + // counts as a row activation. + let mut action_clicked = false; + if !complete { + ui.horizontal(|ui| { + ui.add_space(20.0 + Spacing::SM); // align with content column + let btn_resp = ui + .add( + egui::Label::new( + RichText::new(step.action_label()) + .small() + .color(DashColors::DASH_BLUE) + .underline(), + ) + .sense(Sense::click()), + ) + .clickable_tooltip(step.tooltip()); + if btn_resp.clicked() { + action_clicked = true; + } + }); + } + + row_scope.response.clicked() || action_clicked + } +} + +impl Default for OnboardingChecklist { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // UT-CHECKLIST-01 — Onboarding checklist completion. + // + // Preconditions: checklist with three steps, `Pick a username` marked + // complete. Expected: first step rendered with check mark; remaining two + // with empty circle. + #[test] + fn checklist_marks_pick_username_complete_only() { + let checklist = OnboardingChecklist::new().mark_complete(ChecklistStep::PickUsername); + assert!(checklist.is_complete(ChecklistStep::PickUsername)); + assert!(!checklist.is_complete(ChecklistStep::SetDisplayName)); + assert!(!checklist.is_complete(ChecklistStep::AddFirstContact)); + assert!(!checklist.all_complete()); + assert_eq!(checklist.visible_steps(), ChecklistStep::ALL.as_slice()); + } + + // UT-CHECKLIST-02 — Dismiss persists. + // + // Preconditions: checklist rendered; user clicks the dismiss button. + // Expected: response reports dismissed == true; caller must persist via + // settings. + #[test] + fn dismiss_response_reports_dismissed_true() { + let resp = ChecklistResponse::new(Some(ChecklistAction::Dismissed)); + assert!(resp.dismissed()); + assert!(resp.has_changed()); + assert!(resp.is_valid()); + assert_eq!(resp.changed_value(), &Some(ChecklistAction::Dismissed)); + // The response API lets callers persist the dismissal — the widget + // itself does not mutate disk. This is the contract in UT-CHECKLIST-02. + assert_eq!(resp.activated_step(), None); + } + + #[test] + fn empty_default_response_has_no_action() { + let resp = ChecklistResponse::default(); + assert!(!resp.has_changed()); + assert!(resp.action().is_none()); + assert!(!resp.dismissed()); + assert_eq!(resp.activated_step(), None); + } + + #[test] + fn activated_step_response_has_step_value() { + let resp = ChecklistResponse::new(Some(ChecklistAction::Activated( + ChecklistStep::PickUsername, + ))); + assert_eq!(resp.activated_step(), Some(ChecklistStep::PickUsername)); + assert!(!resp.dismissed()); + } + + #[test] + fn hide_removes_step_from_visible_and_completed() { + let checklist = OnboardingChecklist::new() + .mark_complete(ChecklistStep::SetDisplayName) + .hide(ChecklistStep::SetDisplayName); + assert!( + !checklist + .visible_steps() + .contains(&ChecklistStep::SetDisplayName) + ); + assert!(!checklist.is_complete(ChecklistStep::SetDisplayName)); + } + + #[test] + fn mark_complete_is_idempotent() { + let checklist = OnboardingChecklist::new() + .mark_complete(ChecklistStep::PickUsername) + .mark_complete(ChecklistStep::PickUsername); + assert!(checklist.is_complete(ChecklistStep::PickUsername)); + } + + #[test] + fn all_complete_true_only_when_every_visible_step_done() { + let checklist = OnboardingChecklist::new() + .mark_complete(ChecklistStep::PickUsername) + .mark_complete(ChecklistStep::SetDisplayName) + .mark_complete(ChecklistStep::AddFirstContact); + assert!(checklist.all_complete()); + } + + #[test] + fn all_complete_false_on_empty_checklist() { + // A checklist with every step hidden is not "all complete" — there + // is nothing to complete. This guards against a caller inadvertently + // hiding the last step and then thinking the user finished setup. + let checklist = OnboardingChecklist::new() + .hide(ChecklistStep::PickUsername) + .hide(ChecklistStep::SetDisplayName) + .hide(ChecklistStep::AddFirstContact); + assert!(!checklist.all_complete()); + assert!(checklist.visible_steps().is_empty()); + } + + #[test] + fn labels_are_from_design_spec() { + // Lock the exact strings — any future wording change should bump + // design-spec §B.2 first. + assert_eq!(ChecklistStep::PickUsername.label(), "Pick a username"); + assert_eq!(ChecklistStep::SetDisplayName.label(), "Set a display name"); + assert_eq!( + ChecklistStep::AddFirstContact.label(), + "Add your first contact" + ); + } +} diff --git a/src/ui/identity/picker.rs b/src/ui/identity/picker.rs new file mode 100644 index 000000000..01db45836 --- /dev/null +++ b/src/ui/identity/picker.rs @@ -0,0 +1,224 @@ +//! Identity picker grid — rendered when the hub detects ≥ 2 identities on the +//! active network. See design-spec +//! `docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md` §B.14. +//! +//! Layout: a responsive grid of [`IdentityPickerCard`]s followed by an +//! [`IdentityPickerAddCard`]. Cards flow left-to-right, wrapping based on the +//! available panel width. Clicking an identity card is reported to the caller +//! so the hub can route to Identity Home. Clicking the add card routes to the +//! **existing** `AddNewIdentityScreen` via `AppAction::AddScreen` — no new +//! navigation surface is introduced. +//! +//! This module is the UI shell only. No backend tasks are dispatched here — +//! identity lookup is handled upstream by `IdentityHubScreen::landing()`. + +use super::identity_picker_add_card::IdentityPickerAddCard; +use super::identity_picker_card::{CARD_MIN_WIDTH, IdentityPickerCard}; +use crate::app::AppAction; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::Screen; +use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{RichText, Ui}; +use std::sync::Arc; + +/// Minimum horizontal gap between cards in the grid. +const GRID_GAP: f32 = 16.0; + +/// Default fallback balance label when the identity has no known credits — a +/// complete sentence fragment, never empty. +const EMPTY_BALANCE_LABEL: &str = "No balance"; + +/// Rendered the picker grid. Returns the `AppAction` the caller must propagate: +/// +/// * `AppAction::None` on hover / no interaction. +/// * `AppAction::AddScreen(Screen::AddNewIdentityScreen(...))` when the +/// "Add a new identity" card is clicked. +/// * For identity-card clicks the action is `AppAction::None` by default — +/// identity selection is deferred to a follow-up task (Home-tab routing +/// lands in T8); the visible banner is left to the hub screen. +/// +/// `selected_id_out`, if `Some`, is populated with the Base58 identity id of +/// the clicked card so a composing `IdentityHubScreen` can remember the +/// selection and flip its landing to Home. +pub fn render( + ui: &mut Ui, + app_context: &Arc<AppContext>, + identities: &[QualifiedIdentity], + selected_id_out: Option<&mut Option<String>>, +) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut action = AppAction::None; + let mut captured_selection: Option<String> = None; + + // Heading + sub-heading — verbatim from design-spec §B.14. + ui.vertical_centered(|ui| { + ui.add_space(24.0); + ui.label( + RichText::new("Pick an identity") + .size(24.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + RichText::new( + "Each identity has its own balance, keys, and optional social profile. \ + Choose one to open it, or add a new identity.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(20.0); + }); + + // Grid layout: horizontal flow with manual wrapping. + // + // We do not use `egui::Grid` because it requires a fixed column count; + // the design-spec `repeat(auto-fill, minmax(260px, 1fr))` rule needs the + // column count to depend on the available width. + let available_width = ui.available_width(); + let columns = compute_column_count(available_width); + + ui.vertical(|ui| { + let cells: Vec<PickerCell<'_>> = identities + .iter() + .map(PickerCell::Identity) + .chain(std::iter::once(PickerCell::Add)) + .collect(); + + for row in cells.chunks(columns.max(1)) { + ui.horizontal(|ui| { + for (i, cell) in row.iter().enumerate() { + if i > 0 { + ui.add_space(GRID_GAP); + } + match cell { + PickerCell::Identity(identity) => { + let card = build_card(identity); + let response = card.show(ui); + if response.clicked { + captured_selection = Some(response.identity_id.clone()); + } + } + PickerCell::Add => { + let card = IdentityPickerAddCard::new(); + let response = card.show(ui); + if response.add_requested { + // Navigate to the existing AddNewIdentityScreen — + // no duplicated screen, no new backend task. + action = AppAction::AddScreen(Screen::AddNewIdentityScreen( + AddNewIdentityScreen::new(app_context), + )); + } + } + } + } + }); + ui.add_space(GRID_GAP); + } + }); + + // Propagate captured selection to the caller, if they supplied a slot. + // Only update when an identity was actually clicked — never overwrite with + // `None` on frames where no click occurred (T17). + if let Some(slot) = selected_id_out + && let Some(sel) = captured_selection + { + *slot = Some(sel); + } + + action +} + +/// Compute how many cards fit per row at the given available width. Matches +/// the design-spec `minmax(260px, 1fr)` rule — at least 260 px per column, +/// stretching to fill the remaining space when extra room exists. +pub fn compute_column_count(available_width: f32) -> usize { + let min_width = CARD_MIN_WIDTH + GRID_GAP; + let columns = (available_width / min_width).floor() as usize; + columns.max(1) +} + +enum PickerCell<'a> { + Identity(&'a QualifiedIdentity), + Add, +} + +/// Build a [`IdentityPickerCard`] from a [`QualifiedIdentity`]. +/// +/// Display-name is not yet a first-class field on `QualifiedIdentity` (it +/// comes from the DashPay social profile, loaded asynchronously). Until that +/// integration lands we use the local nickname (`alias`) as a stand-in for +/// display-name so users who have labelled their identities still see a +/// familiar heading. When a real social-profile display name becomes +/// available upstream, wiring it into this function is a one-line change. +fn build_card(identity: &QualifiedIdentity) -> IdentityPickerCard { + let id_base58 = identity.identity.id().to_string(Encoding::Base58); + + // Balance formatting: design-spec §B.14 uses `{amount} DASH` with tabular + // numerals. We keep the formatting simple here so the picker does not + // duplicate fee-estimation logic; identities with a zero balance still + // render a readable label ("No balance") rather than the misleading + // "0 DASH". + let balance_credits = identity.identity.balance(); + let balance_label = if balance_credits == 0 { + EMPTY_BALANCE_LABEL.to_string() + } else { + let dash = balance_credits as f64 * 1e-11; + format!("{dash:.6} DASH") + }; + + let mut card = + IdentityPickerCard::new(id_base58.clone(), identity.identity_type, balance_label) + .with_tooltip( + "Open this identity. You can switch between identities anytime from the \ + breadcrumb.", + ); + + // Heading priority: local alias → DPNS → shortened id. + if let Some(alias) = identity.alias.as_deref().filter(|s| !s.trim().is_empty()) { + card = card.with_display_name(alias); + } + if let Some(dpns) = identity.dpns_names.first().map(|n| n.name.as_str()) + && !dpns.is_empty() + { + card = card.with_dpns_handle(dpns); + } + + card +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn column_count_narrow_panel_is_one() { + // 260 px viewport — exactly one column worth of space. + assert_eq!(compute_column_count(260.0), 1); + } + + #[test] + fn column_count_standard_panel_is_three() { + // A reasonably wide panel fits three 260 + 16 = 276 px columns. + assert_eq!(compute_column_count(900.0), 3); + } + + #[test] + fn column_count_zero_or_negative_returns_one() { + // Defensive: never report zero columns — we'd divide by it elsewhere. + assert_eq!(compute_column_count(0.0), 1); + assert_eq!(compute_column_count(-10.0), 1); + } + + #[test] + fn column_count_grows_with_width() { + // Monotonic: wider panel can never fit fewer columns. + let a = compute_column_count(600.0); + let b = compute_column_count(1200.0); + assert!(b >= a); + } +} diff --git a/src/ui/identity/profile_cache.rs b/src/ui/identity/profile_cache.rs new file mode 100644 index 000000000..71547a3fa --- /dev/null +++ b/src/ui/identity/profile_cache.rs @@ -0,0 +1,113 @@ +//! Best-effort, async-populated DashPay profile cache for the Identities hub. +//! +//! The local SQLite DashPay-profile cache was removed in the platform-wallet +//! migration; profiles now live in the upstream `DashpayView` and are only +//! reachable through the async [`DashPayTask::LoadProfile`] task. Hub tabs +//! render synchronously, so they read this cache (empty until the first load +//! completes) and queue a load on a miss. The hub dispatches the queued load +//! after rendering and feeds the result back in via [`ProfileCache::record_result`]. + +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use std::collections::{HashMap, HashSet}; + +/// Loaded DashPay profile fields for one identity. Mirrors the +/// `BackendTaskSuccessResult::DashPayProfile` tuple; empty strings mean unset. +#[derive(Debug, Clone, Default)] +pub struct ProfileFields { + pub display_name: String, + pub bio: String, + pub avatar_url: String, +} + +impl ProfileFields { + /// Display name when set to a non-blank value. + pub fn display_name_opt(&self) -> Option<&str> { + let trimmed = self.display_name.trim(); + (!trimmed.is_empty()).then_some(trimmed) + } +} + +/// Per-identity profile cache with a single in-flight async load. +#[derive(Debug, Default)] +pub struct ProfileCache { + /// Loaded state per identity: `Some(fields)` = a profile exists, + /// `None` = loaded but no published profile. Absent key = not loaded yet. + loaded: HashMap<Identifier, Option<ProfileFields>>, + /// Identities a load has already been dispatched for (debounce). + requested: HashSet<Identifier>, + /// Identity of the in-flight load. The result variant carries no owner id, + /// so it is associated with this id on arrival. + in_flight: Option<Identifier>, + /// Identities a tab asked for this frame that still need a load dispatched. + wanted: Vec<QualifiedIdentity>, +} + +impl ProfileCache { + /// Loaded profile state for `identity`, queuing a load on a miss. + /// + /// `Some(Some(_))` = profile present, `Some(None)` = loaded with none + /// published, `None` = not loaded yet (a load is queued for dispatch). + pub fn get_or_request( + &mut self, + identity: &QualifiedIdentity, + ) -> Option<&Option<ProfileFields>> { + let id = identity.identity.id(); + let known = self.loaded.contains_key(&id) + || self.requested.contains(&id) + || self.wanted.iter().any(|q| q.identity.id() == id); + if !known { + self.wanted.push(identity.clone()); + } + self.loaded.get(&id) + } + + /// Dispatch one queued profile load, if any and none is in flight. Call + /// after rendering the tabs; fold the returned action into the frame's. + pub fn dispatch_pending(&mut self) -> AppAction { + if self.in_flight.is_some() { + return AppAction::None; + } + let Some(identity) = self.wanted.pop() else { + return AppAction::None; + }; + let id = identity.identity.id(); + self.requested.insert(id); + self.in_flight = Some(id); + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadProfile { identity }, + ))) + } + + /// Record a `LoadProfile` result against the in-flight identity. Returns + /// `true` when the result was consumed (a load was in flight). + pub fn record_result(&mut self, result: &BackendTaskSuccessResult) -> bool { + let BackendTaskSuccessResult::DashPayProfile(data) = result else { + return false; + }; + let Some(id) = self.in_flight.take() else { + return false; + }; + let fields = data + .clone() + .map(|(display_name, bio, avatar_url)| ProfileFields { + display_name, + bio, + avatar_url, + }); + self.loaded.insert(id, fields); + true + } + + /// Drop cached state and pending loads so a refresh re-resolves profiles. + pub fn reset(&mut self) { + self.loaded.clear(); + self.requested.clear(); + self.in_flight = None; + self.wanted.clear(); + } +} diff --git a/src/ui/identity/request_card.rs b/src/ui/identity/request_card.rs new file mode 100644 index 000000000..40a1d66ef --- /dev/null +++ b/src/ui/identity/request_card.rs @@ -0,0 +1,443 @@ +//! Request card — a small card used by the Contacts tab to surface received +//! and sent contact requests. See design-spec §B.4. +//! +//! Two variants: +//! - **Received** — amber left-border, `Accept` + `Decline` buttons. +//! - **Sent** — blue left-border, `Pending` pill + `Cancel request` ghost button. +//! +//! Follows the project's lazy-init component pattern +//! (`docs/COMPONENT_DESIGN_PATTERN.md`): domain/config fields stored on the +//! struct; the inner frame is built on every `show()` call. + +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use eframe::egui::{ + Color32, CornerRadius, Frame, Margin, Pos2, Rect, RichText, Sense, Stroke, Ui, Vec2, +}; + +/// Copy constants, kept public so tests and sibling callsites share a single +/// source of truth. Any future i18n extraction touches one line per string. +pub const ACCEPT_LABEL: &str = "Accept"; +pub const DECLINE_LABEL: &str = "Decline"; +pub const PENDING_LABEL: &str = "Pending"; +pub const CANCEL_LABEL: &str = "Cancel request"; + +/// Width of the colored strip on the left edge of each card, in logical +/// pixels. Matches the `3px` specified by the design wireframe. +pub const LEFT_BORDER_WIDTH: f32 = 3.0; + +/// Which kind of request this card represents. Controls the border color, +/// button set, and accessibility metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestCardVariant { + /// Request awaiting your approval — amber strip + Accept/Decline. + Received, + /// Request you've sent, waiting for the other side — blue strip + Cancel. + Sent, +} + +impl RequestCardVariant { + /// Color of the 3px left strip. Uses the shared theme palette so dark and + /// light modes stay consistent with the rest of the surface. + pub fn border_color(self, dark_mode: bool) -> Color32 { + match self { + RequestCardVariant::Received => DashColors::warning_color(dark_mode), + RequestCardVariant::Sent => DashColors::info_color(dark_mode), + } + } +} + +/// Typed action from a request card. Replaces bare booleans as the +/// `ComponentResponse::DomainType` (T11). The raw booleans on +/// [`RequestCardResponse`] are kept for backward-compat call sites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RequestAction { + /// User accepted the incoming contact request. + Accepted, + /// User declined the incoming contact request. + Declined, + /// User cancelled their outgoing contact request. + Cancelled, +} + +/// Response returned by [`RequestCard::show`]. The ad-hoc booleans +/// (`accepted`, `declined`, `cancelled`) are kept for backward-compat call +/// sites. The typed [`RequestAction`] (T11) is available via +/// [`action`](Self::action) and via the [`ComponentResponse`] impl's +/// `changed_value()`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RequestCardResponse { + pub accepted: bool, + pub declined: bool, + pub cancelled: bool, + /// Identifier payload attached by the caller, echoed back verbatim so the + /// caller can route the click without re-indexing into its own state. + pub id: Option<String>, + /// Typed action cache populated by [`RequestCard::show`] so that + /// `ComponentResponse::changed_value()` can return a borrow. Private — + /// callers should use `action()` or the public booleans. + action_cache: Option<RequestAction>, +} + +impl RequestCardResponse { + /// Derive the typed [`RequestAction`] from the ad-hoc booleans, if any. + pub fn action(&self) -> Option<RequestAction> { + if self.accepted { + Some(RequestAction::Accepted) + } else if self.declined { + Some(RequestAction::Declined) + } else if self.cancelled { + Some(RequestAction::Cancelled) + } else { + None + } + } +} + +impl ComponentResponse for RequestCardResponse { + /// The typed action this frame, derived from the card's button cluster. + type DomainType = RequestAction; + + fn has_changed(&self) -> bool { + self.accepted || self.declined || self.cancelled + } + + fn is_valid(&self) -> bool { + true + } + + /// Returns the typed action that `show()` populated into the cache. + /// Callers using the raw booleans (`response.accepted` etc.) are + /// unaffected — this is an additive typed view. + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.action_cache + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// A single request card. Call [`received`](Self::received) or +/// [`sent`](Self::sent) to build — direct struct construction is +/// intentionally not public so every card has a clear variant. +#[derive(Clone, Debug)] +pub struct RequestCard { + variant: RequestCardVariant, + display_name: String, + handle: String, + /// Caller-supplied relative timestamp ("2m ago", "yesterday"). Empty string + /// hides the timestamp slot so callers that don't have one don't need to + /// fabricate a placeholder. + relative_time: String, + /// Optional identifier propagated to the response. Useful for routing + /// clicks without maintaining a parallel index on the caller side. + id: Option<String>, +} + +impl RequestCard { + /// Received-variant constructor — amber strip, Accept/Decline buttons. + pub fn received( + display_name: impl Into<String>, + handle: impl Into<String>, + relative_time: impl Into<String>, + ) -> Self { + Self { + variant: RequestCardVariant::Received, + display_name: display_name.into(), + handle: handle.into(), + relative_time: relative_time.into(), + id: None, + } + } + + /// Sent-variant constructor — blue strip, Pending pill + Cancel button. + pub fn sent(display_name: impl Into<String>, handle: impl Into<String>) -> Self { + Self { + variant: RequestCardVariant::Sent, + display_name: display_name.into(), + handle: handle.into(), + relative_time: String::new(), + id: None, + } + } + + /// Attach an identifier echoed back via the response on click. + pub fn with_id(mut self, id: impl Into<String>) -> Self { + self.id = Some(id.into()); + self + } + + /// The variant (for tests and compositional callers). + pub fn variant(&self) -> RequestCardVariant { + self.variant + } + + /// Resolved border color for the current variant + theme mode. + pub fn border_color(&self, dark_mode: bool) -> Color32 { + self.variant.border_color(dark_mode) + } + + /// Render the card and return its click response. + pub fn show(&self, ui: &mut Ui) -> RequestCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut response = RequestCardResponse { + id: self.id.clone(), + ..Default::default() + }; + + let border_color = self.border_color(dark_mode); + let frame = Frame::new() + .fill(DashColors::surface_elevated(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .inner_margin(Margin { + left: 12 + (LEFT_BORDER_WIDTH as i8), + right: 12, + top: 10, + bottom: 10, + }); + + let outer = frame.show(ui, |ui| { + ui.horizontal(|ui| { + // Avatar monogram placeholder — the contact avatar lookup + // lives in a sibling component and is wired in a follow-up. + paint_monogram(ui, initials(&self.display_name), dark_mode); + + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&self.display_name) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.label( + RichText::new(format!("@{}", self.handle)) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + if !self.relative_time.is_empty() { + ui.label( + RichText::new(&self.relative_time) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + }); + + // 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; + } + } + RequestCardVariant::Sent => { + if ui + .add(ComponentStyles::secondary_button(CANCEL_LABEL, dark_mode)) + .clicked() + { + response.cancelled = true; + } + ui.add_space(8.0); + paint_pending_pill(ui, dark_mode); + } + }, + ); + }); + }); + + // Paint the colored 3px left strip inside the frame's allocated rect. + let outer_rect = outer.response.rect; + let strip_rect = Rect::from_min_max( + Pos2::new(outer_rect.min.x + 1.0, outer_rect.min.y + 1.0), + Pos2::new( + outer_rect.min.x + 1.0 + LEFT_BORDER_WIDTH, + outer_rect.max.y - 1.0, + ), + ); + ui.painter().rect_filled( + strip_rect, + CornerRadius::same(Shape::RADIUS_SM), + border_color, + ); + + // Populate the typed action cache so ComponentResponse::changed_value() + // can return a borrow (T11). + response.action_cache = response.action(); + response + } +} + +/// Extract up to two uppercase initials from a display name. Falls back to +/// `?` if the name is empty or contains no word characters. +fn initials(display_name: &str) -> String { + let mut out = String::new(); + for word in display_name.split_whitespace().take(2) { + if let Some(c) = word.chars().find(|c| c.is_alphanumeric()) { + out.extend(c.to_uppercase()); + } + } + if out.is_empty() { "?".to_string() } else { out } +} + +/// Paint a small square monogram with the extracted initials. This is a +/// deliberately minimal placeholder — the real avatar pipeline is shared with +/// the legacy DashPay screens and will be wired in a follow-up task. +fn paint_monogram(ui: &mut Ui, initials: String, dark_mode: bool) { + let size = Vec2::splat(36.0); + let (rect, _response) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().rect_filled( + rect, + CornerRadius::same(Shape::RADIUS_FULL), + DashColors::surface(dark_mode), + ); + ui.painter().rect_stroke( + rect, + CornerRadius::same(Shape::RADIUS_FULL), + Stroke::new(Shape::BORDER_WIDTH, DashColors::border(dark_mode)), + eframe::egui::StrokeKind::Middle, + ); + ui.painter().text( + rect.center(), + eframe::egui::Align2::CENTER_CENTER, + initials, + eframe::egui::FontId::proportional(14.0), + DashColors::text_primary(dark_mode), + ); +} + +/// Paint the small `Pending` pill used on sent request cards. +fn paint_pending_pill(ui: &mut Ui, dark_mode: bool) { + let frame = Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::info_color(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_FULL)) + .inner_margin(Margin::symmetric(8, 2)); + frame.show(ui, |ui| { + ui.label( + RichText::new(PENDING_LABEL) + .small() + .color(DashColors::info_color(dark_mode)), + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// UT-REQUEST-CARD-01 — Received vs Sent styling. The received variant + /// resolves to the amber/warning color and carries Accept/Decline + /// affordances; the sent variant resolves to the blue/info color and + /// carries the Cancel/Pending affordances. + #[test] + fn ut_request_card_01_variant_styling() { + let received = RequestCard::received("Alex Kim", "alex.dash", "2m ago"); + let sent = RequestCard::sent("Bao Tran", "bao.dash"); + + assert_eq!(received.variant(), RequestCardVariant::Received); + assert_eq!(sent.variant(), RequestCardVariant::Sent); + + // Received uses the amber/warning palette; sent uses the info/blue. + for dark in [false, true] { + assert_eq!( + received.border_color(dark), + DashColors::warning_color(dark), + "received variant must use the warning/amber color" + ); + assert_eq!( + sent.border_color(dark), + DashColors::info_color(dark), + "sent variant must use the info/blue color" + ); + assert_ne!( + received.border_color(dark), + sent.border_color(dark), + "received and sent variants must be visually distinct" + ); + } + + // Received carries Accept + Decline labels; sent carries Cancel + + // Pending labels. Confirming via constants locks the copy down. + assert_eq!(ACCEPT_LABEL, "Accept"); + assert_eq!(DECLINE_LABEL, "Decline"); + assert_eq!(PENDING_LABEL, "Pending"); + assert_eq!(CANCEL_LABEL, "Cancel request"); + } + + #[test] + fn id_round_trips_through_response() { + let card = RequestCard::received("Alex Kim", "alex.dash", "2m ago").with_id("req-123"); + // Build a fresh response the way `show` would and confirm the id is + // propagated into the default click response. + let response = RequestCardResponse { + id: card.id.clone(), + ..Default::default() + }; + assert_eq!(response.id.as_deref(), Some("req-123")); + } + + #[test] + fn initials_extracts_two_uppercase_letters() { + assert_eq!(initials("Alex Kim"), "AK"); + assert_eq!(initials("bob"), "B"); + assert_eq!(initials(" "), "?"); + assert_eq!(initials(""), "?"); + // Non-ASCII still works — first alphanumeric wins. + assert_eq!(initials("zoë müller"), "ZM"); + } + + #[test] + fn received_constructor_records_timestamp() { + let c = RequestCard::received("Alex Kim", "alex.dash", "2m ago"); + assert_eq!(c.relative_time, "2m ago"); + } + + #[test] + fn sent_constructor_has_no_timestamp() { + let c = RequestCard::sent("Bao Tran", "bao.dash"); + assert!(c.relative_time.is_empty()); + } + + #[test] + fn default_response_is_all_false() { + let r = RequestCardResponse::default(); + assert!(!r.accepted); + assert!(!r.declined); + assert!(!r.cancelled); + assert!(r.id.is_none()); + } + + #[test] + fn action_derives_from_booleans() { + let mut r = RequestCardResponse::default(); + assert_eq!(r.action(), None); + r.accepted = true; + assert_eq!(r.action(), Some(RequestAction::Accepted)); + r.accepted = false; + r.declined = true; + assert_eq!(r.action(), Some(RequestAction::Declined)); + r.declined = false; + r.cancelled = true; + assert_eq!(r.action(), Some(RequestAction::Cancelled)); + } +} diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs new file mode 100644 index 000000000..4064b0f9f --- /dev/null +++ b/src/ui/identity/settings.rs @@ -0,0 +1,1057 @@ +//! Identity Hub — Settings tab. +//! +//! Two-column layout inside the central island: social profile (left) and +//! username + aliases (right), with a full-width `Advanced` expander below. +//! See design-spec §B.8 and dev-plan task T11. +//! +//! ## Backend integration +//! +//! This tab is **additive** with respect to the backend: it dispatches only +//! backend tasks that already exist and never introduces new variants. As of +//! 2026-04-23 the following controls cannot be wired to a backend task and are +//! therefore feature-gated — rendered as non-interactive affordances with a +//! `disabled_tooltip` explaining that the action is coming in a follow-up: +//! +//! - **Delete social profile** — no `DashPayTask::DeleteProfile` variant. +//! - **Add / remove alias** and **Make primary** — no `IdentityTask::AddAlias` +//! / `RemoveAlias` / `MakePrimaryAlias` variants. +//! - **Unload this identity from this device** — no identity-unload task; the +//! existing `wallet_lifecycle` unload path is wallet-scoped, not identity- +//! scoped, and wiring it here would bypass the dashpay / DPNS state cleanup +//! the operation implies. +//! +//! These appear as `Gated(missing_task)` non-interactive rows with the copy +//! from design-spec §D (tooltip catalog entries #49 and #59). A TODO comment +//! marks each one so the backend follow-up can search for the flag. + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +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::ScreenType; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{Id, Margin, RichText, TextEdit, Ui}; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// Tooltip copy (from design-spec §D "Tooltip Catalog"). Kept as `const` so +// they are extractable as a single translation unit when i18n lands. +// --------------------------------------------------------------------------- + +const TIP_CHANGE_PHOTO: &str = "Upload a square image. Other apps will see this avatar."; +const TIP_SAVE_NO_CHANGES: &str = "There are no changes to save."; +const TIP_SAVE_INVALID: &str = "Fix the highlighted fields before saving."; +const TIP_DELETE_PROFILE: &str = "Remove the display name, bio, and avatar from DashPay. Your identity, usernames, and \ + balance stay."; +const TIP_PRIMARY_PILL: &str = "Your primary username is what people see by default."; +const TIP_MAKE_PRIMARY: &str = + "Use this username as your main one. Your old primary will become an alias."; +const TIP_REMOVE_ALIAS: &str = "Remove this alias. You will keep your other usernames."; +const TIP_ADD_ALIAS: &str = "Register another DPNS name that points to this identity."; +const TIP_ADD_KEY: &str = + "Register a new key for this identity. You will choose its purpose and type."; +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_ID_COPY: &str = "Copy the full identity ID to your clipboard."; +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 = + "An identity tied to a Dash masternode. It can vote on name contests."; +const TIP_BADGE_EVONODE: &str = + "An identity tied to a Dash evonode. It can vote and validate Platform transactions."; + +// Marker strings for controls without a matching backend task. Surfaced in +// disabled_tooltip and as a prefix on the row so users know it is a coming +// feature, not a stuck UI. +const GATED_COMING_SOON: &str = + "Coming soon. This control will activate when the backend task lands."; + +// Limits and validation come from the shared model validator, the single +// source of truth also used by the DashPay profile editor and the backend. +use crate::model::dashpay::{ + MAX_AVATAR_URL_CHARS as MAX_AVATAR_URL, MAX_BIO_CHARS as MAX_BIO, + MAX_DISPLAY_NAME_CHARS as MAX_DISPLAY_NAME, ProfileFieldError, validate_profile_fields, +}; + +// --------------------------------------------------------------------------- +// Stateful tab component +// --------------------------------------------------------------------------- + +/// Settings tab state. Holds the currently-selected identity (picked on +/// construction) plus per-field edit state. Follows the project's stateful-UI +/// pattern used by `ProfileScreen`: form fields, dirty tracking, confirmation +/// dialogs. +#[derive(Default)] +pub struct SettingsTab { + /// Identity whose settings we are editing. `None` when no identities + /// exist yet (the hub's `HubLanding::Onboarding` path means we should + /// normally not even render this tab, but we defend against it anyway). + selected_identity: Option<QualifiedIdentity>, + /// Editable social profile fields. Loaded on `ensure_selected` from the + /// cached DashPay profile (identity_id + network). + edit_display_name: String, + edit_bio: String, + edit_avatar_url: String, + /// Copy of the originals for `has_changes` comparison. Updated only + /// after a CONFIRMED backend success via `on_profile_saved()`. + original_display_name: String, + original_bio: String, + original_avatar_url: String, + /// The values that were actually submitted to `UpdateProfile`. Stored so + /// that `on_profile_saved()` commits the submitted snapshot, NOT the + /// current edit-field state (which may have changed while the round-trip + /// was in-flight). Cleared on identity switch or when committed. (T21) + pending_save: Option<(String, String, String)>, + /// `Advanced` expander state. Defaults closed per §B.8; callers (tests) + /// may flip this via `open_advanced_for_test` to assert the section + /// renders without a click. + advanced_open: bool, + /// Confirmation dialog for the (gated) "Delete social profile" action. + confirm_delete_profile: Option<ConfirmationDialog>, + /// Confirmation dialog for the (gated) "Unload this identity" action. + confirm_unload: Option<ConfirmationDialog>, + /// Track whether we have loaded the cached profile for the current + /// identity. Reset on identity change. + profile_loaded: bool, +} + +impl SettingsTab { + /// Construct an empty tab. The hub owns the lifecycle so we do not take an + /// `AppContext` here — `ensure_selected` pulls the first identity when + /// `render` is first called. + pub fn new() -> Self { + Self::default() + } + + /// Borrow the currently-selected identity, if any. Used by the hub's + /// `display_task_result` to guard `on_profile_saved` against stale results. + pub fn selected_identity( + &self, + ) -> Option<&crate::model::qualified_identity::QualifiedIdentity> { + self.selected_identity.as_ref() + } + + /// Render the tab; returns any `AppAction` generated by the user. + pub fn render( + &mut self, + ui: &mut Ui, + app_context: &Arc<AppContext>, + profiles: &mut super::profile_cache::ProfileCache, + ) -> AppAction { + self.ensure_selected(app_context, profiles); + + let Some(identity) = self.selected_identity.clone() else { + return render_empty_state(ui); + }; + + let mut action = AppAction::None; + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Two-column layout. egui's `columns` takes care of splitting + // the available width evenly; on narrow panels the individual + // rows inside each column wrap gracefully because we use + // `set_max_width` on the text fields. + ui.columns(2, |cols| { + action |= self.render_social_profile(&mut cols[0], app_context, &identity); + action |= + self.render_username_and_aliases(&mut cols[1], app_context, &identity); + }); + + ui.add_space(16.0); + + // Full-width Advanced expander. + let header = RichText::new("Advanced") + .strong() + .size(16.0) + .color(DashColors::text_primary(dark_mode)); + // Remember open state across frames via a stable id so a test + // that clicks the header keeps it open for the next step. + let resp = egui::CollapsingHeader::new(header) + .id_salt(Id::new("identity_hub_settings_advanced")) + .default_open(self.advanced_open) + .show(ui, |ui| { + ui.label( + RichText::new("Keys, raw identifiers, and identity type.") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + action |= self.render_advanced(ui, app_context, &identity); + }); + // Track open state so test helpers can read it if needed. + self.advanced_open = resp.fully_open(); + }); + + // Dialogs on top. + action |= self.show_gated_dialogs(ui); + + action + } + + // ----------------------------------------------------------------- + // Section renderers + // ----------------------------------------------------------------- + + fn render_social_profile( + &mut self, + ui: &mut Ui, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + section_heading(ui, "Social profile", dark_mode); + ui.label( + RichText::new("This information is visible to everyone on Dash Platform.") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + // Avatar block — placeholder glyph + "Change photo" ghost button. + // The actual file-picker wiring lives in `ProfileScreen`; we surface + // the button and let the user click through to the legacy edit path + // in a follow-up (no backend task needed yet). + ui.horizontal(|ui| { + ui.label(RichText::new("👤").size(48.0).color(DashColors::DEEP_BLUE)); + ui.vertical(|ui| { + let btn = ComponentStyles::add_secondary_button(ui, "Change photo", dark_mode) + .clickable_tooltip(TIP_CHANGE_PHOTO); + if btn.clicked() { + // Route to legacy DashPay Profile screen for the full + // image-upload flow. This is NOT a backend task and does + // not violate the "additive only" rule. + action = AppAction::SetMainScreen( + crate::ui::RootScreenType::RootScreenDashPayProfile, + ); + } + }); + }); + + ui.add_space(8.0); + + // Display name input. + ui.label(RichText::new("Display name").color(DashColors::text_primary(dark_mode))); + let display_name = ui.add( + TextEdit::singleline(&mut self.edit_display_name) + .hint_text("How should people see your name?") + .desired_width(f32::INFINITY), + ); + counter( + ui, + self.edit_display_name.len(), + MAX_DISPLAY_NAME, + dark_mode, + ); + let _ = display_name; // response not needed beyond widget side-effects + + ui.add_space(8.0); + + // Bio textarea. + ui.label(RichText::new("About").color(DashColors::text_primary(dark_mode))); + ui.add( + TextEdit::multiline(&mut self.edit_bio) + .hint_text(format!("A short description, up to {MAX_BIO} characters.")) + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + counter(ui, self.edit_bio.len(), MAX_BIO, dark_mode); + + ui.add_space(8.0); + + // Avatar URL — we include it so users can still set the avatar when + // the file-picker flow is not yet available from this tab. Kept under + // the visual avatar block so it is clearly secondary. + ui.label(RichText::new("Avatar URL").color(DashColors::text_primary(dark_mode))); + ui.add( + TextEdit::singleline(&mut self.edit_avatar_url) + .hint_text("https://example.com/avatar.jpg") + .desired_width(f32::INFINITY), + ); + counter(ui, self.edit_avatar_url.len(), MAX_AVATAR_URL, dark_mode); + + ui.add_space(12.0); + + // Save / Delete buttons row. + let invalid = self.validation_error().is_some(); + let dirty = self.has_changes(); + let can_save = !invalid && dirty; + let save_tooltip = if !dirty { + TIP_SAVE_NO_CHANGES.to_string() + } else if invalid { + TIP_SAVE_INVALID.to_string() + } else { + "Save your social profile to DashPay.".to_string() + }; + + ui.horizontal(|ui| { + let save = + ComponentStyles::add_primary_button_enabled(ui, can_save, "Save social profile"); + let save = if can_save { + save.clickable_tooltip(save_tooltip) + } else { + save.disabled_tooltip(save_tooltip) + }; + if save.clicked() && can_save { + // Capture the exact values being submitted. `on_profile_saved()` + // commits THIS snapshot as the new baseline — not whatever is in + // the edit fields at the time the success arrives, which may have + // changed while the round-trip was in-flight. + self.pending_save = Some(( + self.edit_display_name.clone(), + self.edit_bio.clone(), + self.edit_avatar_url.clone(), + )); + action = AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::UpdateProfile { + identity: identity.clone(), + display_name: string_if_set(&self.edit_display_name), + bio: string_if_set(&self.edit_bio), + avatar_url: string_if_set(&self.edit_avatar_url), + }, + ))); + } + + ui.add_space(12.0); + + // GATED: DashPayTask::DeleteProfile does not exist (2026-04-23). + // Render as a non-interactive danger-style link so Alex can see + // the affordance and knows it is planned. + // TODO(identity-hub): wire once DashPayTask::DeleteProfile lands. + let delete = ui + .add_enabled( + false, + egui::Button::new( + RichText::new("Delete social profile").color(DashColors::ERROR), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ) + .disabled_tooltip(format!("{TIP_DELETE_PROFILE} {GATED_COMING_SOON}")); + if delete.clicked() { + // Unreachable while disabled; defensive — open the confirm + // dialog so, once the backend exists, this path activates + // with a single-line change (remove `add_enabled(false, …)`). + self.confirm_delete_profile = Some( + ConfirmationDialog::new( + "Delete social profile", + "Remove the display name, bio, and avatar from DashPay. Your \ + identity, usernames, and balance stay intact. Are you sure?", + ) + .confirm_text(Some("Delete")) + .cancel_text(Some("Keep")) + .danger_mode(true), + ); + } + }); + + // Prevent unused-variable warning when app_context is not needed + // by the current code path (kept for future backend dispatches). + let _ = app_context; + + action + } + + fn render_username_and_aliases( + &mut self, + ui: &mut Ui, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + // Identity-type badge + tooltip — design-spec §B.8 rule 1 moves this + // into Advanced, but we also surface a compact badge here so the + // user knows which identity they are editing. Matches wireframe Frame 8. + let (badge_label, badge_tip) = identity_type_badge(identity.identity_type); + let badge = egui::Button::new(RichText::new(badge_label).small()) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )); + ui.add(badge).info_tooltip(badge_tip); + ui.add_space(8.0); + + section_heading(ui, "Username", dark_mode); + + // Primary DPNS name. If none, show the CTA card. + let primary = identity.dpns_names.first(); + if let Some(name) = primary { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("@{}", name.name)) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + // Primary pill. + let pill = egui::Button::new(RichText::new("Primary").small()) + .fill(DashColors::DASH_BLUE) + .stroke(egui::Stroke::NONE); + ui.add(pill).info_tooltip(TIP_PRIMARY_PILL); + + ui.add_space(4.0); + if ui + .button("Copy") + .clickable_tooltip(format!("Copy @{} to your clipboard.", name.name)) + .clicked() + { + ui.ctx().copy_text(format!("@{}", name.name)); + } + }); + } else { + // Pick-a-username CTA. + egui::Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::same(10)) + .corner_radius(egui::CornerRadius::same(6)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.label( + RichText::new("Pick a username") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + let reg = ComponentStyles::add_primary_button(ui, "Register a username") + .clickable_tooltip( + "Register a DPNS name and bind it to this identity.", + ); + if reg.clicked() { + action = AppAction::AddScreen( + ScreenType::RegisterDpnsName(RegisterDpnsNameSource::Identities) + .create_screen(app_context), + ); + } + }); + }); + } + + 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. + section_heading(ui, "Aliases", dark_mode); + ui.label( + RichText::new("Extra usernames that also point to your identity.") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + + let aliases: Vec<_> = identity.dpns_names.iter().skip(1).cloned().collect(); + if aliases.is_empty() { + ui.label(RichText::new("No aliases yet.").color(DashColors::text_secondary(dark_mode))); + } else { + for alias in &aliases { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("@{}", alias.name)) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + // TODO(identity-hub): wire once IdentityTask::MakePrimaryAlias exists. + let mp = ui + .add_enabled(false, egui::Button::new("Make primary")) + .disabled_tooltip(format!("{TIP_MAKE_PRIMARY} {GATED_COMING_SOON}")); + let _ = mp; + // TODO(identity-hub): wire once IdentityTask::RemoveAlias exists. + let rm = ui + .add_enabled(false, egui::Button::new("Remove")) + .disabled_tooltip(format!("{TIP_REMOVE_ALIAS} {GATED_COMING_SOON}")); + let _ = rm; + }); + } + } + + ui.add_space(6.0); + + // TODO(identity-hub): "Add an alias" requires IdentityTask::AddAlias, + // which does not exist yet. Render as disabled so Alex sees the + // affordance and learns it is planned. + let add = ui + .add_enabled(false, egui::Button::new("Add an alias")) + .disabled_tooltip(format!("{TIP_ADD_ALIAS} {GATED_COMING_SOON}")); + let _ = add; + + action + } + + fn render_advanced( + &mut self, + ui: &mut Ui, + app_context: &Arc<AppContext>, + identity: &QualifiedIdentity, + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + // 1. Identity type + raw ID + (optional) ProTxHash. + sub_heading(ui, "Identity", dark_mode); + let id_base58 = identity.identity.id().to_string(Encoding::Base58); + ui.horizontal(|ui| { + ui.label(RichText::new("Identity ID").color(DashColors::text_secondary(dark_mode))); + ui.label( + RichText::new(&id_base58) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + if ui + .small_button("Copy") + .clickable_tooltip(TIP_ID_COPY) + .clicked() + { + ui.ctx().copy_text(id_base58.clone()); + } + }); + + if identity.identity_type != IdentityType::User { + ui.horizontal(|ui| { + ui.label( + RichText::new("Masternode ID").color(DashColors::text_secondary(dark_mode)), + ); + // The ProTxHash is the raw identity ID in hex for masternode / + // evonode identities. We display it as hex per §C. + let protx_hex = identity.identity.id().to_string(Encoding::Hex); + ui.label( + RichText::new(&protx_hex) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + if ui + .small_button("Copy") + .clickable_tooltip(TIP_PROTX_COPY) + .clicked() + { + ui.ctx().copy_text(protx_hex); + } + }); + } + + ui.add_space(10.0); + + // 2. Keys table. + sub_heading(ui, "Keys", dark_mode); + ui.label( + RichText::new( + "Keys let this identity sign actions. Most people never need to manage these \ + directly.", + ) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + let key_count = identity.identity.public_keys().len(); + ui.label( + RichText::new(format!( + "This identity has {key_count} key{s}.", + s = if key_count == 1 { "" } else { "s" } + )) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + // `Add a new key` routes to the existing AddKeyScreen — no new + // backend work required, and the screen handles its own dispatch. + let add_key = + ComponentStyles::add_primary_button(ui, "Add a new key").clickable_tooltip(TIP_ADD_KEY); + if add_key.clicked() { + action = AppAction::AddScreen( + ScreenType::AddKeyScreen(identity.clone()).create_screen(app_context), + ); + } + + ui.add_space(12.0); + + // 3. Refresh. + sub_heading(ui, "Refresh and diagnostics", dark_mode); + let refresh = ComponentStyles::add_secondary_button(ui, "Refresh identity data", dark_mode) + .clickable_tooltip(TIP_REFRESH); + if refresh.clicked() { + action = AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RefreshIdentity(identity.clone()), + )); + } + + ui.add_space(16.0); + + // 4. Danger zone — red-bordered card. + let danger_color = DashColors::ERROR; + egui::Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, danger_color)) + .inner_margin(Margin::same(12)) + .corner_radius(egui::CornerRadius::same(6)) + .show(ui, |ui| { + sub_heading(ui, "Danger zone", dark_mode); + ui.label( + RichText::new( + "These actions affect this device only. Your identity stays on Dash \ + Platform.", + ) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(6.0); + // TODO(identity-hub): wire once an identity-scoped unload task + // exists. Wallet-scoped unload (wallet_lifecycle) is too broad + // — it would silently drop sibling identities on the same wallet. + let unload = ui + .add_enabled( + false, + ComponentStyles::danger_button("Unload this identity from this device"), + ) + .disabled_tooltip(format!("{TIP_UNLOAD} {GATED_COMING_SOON}")); + if unload.clicked() { + self.confirm_unload = Some( + ConfirmationDialog::new( + "Unload this identity", + "This removes the identity from this device. It remains on Dash \ + Platform — you can load it again later.", + ) + .confirm_text(Some("Unload")) + .cancel_text(Some("Keep")) + .danger_mode(true), + ); + } + }); + + action + } + + // ----------------------------------------------------------------- + // Dialog handling + // ----------------------------------------------------------------- + + fn show_gated_dialogs(&mut self, ui: &mut Ui) -> AppAction { + let action = AppAction::None; + + if let Some(dialog) = self.confirm_delete_profile.as_mut() { + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) | Some(ConfirmationStatus::Canceled) => { + self.confirm_delete_profile = None; + } + None => {} + } + } + + if let Some(dialog) = self.confirm_unload.as_mut() { + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) | Some(ConfirmationStatus::Canceled) => { + self.confirm_unload = None; + } + None => {} + } + } + + action + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + /// Load the first available identity if none selected. Caches the + /// cached-profile fields on identity change so the text inputs reflect + /// the stored state immediately. + fn ensure_selected( + &mut self, + app_context: &Arc<AppContext>, + profiles: &mut super::profile_cache::ProfileCache, + ) { + // Read the app-scoped active identity (selected → first → none), not + // `.first()`, so the Settings tab agrees with the breadcrumb/hub and + // does not flip-flop across frames (D4). + let incoming = app_context.resolve_selected_identity(); + + let changed = match (&self.selected_identity, &incoming) { + (Some(a), Some(b)) => a.identity.id() != b.identity.id(), + (None, Some(_)) | (Some(_), None) => true, + (None, None) => false, + }; + + if changed { + self.selected_identity = incoming; + self.profile_loaded = false; + // Clear the editor to a clean slate; fields repopulate once the + // async profile load lands (see `load_cached_profile`). + self.edit_display_name.clear(); + self.edit_bio.clear(); + self.edit_avatar_url.clear(); + self.original_display_name.clear(); + self.original_bio.clear(); + self.original_avatar_url.clear(); + // A pending save for the old identity must not be committed for + // the new one — clear it on switch (T21). + self.pending_save = None; + } + + if self.selected_identity.is_some() && !self.profile_loaded { + self.load_cached_profile(profiles); + } + } + + /// Populate the editor from the hub's async profile cache. The local DB + /// profile cache was removed in the platform-wallet migration, so this + /// reads the cache (queuing a load on a miss) and fills the fields once the + /// profile arrives — without clobbering edits the user has already made. + fn load_cached_profile(&mut self, profiles: &mut super::profile_cache::ProfileCache) { + let Some(identity) = self.selected_identity.clone() else { + self.profile_loaded = true; + return; + }; + let fields = match profiles.get_or_request(&identity) { + Some(loaded) => loaded.clone(), + // Not loaded yet — a load is queued; retry on the next frame. + None => return, + }; + if self.has_changes() { + // The async load landed after the user started editing; keep their + // input and stop trying to repopulate. + self.profile_loaded = true; + return; + } + let (display_name, bio, avatar_url) = match fields { + Some(f) => (f.display_name, f.bio, f.avatar_url), + None => (String::new(), String::new(), String::new()), + }; + self.edit_display_name = display_name; + self.edit_bio = bio; + self.edit_avatar_url = avatar_url; + self.original_display_name = self.edit_display_name.clone(); + self.original_bio = self.edit_bio.clone(); + self.original_avatar_url = self.edit_avatar_url.clone(); + self.profile_loaded = true; + } + + fn has_changes(&self) -> bool { + self.edit_display_name != self.original_display_name + || self.edit_bio != self.original_bio + || self.edit_avatar_url != self.original_avatar_url + } + + /// Called by the hub after a confirmed `DashPayProfileUpdated` backend + /// success result for the currently-selected identity. Commits the + /// SUBMITTED values (captured at click time) as the new baseline, which + /// disables the Save button until the user makes another change. (T21) + /// + /// Using the submitted snapshot — not the current edit fields — prevents + /// the data-loss scenario where the user keeps typing after clicking Save: + /// the deferred-success must not silently treat never-saved edits as saved. + /// + /// A failed `UpdateProfile` task does NOT call this method, so the baseline + /// stays at the last-confirmed state and the user can retry. + pub fn on_profile_saved(&mut self) { + if let Some((dn, bio, url)) = self.pending_save.take() { + self.original_display_name = dn; + self.original_bio = bio; + self.original_avatar_url = url; + } + // If pending_save is None (e.g. stale success after an identity switch + // cleared it) we do nothing — the hub's identity-ID guard should have + // prevented this call, but defending is harmless. + } + + /// Clear any in-flight pending save snapshot. Called by the hub's + /// `display_task_error` so a failed `UpdateProfile` doesn't leave a stale + /// snapshot that would be committed if a later `DashPayProfileUpdated` from + /// a different path (e.g. legacy ProfileScreen "Change photo") arrives. + pub fn clear_pending_save(&mut self) { + self.pending_save = None; + } + + /// Validation check used to drive Save button state. Returns `None` when + /// input is valid, else the first violation. We do not persist this in a + /// banner because users can self-correct inline using the counter. + fn validation_error(&self) -> Option<ProfileFieldError> { + validate_profile_fields( + &self.edit_display_name, + &self.edit_bio, + &self.edit_avatar_url, + ) + .into_iter() + .next() + } + + // ----------------------------------------------------------------- + // Test helpers (pub(crate)) + // ----------------------------------------------------------------- + + /// Test helper: force the advanced expander open so the kittest frame sees + /// the interior widgets without a click event. Not used by production code + /// but kept on the struct for future populated-render tests. + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn open_advanced_for_test(&mut self) { + self.advanced_open = true; + } +} + +// --------------------------------------------------------------------------- +// Small layout helpers +// --------------------------------------------------------------------------- + +fn render_empty_state(ui: &mut Ui) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + ui.vertical_centered(|ui| { + ui.add_space(32.0); + ui.label( + RichText::new("No identity selected.") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + ui.label( + RichText::new("Create or load an identity to see its settings.") + .color(DashColors::text_secondary(dark_mode)), + ); + }); + AppAction::None +} + +fn section_heading(ui: &mut Ui, text: &str, dark_mode: bool) { + ui.add_space(4.0); + ui.label( + RichText::new(text) + .strong() + .size(18.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); +} + +fn sub_heading(ui: &mut Ui, text: &str, dark_mode: bool) { + ui.label( + RichText::new(text) + .strong() + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ); +} + +fn counter(ui: &mut Ui, count: usize, max: usize, dark_mode: bool) { + let color = if count > max { + DashColors::ERROR + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label(RichText::new(format!("{count}/{max}")).small().color(color)); +} + +fn string_if_set(s: &str) -> Option<String> { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } +} + +fn identity_type_badge(kind: IdentityType) -> (&'static str, &'static str) { + match kind { + IdentityType::User => ("User identity", TIP_BADGE_USER), + IdentityType::Masternode => ("Masternode identity", TIP_BADGE_MASTERNODE), + IdentityType::Evonode => ("Evonode identity", TIP_BADGE_EVONODE), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_has_no_identity_selected() { + let tab = SettingsTab::new(); + assert!(tab.selected_identity.is_none()); + } + + #[test] + fn has_changes_tracks_baseline() { + let mut tab = SettingsTab::new(); + tab.edit_display_name = "alex".into(); + tab.original_display_name = "alex".into(); + assert!(!tab.has_changes()); + + tab.edit_display_name = "priya".into(); + assert!(tab.has_changes()); + } + + #[test] + fn validation_catches_long_display_name() { + let mut tab = SettingsTab::new(); + tab.edit_display_name = "a".repeat(MAX_DISPLAY_NAME + 1); + assert!(tab.validation_error().is_some()); + } + + #[test] + fn validation_catches_long_bio() { + let mut tab = SettingsTab::new(); + tab.edit_bio = "a".repeat(MAX_BIO + 1); + assert!(tab.validation_error().is_some()); + } + + #[test] + fn validation_requires_http_scheme() { + let mut tab = SettingsTab::new(); + tab.edit_avatar_url = "ftp://example.com/img".into(); + assert!(tab.validation_error().is_some()); + + tab.edit_avatar_url = "https://example.com/img".into(); + assert!(tab.validation_error().is_none()); + } + + #[test] + fn validation_accepts_empty_avatar_url() { + let tab = SettingsTab::new(); + assert!(tab.validation_error().is_none()); + } + + #[test] + fn string_if_set_trims_and_nones_empty() { + assert_eq!(string_if_set(""), None); + assert_eq!(string_if_set(" "), None); + assert_eq!(string_if_set(" alex "), Some("alex".to_string())); + } + + #[test] + fn identity_type_badge_covers_all_variants() { + for ty in [ + IdentityType::User, + IdentityType::Masternode, + IdentityType::Evonode, + ] { + let (label, tip) = identity_type_badge(ty); + assert!(!label.is_empty()); + assert!(!tip.is_empty()); + } + } + + /// IT-SETTINGS-01 (section-heading slice) — verifies the three required + /// section headings are rendered as labels when the layout helpers are + /// called directly. This covers the expected section-name assertions + /// from the test-case spec without bootstrapping a full `AppContext` and + /// `QualifiedIdentity`, which would add significant test-fixture weight. + #[test] + fn section_headings_render_their_text() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 400.0)) + .build_ui(|ui| { + let dark = ui.ctx().global_style().visuals.dark_mode; + section_heading(ui, "Social profile", dark); + section_heading(ui, "Username", dark); + section_heading(ui, "Aliases", dark); + sub_heading(ui, "Advanced", dark); + }); + harness.run(); + assert!( + harness.query_by_label("Social profile").is_some(), + "Social profile heading must render", + ); + assert!( + harness.query_by_label("Username").is_some(), + "Username heading must render", + ); + assert!( + harness.query_by_label("Aliases").is_some(), + "Aliases heading must render", + ); + assert!( + harness.query_by_label("Advanced").is_some(), + "Advanced sub-heading must render", + ); + } + + /// IT-SETTINGS-02 — T21 deferred baseline: on_profile_saved() must commit + /// the values that were SUBMITTED, not whatever is in the edit fields at the + /// time the success arrives (which can differ when the user keeps typing + /// after clicking Save on a slow network). + #[test] + fn on_profile_saved_commits_submitted_snapshot_not_current_edits() { + let mut tab = SettingsTab::new(); + // Simulate the user having loaded a profile ("Alice") and then editing it. + tab.original_display_name = "Alice".into(); + tab.original_bio = String::new(); + tab.original_avatar_url = String::new(); + tab.edit_display_name = "Alicia".into(); + tab.edit_bio = String::new(); + tab.edit_avatar_url = String::new(); + + // User clicks Save — capture the submitted snapshot. + tab.pending_save = Some(("Alicia".into(), String::new(), String::new())); + + // While the round-trip is in-flight the user KEEPS TYPING. + tab.edit_display_name = "Alicia Smith".into(); + + // The success arrives — hub calls on_profile_saved(). + tab.on_profile_saved(); + + // The baseline must reflect "Alicia" (what was submitted), NOT + // "Alicia Smith" (what happens to be in the box right now). + assert_eq!( + tab.original_display_name, "Alicia", + "baseline must be the submitted value, not the current edit" + ); + // The edit field is unchanged — the user can continue editing. + assert_eq!(tab.edit_display_name, "Alicia Smith"); + // has_changes() sees "Alicia Smith" vs "Alicia" → Save re-enables. + assert!( + tab.has_changes(), + "Save must re-enable for the in-flight edits" + ); + // pending_save is cleared. + assert!(tab.pending_save.is_none()); + } + + /// IT-SETTINGS-03 — T21: pending_save is cleared on identity switch so a + /// stale success from the old identity cannot corrupt the new identity's + /// baseline. + #[test] + fn pending_save_cleared_on_identity_change() { + let mut tab = SettingsTab::new(); + tab.edit_display_name = "Alicia".into(); + tab.pending_save = Some(("Alicia".into(), String::new(), String::new())); + + // Simulate what ensure_selected() does on a changed identity. + tab.selected_identity = None; + tab.profile_loaded = false; + tab.edit_display_name.clear(); + tab.edit_bio.clear(); + tab.edit_avatar_url.clear(); + tab.original_display_name.clear(); + tab.original_bio.clear(); + tab.original_avatar_url.clear(); + tab.pending_save = None; // the line we added in ensure_selected + + assert!( + tab.pending_save.is_none(), + "pending_save must be cleared on identity switch" + ); + // If on_profile_saved is now called (stale result) it must be a no-op. + let original_before = tab.original_display_name.clone(); + tab.on_profile_saved(); + assert_eq!( + tab.original_display_name, original_before, + "stale on_profile_saved must not corrupt baseline" + ); + } +} diff --git a/src/ui/identity/social_profile_gate_card.rs b/src/ui/identity/social_profile_gate_card.rs new file mode 100644 index 000000000..1aa0b42b5 --- /dev/null +++ b/src/ui/identity/social_profile_gate_card.rs @@ -0,0 +1,323 @@ +//! Social-profile gate card — the centered card shown on the Contacts tab when +//! the current identity has no social profile. See design-spec §B.4.1. +//! +//! The card has: +//! - A heading (`Set up a social profile first.`), +//! - A body paragraph that interpolates the user's `@handle` when known, +//! - A primary button (`Add a display name`), and +//! - A secondary `Why?` button that toggles an inline explanation panel. +//! +//! Follows the project's lazy-init component pattern +//! (`docs/COMPONENT_DESIGN_PATTERN.md`): domain/config fields stored on the +//! struct; visual primitives are built on every `show()` call. + +use crate::ui::components::component_trait::ComponentResponse; +use crate::ui::theme::{ComponentStyles, DashColors, Shadow, Shape}; +use eframe::egui::{CornerRadius, Frame, Margin, RichText, Stroke, Ui}; + +/// Copy constants, kept as `pub const` so tests and sibling callsites share a +/// single source of truth and any future i18n extraction touches one line. +pub const HEADING: &str = "Set up a social profile first."; +pub const PRIMARY_LABEL: &str = "Add a display name"; +pub const WHY_LABEL: &str = "Why?"; +pub const WHY_EXPANDED_LABEL: &str = "Hide details"; + +/// Body text when the caller knows the identity's DPNS handle. The `{handle}` +/// placeholder is i18n-ready (named, no positional assumptions). +pub const BODY_WITH_HANDLE: &str = "Contacts use your display name and avatar to let people find you. Your username \ + @{handle} already works for payments — a social profile only unlocks contacts. \ + Without a social profile, you cannot add contacts or receive contact requests."; + +/// Fallback body used when no DPNS handle is available yet. Avoids emitting a +/// stray `@` placeholder that would confuse everyday users. +pub const BODY_NO_HANDLE: &str = "Contacts use your display name and avatar to let people find you. A social profile \ + only unlocks contacts. Without a social profile, you cannot add contacts or \ + receive contact requests."; + +/// Text shown inside the expanded "Why?" panel. +pub const WHY_PANEL_BODY: &str = "Your username is already yours — it's on Platform and anyone who knows it can pay \ + you. A social profile is different: it adds a display name and avatar, and unlocks \ + the contacts feature so your friends can find you, send you money by name, and see \ + your recent activity if you allow it."; + +/// Interpolate `{handle}` into a template. Public for unit tests. Missing +/// placeholder returns the template unchanged — callers that pass a template +/// without a placeholder still get a sensible output. +pub(crate) fn interpolate_handle(template: &str, handle: &str) -> String { + template.replace("{handle}", handle) +} + +/// Typed action emitted by [`SocialProfileGateCard`] (T12). Replaces the +/// meaning of the raw booleans with an explicit discriminant while keeping the +/// booleans for backward-compat call sites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GateCardAction { + /// The primary CTA ("Add a display name") was clicked. + PrimaryClicked, + /// The "Why?" / "Hide details" toggle was clicked. + WhyToggled, +} + +/// Response returned by [`SocialProfileGateCard::show`]. The raw booleans +/// (`primary_clicked`, `why_toggled`) are kept for backward-compat call sites. +/// The typed [`GateCardAction`] (T12) is available via +/// [`action`](Self::action) and via the [`ComponentResponse`] impl. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SocialProfileGateCardResponse { + /// The primary button was clicked this frame. + pub primary_clicked: bool, + /// The `Why?` toggle was clicked this frame. + pub why_toggled: bool, + /// Typed action cache populated by [`SocialProfileGateCard::show`] so that + /// `ComponentResponse::changed_value()` can return a borrow. Private. + action_cache: Option<GateCardAction>, +} + +impl SocialProfileGateCardResponse { + /// Derive the typed [`GateCardAction`] from the raw booleans, if any. + pub fn action(&self) -> Option<GateCardAction> { + if self.primary_clicked { + Some(GateCardAction::PrimaryClicked) + } else if self.why_toggled { + Some(GateCardAction::WhyToggled) + } else { + None + } + } +} + +impl ComponentResponse for SocialProfileGateCardResponse { + /// The typed action from this frame's interaction with the gate card. + type DomainType = GateCardAction; + + fn has_changed(&self) -> bool { + self.primary_clicked || self.why_toggled + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option<Self::DomainType> { + &self.action_cache + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// The centered no-profile gate card. Interactive-only state is the `expanded` +/// flag for the explanation panel, which the caller owns so the card can stay +/// stateless between frames and is trivial to test. +#[derive(Clone, Debug, Default)] +pub struct SocialProfileGateCard { + handle: Option<String>, + expanded: bool, + max_width: Option<f32>, +} + +impl SocialProfileGateCard { + /// Construct a new gate card. Handle is optional — when absent, the card + /// falls back to the no-handle body copy so users in the pre-DPNS state + /// never see a stray `@{handle}` placeholder. + pub fn new(handle: Option<&str>) -> Self { + Self { + handle: handle + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + expanded: false, + max_width: None, + } + } + + /// Set whether the "Why?" explanation panel is expanded. Caller-owned so + /// the card has no cross-frame mutable state. + pub fn with_expanded(mut self, expanded: bool) -> Self { + self.expanded = expanded; + self + } + + /// Clamp the card body width. Defaults to a reasonable reading width. + pub fn with_max_width(mut self, max_width: f32) -> Self { + self.max_width = Some(max_width); + self + } + + /// Resolved body text — handle-aware when a handle is set. + pub fn resolved_body(&self) -> String { + match &self.handle { + Some(h) => interpolate_handle(BODY_WITH_HANDLE, h), + None => BODY_NO_HANDLE.to_string(), + } + } + + /// Resolved secondary-button label: flips between "Why?" and a collapse + /// affordance when the panel is expanded. Exposed for tests. + pub fn resolved_why_label(&self) -> &'static str { + if self.expanded { + WHY_EXPANDED_LABEL + } else { + WHY_LABEL + } + } + + /// Render the card. Returns a response describing which buttons were + /// clicked this frame; the caller owns the expansion state. + pub fn show(&self, ui: &mut Ui) -> SocialProfileGateCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let max_width = self.max_width.unwrap_or(520.0); + let mut response = SocialProfileGateCardResponse::default(); + + ui.vertical_centered(|ui| { + ui.set_max_width(max_width); + ui.add_space(24.0); + + let frame = Frame::new() + .fill(DashColors::surface_elevated(dark_mode)) + .stroke(Stroke::new( + Shape::BORDER_WIDTH, + DashColors::border(dark_mode), + )) + .corner_radius(CornerRadius::same(Shape::RADIUS_MD)) + .shadow(Shadow::medium()) + .inner_margin(Margin::symmetric(24, 24)); + + frame.show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(HEADING) + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); + ui.label( + RichText::new(self.resolved_body()) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(16.0); + ui.horizontal(|ui| { + // Spacer to center-align the two buttons inside the + // vertical_centered column. + ui.add_space(0.0); + if ui + .add(ComponentStyles::primary_button(PRIMARY_LABEL)) + .clicked() + { + response.primary_clicked = true; + } + ui.add_space(8.0); + if ui + .add(ComponentStyles::secondary_button( + self.resolved_why_label(), + dark_mode, + )) + .clicked() + { + response.why_toggled = true; + } + }); + if self.expanded { + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + ui.label( + RichText::new(WHY_PANEL_BODY) + .color(DashColors::text_secondary(dark_mode)) + .small(), + ); + } + }); + }); + + ui.add_space(24.0); + }); + + // Populate the typed action cache so ComponentResponse::changed_value() + // can return a borrow (T12). + response.action_cache = response.action(); + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// UT-GATE-01 — No-social-profile gate card: interpolates `{handle}` + /// correctly and the primary button label is `Add a display name`. + #[test] + fn ut_gate_01_interpolates_handle_and_primary_label() { + let card = SocialProfileGateCard::new(Some("alex.dash")); + let body = card.resolved_body(); + assert!( + body.contains("@alex.dash"), + "body must interpolate the handle with a leading @, got: {body}" + ); + assert!( + !body.contains("{handle}"), + "body must not contain the raw placeholder after interpolation" + ); + assert_eq!(PRIMARY_LABEL, "Add a display name"); + } + + #[test] + fn interpolate_handle_replaces_placeholder() { + assert_eq!( + interpolate_handle("Hello @{handle}, welcome", "bob"), + "Hello @bob, welcome" + ); + } + + #[test] + fn interpolate_handle_leaves_template_without_placeholder_unchanged() { + assert_eq!( + interpolate_handle("No placeholder here.", "bob"), + "No placeholder here." + ); + } + + #[test] + fn missing_handle_falls_back_to_no_handle_body() { + let card = SocialProfileGateCard::new(None); + let body = card.resolved_body(); + assert_eq!(body, BODY_NO_HANDLE); + assert!( + !body.contains("{handle}"), + "no-handle body must never contain a raw placeholder" + ); + assert!( + !body.contains('@'), + "no-handle body must not contain a stray @ marker" + ); + } + + #[test] + fn empty_or_whitespace_handle_treated_as_absent() { + assert!(SocialProfileGateCard::new(Some("")).handle.is_none()); + assert!(SocialProfileGateCard::new(Some(" ")).handle.is_none()); + } + + #[test] + fn why_label_flips_when_expanded() { + let collapsed = SocialProfileGateCard::new(None); + let expanded = SocialProfileGateCard::new(None).with_expanded(true); + assert_eq!(collapsed.resolved_why_label(), WHY_LABEL); + assert_eq!(expanded.resolved_why_label(), WHY_EXPANDED_LABEL); + } + + #[test] + fn heading_is_complete_sentence() { + assert!(HEADING.ends_with('.')); + assert!(HEADING.chars().next().unwrap().is_ascii_uppercase()); + } + + #[test] + fn default_response_has_no_clicks() { + let resp = SocialProfileGateCardResponse::default(); + assert!(!resp.primary_clicked); + assert!(!resp.why_toggled); + } +} diff --git a/src/ui/identity/tabs.rs b/src/ui/identity/tabs.rs new file mode 100644 index 000000000..f54fc391b --- /dev/null +++ b/src/ui/identity/tabs.rs @@ -0,0 +1,116 @@ +//! Tab identity for the hub screen. + +use std::fmt; + +/// One of the four tabs inside `IdentityHubScreen`. +/// +/// Order matters: the tab bar renders these left to right in variant order. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IdentityHubTab { + /// Identity home: hero, quick actions, checklist, recent activity. + #[default] + Home, + /// Contacts: requests, active contacts, sent requests. Gated on social profile. + Contacts, + /// Unified activity timeline (payments · funding · platform ops). + Activity, + /// Identity settings: social profile, username + aliases, advanced, danger zone. + Settings, +} + +impl IdentityHubTab { + /// All tabs in display order. + pub const ALL: [IdentityHubTab; 4] = [ + IdentityHubTab::Home, + IdentityHubTab::Contacts, + IdentityHubTab::Activity, + IdentityHubTab::Settings, + ]; + + /// Alex-facing label used in the tab bar and topbar. + pub fn label(self) -> &'static str { + match self { + IdentityHubTab::Home => "Home", + IdentityHubTab::Contacts => "Contacts", + IdentityHubTab::Activity => "Activity", + IdentityHubTab::Settings => "Settings", + } + } + + /// Accessible description used by screen readers. Always a complete sentence. + pub fn accessible_description(self) -> &'static str { + match self { + IdentityHubTab::Home => "Identity home. Balance, quick actions, and recent activity.", + IdentityHubTab::Contacts => { + "Contacts. Received requests, your contacts, and sent requests." + } + IdentityHubTab::Activity => { + "Activity. A unified timeline of payments, funding, and platform actions." + } + IdentityHubTab::Settings => { + "Settings. Social profile, usernames, keys, and advanced options." + } + } + } +} + +impl fmt::Display for IdentityHubTab { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_in_display_order() { + assert_eq!(IdentityHubTab::ALL[0], IdentityHubTab::Home); + assert_eq!(IdentityHubTab::ALL[1], IdentityHubTab::Contacts); + assert_eq!(IdentityHubTab::ALL[2], IdentityHubTab::Activity); + assert_eq!(IdentityHubTab::ALL[3], IdentityHubTab::Settings); + } + + #[test] + fn labels_are_non_empty_complete_words() { + for tab in IdentityHubTab::ALL { + let label = tab.label(); + assert!(!label.is_empty(), "tab label must not be empty"); + assert!( + label.chars().next().unwrap().is_ascii_uppercase(), + "tab label '{label}' must start with an uppercase letter" + ); + } + } + + #[test] + fn accessible_description_is_complete_sentence() { + for tab in IdentityHubTab::ALL { + let desc = tab.accessible_description(); + assert!( + desc.ends_with('.'), + "accessible description '{desc}' must end with a period" + ); + } + } + + #[test] + fn default_is_home() { + assert_eq!(IdentityHubTab::default(), IdentityHubTab::Home); + } + + /// Pins the exact tab-bar label sequence consumed by `IdentityHubTabBar`. + #[test] + fn labels_in_display_order() { + let labels: Vec<&str> = IdentityHubTab::ALL.iter().map(|t| t.label()).collect(); + assert_eq!(labels, ["Home", "Contacts", "Activity", "Settings"]); + } + + #[test] + fn display_matches_label() { + for tab in IdentityHubTab::ALL { + assert_eq!(format!("{tab}"), tab.label()); + } + } +} diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs new file mode 100644 index 000000000..cc91886b9 --- /dev/null +++ b/src/ui/masternodes/card.rs @@ -0,0 +1,376 @@ +//! Masternode/evonode card for the Masternodes grid — reuses +//! `identity_picker_card.rs`'s visual language, adding voter-readiness, +//! key-presence, and DPNS-status rows (colour always paired with text, NFR-6). + +use crate::model::contested_name::MasternodeContestSummary; +use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; +use crate::ui::identity::identity_picker_card::{ + CARD_HEIGHT, CARD_MIN_WIDTH, draw_monogram, draw_type_badge, +}; +use crate::ui::theme::DashColors; +use eframe::egui::{ + self, Color32, CornerRadius, Frame, Margin, Response, RichText, Sense, Stroke, Ui, Vec2, + WidgetInfo, WidgetType, +}; + +/// Heading for a masternode card: the alias when set, otherwise the shortened +/// ProTxHash. Mirrors TC-FR3-02 / TC-FR3-03. +pub fn card_heading(alias: Option<&str>, shortened_pro_tx_hash: &str) -> String { + match alias.map(str::trim).filter(|s| !s.is_empty()) { + Some(alias) => alias.to_string(), + None => shortened_pro_tx_hash.to_string(), + } +} + +/// Sub-line for a masternode card: the shortened ProTxHash shown beneath the +/// heading only when an alias provides the heading. When the ProTxHash is +/// already the heading it is not repeated. Mirrors TC-FR3-03. +pub fn card_sub_line(alias: Option<&str>, shortened_pro_tx_hash: &str) -> Option<String> { + alias + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|_| shortened_pro_tx_hash.to_string()) +} + +/// Voter-readiness label. Green + `Voting ready` when a voting key is loaded, +/// warning + `No voting key` otherwise (§7 copy). +pub fn voter_readiness_label(voting_present: bool) -> &'static str { + if voting_present { + "Voting ready" + } else { + "No voting key" + } +} + +/// DPNS status line with count-first precedence (§10.1): open contests first +/// (actionable), then a pending scheduled vote, then none. +pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { + if summary.open_contest_count > 0 { + format!("{} contests to vote on", summary.open_contest_count) + } else if summary.has_scheduled_vote { + "Vote scheduled".to_string() + } else { + "No open contests".to_string() + } +} + +/// The three `Keys:` role tokens in display order, each paired with whether it +/// is present. Present roles render as their letter, absent roles as `·`. +pub fn key_status_tokens(presence: MasternodeKeyPresence) -> [(&'static str, bool); 3] { + [ + ("V", presence.voting), + ("O", presence.owner), + ("P", presence.payout), + ] +} + +/// Response from [`MasternodeCard::show`]: whether the card was activated, plus +/// the node id echoed from construction so a grid can route selection without +/// tracking indices. +#[derive(Clone, Debug)] +pub struct MasternodeCardResponse { + pub clicked: bool, + pub node_id: String, +} + +/// A single masternode/evonode card. Built from already-resolved display data +/// so the component stays free of `AppContext` and is unit-testable. +#[derive(Clone, Debug)] +pub struct MasternodeCard { + node_id: String, + node_id_short: String, + alias: Option<String>, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, +} + +impl MasternodeCard { + pub fn new( + node_id: impl Into<String>, + node_id_short: impl Into<String>, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, + ) -> Self { + Self { + node_id: node_id.into(), + node_id_short: node_id_short.into(), + alias: None, + node_type, + key_presence, + contest_summary, + status, + } + } + + pub fn with_alias(mut self, alias: Option<String>) -> Self { + self.alias = alias.filter(|s| !s.trim().is_empty()); + self + } + + /// Heading string — exposed for tests (TC-FR3-02 / 03). + pub fn heading(&self) -> String { + card_heading(self.alias.as_deref(), &self.node_id_short) + } + + /// Sub-line string — exposed for tests. + pub fn sub_line(&self) -> Option<String> { + card_sub_line(self.alias.as_deref(), &self.node_id_short) + } + + /// Badge pill text (`Masternode` / `Evonode`). + pub fn badge_label(&self) -> &'static str { + match self.node_type { + IdentityType::Evonode => "Evonode", + // A User identity never reaches this grid; fall back to Masternode. + _ => "Masternode", + } + } + + pub fn show(&self, ui: &mut Ui) -> MasternodeCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let border = Stroke::new(1.0, DashColors::border(dark_mode)); + let fill = DashColors::surface(dark_mode); + let heading = self.heading(); + let heading_for_a11y = heading.clone(); + + let frame = Frame::new() + .fill(fill) + .stroke(border) + .corner_radius(CornerRadius::same(16)) + .inner_margin(Margin::symmetric(16, 16)); + + let desired_size = Vec2::new(CARD_MIN_WIDTH, CARD_HEIGHT); + + let inner = frame.show(ui, |ui| { + ui.set_min_size(desired_size); + ui.set_max_width(desired_size.x); + ui.vertical(|ui| { + // Top row: monogram (left) + type badge (right). + ui.horizontal(|ui| { + draw_monogram(ui, &heading, false, dark_mode); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + draw_type_badge(ui, self.badge_label(), dark_mode); + }); + }); + ui.add_space(12.0); + + // Heading + optional ProTxHash sub-line. + ui.label( + RichText::new(&heading) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + if let Some(sub) = self.sub_line() { + ui.label( + RichText::new(sub) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + } + ui.add_space(8.0); + + // Voter readiness (colour + text — NFR-6). + let voting = self.key_presence.voting; + let (voter_color, voter_label) = if voting { + ( + DashColors::success_color(dark_mode), + voter_readiness_label(true), + ) + } else { + ( + DashColors::warning_color(dark_mode), + voter_readiness_label(false), + ) + }; + draw_status_row(ui, voter_color, voter_label, dark_mode); + + // Compact key status: `Keys: V O P` (present emphasised). + ui.horizontal(|ui| { + ui.label( + RichText::new("Keys:") + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + for (letter, present) in key_status_tokens(self.key_presence) { + if present { + ui.label( + RichText::new(letter) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(13.0), + ); + } else { + ui.label( + RichText::new("·") + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + } + } + }); + + // DPNS status line (count-first precedence). + ui.label( + RichText::new(dpns_status_line(self.contest_summary)) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + ui.add_space(4.0); + + // Identity status dot + label (all five states). + draw_status_row( + ui, + Color32::from(self.status), + &self.status.to_string(), + dark_mode, + ); + }); + }); + + // Whole card = one click target with an accessible label (NFR-6). + let rect = inner.response.rect; + let id = ui.id().with(("masternode-card", &self.node_id)); + let response: Response = ui.interact(rect, id, Sense::click()); + if response.hovered() { + ui.painter().rect_stroke( + rect, + CornerRadius::same(16), + Stroke::new(1.5, DashColors::border_light(dark_mode)), + egui::StrokeKind::Inside, + ); + } + response.widget_info(|| { + WidgetInfo::labeled(WidgetType::Button, true, format!("Open {heading_for_a11y}")) + }); + + MasternodeCardResponse { + clicked: response.clicked(), + node_id: self.node_id.clone(), + } + } +} + +/// Paint a small status dot followed by its text label. The label is always +/// present, so status is never conveyed by colour alone (NFR-6). +fn draw_status_row(ui: &mut Ui, color: Color32, label: &str, dark_mode: bool) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, color); + ui.add_space(4.0); + ui.label( + RichText::new(label) + .color(DashColors::text_primary(dark_mode)) + .size(13.0), + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tc_fr3_02_heading_is_shortened_pro_tx_hash_when_no_alias() { + assert_eq!(card_heading(None, "9a3f…d7e2"), "9a3f…d7e2"); + assert_eq!(card_sub_line(None, "9a3f…d7e2"), None); + } + + #[test] + fn tc_fr3_03_alias_heading_with_pro_tx_hash_sub_line() { + assert_eq!(card_heading(Some("mn-east-01"), "9a3f…d7e2"), "mn-east-01"); + assert_eq!( + card_sub_line(Some("mn-east-01"), "9a3f…d7e2"), + Some("9a3f…d7e2".to_string()) + ); + } + + #[test] + fn blank_alias_falls_through_to_pro_tx_hash() { + assert_eq!(card_heading(Some(" "), "9a3f…d7e2"), "9a3f…d7e2"); + assert_eq!(card_sub_line(Some(" "), "9a3f…d7e2"), None); + } + + #[test] + fn tc_fr3_06_07_voter_readiness_copy() { + assert_eq!(voter_readiness_label(true), "Voting ready"); + assert_eq!(voter_readiness_label(false), "No voting key"); + } + + #[test] + fn tc_fr3_09_dpns_open_contest_count() { + let summary = MasternodeContestSummary { + open_contest_count: 3, + has_scheduled_vote: false, + }; + assert_eq!(dpns_status_line(summary), "3 contests to vote on"); + } + + #[test] + fn tc_fr3_10_dpns_no_open_contests() { + assert_eq!( + dpns_status_line(MasternodeContestSummary::default()), + "No open contests" + ); + } + + #[test] + fn tc_fr3_11_count_takes_precedence_over_scheduled() { + // Both an open contest AND a scheduled vote present → count wins. + let summary = MasternodeContestSummary { + open_contest_count: 2, + has_scheduled_vote: true, + }; + assert_eq!(dpns_status_line(summary), "2 contests to vote on"); + } + + #[test] + fn dpns_scheduled_shown_only_when_no_open_contests() { + let summary = MasternodeContestSummary { + open_contest_count: 0, + has_scheduled_vote: true, + }; + assert_eq!(dpns_status_line(summary), "Vote scheduled"); + } + + #[test] + fn tc_fr3_08_key_tokens_reflect_presence() { + let presence = MasternodeKeyPresence { + voting: true, + owner: false, + payout: true, + }; + assert_eq!( + key_status_tokens(presence), + [("V", true), ("O", false), ("P", true)] + ); + } + + #[test] + fn tc_fr3_04_05_badge_label_by_type() { + let card = MasternodeCard::new( + "id", + "9a3f…d7e2", + IdentityType::Masternode, + MasternodeKeyPresence::default(), + MasternodeContestSummary::default(), + IdentityStatus::Active, + ); + assert_eq!(card.badge_label(), "Masternode"); + + let evo = MasternodeCard::new( + "id", + "9a3f…d7e2", + IdentityType::Evonode, + MasternodeKeyPresence::default(), + MasternodeContestSummary::default(), + IdentityStatus::Active, + ); + assert_eq!(evo.badge_label(), "Evonode"); + } +} diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs new file mode 100644 index 000000000..a92a21b98 --- /dev/null +++ b/src/ui/masternodes/detail_screen.rs @@ -0,0 +1,956 @@ +//! Masternode/evonode detail view (FR-5). +//! +//! Section order (header, actions, keys, DPNS voting, remove) is fixed by +//! design; each action pushes an existing screen — no parallel MN-specific +//! reimplementation (NFR-1). See `docs/ai-design/2026-07-09-masternode-page-design/`. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, RichText, Ui}; + +use std::collections::BTreeMap; + +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; +use crate::context::AppContext; +use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; +use crate::model::qualified_identity::{ + IdentityType, MasternodeKeyPresence, PrivateKeyTarget, QualifiedIdentity, +}; +use crate::model::secret::Secret; +use crate::ui::components::MessageBanner; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::identity::identity_picker_card::draw_type_badge; +use crate::ui::identity::identity_pill::shorten_id; +use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; +use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; +use crate::ui::{MessageType, Screen, ScreenType}; +use crate::wallet_backend::IdentityKeyView; +use crate::wallet_backend::secret_seam::SecretScheme; + +/// §7 copy: shown when the node has no voting key loaded. +const MISSING_VOTER_MESSAGE: &str = + "This node has no voting key loaded. Add its voting private key to cast votes."; +/// §7 copy: shown when the node has a voter identity but no open contests. +const NO_OPEN_CONTESTS_MESSAGE: &str = + "There are no open name contests for this node to vote on right now."; + +/// The collapsible DPNS section header, with the open-contest count (TC-DPNS-02). +fn dpns_section_header(open_contest_count: usize) -> String { + format!("DPNS name contests to vote on ({open_contest_count})") +} + +/// The fixed top→bottom section order. Actions must precede Keys (TC-FR5-01). +pub const SECTION_ORDER: [&str; 5] = ["Header", "Actions", "Keys", "DPNS", "Remove"]; + +/// A short, human label for a masternode key button, derived from its purpose +/// and the identity it lives on. Voter-identity keys are always "Voting"; on +/// the main identity, Owner/Payout keys are named by purpose, everything else +/// falls back to its purpose name. +fn key_role_label( + target: &PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, +) -> String { + use dash_sdk::dpp::identity::Purpose; + if *target == PrivateKeyTarget::PrivateKeyOnVoterIdentity { + return "Voting".to_string(); + } + match key.purpose() { + Purpose::OWNER => "Owner".to_string(), + Purpose::TRANSFER => "Payout".to_string(), + Purpose::AUTHENTICATION => "Authentication".to_string(), + other => format!("{other:?}"), + } +} + +/// At-rest protection posture of a node's vault keys, reduced to what the detail +/// view needs: the tier label and whether an `Add password protection…` action +/// applies (only when there are unprotected vault keys to seal). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProtectionTier { + /// No vault-stored keys (read-only node or resident-plaintext only). + NoVaultKeys, + /// At least one unprotected (Tier-1) vault key, none protected. + Unprotected, + /// Every vault key is password-protected (Tier-2), or a mix. + Protected, +} + +impl ProtectionTier { + fn label(self) -> &'static str { + match self { + ProtectionTier::Protected => "Keys: password-protected", + // A read-only node has nothing sealed either — "unprotected" is the + // accurate, non-alarming description of its at-rest posture. + _ => "Keys: unprotected", + } + } + + /// `Add password protection…` is offered only when there are Tier-1 keys to + /// seal (§FR-8 / NFR-4). + fn offers_add_protection(self) -> bool { + matches!(self, ProtectionTier::Unprotected) + } +} + +/// Outcome of rendering the detail view for one frame. +pub enum DetailOutcome { + /// No terminal interaction this frame. + None, + /// Return to the card list (`‹ All masternodes`). + Back, + /// The node was removed — return to the list and reload. + Removed, + /// Push a reused screen / navigate. Boxed because `AppAction` is large. + Forward(Box<AppAction>), +} + +/// Masternode/evonode detail view state. +pub struct MasternodeDetailView { + app_context: Arc<AppContext>, + identity: QualifiedIdentity, + node_id_hex_full: String, + node_id_short: String, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + /// Open contests this node can still vote on (loaded at construction / + /// refresh). Active/open only — scheduled/past history lives on the DPNS + /// Scheduled Votes screen (§10.7). + open_contests: Vec<ContestedName>, + /// Per-contest pending vote choice, keyed by normalized contested name. + vote_selections: BTreeMap<String, ResourceVoteChoice>, + /// The scoped, in-place "Add voting key" prompt (US-3 / §10.8) — distinct + /// from FR-4's load form. `Some` while the prompt is open. + voter_key_prompt: Option<PasswordInput>, + remove_dialog: Option<ConfirmationDialog>, +} + +impl MasternodeDetailView { + pub fn new(app_context: &Arc<AppContext>, identity: QualifiedIdentity) -> Self { + let node_id_hex_full = identity.identity.id().to_string(Encoding::Hex); + let node_id_short = shorten_id(&node_id_hex_full); + let key_presence = identity.masternode_key_presence(); + let voter_id = identity + .associated_voter_identity + .as_ref() + .map(|(voter, _)| voter.id()); + let contest_summary = app_context + .masternode_contest_summary(voter_id) + .unwrap_or_default(); + let open_contests = Self::load_open_contests(app_context, voter_id); + Self { + app_context: app_context.clone(), + identity, + node_id_hex_full, + node_id_short, + key_presence, + contest_summary, + open_contests, + vote_selections: BTreeMap::new(), + voter_key_prompt: None, + remove_dialog: None, + } + } + + /// Load the contests this node can still vote on. Empty when the node has no + /// voting key (no voter id) or the read fails. + fn load_open_contests( + app_context: &Arc<AppContext>, + voter_id: Option<dash_sdk::platform::Identifier>, + ) -> Vec<ContestedName> { + let Some(voter_id) = voter_id else { + return Vec::new(); + }; + app_context + .ongoing_contested_names() + .unwrap_or_default() + .into_iter() + .filter(|contest| contest.is_open_for_voter(&voter_id)) + .collect() + } + + /// Refresh the DPNS contest summary + open-contest list from the store. + fn refresh_contests(&mut self) { + let voter_id = self + .identity + .associated_voter_identity + .as_ref() + .map(|(voter, _)| voter.id()); + self.contest_summary = self + .app_context + .masternode_contest_summary(voter_id) + .unwrap_or_default(); + self.open_contests = Self::load_open_contests(&self.app_context, voter_id); + } + + /// Build the network re-fetch dispatched by the detail Refresh button: + /// refresh this node's identity, plus a DPNS contests re-query + /// when the node has a voter identity that can vote. + fn refresh_from_network(&self) -> AppAction { + let mut tasks = vec![BackendTask::IdentityTask(IdentityTask::RefreshIdentity( + self.identity.clone(), + ))]; + if self.identity.associated_voter_identity.is_some() { + tasks.push(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + )); + } + AppAction::BackendTasks(tasks, crate::app::BackendTasksExecutionMode::Concurrent) + } + + /// The node's identity id — used by the list screen to match the open node. + pub fn node_id(&self) -> dash_sdk::platform::Identifier { + self.identity.identity.id() + } + + fn is_evonode(&self) -> bool { + self.identity.identity_type == IdentityType::Evonode + } + + /// Build an `AddScreen` action for a reused screen type, scoped to this node. + fn push(&self, screen_type: ScreenType) -> AppAction { + AppAction::AddScreen(screen_type.create_screen(&self.app_context)) + } + + fn badge_label(&self) -> &'static str { + if self.is_evonode() { + "Evonode" + } else { + "Masternode" + } + } + + /// Probe the at-rest protection posture of this node's vault keys. + fn protection_tier(&self) -> ProtectionTier { + let Ok(backend) = self.app_context.wallet_backend() else { + return ProtectionTier::NoVaultKeys; + }; + let view = IdentityKeyView::new( + backend.secret_store(), + self.identity.identity.id().to_buffer(), + ); + let (mut protected, mut unprotected) = (0usize, 0usize); + for (target, key_id) in self.identity.private_keys.keys_set() { + match view.scheme(&target, key_id) { + Ok(SecretScheme::Protected) => protected += 1, + Ok(SecretScheme::Unprotected) => unprotected += 1, + _ => {} + } + } + // TODO: a mixed state (some Tier-1, some Tier-2) currently maps to + // Protected, so the aggregate "Add password protection…" CTA is hidden + // even though unprotected keys remain. This is mitigated by the per-key + // Manage-keys list (each unprotected key can still be sealed from its + // KeyInfoScreen); a dedicated "partially protected" tier could re-offer + // the aggregate CTA. + match (protected, unprotected) { + (0, 0) => ProtectionTier::NoVaultKeys, + (0, _) => ProtectionTier::Unprotected, + _ => ProtectionTier::Protected, + } + } + + pub fn show(&mut self, ui: &mut Ui, network_accent: Color32) -> DetailOutcome { + let dark_mode = ui.style().visuals.dark_mode; + let mut outcome = DetailOutcome::None; + + // Back row + Refresh (content-panel, not the global header). FR-7. + ui.horizontal(|ui| { + if ui + .selectable_label(false, RichText::new("‹ All masternodes")) + .clicked() + { + outcome = DetailOutcome::Back; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ComponentStyles::add_toolbar_button(ui, "Refresh", network_accent).clicked() { + // Re-read the local contest cache immediately (optimistic) + // AND dispatch a network re-fetch of this node plus the DPNS + // contests — Refresh must reach the network. + self.refresh_contests(); + outcome = DetailOutcome::Forward(Box::new(self.refresh_from_network())); + } + }); + }); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + self.render_header(ui, dark_mode); + ui.add_space(12.0); + if let Some(action) = self.render_actions_row(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if let Some(action) = self.render_keys_section(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if let Some(action) = self.render_dpns_section(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if self.render_remove_section(ui, dark_mode) { + outcome = DetailOutcome::Removed; + } + }); + + outcome + } + + fn render_header(&self, ui: &mut Ui, dark_mode: bool) { + // Conditional alias line — omitted entirely when unset (TC-FR5-02). + if let Some(alias) = self + .identity + .alias + .as_deref() + .filter(|s| !s.trim().is_empty()) + { + ui.label( + RichText::new(alias) + .size(20.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + } + ui.horizontal(|ui| { + ui.label( + RichText::new(&self.node_id_short) + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + // Copy the FULL ProTxHash, not the shortened display string (TC-FR5-03). + if ui.button("⧉").on_hover_text("Copy ProTxHash").clicked() { + ui.ctx().copy_text(self.node_id_hex_full.clone()); + } + draw_type_badge(ui, self.badge_label(), dark_mode); + }); + // Status dot + label (never colour-only — TC-FR5-05 / NFR-6). + ui.horizontal(|ui| { + let (rect, _) = + ui.allocate_exact_size(egui::Vec2::new(10.0, 10.0), egui::Sense::hover()); + ui.painter() + .circle_filled(rect.center(), 4.0, Color32::from(self.identity.status)); + ui.add_space(4.0); + ui.label( + RichText::new(self.identity.status.to_string()) + .color(DashColors::text_primary(dark_mode)), + ); + }); + } + + fn render_actions_row(&self, ui: &mut Ui, dark_mode: bool) -> Option<AppAction> { + let mut action = None; + ui.label( + RichText::new("Actions") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal_wrapped(|ui| { + // All three credit screens are reused, scoped to THIS node (FR-9). + if ui.button("Withdraw").clicked() { + action = Some(self.push(ScreenType::WithdrawalScreen(self.identity.clone()))); + } + if ui.button("Top up").clicked() { + action = Some(self.push(ScreenType::TopUpIdentity(self.identity.clone()))); + } + if ui.button("Transfer").clicked() { + action = Some(self.push(ScreenType::TransferScreen(self.identity.clone()))); + } + // Evonode-only token-rewards cross-link (FR-11); absent for a plain + // masternode (TC-FR11-02). + if self.is_evonode() + && ui + .button("Claim token rewards ›") + .on_hover_text("Claim this evonode's token rewards.") + .clicked() + { + action = Some(self.claim_token_rewards_action(ui.ctx())); + } + }); + action + } + + /// Route the evonode "Claim token rewards" CTA (FR-11). When this + /// evonode holds exactly one token in the local registry, push a + /// `ClaimTokensScreen` scoped to it (the real claim flow). With zero or + /// several tokens the correct target is ambiguous, so fall back to the My + /// Tokens area where the user picks the token to claim. + fn claim_token_rewards_action(&self, ctx: &egui::Context) -> AppAction { + let fallback = + AppAction::SetMainScreen(crate::ui::RootScreenType::RootScreenMyTokenBalances); + let node_id = self.identity.identity.id(); + let mut mine: Vec<_> = self + .app_context + .identity_token_balances() + .unwrap_or_default() + .into_iter() + .filter(|(key, _)| key.identity_id == node_id) + .map(|(_, balance)| balance) + .collect(); + if mine.len() != 1 { + return fallback; + } + let itb = mine.remove(0); + match self.app_context.get_contract_by_token_id(&itb.token_id) { + Ok(Some(contract)) => { + let basic = IdentityTokenBasicInfo { + token_id: itb.token_id, + token_alias: itb.token_alias.clone(), + identity_id: itb.identity_id, + contract_id: itb.data_contract_id, + token_position: itb.token_position, + }; + AppAction::AddScreen(Screen::ClaimTokensScreen(ClaimTokensScreen::new( + basic, + contract, + itb.token_config, + &self.app_context, + ))) + } + _ => { + MessageBanner::set_global( + ctx, + "This token's details aren't available yet. Open My Tokens to claim.", + MessageType::Info, + ); + fallback + } + } + } + + fn render_keys_section(&mut self, ui: &mut Ui, dark_mode: bool) -> Option<AppAction> { + let mut action = None; + ui.label( + RichText::new("Keys") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Compact V/O/P presence (glyph, not colour-only — NFR-6). + ui.horizontal(|ui| { + ui.label(RichText::new("Roles:").color(DashColors::text_secondary(dark_mode))); + for (letter, present) in [ + ("V", self.key_presence.voting), + ("O", self.key_presence.owner), + ("P", self.key_presence.payout), + ] { + let text = if present { + RichText::new(letter) + .strong() + .color(DashColors::text_primary(dark_mode)) + } else { + RichText::new("·").color(DashColors::text_secondary(dark_mode)) + }; + ui.label(text); + } + }); + + // Copyable voter-identity id, when a voter identity is loaded. + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { + let voter_full = voter.id().to_string(Encoding::Base58); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Voter identity: {}", shorten_id(&voter_full))) + .color(DashColors::text_secondary(dark_mode)), + ); + if ui + .button("⧉") + .on_hover_text("Copy voter identity ID") + .clicked() + { + ui.ctx().copy_text(voter_full.clone()); + } + }); + } + + // Protection tier + conditional Add-protection (FR-8 / NFR-4). + let tier = self.protection_tier(); + ui.label(RichText::new(tier.label()).color(DashColors::text_secondary(dark_mode))); + + // Per-key "Manage keys" list. Each key opens its own `KeyInfoScreen` — + // the real, interactive per-key screen with view/sign/seal actions — + // not the static read-only `KeysScreen` table. This mirrors + // `identities_screen.rs`: one button per key, each pushing + // `Screen::KeyInfoScreen`. + ui.add_space(4.0); + ui.label( + RichText::new("Manage keys") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + for (target, key) in self.identity_keys() { + if ui + .button(format!("{} key ›", key_role_label(&target, &key))) + .clicked() + { + action = Some(self.open_key_info(target, &key)); + } + } + + // Add-protection CTA (FR-8): the seal form (password entry → + // `IdentityTask::ProtectIdentityKeys`, which seals the whole identity) + // lives inside `KeyInfoScreen`. Open the first held key so the user + // lands directly on the interactive seal flow. + if tier.offers_add_protection() + && let Some((target, key)) = self.first_protectable_key() + && ui.button("Add password protection…").clicked() + { + action = Some(self.open_key_info(target, &key)); + } + action + } + + /// Every key of this node, main-identity keys first then voter-identity + /// keys, each paired with the `PrivateKeyTarget` that scopes it. Backs the + /// per-key "Manage keys" list and the Add-protection routing. + fn identity_keys(&self) -> Vec<(PrivateKeyTarget, dash_sdk::platform::IdentityPublicKey)> { + let mut keys = Vec::new(); + for key in self.identity.identity.public_keys().values() { + keys.push((PrivateKeyTarget::PrivateKeyOnMainIdentity, key.clone())); + } + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { + for key in voter.public_keys().values() { + keys.push((PrivateKeyTarget::PrivateKeyOnVoterIdentity, key.clone())); + } + } + keys + } + + /// The first key whose private material this node actually holds — the only + /// keys that can be sealed. Used to route the Add-protection CTA straight + /// into an interactive `KeyInfoScreen` seal flow. + fn first_protectable_key( + &self, + ) -> Option<(PrivateKeyTarget, dash_sdk::platform::IdentityPublicKey)> { + self.identity_keys().into_iter().find(|(target, key)| { + self.identity + .private_keys + .get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id())) + .is_some() + }) + } + + /// Build the `AddScreen` action that opens `KeyInfoScreen` for one key, + /// carrying its held private-key data if any. Mirrors the + /// per-key push in `identities_screen.rs`. + fn open_key_info( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + let holding = self + .identity + .private_keys + .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); + AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + holding, + &self.app_context, + ))) + } + + /// Render the collapsible DPNS voting section (collapsed by default, + /// open-contest count in the header). Inline voting reuses the existing + /// `vote_on_dpns_name` backend (locked decision #1 — not a deep-link). + fn render_dpns_section(&mut self, ui: &mut Ui, dark_mode: bool) -> Option<AppAction> { + // When the node has no voter key, the "Add voting key" CTA is + // the primary next step — render it above, outside the collapsed-by- + // default DPNS section, so it is visible without expanding anything. + // The empty DPNS section (no contests possible without a voter) is + // omitted in that state. + if self.identity.associated_voter_identity.is_none() { + return self.render_missing_voter(ui, dark_mode); + } + + let mut action = None; + let header = dpns_section_header(self.contest_summary.open_contest_count); + egui::CollapsingHeader::new(header) + .default_open(false) + .show(ui, |ui| { + if self.open_contests.is_empty() { + ui.label( + RichText::new(NO_OPEN_CONTESTS_MESSAGE) + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + action = self.render_vote_table(ui, dark_mode); + } + }); + action + } + + /// Missing-voter-identity state (US-3 / §10.9): an actionable message plus a + /// scoped in-place `Add voting key` prompt — never the raw error, never + /// FR-4's load form. + fn render_missing_voter(&mut self, ui: &mut Ui, dark_mode: bool) -> Option<AppAction> { + let mut action = None; + ui.label(RichText::new(MISSING_VOTER_MESSAGE).color(DashColors::warning_color(dark_mode))); + + match self.voter_key_prompt.as_mut() { + None => { + if ui.button("Add voting key").clicked() { + // Node context is already bound (`self.identity`) — the + // prompt only asks for the voting key, no ProTxHash re-entry. + self.voter_key_prompt = Some( + PasswordInput::new() + .with_hint_text("Voting private key (WIF or hex)") + .with_monospace(), + ); + } + } + Some(prompt) => { + prompt.show(ui); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.voter_key_prompt = None; + } + let has_key = !self + .voter_key_prompt + .as_ref() + .map(PasswordInput::is_empty) + .unwrap_or(true); + if ui.add_enabled(has_key, egui::Button::new("Save")).clicked() { + action = self.submit_voter_key(); + } + }); + } + } + action + } + + /// Build the scoped voter-key update: re-load THIS node (context pre-bound) + /// with just the entered voting key, updating its voter identity in place. + /// Distinct from FR-4's load form and exempt from duplicate-ProTxHash + /// rejection (§10.8). + fn submit_voter_key(&mut self) -> Option<AppAction> { + let voting_key = self.voter_key_prompt.as_mut()?.take_secret(); + self.voter_key_prompt = None; + let input = IdentityInputToLoad { + identity_id_input: self.node_id_hex_full.clone(), + identity_type: self.identity.identity_type, + alias_input: self.identity.alias.clone().unwrap_or_default(), + voting_private_key_input: voting_key, + owner_private_key_input: Secret::default(), + payout_address_private_key_input: Secret::default(), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + // In-place update: merge the new voting key into the already-loaded + // node, preserving its Owner/Payout keys (§10.8). Never overwrite. + load_mode: IdentityLoadMode::MergeIntoExisting, + }; + Some(AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::LoadIdentity(input), + ))) + } + + /// Per-contest voting choices + Cast votes, dispatching the existing + /// `VoteOnDPNSNames` backend for the selected choices. + fn render_vote_table(&mut self, ui: &mut Ui, dark_mode: bool) -> Option<AppAction> { + let mut action = None; + // Collect the render data up front so the choice-writing loop does not + // borrow `self.open_contests` while mutating `self.vote_selections`. + let contests: Vec<(String, Vec<(dash_sdk::platform::Identifier, String)>)> = self + .open_contests + .iter() + .map(|contest| { + let candidates = contest + .contestants + .as_ref() + .map(|list| list.iter().map(|c| (c.id, c.name.clone())).collect()) + .unwrap_or_default(); + (contest.normalized_contested_name.clone(), candidates) + }) + .collect(); + + for (name, candidates) in &contests { + ui.separator(); + ui.label( + RichText::new(name) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let selected = self.vote_selections.get(name).copied(); + ui.horizontal_wrapped(|ui| { + if ui + .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") + .clicked() + { + self.vote_selections + .insert(name.clone(), ResourceVoteChoice::Abstain); + } + if ui + .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock") + .clicked() + { + self.vote_selections + .insert(name.clone(), ResourceVoteChoice::Lock); + } + // Candidate choices are scoped to THIS contest's contestants. + for (candidate_id, candidate_name) in candidates { + let choice = ResourceVoteChoice::TowardsIdentity(*candidate_id); + if ui + .selectable_label( + selected == Some(choice), + format!("Vote for {candidate_name}"), + ) + .clicked() + { + self.vote_selections.insert(name.clone(), choice); + } + } + }); + } + + ui.separator(); + let votes: Vec<(String, ResourceVoteChoice)> = self + .vote_selections + .iter() + .filter(|(name, _)| contests.iter().any(|(n, _)| n == *name)) + .map(|(name, choice)| (name.clone(), *choice)) + .collect(); + let has_votes = !votes.is_empty(); + if ui + .add_enabled(has_votes, egui::Button::new("Cast votes")) + .clicked() + { + action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::VoteOnDPNSNames(votes, vec![self.identity.clone()]), + ))); + } + action + } + + /// Returns `true` once the node has been removed. + fn render_remove_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> bool { + if ui.button("Remove masternode").clicked() { + self.remove_dialog = Some( + ConfirmationDialog::new( + "Remove masternode", + "This removes the node and its voting identity from this device. \ + You can load it again later with its ProTxHash.", + ) + .danger_mode(true) + // §7 confirm verb (TC-US4-02). + .confirm_text(Some("Remove masternode")), + ); + } + + let mut removed = false; + if let Some(dialog) = self.remove_dialog.as_mut() { + use crate::ui::components::component_trait::Component; + let response = dialog.show(ui); + if let Some(status) = response.inner.dialog_response { + self.remove_dialog = None; + if status == ConfirmationStatus::Confirmed { + removed = self.remove_node(ui.ctx()); + } + } + } + removed + } + + /// Delete the node and its associated voter identity from local storage. + /// On the primary delete failing, surface an actionable error banner rather + /// than failing silently, and keep the detail view open so the user can + /// retry. The secondary voter-identity delete failing is non-fatal (the node + /// is already gone) and only logged. + fn remove_node(&self, ctx: &egui::Context) -> bool { + let node_id = self.identity.identity.id(); + if let Err(e) = self.app_context.delete_local_qualified_identity(&node_id) { + MessageBanner::set_global( + ctx, + "This masternode couldn't be removed from this device. Try again in a moment.", + MessageType::Error, + ) + .with_details(e); + return false; + } + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() + && let Err(e) = self + .app_context + .delete_local_qualified_identity(&voter.id()) + { + tracing::warn!("Failed to remove voter identity: {e}"); + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tc_fr5_01_actions_render_before_keys() { + let actions = SECTION_ORDER.iter().position(|s| *s == "Actions").unwrap(); + let keys = SECTION_ORDER.iter().position(|s| *s == "Keys").unwrap(); + assert!( + actions < keys, + "Actions must render before Keys (TC-FR5-01)" + ); + } + + #[test] + fn section_order_is_header_actions_keys_dpns_remove() { + assert_eq!( + SECTION_ORDER, + ["Header", "Actions", "Keys", "DPNS", "Remove"] + ); + } + + #[test] + fn tc_dpns_02_header_shows_open_contest_count() { + assert_eq!(dpns_section_header(3), "DPNS name contests to vote on (3)"); + assert_eq!(dpns_section_header(0), "DPNS name contests to vote on (0)"); + } + + #[test] + fn protection_tier_label_and_add_gate() { + assert_eq!(ProtectionTier::Unprotected.label(), "Keys: unprotected"); + assert_eq!( + ProtectionTier::Protected.label(), + "Keys: password-protected" + ); + assert_eq!(ProtectionTier::NoVaultKeys.label(), "Keys: unprotected"); + assert!(ProtectionTier::Unprotected.offers_add_protection()); + assert!(!ProtectionTier::Protected.offers_add_protection()); + assert!(!ProtectionTier::NoVaultKeys.offers_add_protection()); + } + + /// TC-FR8-07 — a load-time / after-load Tier-2 seal is reflected by + /// `protection_tier()`: an unsealed keyed node reports `Unprotected`, and + /// once its keys are sealed under a password it reports `Protected` (so the + /// detail view shows "Keys: password-protected" and stops offering + /// Add-protection). Drives the real `IdentityKeyView` scheme path on an + /// offline wired `AppContext` — no network I/O. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tc_fr8_07_protection_tier_reflects_tier2_seal() { + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // A masternode-shaped identity carrying one owner key on the main + // identity — enough for `protection_tier` to have a key to inspect. + let pv = PlatformVersion::latest(); + let owner = IdentityPublicKey::random_key(1, Some(1), pv); + let mut ks = KeyStorage::default(); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, owner.id()), + ( + QualifiedIdentityPublicKey::from(owner), + PrivateKeyData::Clear([0xA0; 32]), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + + // Before sealing: the key is keyless (Tier-1) → Unprotected. + let view = MasternodeDetailView::new(&ctx, qi.clone()); + assert_eq!( + view.protection_tier(), + ProtectionTier::Unprotected, + "an unsealed keyed node must report Unprotected", + ); + assert!( + view.protection_tier().offers_add_protection(), + "an unsealed node must offer Add-protection", + ); + + // Seal the node's keys Tier-2 via the real backend task (the same task + // the FR-8 seal flow dispatches). + ctx.run_backend_task( + BackendTask::IdentityTask(IdentityTask::ProtectIdentityKeys { + identity_id, + password: Secret::new("one-identity-password"), + hint: None, + }), + SenderAsync::new( + tokio::sync::mpsc::channel::<TaskResult>(4).0, + ctx.egui_ctx().clone(), + ), + ) + .await + .expect("seal task must succeed"); + + // After sealing: the detail view reports Protected and stops offering + // Add-protection. Rebuild the view to re-read the vault scheme. + let view = MasternodeDetailView::new(&ctx, qi); + assert_eq!( + view.protection_tier(), + ProtectionTier::Protected, + "a Tier-2 sealed node must report Protected", + ); + assert!( + !view.protection_tier().offers_add_protection(), + "a sealed node must not re-offer Add-protection", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs new file mode 100644 index 000000000..52e4a9390 --- /dev/null +++ b/src/ui/masternodes/list_screen.rs @@ -0,0 +1,549 @@ +//! The Masternodes list root screen (FR-2 empty state, FR-3 card grid, FR-7 +//! refresh). Reuses the identity onboarding empty-state pattern and the +//! identity-picker card visual language via [`MasternodeCard`]; a card click +//! opens the detail view. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, RichText}; + +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::BackendTask; +use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::identity::IdentityTask; +use crate::context::AppContext; +use crate::model::contested_name::MasternodeContestSummary; +use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; +use crate::ui::components::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::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::theme::{ComponentStyles, DashColors}; +use crate::ui::{RootScreenType, ScreenLike}; + +/// Minimum horizontal gap between cards in the grid (matches the identity +/// picker grid). +const GRID_GAP: f32 = 16.0; + +/// Pre-resolved display data for one masternode/evonode card. Computed at +/// reload time so the per-frame render never touches the database. +struct NodeCardData { + node_id: Identifier, + node_id_short: String, + alias: Option<String>, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, +} + +/// Which sub-view of the Masternodes section is showing. The detail view (B5a) +/// and the page-scoped view-state machine (B7) extend this enum. +enum MasternodesView { + /// Empty state or card grid. + List, + /// The masternode/evonode load form (FR-4). + Load(Box<MasternodeLoadForm>), + /// A node's detail / voting view (FR-5). + Detail(Box<MasternodeDetailView>), +} + +/// Root screen for the Masternodes section. +pub struct MasternodesScreen { + pub app_context: Arc<AppContext>, + /// Cached card data for the active network, refreshed on arrival, on + /// `refresh`, and on the Refresh button. + nodes: Vec<NodeCardData>, + /// The active sub-view (list / load / detail). + view: MasternodesView, + /// True while a node-load task is in flight. Gates the entry points that + /// could re-submit a load (the `+ Load` toolbar button and the empty-state + /// CTA) so a rapid double-submit of a brand-new ProTxHash cannot race two + /// loads past the pre-fetch existence check. Cleared on the task's result + /// or error. + load_in_flight: bool, +} + +impl MasternodesScreen { + /// Construct the Masternodes screen. Follows the project convention: + /// constructors handle errors internally and return `Self` (degraded to an + /// empty list if the read fails; the empty state renders). + pub fn new(app_context: &Arc<AppContext>) -> Self { + let mut screen = Self { + app_context: app_context.clone(), + nodes: Vec::new(), + view: MasternodesView::List, + load_in_flight: false, + }; + screen.reload(); + screen + } + + /// Re-read the loaded masternode/evonode identities and their DPNS contest + /// summaries from the local store. A read failure degrades to an empty list + /// rather than surfacing a technical error — the empty state is a safe, + /// meaningful fallback. + fn reload(&mut self) { + let identities = self + .app_context + .load_local_masternode_identities() + .unwrap_or_default(); + + self.nodes = identities + .into_iter() + .map(|qi| { + let node_id = qi.identity.id(); + let node_id_short = shorten_id(&node_id.to_string(Encoding::Hex)); + let voter_id = qi + .associated_voter_identity + .as_ref() + .map(|(identity, _)| identity.id()); + let contest_summary = self + .app_context + .masternode_contest_summary(voter_id) + .unwrap_or_default(); + NodeCardData { + node_id, + node_id_short, + alias: qi.alias.clone(), + node_type: qi.identity_type, + key_presence: qi.masternode_key_presence(), + contest_summary, + status: qi.status, + } + }) + .collect(); + } + + /// Reset the screen after a network switch. A load form or detail + /// view left open belongs to the previous network's node — keeping it + /// actionable would let the user submit a cross-network operation. Drop back + /// to the List view and reload from the now-active network's local store. + pub fn reset_for_network_change(&mut self) { + self.view = MasternodesView::List; + self.load_in_flight = false; + self.reload(); + } + + /// Build a fresh load form, attaching the Testnet Fill-Random fixture only + /// on Testnet (loaded once here, not per frame). + fn new_load_form(&self) -> Box<MasternodeLoadForm> { + let fixture = if self.app_context.network == dash_sdk::dpp::dashcore::Network::Testnet { + crate::ui::masternodes::testnet_fixture::load_testnet_nodes() + } else { + None + }; + Box::new(MasternodeLoadForm::new().with_testnet_fixture(fixture)) + } + + /// Render the centered empty state (FR-2). Returns the action produced by + /// the primary CTA. + fn render_empty_state(&mut self, ui: &mut egui::Ui) -> AppAction { + let dark_mode = ui.style().visuals.dark_mode; + + ui.vertical_centered(|ui| { + ui.add_space(48.0); + ui.label( + RichText::new("No masternodes loaded") + .size(22.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(12.0); + ui.label( + RichText::new( + "Load a masternode or evonode to vote on DPNS name contests and manage its \ + owner and payout keys.", + ) + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(20.0); + // Gate the CTA while a load is in flight. + if self.load_in_flight { + ui.spinner(); + ui.add_enabled(false, egui::Button::new("Loading…")); + } else if ComponentStyles::add_primary_button(ui, "Load a masternode").clicked() { + self.view = MasternodesView::Load(self.new_load_form()); + } + ui.add_space(12.0); + ui.label( + RichText::new( + "Have your node's ProTxHash to hand. Keys are optional — a node loads \ + read-only without them.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(48.0); + }); + + AppAction::None + } + + /// Render the responsive card grid (FR-3). Sets `selected_node` when a card + /// is clicked (routed to the detail view in B5a/B7). + fn render_card_grid(&mut self, ui: &mut egui::Ui) -> AppAction { + let available_width = ui.available_width(); + let columns = compute_column_count(available_width).max(1); + let count = self.nodes.len(); + // Capture the clicked node id locally so the render loop only borrows + // `self.nodes` immutably; `selected_node` is written after the loop. + let mut clicked: Option<Identifier> = None; + + egui::ScrollArea::vertical().show(ui, |ui| { + for row_start in (0..count).step_by(columns) { + ui.horizontal(|ui| { + for idx in row_start..(row_start + columns).min(count) { + if idx > row_start { + ui.add_space(GRID_GAP); + } + let node = &self.nodes[idx]; + let card = MasternodeCard::new( + node.node_id.to_string(Encoding::Hex), + node.node_id_short.clone(), + node.node_type, + node.key_presence, + node.contest_summary, + node.status, + ) + .with_alias(node.alias.clone()); + if card.show(ui).clicked { + clicked = Some(node.node_id); + } + } + }); + ui.add_space(GRID_GAP); + } + }); + + if let Some(node_id) = clicked { + self.open_detail(node_id); + } + + AppAction::None + } + + /// 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. + fn open_detail(&mut self, node_id: Identifier) { + let Ok(identities) = self.app_context.load_local_masternode_identities() else { + return; + }; + if let Some(identity) = identities + .into_iter() + .find(|qi| qi.identity.id() == node_id) + { + self.view = MasternodesView::Detail(Box::new(MasternodeDetailView::new( + &self.app_context, + identity, + ))); + } + } + + /// Render the detail view; map its outcome to navigation / a reused screen. + fn render_detail_view( + &mut self, + ui: &mut egui::Ui, + network_accent: egui::Color32, + ) -> AppAction { + let outcome = match &mut self.view { + MasternodesView::Detail(detail) => detail.show(ui, network_accent), + _ => return AppAction::None, + }; + match outcome { + DetailOutcome::None => AppAction::None, + DetailOutcome::Back => { + self.view = MasternodesView::List; + AppAction::None + } + DetailOutcome::Removed => { + self.view = MasternodesView::List; + self.reload(); + AppAction::None + } + DetailOutcome::Forward(action) => *action, + } + } + + /// Render the list view: toolbar (`+ Load`, `Refresh`) + empty state or grid. + fn render_list_view(&mut self, ui: &mut egui::Ui, network_accent: egui::Color32) -> AppAction { + let mut inner = AppAction::None; + + // Top-right toolbar: `+ Load` (FR-4 entry) + `Refresh` (FR-7). + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ComponentStyles::add_toolbar_button(ui, "Refresh", network_accent).clicked() { + // Re-read the local cache immediately (optimistic) AND + // dispatch a network re-fetch of every loaded node plus the + // DPNS contests — Refresh must reach the network, + // not just re-read the store. + self.reload(); + inner = self.refresh_from_network(); + } + ui.add_space(8.0); + // Gate re-entry into the load form while a load is in flight + //, and surface a spinner so the wait is visible. + if self.load_in_flight { + ui.spinner(); + ui.add_enabled(false, egui::Button::new("Loading…")); + } else if ComponentStyles::add_toolbar_button(ui, "+ Load", network_accent) + .clicked() + { + self.view = MasternodesView::Load(self.new_load_form()); + } + }); + }); + ui.add_space(8.0); + + if self.nodes.is_empty() { + inner |= self.render_empty_state(ui); + } else { + inner |= self.render_card_grid(ui); + } + inner + } + + /// Build the network re-fetch dispatched by the list Refresh button: + /// one `RefreshIdentity` per loaded node, plus a DPNS contests + /// re-query so vote counts refresh too. Returns `None` when no node is + /// loaded (nothing to refresh). + fn refresh_from_network(&self) -> AppAction { + let identities = self + .app_context + .load_local_masternode_identities() + .unwrap_or_default(); + if identities.is_empty() { + return AppAction::None; + } + let mut tasks: Vec<BackendTask> = identities + .into_iter() + .map(|qi| BackendTask::IdentityTask(IdentityTask::RefreshIdentity(qi))) + .collect(); + tasks.push(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + )); + AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) + } + + /// Render the load form; map its outcome to a backend load task and return + /// to the list on cancel or submit. + fn render_load_view(&mut self, ui: &mut egui::Ui) -> AppAction { + let dev_mode = self.app_context.is_developer_mode(); + let outcome = match &mut self.view { + MasternodesView::Load(form) => form.show(ui, dev_mode), + _ => return AppAction::None, + }; + match outcome { + LoadFormOutcome::None => AppAction::None, + LoadFormOutcome::Cancel => { + self.view = MasternodesView::List; + AppAction::None + } + LoadFormOutcome::Submit(input) => { + self.view = MasternodesView::List; + // Gate re-submission until this load resolves. + self.load_in_flight = true; + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::LoadIdentity( + *input, + ))) + } + } + } +} + +impl ScreenLike for MasternodesScreen { + fn refresh(&mut self) { + self.reload(); + } + + fn refresh_on_arrival(&mut self) { + // Backstop for a stranded load gate: if the load result was routed to a + // different screen while this tab was away (tab switched mid-load), + // `display_task_result` never fired here to clear the gate. Clear it on + // return so `+ Load` can never sit at "Loading…" forever. + self.load_in_flight = false; + self.reload(); + } + + fn display_task_result(&mut self, result: crate::backend_task::BackendTaskSuccessResult) { + // Clear the load gate only on the load task's OWN result variant. This + // screen also receives detail-view results (voting, RefreshIdentity) — + // clearing on any of those would re-enable `+ Load` while a real load is + // still in flight, so match the load's result specifically. + if matches!( + result, + crate::backend_task::BackendTaskSuccessResult::LoadedIdentity(_) + ) { + self.load_in_flight = false; + } + self.reload(); + // if a detail view is open, its own backend task (voting, an + // Add-voting-key merge, a RefreshIdentity) just updated the store. + // Re-open the detail view for that node so the on-screen view reflects + // the fresh data instead of the stale clone captured at open time. + if let MasternodesView::Detail(detail) = &self.view { + let node_id = detail.node_id(); + self.open_detail(node_id); + } + } + + fn display_task_error(&mut self, _error: &crate::backend_task::error::TaskError) -> bool { + // A load failed — re-enable the load entry points. Let the + // global banner render the error (return false, do not claim it). + self.load_in_flight = false; + false + } + + 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![]); + + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenMasternodes); + + let network_accent = + DashColors::network_accent(self.app_context.network, ui.style().visuals.dark_mode); + + action |= island_central_panel(ui, |ui| { + ui.set_min_width(ui.available_width()); + match self.view { + MasternodesView::Load(_) => self.render_load_view(ui), + MasternodesView::Detail(_) => self.render_detail_view(ui, network_accent), + MasternodesView::List => self.render_list_view(ui, network_accent), + } + }); + + action + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::QualifiedIdentity; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + use std::collections::BTreeMap; + + /// Build an offline, wallet-backend-wired `AppContext` (no network I/O). + async fn offline_ctx() -> (Arc<AppContext>, tempfile::TempDir) { + 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, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + (ctx, temp_dir) + } + + fn seed_masternode(ctx: &Arc<AppContext>, byte: u8) { + let pv = PlatformVersion::latest(); + let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: ctx.network(), + }; + ctx.insert_local_qualified_identity(&qi, &None) + .expect("seed masternode"); + } + + /// The list Refresh builds one `RefreshIdentity` per loaded node plus a + /// single trailing `QueryDPNSContests`, and yields `None` when no node is + /// loaded (nothing to refresh). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn refresh_from_network_builds_per_node_refresh_plus_contest_requery() { + let (ctx, _tmp) = offline_ctx().await; + + // No nodes loaded → nothing to refresh. + let screen = MasternodesScreen::new(&ctx); + assert!( + matches!(screen.refresh_from_network(), AppAction::None), + "an empty node list must produce no refresh task" + ); + + // Two nodes loaded → two RefreshIdentity + one trailing QueryDPNSContests. + seed_masternode(&ctx, 0x11); + seed_masternode(&ctx, 0x22); + let screen = MasternodesScreen::new(&ctx); + let AppAction::BackendTasks(tasks, mode) = screen.refresh_from_network() else { + panic!("expected BackendTasks"); + }; + assert!(matches!(mode, BackendTasksExecutionMode::Concurrent)); + assert_eq!(tasks.len(), 3, "two node refreshes + one contest re-query"); + let refreshes = tasks + .iter() + .filter(|t| { + matches!( + t, + BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_)) + ) + }) + .count(); + assert_eq!(refreshes, 2, "one RefreshIdentity per loaded node"); + assert!( + matches!( + tasks.last(), + Some(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests + )) + ), + "the contest re-query must be the trailing task", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/ui/masternodes/load_form.rs b/src/ui/masternodes/load_form.rs new file mode 100644 index 000000000..e75fa32cc --- /dev/null +++ b/src/ui/masternodes/load_form.rs @@ -0,0 +1,526 @@ +//! Masternode/evonode load form (FR-4) — ProTxHash, type toggle, alias, the +//! three optional V/O/P keys, and an optional at-load encryption password +//! (FR-8). No auto-derive: masternode keys never live in a wallet's HD tree. + +use bip39::rand::prelude::IteratorRandom; +use bip39::rand::thread_rng; + +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode}; +use crate::model::masternode_input::is_valid_pro_tx_hash; +use crate::model::qualified_identity::IdentityType; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::masternodes::testnet_fixture::TestnetNodes; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use eframe::egui::{self, RichText, Ui}; + +const KEY_PLACEHOLDER: &str = "Private key (WIF or hex)"; +const WARNING_NOTE: &str = "Set an optional password to encrypt these keys on this device. \ + Without one, they are stored unencrypted and you can add protection later from the key \ + screen."; +const PRO_TX_HASH_FORMAT_ERROR: &str = "This doesn't look like a valid ProTxHash. Enter a hex or Base58 ProTxHash from your \ + masternode configuration."; +const LOAD_DISABLED_TOOLTIP: &str = "Enter a ProTxHash to continue."; + +/// Outcome of rendering the load form for one frame. +pub enum LoadFormOutcome { + /// No terminal interaction this frame. + None, + /// The user cancelled — discard the form, return to the list. + Cancel, + /// The user submitted a valid load request. + Submit(Box<IdentityInputToLoad>), +} + +/// Masternode/evonode load form state. Rendered by the Masternodes screen when +/// in the load view; holds its own field state and is dropped on cancel/submit +/// so reopening always yields a fresh form (TC-FR4-20). +pub struct MasternodeLoadForm { + node_type: IdentityType, + pro_tx_hash_input: String, + /// Set once the ProTxHash field has lost focus, gating the inline error so + /// it never flashes while the user is still typing (on-blur semantics). + pro_tx_hash_touched: bool, + alias_input: String, + voting_key: PasswordInput, + owner_key: PasswordInput, + payout_key: PasswordInput, + encryption_password: PasswordInput, + /// Testnet-only Fill-Random fixture (FR-12). `None` off Testnet or when the + /// `.testnet_nodes.yml` file is missing/malformed — the button never shows. + testnet_nodes: Option<TestnetNodes>, +} + +impl Default for MasternodeLoadForm { + fn default() -> Self { + Self::new() + } +} + +impl MasternodeLoadForm { + pub fn new() -> Self { + Self { + node_type: IdentityType::Masternode, + pro_tx_hash_input: String::new(), + pro_tx_hash_touched: false, + alias_input: String::new(), + voting_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + owner_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + payout_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + encryption_password: PasswordInput::new() + .with_hint_text("Password to encrypt these keys"), + testnet_nodes: None, + } + } + + /// Attach the Testnet Fill-Random fixture (FR-12). Pass `None` off Testnet. + pub fn with_testnet_fixture(mut self, testnet_nodes: Option<TestnetNodes>) -> Self { + self.testnet_nodes = testnet_nodes; + self + } + + /// The active node type — exposed for the B6 Fill-Random label + tests. + pub fn node_type(&self) -> IdentityType { + self.node_type + } + + /// Switch the node type, clearing every field when it actually changes: a + /// real node's identity is tied to one type, so autofilled or entered data + /// for one is never valid for the other (§10.6). + fn set_node_type(&mut self, node_type: IdentityType) { + if node_type == self.node_type { + return; + } + self.node_type = node_type; + self.pro_tx_hash_input.clear(); + self.pro_tx_hash_touched = false; + self.alias_input.clear(); + self.voting_key.clear(); + self.owner_key.clear(); + self.payout_key.clear(); + } + + /// The Fill-Random button label, following the node-type toggle (FR-12). + fn fill_random_label(&self) -> &'static str { + match self.node_type { + IdentityType::Evonode => "🎲 Fill Random Evonode", + _ => "🎲 Fill Random Masternode", + } + } + + /// Populate the form from a random fixture entry matching the current node + /// type (TC-FR12-07/08): Masternode pulls from `masternodes` (Voting + Owner + /// only — the fixture has no payout key); Evonode pulls from `hp_masternodes` + /// (all three keys). A no-op when the fixture is absent or the list is empty. + fn fill_random(&mut self) { + let Some(nodes) = self.testnet_nodes.as_ref() else { + return; + }; + match self.node_type { + IdentityType::Evonode => { + if let Some((name, node)) = nodes.hp_masternodes.iter().choose(&mut thread_rng()) { + self.pro_tx_hash_input = node.protx_tx_hash.clone(); + self.alias_input = name.clone(); + self.voting_key + .set_text(node.voter.private_key.expose_secret()); + self.owner_key + .set_text(node.owner.private_key.expose_secret()); + self.payout_key + .set_text(node.payout.private_key.expose_secret()); + self.pro_tx_hash_touched = false; + } + } + _ => { + if let Some((name, node)) = nodes.masternodes.iter().choose(&mut thread_rng()) { + self.pro_tx_hash_input = node.pro_tx_hash.clone(); + self.alias_input = name.clone(); + self.voting_key + .set_text(node.voter.private_key.expose_secret()); + self.owner_key + .set_text(node.owner.private_key.expose_secret()); + // MasternodeInfo has no payout key — leave Payout blank. + self.pro_tx_hash_touched = false; + } + } + } + } + + /// Whether the Load button is enabled: a non-empty ProTxHash. Shape and + /// existence are enforced inline / by the backend respectively — an empty + /// field is the only hard gate on submission (TC-FR4-05/07). + fn can_submit(&self) -> bool { + !self.pro_tx_hash_input.trim().is_empty() + } + + /// Build the backend load input from the current field state. + fn build_input(&mut self) -> IdentityInputToLoad { + let password = self.encryption_password.take_secret(); + let encryption_password = (!password.is_blank()).then_some(password); + IdentityInputToLoad { + identity_id_input: self.pro_tx_hash_input.trim().to_string(), + identity_type: self.node_type, + alias_input: self.alias_input.trim().to_string(), + voting_private_key_input: self.voting_key.take_secret(), + owner_private_key_input: self.owner_key.take_secret(), + payout_address_private_key_input: self.payout_key.take_secret(), + keys_input: vec![], + // No auto-derive: masternode keys are never wallet-derived (§Locked-#4). + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password, + // A fresh load rejects an already-loaded ProTxHash (§10.9) rather + // than silently overwriting the existing node. + load_mode: IdentityLoadMode::RejectIfExists, + } + } + + /// Render the form. `dev_mode` gates the Testnet-only Fill-Random button as + /// a defense-in-depth check (FR-12 / TC-FR12-09): the whole tab is already + /// Expert-gated, but a plaintext-key-reading dev tool stays inside the + /// Expert-Mode envelope regardless of any future non-nav entry point. + pub fn show(&mut self, ui: &mut Ui, dev_mode: bool) -> LoadFormOutcome { + let dark_mode = ui.style().visuals.dark_mode; + let mut outcome = LoadFormOutcome::None; + + // Back row (content-panel, not the global header) — matches the detail + // view's `‹ All masternodes` link so both views return to the card list + // the same way. Emits `Cancel`, the existing "return to list" outcome. + if ui + .selectable_label(false, RichText::new("‹ All masternodes")) + .clicked() + { + outcome = LoadFormOutcome::Cancel; + } + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + ui.label( + RichText::new("Load a masternode") + .size(20.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Load a masternode or evonode that already exists on the Dash network.", + ) + .size(13.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(16.0); + + // Node-type toggle (Masternode / Evonode) — no User option. + ui.horizontal(|ui| { + if ui + .selectable_label(self.node_type == IdentityType::Masternode, "Masternode") + .clicked() + { + self.set_node_type(IdentityType::Masternode); + } + if ui + .selectable_label(self.node_type == IdentityType::Evonode, "Evonode") + .clicked() + { + self.set_node_type(IdentityType::Evonode); + } + }); + ui.add_space(12.0); + + // Fill-Random dev convenience (FR-12): Testnet + fixture present + + // Expert Mode. Entire row is absent otherwise — never shown-disabled. + if dev_mode && self.testnet_nodes.is_some() { + if ui.button(self.fill_random_label()).clicked() { + self.fill_random(); + } + ui.label( + RichText::new( + "Testnet-only dev convenience — visible only when a local test-node \ + fixture is found.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(12.0); + } + + // ProTxHash (required) with inline on-blur shape validation. + ui.label( + RichText::new("ProTxHash") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let response = ui.add( + egui::TextEdit::singleline(&mut self.pro_tx_hash_input) + .hint_text("Enter the node's ProTxHash. You can find it in your masternode configuration.") + .desired_width(f32::INFINITY), + ); + if response.lost_focus() { + self.pro_tx_hash_touched = true; + } + if self.pro_tx_hash_touched + && !self.pro_tx_hash_input.trim().is_empty() + && !is_valid_pro_tx_hash(&self.pro_tx_hash_input) + { + ui.label( + RichText::new(PRO_TX_HASH_FORMAT_ERROR) + .size(12.0) + .color(DashColors::error_color(dark_mode)), + ); + } + ui.add_space(12.0); + + // Alias (optional, local-only). + ui.label( + RichText::new("Alias (optional)") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + egui::TextEdit::singleline(&mut self.alias_input) + .hint_text( + "An alias helps you recognize this node inside Dash Evo Tool. It is not \ + saved to the Dash network.", + ) + .desired_width(f32::INFINITY), + ); + ui.add_space(12.0); + + // Optional V/O/P key inputs (WIF or hex, hold-to-reveal). + ui.label( + RichText::new("Voting private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.voting_key.show(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Owner private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.owner_key.show(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Payout address private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.payout_key.show(ui); + ui.add_space(12.0); + + // Optional at-load encryption password (FR-8). + ui.label( + RichText::new("Encryption password (optional)") + .color(DashColors::text_primary(dark_mode)), + ); + self.encryption_password.show(ui); + ui.label( + RichText::new( + "Set a password to encrypt these keys on this device. Leave it blank to \ + store them unencrypted and add protection later.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(12.0); + + // Always-visible Warning-tone key-storage note (NFR-4). + ui.label( + RichText::new(WARNING_NOTE) + .size(12.0) + .color(DashColors::warning_color(dark_mode)), + ); + ui.add_space(16.0); + + // Actions: Cancel + Load. Load disabled until a ProTxHash is present. + ui.horizontal(|ui| { + if ComponentStyles::add_toolbar_button( + ui, + "Cancel", + DashColors::surface_elevated(dark_mode), + ) + .clicked() + { + outcome = LoadFormOutcome::Cancel; + } + + let enabled = self.can_submit(); + let clicked = ComponentStyles::add_primary_button_enabled( + ui, + enabled, + "Load masternode", + ) + .disabled_tooltip(LOAD_DISABLED_TOOLTIP) + .clicked(); + if clicked && enabled { + outcome = LoadFormOutcome::Submit(Box::new(self.build_input())); + } + }); + }); + + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::secret::Secret; + use crate::ui::masternodes::testnet_fixture::{HpMasternodeInfo, KeyInfo, MasternodeInfo}; + use std::collections::HashMap; + + fn fixture() -> TestnetNodes { + let mut masternodes = HashMap::new(); + masternodes.insert( + "mn-fixture".to_string(), + MasternodeInfo { + pro_tx_hash: "aa".repeat(32), + owner: KeyInfo { + private_key: Secret::new("owner-wif"), + }, + voter: KeyInfo { + private_key: Secret::new("voter-wif"), + }, + }, + ); + let mut hp_masternodes = HashMap::new(); + hp_masternodes.insert( + "evo-fixture".to_string(), + HpMasternodeInfo { + protx_tx_hash: "bb".repeat(32), + owner: KeyInfo { + private_key: Secret::new("hp-owner-wif"), + }, + voter: KeyInfo { + private_key: Secret::new("hp-voter-wif"), + }, + payout: KeyInfo { + private_key: Secret::new("hp-payout-wif"), + }, + }, + ); + TestnetNodes { + masternodes, + hp_masternodes, + } + } + + #[test] + fn tc_fr12_01_02_label_follows_node_type() { + let mut form = MasternodeLoadForm::new(); + assert_eq!(form.fill_random_label(), "🎲 Fill Random Masternode"); + form.set_node_type(IdentityType::Evonode); + assert_eq!(form.fill_random_label(), "🎲 Fill Random Evonode"); + } + + #[test] + fn tc_fr12_07_masternode_fill_populates_voting_and_owner_only() { + let mut form = MasternodeLoadForm::new().with_testnet_fixture(Some(fixture())); + form.fill_random(); + assert_eq!(form.pro_tx_hash_input, "aa".repeat(32)); + assert_eq!(form.alias_input, "mn-fixture"); + assert_eq!(form.voting_key.text(), "voter-wif"); + assert_eq!(form.owner_key.text(), "owner-wif"); + // MasternodeInfo has no payout key — Payout stays blank. + assert!(form.payout_key.is_empty()); + } + + #[test] + fn tc_fr12_08_evonode_fill_populates_all_three_keys() { + let mut form = MasternodeLoadForm::new().with_testnet_fixture(Some(fixture())); + form.set_node_type(IdentityType::Evonode); + form.fill_random(); + assert_eq!(form.pro_tx_hash_input, "bb".repeat(32)); + assert_eq!(form.alias_input, "evo-fixture"); + assert_eq!(form.voting_key.text(), "hp-voter-wif"); + assert_eq!(form.owner_key.text(), "hp-owner-wif"); + assert_eq!(form.payout_key.text(), "hp-payout-wif"); + } + + #[test] + fn fill_random_without_fixture_is_a_noop() { + let mut form = MasternodeLoadForm::new(); + form.fill_random(); + assert!(form.pro_tx_hash_input.is_empty()); + } + + #[test] + fn tc_fr4_02_defaults_to_masternode() { + assert_eq!( + MasternodeLoadForm::new().node_type(), + IdentityType::Masternode + ); + } + + #[test] + fn tc_fr4_05_07_submit_gated_on_non_empty_pro_tx_hash() { + let mut form = MasternodeLoadForm::new(); + assert!(!form.can_submit(), "empty ProTxHash must disable submit"); + form.pro_tx_hash_input = " ".to_string(); + assert!(!form.can_submit(), "whitespace-only must disable submit"); + form.pro_tx_hash_input = "abc".to_string(); + assert!(form.can_submit(), "any non-empty ProTxHash enables submit"); + } + + #[test] + fn tc_edge_type_toggle_clears_fields() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.pro_tx_hash_touched = true; + form.alias_input = "mn-east-01".to_string(); + form.voting_key.set_text("wif"); + + form.set_node_type(IdentityType::Evonode); + + assert_eq!(form.node_type(), IdentityType::Evonode); + assert!(form.pro_tx_hash_input.is_empty()); + assert!(!form.pro_tx_hash_touched); + assert!(form.alias_input.is_empty()); + assert!(form.voting_key.is_empty()); + } + + #[test] + fn selecting_same_type_preserves_fields() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.set_node_type(IdentityType::Masternode); + assert_eq!(form.pro_tx_hash_input, "deadbeef"); + } + + #[test] + fn tc_fr4_build_input_maps_fields_and_omits_blank_password() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = " deadbeef ".to_string(); + form.alias_input = " mn-east-01 ".to_string(); + form.set_node_type(IdentityType::Masternode); + form.pro_tx_hash_input = " deadbeef ".to_string(); + form.alias_input = " mn-east-01 ".to_string(); + + let input = form.build_input(); + assert_eq!(input.identity_id_input, "deadbeef"); + assert_eq!(input.alias_input, "mn-east-01"); + assert_eq!(input.identity_type, IdentityType::Masternode); + assert!( + !input.derive_keys_from_wallets, + "never auto-derive (§Locked-#4)" + ); + assert!(input.selected_wallet_seed_hash.is_none()); + assert!(input.keys_input.is_empty()); + assert!( + input.encryption_password.is_none(), + "a blank password must map to None (Tier-1 keyless)" + ); + } + + #[test] + fn build_input_keeps_non_blank_password() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.encryption_password.set_text("hunter2"); + let input = form.build_input(); + assert!(input.encryption_password.is_some()); + } +} diff --git a/src/ui/masternodes/mod.rs b/src/ui/masternodes/mod.rs new file mode 100644 index 000000000..129aa571a --- /dev/null +++ b/src/ui/masternodes/mod.rs @@ -0,0 +1,14 @@ +//! The Masternodes root screen domain (Expert-Mode gated). +//! +//! Node operators (the Priya persona) load masternode/evonode identities to +//! vote on DPNS name contests and manage owner/voting/payout keys. The page is +//! a sibling root screen behind the Expert-Mode nav gate (FR-1); its identities +//! are page-scoped and never leak into the everyday-user surfaces (FR-6, B1). + +pub mod card; +pub mod detail_screen; +pub mod list_screen; +pub mod load_form; +pub mod testnet_fixture; + +pub use list_screen::MasternodesScreen; diff --git a/src/ui/masternodes/testnet_fixture.rs b/src/ui/masternodes/testnet_fixture.rs new file mode 100644 index 000000000..dcdba5676 --- /dev/null +++ b/src/ui/masternodes/testnet_fixture.rs @@ -0,0 +1,80 @@ +//! Testnet node fixture loader for the Fill-Random dev convenience (FR-12). +//! +//! A local `.testnet_nodes.yml` file (Testnet-only, developer machines) supplies +//! real masternode/evonode ProTxHashes and keys so a developer can populate the +//! load form with one click. The fixture is a dev tool: it is only ever surfaced +//! inside the Expert-Mode-gated Masternodes tab, on Testnet, when the file is +//! present and parses. +//! +//! Divergence from the legacy add-existing-identity screen (§FR-12 / TC-FR12-04): +//! a **malformed** file is swallowed to `None` (logged at `debug`), never +//! surfaced as an error banner — the button simply does not appear. The parse +//! error is logged by *position only* (line/column), never its message, because +//! a serde error's text can echo the offending line — which here holds a real +//! private key. + +use crate::model::secret::Secret; +use serde::Deserialize; +use std::collections::HashMap; + +/// The fixture file name, resolved relative to the process working directory. +pub const TESTNET_NODES_FILE: &str = ".testnet_nodes.yml"; + +#[derive(Debug, Clone, Deserialize)] +pub struct KeyInfo { + /// Parsed as a [`Secret`] so the private key is redacted in `Debug`/logs and + /// zeroized on drop, rather than living as a bare `String`. + pub private_key: Secret, +} + +/// A regular masternode fixture entry. Has **no** payout key — so Fill-Random +/// for a Masternode populates Voting + Owner only (TC-FR12-07). +#[derive(Debug, Clone, Deserialize)] +pub struct MasternodeInfo { + #[serde(rename = "pro-tx-hash")] + pub pro_tx_hash: String, + pub owner: KeyInfo, + pub voter: KeyInfo, +} + +/// A high-performance (evo) masternode fixture entry, carrying all three keys. +#[derive(Debug, Clone, Deserialize)] +pub struct HpMasternodeInfo { + #[serde(rename = "protx-tx-hash")] + pub protx_tx_hash: String, + pub owner: KeyInfo, + pub voter: KeyInfo, + pub payout: KeyInfo, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestnetNodes { + pub masternodes: HashMap<String, MasternodeInfo>, + pub hp_masternodes: HashMap<String, HpMasternodeInfo>, +} + +/// Load the testnet node fixture. Returns `None` for **both** a missing and a +/// malformed file — a malformed file is logged at `debug` and treated as absent +/// (TC-FR12-04), so the Fill-Random button never appears on bad input and no +/// error banner is surfaced. +/// +/// The parse error is logged by position only (line/column). Its `Display` text +/// is deliberately NOT logged: serde echoes the offending source line, which in +/// this file is a real private key. +pub fn load_testnet_nodes() -> Option<TestnetNodes> { + let content = std::fs::read_to_string(TESTNET_NODES_FILE).ok()?; + match serde_yaml_ng::from_str::<TestnetNodes>(&content) { + Ok(nodes) => Some(nodes), + Err(e) => { + match e.location() { + Some(loc) => tracing::debug!( + "Ignoring malformed {TESTNET_NODES_FILE} (parse error at line {}, column {})", + loc.line(), + loc.column(), + ), + None => tracing::debug!("Ignoring malformed {TESTNET_NODES_FILE}"), + } + None + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bcc2b8ce6..cabf7cb55 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,7 +7,6 @@ use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; use crate::model::wallet::Wallet; -use crate::model::wallet::WalletSeedHash; use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::contracts_documents::document_action_screen::{ @@ -15,7 +14,6 @@ use crate::ui::contracts_documents::document_action_screen::{ }; use crate::ui::dashpay::add_contact_screen::AddContactScreen; use crate::ui::dashpay::contact_details::ContactDetailsScreen; -use crate::ui::dashpay::contact_info_editor::ContactInfoEditorScreen; use crate::ui::dashpay::contact_profile_viewer::ContactProfileViewerScreen; use crate::ui::dashpay::profile_search::ProfileSearchScreen; use crate::ui::dashpay::qr_code_generator::QRCodeGeneratorScreen; @@ -37,14 +35,12 @@ use crate::ui::tools::address_balance_screen::AddressBalanceScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; use crate::ui::tools::grovestark_screen::GroveSTARKScreen; -use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; -use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::wallets::asset_lock_detail_screen::AssetLockDetailScreen; use crate::ui::wallets::create_asset_lock_screen::CreateAssetLockScreen; use crate::ui::wallets::import_mnemonic_screen::ImportMnemonicScreen; -use crate::ui::wallets::send_screen::WalletSendScreen; +use crate::ui::wallets::send_screen::{SendFlow, WalletSendScreen}; use crate::ui::wallets::single_key_send_screen::SingleKeyWalletSendScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use contracts_documents::add_contracts_screen::AddContractsScreen; @@ -55,13 +51,13 @@ use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; use dash_sdk::platform::Identifier; use dpns::dpns_contested_names_screen::DPNSSubscreen; -use egui::Context; use identities::add_existing_identity_screen::AddExistingIdentityScreen; use identities::add_new_identity_screen::AddNewIdentityScreen; use identities::identities_screen::IdentitiesScreen; use identities::register_dpns_name_screen::{RegisterDpnsNameScreen, RegisterDpnsNameSource}; +use identity::IdentityHubScreen; +use masternodes::MasternodesScreen; use std::fmt; -use std::hash::Hash; use std::sync::Arc; use std::sync::RwLock; use tokens::burn_tokens_screen::BurnTokensScreen; @@ -78,121 +74,24 @@ use tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; -use wallets::shield_screen::ShieldScreen; -use wallets::shielded_send_screen::ShieldedSendScreen; -use wallets::unshield_credits_screen::UnshieldCreditsScreen; pub mod components; pub mod contracts_documents; pub mod dashpay; pub mod dpns; pub mod helpers; -pub(crate) mod identities; +pub mod identities; +pub mod identity; +pub mod masternodes; pub mod network_chooser_screen; +pub mod state; pub mod theme; pub mod tokens; pub mod tools; -pub(crate) mod wallets; +pub mod wallets; pub mod welcome_screen; -#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] -#[allow(clippy::enum_variant_names)] -pub enum RootScreenType { - RootScreenIdentities, - RootScreenDPNSActiveContests, - RootScreenDPNSPastContests, - RootScreenDPNSOwnedNames, - RootScreenDPNSScheduledVotes, - RootScreenDocumentQuery, - RootScreenWalletsBalances, - RootScreenToolsProofLogScreen, - RootScreenToolsTransitionVisualizerScreen, - RootScreenToolsDocumentVisualizerScreen, - RootScreenNetworkChooser, - RootScreenToolsProofVisualizerScreen, - RootScreenMyTokenBalances, - RootScreenTokenSearch, - RootScreenTokenCreator, - RootScreenToolsMasternodeListDiffScreen, - RootScreenToolsContractVisualizerScreen, - RootScreenToolsPlatformInfoScreen, - RootScreenDashPayContacts, - RootScreenDashPayProfile, - RootScreenDashPayPayments, - RootScreenDashPayProfileSearch, - RootScreenToolsGroveSTARKScreen, - RootScreenToolsAddressBalanceScreen, - RootScreenDashpay, -} - -impl RootScreenType { - /// Convert `RootScreenType` to an integer - pub fn to_int(self) -> u32 { - match self { - RootScreenType::RootScreenIdentities => 0, - RootScreenType::RootScreenDPNSActiveContests => 1, - RootScreenType::RootScreenDPNSPastContests => 2, - RootScreenType::RootScreenDPNSOwnedNames => 3, - RootScreenType::RootScreenDocumentQuery => 4, - RootScreenType::RootScreenWalletsBalances => 5, - RootScreenType::RootScreenToolsTransitionVisualizerScreen => 6, - RootScreenType::RootScreenNetworkChooser => 7, - // 8 used to be the Withdrawals Statuses screen - RootScreenType::RootScreenToolsProofLogScreen => 9, - RootScreenType::RootScreenDPNSScheduledVotes => 10, - RootScreenType::RootScreenToolsProofVisualizerScreen => 11, - RootScreenType::RootScreenMyTokenBalances => 12, - RootScreenType::RootScreenTokenSearch => 13, - RootScreenType::RootScreenTokenCreator => 14, - RootScreenType::RootScreenToolsDocumentVisualizerScreen => 15, - RootScreenType::RootScreenToolsContractVisualizerScreen => 16, - RootScreenType::RootScreenToolsPlatformInfoScreen => 17, - RootScreenType::RootScreenDashPayContacts => 18, - // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) - RootScreenType::RootScreenDashPayProfile => 20, - RootScreenType::RootScreenDashPayPayments => 21, - RootScreenType::RootScreenDashPayProfileSearch => 22, - RootScreenType::RootScreenToolsMasternodeListDiffScreen => 23, - RootScreenType::RootScreenDashpay => 24, - RootScreenType::RootScreenToolsGroveSTARKScreen => 25, - RootScreenType::RootScreenToolsAddressBalanceScreen => 26, - } - } - - /// Convert an integer to a `RootScreenType` - pub fn from_int(value: u32) -> Option<Self> { - match value { - 0 => Some(RootScreenType::RootScreenIdentities), - 1 => Some(RootScreenType::RootScreenDPNSActiveContests), - 2 => Some(RootScreenType::RootScreenDPNSPastContests), - 3 => Some(RootScreenType::RootScreenDPNSOwnedNames), - 4 => Some(RootScreenType::RootScreenDocumentQuery), - 5 => Some(RootScreenType::RootScreenWalletsBalances), - 6 => Some(RootScreenType::RootScreenToolsTransitionVisualizerScreen), - 7 => Some(RootScreenType::RootScreenNetworkChooser), - // 8 used to be the Withdrawals Statuses screen - 9 => Some(RootScreenType::RootScreenToolsProofLogScreen), - 10 => Some(RootScreenType::RootScreenDPNSScheduledVotes), - 11 => Some(RootScreenType::RootScreenToolsProofVisualizerScreen), - 12 => Some(RootScreenType::RootScreenMyTokenBalances), - 13 => Some(RootScreenType::RootScreenTokenSearch), - 14 => Some(RootScreenType::RootScreenTokenCreator), - 15 => Some(RootScreenType::RootScreenToolsDocumentVisualizerScreen), - 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), - 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), - 18 => Some(RootScreenType::RootScreenDashPayContacts), - // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) - 20 => Some(RootScreenType::RootScreenDashPayProfile), - 21 => Some(RootScreenType::RootScreenDashPayPayments), - 22 => Some(RootScreenType::RootScreenDashPayProfileSearch), - 23 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), - 24 => Some(RootScreenType::RootScreenDashpay), - 25 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), - 26 => Some(RootScreenType::RootScreenToolsAddressBalanceScreen), - _ => None, - } - } -} +pub use crate::model::settings::RootScreenType; impl From<RootScreenType> for ScreenType { fn from(value: RootScreenType) -> Self { @@ -207,15 +106,11 @@ impl From<RootScreenType> for ScreenType { RootScreenType::RootScreenDocumentQuery => ScreenType::DocumentQuery, RootScreenType::RootScreenNetworkChooser => ScreenType::NetworkChooser, RootScreenType::RootScreenWalletsBalances => ScreenType::WalletsBalances, - RootScreenType::RootScreenToolsProofLogScreen => ScreenType::ProofLog, RootScreenType::RootScreenDPNSScheduledVotes => ScreenType::ScheduledVotes, RootScreenType::RootScreenToolsProofVisualizerScreen => ScreenType::ProofVisualizer, RootScreenType::RootScreenMyTokenBalances => ScreenType::TokenBalances, RootScreenType::RootScreenTokenSearch => ScreenType::TokenSearch, RootScreenType::RootScreenTokenCreator => ScreenType::TokenCreator, - RootScreenType::RootScreenToolsMasternodeListDiffScreen => { - ScreenType::MasternodeListDiff - } RootScreenType::RootScreenToolsDocumentVisualizerScreen => { ScreenType::DocumentsVisualizer } @@ -230,6 +125,8 @@ impl From<RootScreenType> for ScreenType { RootScreenType::RootScreenToolsGroveSTARKScreen => ScreenType::GroveSTARK, RootScreenType::RootScreenToolsAddressBalanceScreen => ScreenType::AddressBalance, RootScreenType::RootScreenDashpay => ScreenType::Dashpay, + RootScreenType::RootScreenIdentityHub => ScreenType::IdentityHub, + RootScreenType::RootScreenMasternodes => ScreenType::Masternodes, } } } @@ -245,7 +142,7 @@ pub enum ScreenType { WalletsBalances, ImportMnemonic, AddNewWallet, - WalletSendScreen(Arc<RwLock<Wallet>>), + WalletSendScreen(Arc<RwLock<Wallet>>, SendFlow), SingleKeyWalletSendScreen(Arc<RwLock<SingleKeyWallet>>), AddExistingIdentity, TransitionVisualizer, @@ -263,8 +160,6 @@ pub enum ScreenType { RegisterDpnsName(RegisterDpnsNameSource), RegisterContract, UpdateContract, - ProofLog, - MasternodeListDiff, TopUpIdentity(QualifiedIdentity), ScheduledVotes, AddContracts, @@ -275,6 +170,10 @@ pub enum ScreenType { GroveSTARK, AddressBalance, Dashpay, + /// Unified Identities hub (new four-tab section). + IdentityHub, + /// Masternodes section (Expert-Mode gated). + Masternodes, CreateDocument, DeleteDocument, ReplaceDocument, @@ -303,14 +202,9 @@ pub enum ScreenType { SetTokenPriceScreen(IdentityTokenInfo), // Wallet screens - AssetLockDetail([u8; 32], usize), + AssetLockDetail([u8; 32], dash_sdk::dpp::dashcore::OutPoint), CreateAssetLock(Arc<RwLock<Wallet>>), - // Shielded screens - ShieldScreen(WalletSeedHash), - ShieldedSendScreen(WalletSeedHash), - UnshieldCreditsScreen(WalletSeedHash), - // DashPay Screens DashPayContacts, DashPayProfile, @@ -320,117 +214,50 @@ pub enum ScreenType { DashPayContactDetails(QualifiedIdentity, Identifier), DashPayContactProfileViewer(QualifiedIdentity, Identifier), DashPaySendPayment(QualifiedIdentity, Identifier), - DashPayContactInfoEditor(QualifiedIdentity, Identifier), DashPayQRGenerator, DashPayProfileSearch, } impl PartialEq for ScreenType { fn eq(&self, other: &Self) -> bool { - // Compare variants, ignoring Arc<RwLock<Wallet>> contents for WalletSendScreen + use ScreenType::*; match (self, other) { - (ScreenType::WalletSendScreen(_), ScreenType::WalletSendScreen(_)) => true, - ( - ScreenType::SingleKeyWalletSendScreen(_), - ScreenType::SingleKeyWalletSendScreen(_), - ) => true, - (ScreenType::CreateAssetLock(_), ScreenType::CreateAssetLock(_)) => true, - (ScreenType::AssetLockDetail(a1, a2), ScreenType::AssetLockDetail(b1, b2)) => { + // Variants whose payload participates in equality. + (AssetLockDetail(a1, a2), AssetLockDetail(b1, b2)) => a1 == b1 && a2 == b2, + (WithdrawalScreen(a), WithdrawalScreen(b)) => a == b, + (TransferScreen(a), TransferScreen(b)) => a == b, + (AddKeyScreen(a), AddKeyScreen(b)) => a == b, + (KeyInfo(a1, a2, a3), KeyInfo(b1, b2, b3)) => a1 == b1 && a2 == b2 && a3 == b3, + (Keys(a), Keys(b)) => a == b, + (RegisterDpnsName(a), RegisterDpnsName(b)) => a == b, + (TopUpIdentity(a), TopUpIdentity(b)) => a == b, + (TransferTokensScreen(a), TransferTokensScreen(b)) => a == b, + (MintTokensScreen(a), MintTokensScreen(b)) => a == b, + (BurnTokensScreen(a), BurnTokensScreen(b)) => a == b, + (DestroyFrozenFundsScreen(a), DestroyFrozenFundsScreen(b)) => a == b, + (FreezeTokensScreen(a), FreezeTokensScreen(b)) => a == b, + (UnfreezeTokensScreen(a), UnfreezeTokensScreen(b)) => a == b, + (PauseTokensScreen(a), PauseTokensScreen(b)) => a == b, + (ResumeTokensScreen(a), ResumeTokensScreen(b)) => a == b, + (ClaimTokensScreen(a), ClaimTokensScreen(b)) => a == b, + (ViewTokenClaimsScreen(a), ViewTokenClaimsScreen(b)) => a == b, + (UpdateTokenConfigScreen(a), UpdateTokenConfigScreen(b)) => a == b, + (PurchaseTokenScreen(a), PurchaseTokenScreen(b)) => a == b, + (SetTokenPriceScreen(a), SetTokenPriceScreen(b)) => a == b, + (DashPayAddContactWithId(a), DashPayAddContactWithId(b)) => a == b, + (DashPayContactDetails(a1, a2), DashPayContactDetails(b1, b2)) => a1 == b1 && a2 == b2, + (DashPayContactProfileViewer(a1, a2), DashPayContactProfileViewer(b1, b2)) => { a1 == b1 && a2 == b2 } - (ScreenType::Identities, ScreenType::Identities) => true, - (ScreenType::DPNSActiveContests, ScreenType::DPNSActiveContests) => true, - (ScreenType::DPNSPastContests, ScreenType::DPNSPastContests) => true, - (ScreenType::DPNSMyUsernames, ScreenType::DPNSMyUsernames) => true, - (ScreenType::AddNewIdentity, ScreenType::AddNewIdentity) => true, - (ScreenType::WalletsBalances, ScreenType::WalletsBalances) => true, - (ScreenType::ImportMnemonic, ScreenType::ImportMnemonic) => true, - (ScreenType::AddNewWallet, ScreenType::AddNewWallet) => true, - (ScreenType::AddExistingIdentity, ScreenType::AddExistingIdentity) => true, - (ScreenType::TransitionVisualizer, ScreenType::TransitionVisualizer) => true, - (ScreenType::WithdrawalScreen(a), ScreenType::WithdrawalScreen(b)) => a == b, - (ScreenType::TransferScreen(a), ScreenType::TransferScreen(b)) => a == b, - (ScreenType::AddKeyScreen(a), ScreenType::AddKeyScreen(b)) => a == b, - (ScreenType::KeyInfo(a1, a2, a3), ScreenType::KeyInfo(b1, b2, b3)) => { - a1 == b1 && a2 == b2 && a3 == b3 - } - (ScreenType::Keys(a), ScreenType::Keys(b)) => a == b, - (ScreenType::DocumentQuery, ScreenType::DocumentQuery) => true, - (ScreenType::NetworkChooser, ScreenType::NetworkChooser) => true, - (ScreenType::RegisterDpnsName(a), ScreenType::RegisterDpnsName(b)) => a == b, - (ScreenType::RegisterContract, ScreenType::RegisterContract) => true, - (ScreenType::UpdateContract, ScreenType::UpdateContract) => true, - (ScreenType::ProofLog, ScreenType::ProofLog) => true, - (ScreenType::MasternodeListDiff, ScreenType::MasternodeListDiff) => true, - (ScreenType::TopUpIdentity(a), ScreenType::TopUpIdentity(b)) => a == b, - (ScreenType::ScheduledVotes, ScreenType::ScheduledVotes) => true, - (ScreenType::AddContracts, ScreenType::AddContracts) => true, - (ScreenType::ProofVisualizer, ScreenType::ProofVisualizer) => true, - (ScreenType::DocumentsVisualizer, ScreenType::DocumentsVisualizer) => true, - (ScreenType::ContractsVisualizer, ScreenType::ContractsVisualizer) => true, - (ScreenType::PlatformInfo, ScreenType::PlatformInfo) => true, - (ScreenType::GroveSTARK, ScreenType::GroveSTARK) => true, - (ScreenType::AddressBalance, ScreenType::AddressBalance) => true, - (ScreenType::Dashpay, ScreenType::Dashpay) => true, - (ScreenType::CreateDocument, ScreenType::CreateDocument) => true, - (ScreenType::DeleteDocument, ScreenType::DeleteDocument) => true, - (ScreenType::ReplaceDocument, ScreenType::ReplaceDocument) => true, - (ScreenType::TransferDocument, ScreenType::TransferDocument) => true, - (ScreenType::PurchaseDocument, ScreenType::PurchaseDocument) => true, - (ScreenType::SetDocumentPrice, ScreenType::SetDocumentPrice) => true, - (ScreenType::GroupActions, ScreenType::GroupActions) => true, - // Token Screens - (ScreenType::TokenBalances, ScreenType::TokenBalances) => true, - (ScreenType::TokenSearch, ScreenType::TokenSearch) => true, - (ScreenType::TokenCreator, ScreenType::TokenCreator) => true, - (ScreenType::AddTokenById, ScreenType::AddTokenById) => true, - (ScreenType::TransferTokensScreen(a), ScreenType::TransferTokensScreen(b)) => a == b, - (ScreenType::MintTokensScreen(a), ScreenType::MintTokensScreen(b)) => a == b, - (ScreenType::BurnTokensScreen(a), ScreenType::BurnTokensScreen(b)) => a == b, - (ScreenType::DestroyFrozenFundsScreen(a), ScreenType::DestroyFrozenFundsScreen(b)) => { - a == b - } - (ScreenType::FreezeTokensScreen(a), ScreenType::FreezeTokensScreen(b)) => a == b, - (ScreenType::UnfreezeTokensScreen(a), ScreenType::UnfreezeTokensScreen(b)) => a == b, - (ScreenType::PauseTokensScreen(a), ScreenType::PauseTokensScreen(b)) => a == b, - (ScreenType::ResumeTokensScreen(a), ScreenType::ResumeTokensScreen(b)) => a == b, - (ScreenType::ClaimTokensScreen(a), ScreenType::ClaimTokensScreen(b)) => a == b, - (ScreenType::ViewTokenClaimsScreen(a), ScreenType::ViewTokenClaimsScreen(b)) => a == b, - (ScreenType::UpdateTokenConfigScreen(a), ScreenType::UpdateTokenConfigScreen(b)) => { - a == b - } - (ScreenType::PurchaseTokenScreen(a), ScreenType::PurchaseTokenScreen(b)) => a == b, - (ScreenType::SetTokenPriceScreen(a), ScreenType::SetTokenPriceScreen(b)) => a == b, - // DashPay Screens - (ScreenType::DashPayContacts, ScreenType::DashPayContacts) => true, - (ScreenType::DashPayProfile, ScreenType::DashPayProfile) => true, - (ScreenType::DashPayPayments, ScreenType::DashPayPayments) => true, - (ScreenType::DashPayAddContact, ScreenType::DashPayAddContact) => true, - (ScreenType::DashPayAddContactWithId(a), ScreenType::DashPayAddContactWithId(b)) => { - a == b - } - ( - ScreenType::DashPayContactDetails(a1, a2), - ScreenType::DashPayContactDetails(b1, b2), - ) => a1 == b1 && a2 == b2, - ( - ScreenType::DashPayContactProfileViewer(a1, a2), - ScreenType::DashPayContactProfileViewer(b1, b2), - ) => a1 == b1 && a2 == b2, - (ScreenType::DashPaySendPayment(a1, a2), ScreenType::DashPaySendPayment(b1, b2)) => { - a1 == b1 && a2 == b2 - } - ( - ScreenType::DashPayContactInfoEditor(a1, a2), - ScreenType::DashPayContactInfoEditor(b1, b2), - ) => a1 == b1 && a2 == b2, - (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, - (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, - // Shielded screens - (ScreenType::ShieldScreen(a), ScreenType::ShieldScreen(b)) => a == b, - (ScreenType::ShieldedSendScreen(a), ScreenType::ShieldedSendScreen(b)) => a == b, - (ScreenType::UnshieldCreditsScreen(a), ScreenType::UnshieldCreditsScreen(b)) => a == b, - _ => false, + (DashPaySendPayment(a1, a2), DashPaySendPayment(b1, b2)) => a1 == b1 && a2 == b2, + // The send screen's wallet payload is intentionally ignored, but the + // flow preset distinguishes Shield / Send-Private / Unshield routes + // so pushing one does not dedup against another. + (WalletSendScreen(_, fa), WalletSendScreen(_, fb)) => fa == fb, + // All other variants are equal iff they share a discriminant. This covers the + // fieldless variants and the wallet screens (SingleKeyWalletSendScreen / + // CreateAssetLock), whose Arc<RwLock<…>> payload is intentionally ignored. + _ => std::mem::discriminant(self) == std::mem::discriminant(other), } } } @@ -504,13 +331,12 @@ impl ScreenType { ScreenType::ImportMnemonic => { Screen::ImportMnemonicScreen(ImportMnemonicScreen::new(app_context)) } - ScreenType::WalletSendScreen(wallet) => { - Screen::WalletSendScreen(WalletSendScreen::new(app_context, wallet.clone())) - } + ScreenType::WalletSendScreen(wallet, flow) => Screen::WalletSendScreen( + WalletSendScreen::new(app_context, wallet.clone()).with_flow(*flow), + ), ScreenType::SingleKeyWalletSendScreen(wallet) => Screen::SingleKeyWalletSendScreen( SingleKeyWalletSendScreen::new(app_context, wallet.clone()), ), - ScreenType::ProofLog => Screen::ProofLogScreen(ProofLogScreen::new(app_context)), ScreenType::ScheduledVotes => { Screen::DPNSScreen(DPNSScreen::new(app_context, DPNSSubscreen::ScheduledVotes)) } @@ -536,6 +362,12 @@ impl ScreenType { ScreenType::Dashpay => { Screen::DashPayScreen(DashPayScreen::new(app_context, DashPaySubscreen::Profile)) } + ScreenType::IdentityHub => { + Screen::IdentityHubScreen(IdentityHubScreen::new(app_context)) + } + ScreenType::Masternodes => { + Screen::MasternodesScreen(MasternodesScreen::new(app_context)) + } ScreenType::CreateDocument => Screen::DocumentActionScreen(DocumentActionScreen::new( app_context.clone(), None, @@ -618,9 +450,6 @@ impl ScreenType { app_context, ))) } - ScreenType::MasternodeListDiff => { - Screen::MasternodeListDiffScreen(MasternodeListDiffScreen::new(app_context)) - } ScreenType::AddTokenById => Screen::AddTokenById(AddTokenByIdScreen::new(app_context)), ScreenType::PurchaseTokenScreen(identity_token_info) => Screen::PurchaseTokenScreen( PurchaseTokenScreen::new(identity_token_info.clone(), app_context), @@ -628,9 +457,13 @@ impl ScreenType { ScreenType::SetTokenPriceScreen(identity_token_info) => Screen::SetTokenPriceScreen( SetTokenPriceScreen::new(identity_token_info.clone(), app_context), ), - ScreenType::AssetLockDetail(wallet_seed_hash, index) => Screen::AssetLockDetailScreen( - AssetLockDetailScreen::new(*wallet_seed_hash, *index, app_context), - ), + ScreenType::AssetLockDetail(wallet_seed_hash, out_point) => { + Screen::AssetLockDetailScreen(AssetLockDetailScreen::new( + *wallet_seed_hash, + *out_point, + app_context, + )) + } ScreenType::CreateAssetLock(wallet) => Screen::CreateAssetLockScreen( CreateAssetLockScreen::new(wallet.clone(), app_context), ), @@ -672,29 +505,12 @@ impl ScreenType { *contact_id, )) } - ScreenType::DashPayContactInfoEditor(identity, contact_id) => { - Screen::DashPayContactInfoEditorScreen(ContactInfoEditorScreen::new( - app_context.clone(), - identity.clone(), - *contact_id, - )) - } ScreenType::DashPayQRGenerator => { Screen::DashPayQRGeneratorScreen(QRCodeGeneratorScreen::new(app_context.clone())) } ScreenType::DashPayProfileSearch => { Screen::DashPayProfileSearchScreen(ProfileSearchScreen::new(app_context.clone())) } - // Shielded screens - ScreenType::ShieldScreen(seed_hash) => { - Screen::ShieldScreen(ShieldScreen::new(*seed_hash, app_context)) - } - ScreenType::ShieldedSendScreen(seed_hash) => { - Screen::ShieldedSendScreen(ShieldedSendScreen::new(*seed_hash, app_context)) - } - ScreenType::UnshieldCreditsScreen(seed_hash) => { - Screen::UnshieldCreditsScreen(UnshieldCreditsScreen::new(*seed_hash, app_context)) - } } } } @@ -719,7 +535,6 @@ pub enum Screen { TopUpIdentityScreen(TopUpIdentityScreen), TransferScreen(TransferScreen), AddKeyScreen(AddKeyScreen), - ProofLogScreen(ProofLogScreen), TransitionVisualizerScreen(TransitionVisualizerScreen), DocumentVisualizerScreen(DocumentVisualizerScreen), ContractVisualizerScreen(ContractVisualizerScreen), @@ -729,7 +544,6 @@ pub enum Screen { SingleKeyWalletSendScreen(SingleKeyWalletSendScreen), AddContractsScreen(AddContractsScreen), ProofVisualizerScreen(ProofVisualizerScreen), - MasternodeListDiffScreen(MasternodeListDiffScreen), PlatformInfoScreen(PlatformInfoScreen), GroveSTARKScreen(GroveSTARKScreen), AddressBalanceScreen(AddressBalanceScreen), @@ -753,20 +567,20 @@ pub enum Screen { AssetLockDetailScreen(AssetLockDetailScreen), CreateAssetLockScreen(CreateAssetLockScreen), - // Shielded Screens - ShieldScreen(ShieldScreen), - ShieldedSendScreen(ShieldedSendScreen), - UnshieldCreditsScreen(UnshieldCreditsScreen), - // DashPay Screens DashPayScreen(DashPayScreen), DashPayAddContactScreen(AddContactScreen), DashPayContactDetailsScreen(ContactDetailsScreen), DashPayContactProfileViewerScreen(ContactProfileViewerScreen), DashPaySendPaymentScreen(SendPaymentScreen), - DashPayContactInfoEditorScreen(ContactInfoEditorScreen), DashPayQRGeneratorScreen(QRCodeGeneratorScreen), DashPayProfileSearchScreen(ProfileSearchScreen), + + // New unified Identities hub + IdentityHubScreen(IdentityHubScreen), + + // Masternodes section (Expert-Mode gated) + MasternodesScreen(MasternodesScreen), } impl Screen { @@ -778,9 +592,11 @@ impl Screen { /// Every `Screen` variant must appear in exactly one of the two lists /// (`set` or `skip`) so the compiler catches new additions. macro_rules! set_ctx { - (set: $($variant:ident),+ $(,)?; skip: $($skip:ident),* $(,)?) => { + (set: $($variant:ident),+ $(,)?; common_set: $($cvariant:ident),* $(,)?; skip: $($skip:ident),* $(,)?) => { match self { $(Screen::$variant(screen) => screen.app_context = app_context,)+ + // Token action screens keep their context under `.common`. + $(Screen::$cvariant(screen) => screen.set_app_context(app_context),)* // Handled by the explicit match above (side-effects + return). $(Screen::$skip(_) => {},)* } @@ -798,7 +614,6 @@ impl Screen { } Screen::AddNewWalletScreen(screen) => { screen.app_context = app_context; - screen.reset_core_wallets_cache(); return; } Screen::TransferScreen(screen) => { @@ -808,7 +623,6 @@ impl Screen { } Screen::WalletsBalancesScreen(screen) => { screen.app_context = app_context; - screen.reset_pending_list_state(); screen.update_selected_wallet_for_network(); screen.invalidate_address_inputs(); screen.reset_transient_state(); @@ -816,14 +630,14 @@ impl Screen { } Screen::ImportMnemonicScreen(screen) => { screen.app_context = app_context; - screen.reset_core_wallets_cache(); return; } Screen::WalletSendScreen(screen) => { screen.app_context = app_context; - screen.invalidate_address_input(); - // Clear wallet reference — it belongs to the old network - screen.selected_wallet = None; + // Drop all state bound to the old network's wallet (wallet, seed + // hash, source/destination/amount) so a preset flow cannot show a + // stale cross-network balance. + screen.reset_for_network_switch(); return; } Screen::SingleKeyWalletSendScreen(screen) => { @@ -838,16 +652,6 @@ impl Screen { screen.selected_wallet = None; return; } - Screen::MasternodeListDiffScreen(screen) => { - let old_net = screen.app_context.network; - if old_net != app_context.network { - screen.app_context = app_context.clone(); - screen.clear(); - } else { - screen.app_context = app_context; - } - return; - } Screen::AddressBalanceScreen(screen) => { screen.app_context = app_context; screen.invalidate_address_input(); @@ -861,19 +665,21 @@ impl Screen { screen.payment_history.app_context = app_context; return; } - Screen::ShieldScreen(screen) => { + Screen::IdentityHubScreen(screen) => { screen.app_context = app_context; - screen.invalidate_address_input(); + // A network switch invalidates all per-identity caches (contacts + // load guard, profile cache, search state). Without this refresh + // the Contacts tab would stay permanently "already loaded" after + // switching networks (T28). + screen.refresh(); return; } - Screen::ShieldedSendScreen(screen) => { + Screen::MasternodesScreen(screen) => { screen.app_context = app_context; - screen.invalidate_address_input(); - return; - } - Screen::UnshieldCreditsScreen(screen) => { - screen.app_context = app_context; - screen.invalidate_address_input(); + // A network switch invalidates any open load form or detail view + // (they belong to the previous network's node). Reset to the List + // view and reload from the now-active network. + screen.reset_for_network_change(); return; } _ => {} @@ -900,7 +706,6 @@ impl Screen { DocumentActionScreen, GroupActionsScreen, TopUpIdentityScreen, - ProofLogScreen, AddContractsScreen, ProofVisualizerScreen, DocumentVisualizerScreen, @@ -908,13 +713,6 @@ impl Screen { GroveSTARKScreen, TokensScreen, TransferTokensScreen, - MintTokensScreen, - BurnTokensScreen, - DestroyFrozenFundsScreen, - FreezeTokensScreen, - UnfreezeTokensScreen, - PauseTokensScreen, - ResumeTokensScreen, ClaimTokensScreen, ViewTokenClaimsScreen, UpdateTokenConfigScreen, @@ -926,10 +724,18 @@ impl Screen { DashPayContactDetailsScreen, DashPayContactProfileViewerScreen, DashPaySendPaymentScreen, - DashPayContactInfoEditorScreen, DashPayQRGeneratorScreen, DashPayProfileSearchScreen; + common_set: + MintTokensScreen, + BurnTokensScreen, + DestroyFrozenFundsScreen, + FreezeTokensScreen, + UnfreezeTokensScreen, + PauseTokensScreen, + ResumeTokensScreen; skip: + MasternodesScreen, NetworkChooserScreen, AddNewWalletScreen, TransferScreen, @@ -938,12 +744,9 @@ impl Screen { WalletSendScreen, SingleKeyWalletSendScreen, CreateAssetLockScreen, - MasternodeListDiffScreen, AddressBalanceScreen, DashPayScreen, - ShieldScreen, - ShieldedSendScreen, - UnshieldCreditsScreen, + IdentityHubScreen, ); } } @@ -961,7 +764,7 @@ pub trait ScreenLike { fn refresh_on_arrival(&mut self) { self.refresh() } - fn ui(&mut self, ctx: &Context) -> AppAction; + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction; /// Called by `AppState` **after** the global banner has already been set. /// /// Override **only for side-effects** such as clearing a progress banner @@ -1057,16 +860,20 @@ impl Screen { Screen::AddNewWalletScreen(_) => ScreenType::AddNewWallet, Screen::WalletsBalancesScreen(_) => ScreenType::WalletsBalances, Screen::ImportMnemonicScreen(_) => ScreenType::ImportMnemonic, - Screen::WalletSendScreen(screen) => { - ScreenType::WalletSendScreen(screen.selected_wallet.clone().unwrap()) - } + Screen::WalletSendScreen(screen) => ScreenType::WalletSendScreen( + screen + .selected_wallet + .clone() + .expect("invariant: a live WalletSendScreen always has a selected wallet"), + screen.flow(), + ), Screen::SingleKeyWalletSendScreen(screen) => { - ScreenType::SingleKeyWalletSendScreen(screen.selected_wallet.clone().unwrap()) + ScreenType::SingleKeyWalletSendScreen(screen.selected_wallet.clone().expect( + "invariant: a live SingleKeyWalletSendScreen always has a selected wallet", + )) } - Screen::ProofLogScreen(_) => ScreenType::ProofLog, Screen::AddContractsScreen(_) => ScreenType::AddContracts, Screen::ProofVisualizerScreen(_) => ScreenType::ProofVisualizer, - Screen::MasternodeListDiffScreen(_) => ScreenType::MasternodeListDiff, Screen::DocumentVisualizerScreen(_) => ScreenType::DocumentsVisualizer, Screen::PlatformInfoScreen(_) => ScreenType::PlatformInfo, Screen::GroveSTARKScreen(_) => ScreenType::GroveSTARK, @@ -1093,25 +900,25 @@ impl Screen { ScreenType::TransferTokensScreen(screen.identity_token_balance.clone()) } Screen::MintTokensScreen(screen) => { - ScreenType::MintTokensScreen(screen.identity_token_info.clone()) + ScreenType::MintTokensScreen(screen.common.identity_token_info.clone()) } Screen::BurnTokensScreen(screen) => { - ScreenType::BurnTokensScreen(screen.identity_token_info.clone()) + ScreenType::BurnTokensScreen(screen.common.identity_token_info.clone()) } Screen::DestroyFrozenFundsScreen(screen) => { - ScreenType::DestroyFrozenFundsScreen(screen.identity_token_info.clone()) + ScreenType::DestroyFrozenFundsScreen(screen.common.identity_token_info.clone()) } Screen::FreezeTokensScreen(screen) => { - ScreenType::FreezeTokensScreen(screen.identity_token_info.clone()) + ScreenType::FreezeTokensScreen(screen.common.identity_token_info.clone()) } Screen::UnfreezeTokensScreen(screen) => { - ScreenType::UnfreezeTokensScreen(screen.identity_token_info.clone()) + ScreenType::UnfreezeTokensScreen(screen.common.identity_token_info.clone()) } Screen::PauseTokensScreen(screen) => { - ScreenType::PauseTokensScreen(screen.identity_token_info.clone()) + ScreenType::PauseTokensScreen(screen.common.identity_token_info.clone()) } Screen::ResumeTokensScreen(screen) => { - ScreenType::ResumeTokensScreen(screen.identity_token_info.clone()) + ScreenType::ResumeTokensScreen(screen.common.identity_token_info.clone()) } Screen::ClaimTokensScreen(screen) => { ScreenType::ClaimTokensScreen(screen.identity_token_basic_info.clone()) @@ -1130,7 +937,7 @@ impl Screen { ScreenType::SetTokenPriceScreen(screen.identity_token_info.clone()) } Screen::AssetLockDetailScreen(screen) => { - ScreenType::AssetLockDetail(screen.wallet_seed_hash, screen.asset_lock_index) + ScreenType::AssetLockDetail(screen.wallet_seed_hash, screen.out_point) } Screen::CreateAssetLockScreen(screen) => { ScreenType::CreateAssetLock(screen.wallet.clone()) @@ -1157,650 +964,107 @@ impl Screen { Screen::DashPaySendPaymentScreen(screen) => { ScreenType::DashPaySendPayment(screen.from_identity.clone(), screen.to_contact_id) } - Screen::DashPayContactInfoEditorScreen(screen) => { - ScreenType::DashPayContactInfoEditor(screen.identity.clone(), screen.contact_id) - } Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, - // Shielded screens - Screen::ShieldScreen(s) => ScreenType::ShieldScreen(s.seed_hash), - Screen::ShieldedSendScreen(s) => ScreenType::ShieldedSendScreen(s.seed_hash), - Screen::UnshieldCreditsScreen(s) => ScreenType::UnshieldCreditsScreen(s.seed_hash), + Screen::IdentityHubScreen(_) => ScreenType::IdentityHub, + Screen::MasternodesScreen(_) => ScreenType::Masternodes, } } } +/// Delegates a [`ScreenLike`] call to the wrapped screen for every [`Screen`] variant. +/// +/// The match is exhaustive, so adding a `Screen` variant without extending this list is a +/// compile error — every screen stays reachable through the trait. +macro_rules! delegate_to_screen { + ($self:expr, $screen:ident => $call:expr) => { + match $self { + Screen::IdentitiesScreen($screen) => $call, + Screen::DPNSScreen($screen) => $call, + Screen::DocumentQueryScreen($screen) => $call, + Screen::AddNewWalletScreen($screen) => $call, + Screen::ImportMnemonicScreen($screen) => $call, + Screen::AddNewIdentityScreen($screen) => $call, + Screen::AddExistingIdentityScreen($screen) => $call, + Screen::KeyInfoScreen($screen) => $call, + Screen::KeysScreen($screen) => $call, + Screen::RegisterDpnsNameScreen($screen) => $call, + Screen::RegisterDataContractScreen($screen) => $call, + Screen::UpdateDataContractScreen($screen) => $call, + Screen::DocumentActionScreen($screen) => $call, + Screen::GroupActionsScreen($screen) => $call, + Screen::WithdrawalScreen($screen) => $call, + Screen::TopUpIdentityScreen($screen) => $call, + Screen::TransferScreen($screen) => $call, + Screen::AddKeyScreen($screen) => $call, + Screen::TransitionVisualizerScreen($screen) => $call, + Screen::DocumentVisualizerScreen($screen) => $call, + Screen::ContractVisualizerScreen($screen) => $call, + Screen::NetworkChooserScreen($screen) => $call, + Screen::WalletsBalancesScreen($screen) => $call, + Screen::WalletSendScreen($screen) => $call, + Screen::SingleKeyWalletSendScreen($screen) => $call, + Screen::AddContractsScreen($screen) => $call, + Screen::ProofVisualizerScreen($screen) => $call, + Screen::PlatformInfoScreen($screen) => $call, + Screen::GroveSTARKScreen($screen) => $call, + Screen::AddressBalanceScreen($screen) => $call, + Screen::TokensScreen($screen) => $call, + Screen::TransferTokensScreen($screen) => $call, + Screen::MintTokensScreen($screen) => $call, + Screen::BurnTokensScreen($screen) => $call, + Screen::DestroyFrozenFundsScreen($screen) => $call, + Screen::FreezeTokensScreen($screen) => $call, + Screen::UnfreezeTokensScreen($screen) => $call, + Screen::PauseTokensScreen($screen) => $call, + Screen::ResumeTokensScreen($screen) => $call, + Screen::ClaimTokensScreen($screen) => $call, + Screen::ViewTokenClaimsScreen($screen) => $call, + Screen::UpdateTokenConfigScreen($screen) => $call, + Screen::AddTokenById($screen) => $call, + Screen::PurchaseTokenScreen($screen) => $call, + Screen::SetTokenPriceScreen($screen) => $call, + Screen::AssetLockDetailScreen($screen) => $call, + Screen::CreateAssetLockScreen($screen) => $call, + Screen::DashPayScreen($screen) => $call, + Screen::DashPayAddContactScreen($screen) => $call, + Screen::DashPayContactDetailsScreen($screen) => $call, + Screen::DashPayContactProfileViewerScreen($screen) => $call, + Screen::DashPaySendPaymentScreen($screen) => $call, + Screen::DashPayQRGeneratorScreen($screen) => $call, + Screen::DashPayProfileSearchScreen($screen) => $call, + Screen::IdentityHubScreen($screen) => $call, + Screen::MasternodesScreen($screen) => $call, + } + }; +} + impl ScreenLike for Screen { fn refresh(&mut self) { - match self { - Screen::IdentitiesScreen(screen) => screen.refresh(), - Screen::DPNSScreen(screen) => screen.refresh(), - Screen::DocumentQueryScreen(screen) => screen.refresh(), - Screen::AddNewWalletScreen(screen) => screen.refresh(), - Screen::ImportMnemonicScreen(screen) => screen.refresh(), - Screen::AddNewIdentityScreen(screen) => screen.refresh(), - Screen::TopUpIdentityScreen(screen) => screen.refresh(), - Screen::AddExistingIdentityScreen(screen) => screen.refresh(), - Screen::KeyInfoScreen(screen) => screen.refresh(), - Screen::KeysScreen(screen) => screen.refresh(), - Screen::RegisterDpnsNameScreen(screen) => screen.refresh(), - Screen::RegisterDataContractScreen(screen) => screen.refresh(), - Screen::UpdateDataContractScreen(screen) => screen.refresh(), - Screen::DocumentActionScreen(screen) => screen.refresh(), - Screen::GroupActionsScreen(screen) => screen.refresh(), - Screen::WithdrawalScreen(screen) => screen.refresh(), - Screen::TransferScreen(screen) => screen.refresh(), - Screen::AddKeyScreen(screen) => screen.refresh(), - Screen::TransitionVisualizerScreen(screen) => screen.refresh(), - Screen::NetworkChooserScreen(screen) => screen.refresh(), - Screen::WalletsBalancesScreen(screen) => screen.refresh(), - Screen::WalletSendScreen(screen) => screen.refresh(), - Screen::SingleKeyWalletSendScreen(screen) => screen.refresh(), - Screen::ProofLogScreen(screen) => screen.refresh(), - Screen::AddContractsScreen(screen) => screen.refresh(), - Screen::ProofVisualizerScreen(screen) => screen.refresh(), - Screen::MasternodeListDiffScreen(screen) => screen.refresh(), - Screen::DocumentVisualizerScreen(screen) => screen.refresh(), - Screen::ContractVisualizerScreen(screen) => screen.refresh(), - Screen::PlatformInfoScreen(screen) => screen.refresh(), - Screen::GroveSTARKScreen(screen) => screen.refresh(), - Screen::AddressBalanceScreen(screen) => screen.refresh(), - - // Token Screens - Screen::TokensScreen(screen) => screen.refresh(), - Screen::TransferTokensScreen(screen) => screen.refresh(), - Screen::MintTokensScreen(screen) => screen.refresh(), - Screen::BurnTokensScreen(screen) => screen.refresh(), - Screen::DestroyFrozenFundsScreen(screen) => screen.refresh(), - Screen::FreezeTokensScreen(screen) => screen.refresh(), - Screen::UnfreezeTokensScreen(screen) => screen.refresh(), - Screen::PauseTokensScreen(screen) => screen.refresh(), - Screen::ResumeTokensScreen(screen) => screen.refresh(), - Screen::ClaimTokensScreen(screen) => screen.refresh(), - Screen::ViewTokenClaimsScreen(screen) => screen.refresh(), - Screen::UpdateTokenConfigScreen(screen) => screen.refresh(), - Screen::AddTokenById(screen) => screen.refresh(), - Screen::PurchaseTokenScreen(screen) => screen.refresh(), - Screen::SetTokenPriceScreen(screen) => screen.refresh(), - Screen::AssetLockDetailScreen(screen) => screen.refresh(), - Screen::CreateAssetLockScreen(screen) => screen.refresh(), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.refresh(), - Screen::DashPayAddContactScreen(screen) => screen.refresh(), - Screen::DashPayContactDetailsScreen(screen) => screen.refresh(), - Screen::DashPayContactProfileViewerScreen(screen) => screen.refresh(), - Screen::DashPaySendPaymentScreen(screen) => screen.refresh(), - Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh(), - Screen::DashPayQRGeneratorScreen(_) => {} - Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), - // Shielded screens - Screen::ShieldScreen(screen) => screen.refresh(), - Screen::ShieldedSendScreen(screen) => screen.refresh(), - Screen::UnshieldCreditsScreen(screen) => screen.refresh(), - } + delegate_to_screen!(self, screen => screen.refresh()) } fn refresh_on_arrival(&mut self) { - match self { - Screen::IdentitiesScreen(screen) => screen.refresh_on_arrival(), - Screen::DPNSScreen(screen) => screen.refresh_on_arrival(), - Screen::DocumentQueryScreen(screen) => screen.refresh_on_arrival(), - Screen::AddNewWalletScreen(screen) => screen.refresh_on_arrival(), - Screen::ImportMnemonicScreen(screen) => screen.refresh_on_arrival(), - Screen::AddNewIdentityScreen(screen) => screen.refresh_on_arrival(), - Screen::TopUpIdentityScreen(screen) => screen.refresh_on_arrival(), - Screen::AddExistingIdentityScreen(screen) => screen.refresh_on_arrival(), - Screen::KeyInfoScreen(screen) => screen.refresh_on_arrival(), - Screen::KeysScreen(screen) => screen.refresh_on_arrival(), - Screen::RegisterDpnsNameScreen(screen) => screen.refresh_on_arrival(), - Screen::RegisterDataContractScreen(screen) => screen.refresh_on_arrival(), - Screen::UpdateDataContractScreen(screen) => screen.refresh_on_arrival(), - Screen::DocumentActionScreen(screen) => screen.refresh_on_arrival(), - Screen::GroupActionsScreen(screen) => screen.refresh_on_arrival(), - Screen::WithdrawalScreen(screen) => screen.refresh_on_arrival(), - Screen::TransferScreen(screen) => screen.refresh_on_arrival(), - Screen::AddKeyScreen(screen) => screen.refresh_on_arrival(), - Screen::TransitionVisualizerScreen(screen) => screen.refresh_on_arrival(), - Screen::NetworkChooserScreen(screen) => screen.refresh_on_arrival(), - Screen::WalletsBalancesScreen(screen) => screen.refresh_on_arrival(), - Screen::WalletSendScreen(screen) => screen.refresh_on_arrival(), - Screen::SingleKeyWalletSendScreen(screen) => screen.refresh_on_arrival(), - Screen::ProofLogScreen(screen) => screen.refresh_on_arrival(), - Screen::AddContractsScreen(screen) => screen.refresh_on_arrival(), - Screen::ProofVisualizerScreen(screen) => screen.refresh_on_arrival(), - Screen::MasternodeListDiffScreen(screen) => screen.refresh_on_arrival(), - Screen::DocumentVisualizerScreen(screen) => screen.refresh_on_arrival(), - Screen::ContractVisualizerScreen(screen) => screen.refresh_on_arrival(), - Screen::PlatformInfoScreen(screen) => screen.refresh_on_arrival(), - Screen::GroveSTARKScreen(screen) => screen.refresh_on_arrival(), - Screen::AddressBalanceScreen(screen) => screen.refresh_on_arrival(), - - // Token Screens - Screen::TokensScreen(screen) => screen.refresh_on_arrival(), - Screen::TransferTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::MintTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::BurnTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::DestroyFrozenFundsScreen(screen) => screen.refresh_on_arrival(), - Screen::FreezeTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::UnfreezeTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::PauseTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::ResumeTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::ClaimTokensScreen(screen) => screen.refresh_on_arrival(), - Screen::ViewTokenClaimsScreen(screen) => screen.refresh_on_arrival(), - Screen::UpdateTokenConfigScreen(screen) => screen.refresh_on_arrival(), - Screen::AddTokenById(screen) => screen.refresh_on_arrival(), - Screen::PurchaseTokenScreen(screen) => screen.refresh_on_arrival(), - Screen::SetTokenPriceScreen(screen) => screen.refresh_on_arrival(), - Screen::AssetLockDetailScreen(screen) => screen.refresh_on_arrival(), - Screen::CreateAssetLockScreen(screen) => screen.refresh_on_arrival(), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayAddContactScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayContactDetailsScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayContactProfileViewerScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPaySendPaymentScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayQRGeneratorScreen(_) => {} - Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), - // Shielded screens - Screen::ShieldScreen(screen) => screen.refresh_on_arrival(), - Screen::ShieldedSendScreen(screen) => screen.refresh_on_arrival(), - Screen::UnshieldCreditsScreen(screen) => screen.refresh_on_arrival(), - } + delegate_to_screen!(self, screen => screen.refresh_on_arrival()) } - fn ui(&mut self, ctx: &Context) -> AppAction { - match self { - Screen::IdentitiesScreen(screen) => screen.ui(ctx), - Screen::DPNSScreen(screen) => screen.ui(ctx), - Screen::DocumentQueryScreen(screen) => screen.ui(ctx), - Screen::AddNewWalletScreen(screen) => screen.ui(ctx), - Screen::ImportMnemonicScreen(screen) => screen.ui(ctx), - Screen::AddNewIdentityScreen(screen) => screen.ui(ctx), - Screen::TopUpIdentityScreen(screen) => screen.ui(ctx), - Screen::AddExistingIdentityScreen(screen) => screen.ui(ctx), - Screen::KeyInfoScreen(screen) => screen.ui(ctx), - Screen::KeysScreen(screen) => screen.ui(ctx), - Screen::RegisterDpnsNameScreen(screen) => screen.ui(ctx), - Screen::RegisterDataContractScreen(screen) => screen.ui(ctx), - Screen::UpdateDataContractScreen(screen) => screen.ui(ctx), - Screen::DocumentActionScreen(screen) => screen.ui(ctx), - Screen::GroupActionsScreen(screen) => screen.ui(ctx), - Screen::WithdrawalScreen(screen) => screen.ui(ctx), - Screen::TransferScreen(screen) => screen.ui(ctx), - Screen::AddKeyScreen(screen) => screen.ui(ctx), - Screen::TransitionVisualizerScreen(screen) => screen.ui(ctx), - Screen::NetworkChooserScreen(screen) => screen.ui(ctx), - Screen::WalletsBalancesScreen(screen) => screen.ui(ctx), - Screen::WalletSendScreen(screen) => screen.ui(ctx), - Screen::SingleKeyWalletSendScreen(screen) => screen.ui(ctx), - Screen::ProofLogScreen(screen) => screen.ui(ctx), - Screen::AddContractsScreen(screen) => screen.ui(ctx), - Screen::ProofVisualizerScreen(screen) => screen.ui(ctx), - Screen::MasternodeListDiffScreen(screen) => screen.ui(ctx), - Screen::DocumentVisualizerScreen(screen) => screen.ui(ctx), - Screen::ContractVisualizerScreen(screen) => screen.ui(ctx), - Screen::PlatformInfoScreen(screen) => screen.ui(ctx), - Screen::GroveSTARKScreen(screen) => screen.ui(ctx), - Screen::AddressBalanceScreen(screen) => screen.ui(ctx), - - // Token Screens - Screen::TokensScreen(screen) => screen.ui(ctx), - Screen::TransferTokensScreen(screen) => screen.ui(ctx), - Screen::MintTokensScreen(screen) => screen.ui(ctx), - Screen::BurnTokensScreen(screen) => screen.ui(ctx), - Screen::DestroyFrozenFundsScreen(screen) => screen.ui(ctx), - Screen::FreezeTokensScreen(screen) => screen.ui(ctx), - Screen::UnfreezeTokensScreen(screen) => screen.ui(ctx), - Screen::PauseTokensScreen(screen) => screen.ui(ctx), - Screen::ResumeTokensScreen(screen) => screen.ui(ctx), - Screen::ClaimTokensScreen(screen) => screen.ui(ctx), - Screen::ViewTokenClaimsScreen(screen) => screen.ui(ctx), - Screen::UpdateTokenConfigScreen(screen) => screen.ui(ctx), - Screen::AddTokenById(screen) => screen.ui(ctx), - Screen::PurchaseTokenScreen(screen) => screen.ui(ctx), - Screen::SetTokenPriceScreen(screen) => screen.ui(ctx), - Screen::AssetLockDetailScreen(screen) => screen.ui(ctx), - Screen::CreateAssetLockScreen(screen) => screen.ui(ctx), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.ui(ctx), - Screen::DashPayAddContactScreen(screen) => screen.ui(ctx), - Screen::DashPayContactDetailsScreen(screen) => screen.ui(ctx), - Screen::DashPayContactProfileViewerScreen(screen) => screen.ui(ctx), - Screen::DashPaySendPaymentScreen(screen) => screen.ui(ctx), - Screen::DashPayContactInfoEditorScreen(screen) => screen.ui(ctx), - Screen::DashPayQRGeneratorScreen(screen) => screen.ui(ctx), - Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), - // Shielded screens - Screen::ShieldScreen(screen) => screen.ui(ctx), - Screen::ShieldedSendScreen(screen) => screen.ui(ctx), - Screen::UnshieldCreditsScreen(screen) => screen.ui(ctx), - } + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + delegate_to_screen!(self, screen => screen.ui(ui)) } fn display_message(&mut self, message: &str, message_type: MessageType) { - match self { - Screen::IdentitiesScreen(screen) => screen.display_message(message, message_type), - Screen::DPNSScreen(screen) => screen.display_message(message, message_type), - Screen::DocumentQueryScreen(screen) => screen.display_message(message, message_type), - Screen::AddNewWalletScreen(screen) => screen.display_message(message, message_type), - Screen::ImportMnemonicScreen(screen) => screen.display_message(message, message_type), - Screen::AddNewIdentityScreen(screen) => screen.display_message(message, message_type), - Screen::TopUpIdentityScreen(screen) => screen.display_message(message, message_type), - Screen::AddExistingIdentityScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::KeyInfoScreen(screen) => screen.display_message(message, message_type), - Screen::KeysScreen(screen) => screen.display_message(message, message_type), - Screen::RegisterDpnsNameScreen(screen) => screen.display_message(message, message_type), - Screen::RegisterDataContractScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::UpdateDataContractScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DocumentActionScreen(screen) => screen.display_message(message, message_type), - Screen::GroupActionsScreen(screen) => screen.display_message(message, message_type), - Screen::WithdrawalScreen(screen) => screen.display_message(message, message_type), - Screen::TransferScreen(screen) => screen.display_message(message, message_type), - Screen::AddKeyScreen(screen) => screen.display_message(message, message_type), - Screen::TransitionVisualizerScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::NetworkChooserScreen(screen) => screen.display_message(message, message_type), - Screen::WalletsBalancesScreen(screen) => screen.display_message(message, message_type), - Screen::WalletSendScreen(screen) => screen.display_message(message, message_type), - Screen::SingleKeyWalletSendScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::ProofLogScreen(screen) => screen.display_message(message, message_type), - Screen::AddContractsScreen(screen) => screen.display_message(message, message_type), - Screen::ProofVisualizerScreen(screen) => screen.display_message(message, message_type), - Screen::MasternodeListDiffScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DocumentVisualizerScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::ContractVisualizerScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::PlatformInfoScreen(screen) => screen.display_message(message, message_type), - Screen::GroveSTARKScreen(screen) => screen.display_message(message, message_type), - Screen::AddressBalanceScreen(screen) => screen.display_message(message, message_type), - - // Token Screens - Screen::TokensScreen(screen) => screen.display_message(message, message_type), - Screen::TransferTokensScreen(screen) => screen.display_message(message, message_type), - Screen::MintTokensScreen(screen) => screen.display_message(message, message_type), - Screen::BurnTokensScreen(screen) => screen.display_message(message, message_type), - Screen::DestroyFrozenFundsScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::FreezeTokensScreen(screen) => screen.display_message(message, message_type), - Screen::UnfreezeTokensScreen(screen) => screen.display_message(message, message_type), - Screen::PauseTokensScreen(screen) => screen.display_message(message, message_type), - Screen::ResumeTokensScreen(screen) => screen.display_message(message, message_type), - Screen::ClaimTokensScreen(screen) => screen.display_message(message, message_type), - Screen::ViewTokenClaimsScreen(screen) => screen.display_message(message, message_type), - Screen::UpdateTokenConfigScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::AddTokenById(screen) => screen.display_message(message, message_type), - Screen::PurchaseTokenScreen(screen) => screen.display_message(message, message_type), - Screen::SetTokenPriceScreen(screen) => screen.display_message(message, message_type), - Screen::AssetLockDetailScreen(screen) => screen.display_message(message, message_type), - Screen::CreateAssetLockScreen(screen) => screen.display_message(message, message_type), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.display_message(message, message_type), - Screen::DashPayAddContactScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPayContactDetailsScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPayContactProfileViewerScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPaySendPaymentScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPayContactInfoEditorScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPayQRGeneratorScreen(screen) => { - screen.display_message(message, message_type) - } - Screen::DashPayProfileSearchScreen(screen) => { - screen.display_message(message, message_type) - } - // Shielded screens - Screen::ShieldScreen(screen) => screen.display_message(message, message_type), - Screen::ShieldedSendScreen(screen) => screen.display_message(message, message_type), - Screen::UnshieldCreditsScreen(screen) => screen.display_message(message, message_type), - } + delegate_to_screen!(self, screen => screen.display_message(message, message_type)) } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - match self { - Screen::IdentitiesScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DPNSScreen(screen) => screen.display_task_result(backend_task_success_result), - Screen::DocumentQueryScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddNewWalletScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ImportMnemonicScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddNewIdentityScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::TopUpIdentityScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddExistingIdentityScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::KeyInfoScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::KeysScreen(screen) => screen.display_task_result(backend_task_success_result), - Screen::RegisterDpnsNameScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::RegisterDataContractScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::UpdateDataContractScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DocumentActionScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::GroupActionsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::WithdrawalScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::TransferScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddKeyScreen(screen) => screen.display_task_result(backend_task_success_result), - Screen::TransitionVisualizerScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DocumentVisualizerScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::NetworkChooserScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::WalletsBalancesScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::WalletSendScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::SingleKeyWalletSendScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ProofLogScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddContractsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ProofVisualizerScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::MasternodeListDiffScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ContractVisualizerScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::PlatformInfoScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::GroveSTARKScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddressBalanceScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - - // Token Screens - Screen::TokensScreen(screen) => screen.display_task_result(backend_task_success_result), - Screen::TransferTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::MintTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::BurnTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DestroyFrozenFundsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::FreezeTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::UnfreezeTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::PauseTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ResumeTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ClaimTokensScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ViewTokenClaimsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::UpdateTokenConfigScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AddTokenById(screen) => screen.display_task_result(backend_task_success_result), - Screen::PurchaseTokenScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::SetTokenPriceScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::AssetLockDetailScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::CreateAssetLockScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - - // DashPay Screens - Screen::DashPayScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayAddContactScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayContactDetailsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayContactProfileViewerScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPaySendPaymentScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayContactInfoEditorScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayQRGeneratorScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::DashPayProfileSearchScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - // Shielded screens - Screen::ShieldScreen(screen) => screen.display_task_result(backend_task_success_result), - Screen::ShieldedSendScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::UnshieldCreditsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - } + delegate_to_screen!(self, screen => screen.display_task_result(backend_task_success_result)) } fn display_task_error(&mut self, error: &TaskError) -> bool { - match self { - Screen::IdentitiesScreen(screen) => screen.display_task_error(error), - Screen::DPNSScreen(screen) => screen.display_task_error(error), - Screen::DocumentQueryScreen(screen) => screen.display_task_error(error), - Screen::AddNewWalletScreen(screen) => screen.display_task_error(error), - Screen::ImportMnemonicScreen(screen) => screen.display_task_error(error), - Screen::AddNewIdentityScreen(screen) => screen.display_task_error(error), - Screen::TopUpIdentityScreen(screen) => screen.display_task_error(error), - Screen::AddExistingIdentityScreen(screen) => screen.display_task_error(error), - Screen::KeyInfoScreen(screen) => screen.display_task_error(error), - Screen::KeysScreen(screen) => screen.display_task_error(error), - Screen::RegisterDpnsNameScreen(screen) => screen.display_task_error(error), - Screen::RegisterDataContractScreen(screen) => screen.display_task_error(error), - Screen::UpdateDataContractScreen(screen) => screen.display_task_error(error), - Screen::DocumentActionScreen(screen) => screen.display_task_error(error), - Screen::GroupActionsScreen(screen) => screen.display_task_error(error), - Screen::WithdrawalScreen(screen) => screen.display_task_error(error), - Screen::TransferScreen(screen) => screen.display_task_error(error), - Screen::AddKeyScreen(screen) => screen.display_task_error(error), - Screen::TransitionVisualizerScreen(screen) => screen.display_task_error(error), - Screen::NetworkChooserScreen(screen) => screen.display_task_error(error), - Screen::WalletsBalancesScreen(screen) => screen.display_task_error(error), - Screen::WalletSendScreen(screen) => screen.display_task_error(error), - Screen::SingleKeyWalletSendScreen(screen) => screen.display_task_error(error), - Screen::ProofLogScreen(screen) => screen.display_task_error(error), - Screen::AddContractsScreen(screen) => screen.display_task_error(error), - Screen::ProofVisualizerScreen(screen) => screen.display_task_error(error), - Screen::MasternodeListDiffScreen(screen) => screen.display_task_error(error), - Screen::DocumentVisualizerScreen(screen) => screen.display_task_error(error), - Screen::ContractVisualizerScreen(screen) => screen.display_task_error(error), - Screen::PlatformInfoScreen(screen) => screen.display_task_error(error), - Screen::GroveSTARKScreen(screen) => screen.display_task_error(error), - Screen::AddressBalanceScreen(screen) => screen.display_task_error(error), - - // Token Screens - Screen::TokensScreen(screen) => screen.display_task_error(error), - Screen::TransferTokensScreen(screen) => screen.display_task_error(error), - Screen::MintTokensScreen(screen) => screen.display_task_error(error), - Screen::BurnTokensScreen(screen) => screen.display_task_error(error), - Screen::DestroyFrozenFundsScreen(screen) => screen.display_task_error(error), - Screen::FreezeTokensScreen(screen) => screen.display_task_error(error), - Screen::UnfreezeTokensScreen(screen) => screen.display_task_error(error), - Screen::PauseTokensScreen(screen) => screen.display_task_error(error), - Screen::ResumeTokensScreen(screen) => screen.display_task_error(error), - Screen::ClaimTokensScreen(screen) => screen.display_task_error(error), - Screen::ViewTokenClaimsScreen(screen) => screen.display_task_error(error), - Screen::UpdateTokenConfigScreen(screen) => screen.display_task_error(error), - Screen::AddTokenById(screen) => screen.display_task_error(error), - Screen::PurchaseTokenScreen(screen) => screen.display_task_error(error), - Screen::SetTokenPriceScreen(screen) => screen.display_task_error(error), - Screen::AssetLockDetailScreen(screen) => screen.display_task_error(error), - Screen::CreateAssetLockScreen(screen) => screen.display_task_error(error), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.display_task_error(error), - Screen::DashPayAddContactScreen(screen) => screen.display_task_error(error), - Screen::DashPayContactDetailsScreen(screen) => screen.display_task_error(error), - Screen::DashPayContactProfileViewerScreen(screen) => screen.display_task_error(error), - Screen::DashPaySendPaymentScreen(screen) => screen.display_task_error(error), - Screen::DashPayContactInfoEditorScreen(screen) => screen.display_task_error(error), - Screen::DashPayQRGeneratorScreen(screen) => screen.display_task_error(error), - Screen::DashPayProfileSearchScreen(screen) => screen.display_task_error(error), - - // Shielded Screens - Screen::ShieldScreen(screen) => screen.display_task_error(error), - Screen::ShieldedSendScreen(screen) => screen.display_task_error(error), - Screen::UnshieldCreditsScreen(screen) => screen.display_task_error(error), - } + delegate_to_screen!(self, screen => screen.display_task_error(error)) } fn pop_on_success(&mut self) { - match self { - Screen::IdentitiesScreen(screen) => screen.pop_on_success(), - Screen::DPNSScreen(screen) => screen.pop_on_success(), - Screen::DocumentQueryScreen(screen) => screen.pop_on_success(), - Screen::AddNewWalletScreen(screen) => screen.pop_on_success(), - Screen::ImportMnemonicScreen(screen) => screen.pop_on_success(), - Screen::AddNewIdentityScreen(screen) => screen.pop_on_success(), - Screen::TopUpIdentityScreen(screen) => screen.pop_on_success(), - Screen::AddExistingIdentityScreen(screen) => screen.pop_on_success(), - Screen::KeyInfoScreen(screen) => screen.pop_on_success(), - Screen::KeysScreen(screen) => screen.pop_on_success(), - Screen::RegisterDpnsNameScreen(screen) => screen.pop_on_success(), - Screen::RegisterDataContractScreen(screen) => screen.pop_on_success(), - Screen::UpdateDataContractScreen(screen) => screen.pop_on_success(), - Screen::DocumentActionScreen(screen) => screen.pop_on_success(), - Screen::GroupActionsScreen(screen) => screen.pop_on_success(), - Screen::WithdrawalScreen(screen) => screen.pop_on_success(), - Screen::TransferScreen(screen) => screen.pop_on_success(), - Screen::AddKeyScreen(screen) => screen.pop_on_success(), - Screen::TransitionVisualizerScreen(screen) => screen.pop_on_success(), - Screen::NetworkChooserScreen(screen) => screen.pop_on_success(), - Screen::WalletsBalancesScreen(screen) => screen.pop_on_success(), - Screen::WalletSendScreen(screen) => screen.pop_on_success(), - Screen::SingleKeyWalletSendScreen(screen) => screen.pop_on_success(), - Screen::ProofLogScreen(screen) => screen.pop_on_success(), - Screen::AddContractsScreen(screen) => screen.pop_on_success(), - Screen::ProofVisualizerScreen(screen) => screen.pop_on_success(), - Screen::MasternodeListDiffScreen(screen) => screen.pop_on_success(), - Screen::DocumentVisualizerScreen(screen) => screen.pop_on_success(), - Screen::ContractVisualizerScreen(screen) => screen.pop_on_success(), - Screen::PlatformInfoScreen(screen) => screen.pop_on_success(), - Screen::GroveSTARKScreen(screen) => screen.pop_on_success(), - Screen::AddressBalanceScreen(screen) => screen.pop_on_success(), - - // Token Screens - Screen::TokensScreen(screen) => screen.pop_on_success(), - Screen::TransferTokensScreen(screen) => screen.pop_on_success(), - Screen::MintTokensScreen(screen) => screen.pop_on_success(), - Screen::BurnTokensScreen(screen) => screen.pop_on_success(), - Screen::DestroyFrozenFundsScreen(screen) => screen.pop_on_success(), - Screen::FreezeTokensScreen(screen) => screen.pop_on_success(), - Screen::UnfreezeTokensScreen(screen) => screen.pop_on_success(), - Screen::PauseTokensScreen(screen) => screen.pop_on_success(), - Screen::ResumeTokensScreen(screen) => screen.pop_on_success(), - Screen::ClaimTokensScreen(screen) => screen.pop_on_success(), - Screen::ViewTokenClaimsScreen(screen) => screen.pop_on_success(), - Screen::UpdateTokenConfigScreen(screen) => screen.pop_on_success(), - Screen::AddTokenById(screen) => screen.pop_on_success(), - Screen::PurchaseTokenScreen(screen) => screen.pop_on_success(), - Screen::SetTokenPriceScreen(screen) => screen.pop_on_success(), - Screen::AssetLockDetailScreen(screen) => screen.pop_on_success(), - Screen::CreateAssetLockScreen(screen) => screen.pop_on_success(), - - // DashPay Screens - Screen::DashPayScreen(screen) => screen.pop_on_success(), - Screen::DashPayAddContactScreen(_) => {} - Screen::DashPayContactDetailsScreen(_) => {} - Screen::DashPayContactProfileViewerScreen(_) => {} - Screen::DashPaySendPaymentScreen(_) => {} - Screen::DashPayContactInfoEditorScreen(_) => {} - Screen::DashPayQRGeneratorScreen(_) => {} - Screen::DashPayProfileSearchScreen(_) => {} - // Shielded screens - Screen::ShieldScreen(_) => {} - Screen::ShieldedSendScreen(_) => {} - Screen::UnshieldCreditsScreen(_) => {} - } + delegate_to_screen!(self, screen => screen.pop_on_success()) } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 3e02d235f..eb3a5ff4d 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -4,10 +4,10 @@ use crate::backend_task::system_task::SystemTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::config::Config; use crate::context::AppContext; -use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; -use crate::model::feature_gate::FeatureGate; +use crate::context::connection_status::OverallConnectionState; +use crate::model::spv_status::{SpvStatus, SpvStatusSnapshot}; use crate::model::wallet::DerivationPathHelpers; -use crate::spv::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; +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; @@ -15,41 +15,17 @@ use crate::ui::components::styled::{ ConfirmationDialog, ConfirmationStatus, StyledCard, StyledCheckbox, island_central_panel, }; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::{DashColors, ResponseExt, Shape, ThemeMode}; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use crate::utils::path::format_path_for_display; use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; -use eframe::egui::{self, Context, Ui}; -use std::collections::{BTreeMap, HashMap}; +use eframe::egui::{self, Ui}; +use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// Reads the dashmate RPC password from `~/.dashmate/config.json`. -fn read_dashmate_rpc_password(config_name: &str) -> Result<String, String> { - let home = directories::UserDirs::new() - .map(|dirs| dirs.home_dir().to_path_buf()) - .ok_or("Could not determine home directory")?; - let config_path = home.join(".dashmate").join("config.json"); - let contents = std::fs::read_to_string(&config_path) - .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?; - let json: serde_json::Value = serde_json::from_str(&contents) - .map_err(|e| format!("Failed to parse dashmate config: {e}"))?; - json.get("configs") - .and_then(|c| c.get(config_name)) - .and_then(|c| c.get("core")) - .and_then(|c| c.get("rpc")) - .and_then(|c| c.get("users")) - .and_then(|c| c.get("dashmate")) - .and_then(|c| c.get("password")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| format!("Password not found in dashmate config '{config_name}'")) -} - #[derive(Debug, Clone)] enum SpvClearMessage { Success(String), @@ -87,18 +63,12 @@ pub struct NetworkChooserScreen { pub network_contexts: BTreeMap<Network, Arc<AppContext>>, /// Shared data directory (same for all networks). data_dir: PathBuf, - /// Shared database handle (same for all networks). - db: Arc<crate::database::Database>, dashmate_password_input: PasswordInput, pub current_network: Network, pub recheck_time: Option<TimestampMillis>, - custom_dash_qt_path: Option<PathBuf>, - overwrite_dash_conf: bool, - disable_zmq: bool, developer_mode: bool, theme_preference: ThemeMode, should_reset_collapsing_states: bool, - backend_modes: HashMap<Network, CoreBackendMode>, spv_progress_network: Option<Network>, headers_stage_start: Option<u32>, filter_headers_stage_start: Option<u32>, @@ -109,14 +79,7 @@ pub struct NetworkChooserScreen { spv_clear_message: Option<SpvClearMessage>, db_clear_dialog: Option<ConfirmationDialog>, db_clear_message: Option<DatabaseClearMessage>, - use_local_spv_node: bool, auto_start_spv: bool, - close_dash_qt_on_exit: bool, - /// Tracks whether the last config save to disk failed (needed to show the - /// correct banner when the async reinit completes). - config_save_failed: bool, - /// Progress banner shown while reinit runs in the background. - reinit_banner: Option<BannerHandle>, discovery_in_progress: bool, fetch_confirmation_dialog: Option<ConfirmationDialog>, /// Set when DAPI discovery completes and an SDK reinit is needed. @@ -125,18 +88,13 @@ pub struct NetworkChooserScreen { } impl NetworkChooserScreen { - pub fn new( - contexts: &BTreeMap<Network, Arc<AppContext>>, - current_network: Network, - overwrite_dash_conf: bool, - ) -> Self { + pub fn new(contexts: &BTreeMap<Network, Arc<AppContext>>, current_network: Network) -> Self { let any_context = contexts .values() .next() .expect("BUG: NetworkChooserScreen requires at least one AppContext"); let data_dir = any_context.data_dir.clone(); - let db = any_context.db.clone(); let mut dashmate_password_input = PasswordInput::new() .with_hint_text("Core RPC password") @@ -152,48 +110,19 @@ impl NetworkChooserScreen { let current_context = contexts.get(&current_network).unwrap_or(any_context); let developer_mode = current_context.is_developer_mode(); - let settings = current_context - .get_settings() - .ok() - .flatten() - .unwrap_or_default(); + let settings = current_context.get_app_settings(); let theme_preference = settings.theme_mode; - let disable_zmq = settings.disable_zmq; - let custom_dash_qt_path = settings.dash_qt_path; - let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); - let auto_start_spv = db.get_auto_start_spv().unwrap_or(false); - let close_dash_qt_on_exit = db.get_close_dash_qt_on_exit().unwrap_or(true); - - let mut backend_modes = HashMap::new(); - for network in [ - Network::Mainnet, - Network::Testnet, - Network::Devnet, - Network::Regtest, - ] { - backend_modes.insert( - network, - contexts - .get(&network) - .map(|ctx| ctx.core_backend_mode()) - .unwrap_or_default(), - ); - } + let auto_start_spv = settings.auto_start_spv; Self { network_contexts: contexts.clone(), data_dir, - db, dashmate_password_input, current_network, recheck_time: None, - custom_dash_qt_path, - overwrite_dash_conf, - disable_zmq, developer_mode, theme_preference, should_reset_collapsing_states: true, // Start with collapsed state - backend_modes, spv_progress_network: None, headers_stage_start: None, filter_headers_stage_start: None, @@ -204,11 +133,7 @@ impl NetworkChooserScreen { spv_clear_message: None, db_clear_dialog: None, db_clear_message: None, - use_local_spv_node, auto_start_spv, - close_dash_qt_on_exit, - config_save_failed: false, - reinit_banner: None, discovery_in_progress: false, fetch_confirmation_dialog: None, pending_reinit_after_discovery: false, @@ -227,21 +152,10 @@ impl NetworkChooserScreen { .expect("BUG: no AppContext available for any network") } - /// Save the current settings to the database - /// - /// TODO: doesn't save local network settings like password yet. - fn save(&self) -> Result<(), String> { - self.current_app_context() - .update_dash_core_execution_settings( - self.custom_dash_qt_path.clone(), - self.overwrite_dash_conf, - ) - .map_err(|e| e.to_string()) - } /// Render the simplified settings interface fn render_network_table(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Connection Settings Card StyledCard::new().padding(24.0).show(ui, |ui| { @@ -254,74 +168,20 @@ impl NetworkChooserScreen { .spacing([40.0, 12.0]) .striped(false) .show(ui, |ui| { - let current_backend_mode = *self - .backend_modes - .entry(self.current_network) - .or_insert(CoreBackendMode::Spv); - - if self.developer_mode { - // Row 1: Connection Type (only shown in Expert mode — the - // connection backend is an advanced power-user choice). - ui.label( - egui::RichText::new("Connection Type:") - .color(DashColors::text_primary(dark_mode)), - ); - - let connection_text = match current_backend_mode { - CoreBackendMode::Spv => "Built-in (SPV)", - CoreBackendMode::Rpc => "Local Dash Core node", - }; - - let mut connection_mode = current_backend_mode; - egui::ComboBox::from_id_salt("connection_mode_selector") - .selected_text(connection_text) - .width(200.0) - .show_ui(ui, |ui| { - if ui - .selectable_value( - &mut connection_mode, - CoreBackendMode::Spv, - "Built-in (SPV)", - ) - .changed() - { - self.backend_modes - .insert(self.current_network, CoreBackendMode::Spv); - let ctx = self.current_app_context(); - ctx.set_core_backend_mode(CoreBackendMode::Spv); - } - if ui - .selectable_value( - &mut connection_mode, - CoreBackendMode::Rpc, - "Local Dash Core node", - ) - .changed() - { - self.backend_modes - .insert(self.current_network, CoreBackendMode::Rpc); - let ctx = self.current_app_context(); - ctx.set_core_backend_mode(CoreBackendMode::Rpc); - ctx.stop_spv(); - } - }); - - ui.end_row(); - } - - // Row 2: Network + // Row: Network ui.label( egui::RichText::new("Network:").color(DashColors::text_primary(dark_mode)), ); - // Check if currently connected via SPV (only SPV restricts network switching) - let is_spv_connected = if current_backend_mode == CoreBackendMode::Spv { - let ctx = self.current_app_context(); - let snapshot = ctx.spv_manager().status(); - snapshot.status.is_active() - } else { - false // Core mode doesn't restrict network switching - }; + // Chain sync is owned by upstream platform-wallet; the + // EventBridge feeds live SPV status into ConnectionStatus. + // While active, the network selector stays disabled so the + // user can't switch networks mid-sync. + let is_spv_connected = self + .current_app_context() + .connection_status() + .spv_status() + .is_active(); let network_text = match self.current_network { Network::Mainnet => "Mainnet", @@ -382,16 +242,14 @@ 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(); + 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); } }); @@ -406,111 +264,6 @@ impl NetworkChooserScreen { ui.end_row(); }); - - // Password input for RPC mode (any network) - let current_backend_mode = *self - .backend_modes - .entry(self.current_network) - .or_insert(CoreBackendMode::Spv); - if current_backend_mode == CoreBackendMode::Rpc { - ui.add_space(20.0); - ui.separator(); - ui.add_space(12.0); - - ui.label( - egui::RichText::new("Core RPC Password") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - // Reserve space for buttons + item spacing - let is_regtest = self.current_network == Network::Regtest; - let buttons_width = if is_regtest { 200.0 } else { 100.0 }; - let input_width = (ui.available_width() - buttons_width).max(100.0); - self.dashmate_password_input.set_desired_width(input_width); - self.dashmate_password_input.show(ui); - - let save_clicked = ui.button("Save").clicked(); - - let mut auto_update_succeeded = false; - if is_regtest && ui.button("Auto Update").clicked() { - match read_dashmate_rpc_password("local_seed") { - Ok(password) => { - self.dashmate_password_input.set_text(password); - auto_update_succeeded = true; - } - Err(e) => { - tracing::error!("Auto update failed: {e}"); - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - } - } - - if (save_clicked || auto_update_succeeded) - && let Ok(mut config) = - Config::load_from(&self.data_dir) - && let Some(network_cfg) = - config.config_for_network(self.current_network).clone() - { - let updated_config = network_cfg.update_core_rpc_password( - self.dashmate_password_input.text().to_string(), - ); - config.update_config_for_network( - self.current_network, - updated_config.clone(), - ); - let save_failed = if let Err(e) = - config.save(&self.data_dir) - { - tracing::error!("Failed to save config to .env: {e}"); - true - } else { - false - }; - - // Update in-memory config and dispatch an async reinit - // so the password takes effect for this session without - // blocking the UI thread. Only do so when the context - // for this network already exists — otherwise - // `context_for_network` would silently fall back to - // mainnet and corrupt its config. The saved file-level - // config will be picked up when the network context is - // created. - if let Some(app_context) = self.context_for_network(self.current_network) - { - { - let mut cfg_lock = app_context.config.write().unwrap(); - *cfg_lock = updated_config; - } - - MessageBanner::clear_all_global(ui.ctx()); - self.config_save_failed = save_failed; - self.reinit_banner = Some(MessageBanner::set_global( - ui.ctx(), - "Reconnecting to Dash Core...", - MessageType::Info, - )); - app_action = AppAction::BackendTask( - BackendTask::ReinitCoreClientAndSdk, - ); - } else if save_failed { - MessageBanner::set_global( - ui.ctx(), - "Could not save the configuration file. Your changes will apply when this network is activated.", - MessageType::Warning, - ); - } else { - MessageBanner::set_global( - ui.ctx(), - "Core RPC password saved successfully.", - MessageType::Success, - ); - } - } - }); - } }); // Connection Status Card @@ -520,25 +273,14 @@ impl NetworkChooserScreen { ui.heading("Connection Status"); ui.add_space(10.0); - let current_backend_mode = *self - .backend_modes - .entry(self.current_network) - .or_insert(CoreBackendMode::Spv); - let ctx = self.current_app_context().clone(); let status = ctx.connection_status(); - let disable_zmq = status.disable_zmq(); - let rpc_online = status.rpc_online(); - let zmq_connected = status.zmq_connected(); let spv_status = status.spv_status(); - let spv_connected = ConnectionStatus::spv_connected(spv_status); - let rpc_last_error = status.rpc_last_error(); + let spv_connected = spv_status.is_active(); let spv_error_detail = status.spv_last_error(); - let snapshot = if current_backend_mode == CoreBackendMode::Spv { - Some(ctx.spv_manager().status().clone()) - } else { - None - }; + // Chain sync is owned by upstream platform-wallet; the EventBridge + // pushes live status + per-phase progress into ConnectionStatus. + let snapshot: Option<SpvStatusSnapshot> = Some(status.spv_status_snapshot()); let overall_state = status.overall_state(); let dapi_total = status.dapi_total_endpoints(); let dapi_available = status.dapi_available(); @@ -547,73 +289,58 @@ impl NetworkChooserScreen { // Button on the left with status ui.horizontal(|ui| { if overall_state != OverallConnectionState::Disconnected { - if current_backend_mode == CoreBackendMode::Spv { - let is_stopping = spv_status == SpvStatus::Stopping; - let disconnect_button = egui::Button::new( - egui::RichText::new("Disconnect").color(DashColors::WHITE), - ) - .fill(DashColors::ERROR) - .stroke(egui::Stroke::NONE) - .corner_radius(Shape::RADIUS_MD) - .min_size(egui::vec2(120.0, 36.0)); - - if ui - .add_enabled(!is_stopping, disconnect_button) - .clicked() - { - self.current_app_context().stop_spv(); - } + let is_stopping = spv_status == SpvStatus::Stopping; + let disconnect_button = egui::Button::new( + egui::RichText::new("Disconnect").color(DashColors::WHITE), + ) + .fill(DashColors::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(Shape::RADIUS_MD) + .min_size(egui::vec2(120.0, 36.0)); + + if ui.add_enabled(!is_stopping, disconnect_button).clicked() { + // The update loop owns the async teardown (upstream + // shutdown is async), so dispatch it as an action rather + // than blocking the frame loop. The indicator flips to + // Stopping → Disconnected as the teardown progresses. + app_action = AppAction::StopSpv; + } - // Show sync status next to button - ui.add_space(12.0); + // Show sync status next to button + ui.add_space(12.0); - if let Some(snap) = &snapshot { - match snap.status { - SpvStatus::Running => { - ui.colored_label(DashColors::SUCCESS, "Synced - The SPV client can now be used for transacting and querying."); - } - SpvStatus::Syncing | SpvStatus::Starting => { - let warning_color = DashColors::warning_color(dark_mode); - ui.style_mut().visuals.widgets.inactive.fg_stroke.color = - warning_color; - ui.style_mut().visuals.widgets.hovered.fg_stroke.color = - warning_color; - ui.style_mut().visuals.widgets.active.fg_stroke.color = - warning_color; - ui.spinner(); - ui.label(egui::RichText::new("Syncing...")); - } - SpvStatus::Stopping => { - ui.style_mut().visuals.widgets.inactive.fg_stroke.color = - DashColors::DASH_BLUE; - ui.style_mut().visuals.widgets.hovered.fg_stroke.color = - DashColors::DASH_BLUE; - ui.style_mut().visuals.widgets.active.fg_stroke.color = - DashColors::DASH_BLUE; - ui.spinner(); - ui.label(egui::RichText::new("Disconnecting...")); - } - _ => {} + if let Some(snap) = &snapshot { + match snap.status { + SpvStatus::Running => { + ui.colored_label(DashColors::SUCCESS, "Synced - The SPV client can now be used for transacting and querying."); + } + SpvStatus::Syncing | SpvStatus::Starting => { + let warning_color = DashColors::warning_color(dark_mode); + ui.style_mut().visuals.widgets.inactive.fg_stroke.color = + warning_color; + ui.style_mut().visuals.widgets.hovered.fg_stroke.color = + warning_color; + ui.style_mut().visuals.widgets.active.fg_stroke.color = + warning_color; + ui.spinner(); + ui.label(egui::RichText::new("Syncing...")); + } + SpvStatus::Stopping => { + ui.style_mut().visuals.widgets.inactive.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.hovered.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.active.fg_stroke.color = + DashColors::DASH_BLUE; + ui.spinner(); + ui.label(egui::RichText::new("Disconnecting...")); } + _ => {} } - } else { - // For Core mode, just show status since it can switch networks freely - let label = if disable_zmq { - "✅ Synced (RPC, ZMQ disabled)" - } else { - "✅ Synced (RPC + ZMQ)" - }; - ui.colored_label(DashColors::DASH_BLUE, label); } } else { - // Don't show Connect button for Local network in RPC mode - // (there's no Dash-Qt to start for local/regtest) - let show_connect_button = match current_backend_mode { - CoreBackendMode::Spv => true, - CoreBackendMode::Rpc => { - !rpc_online && self.current_network != Network::Regtest - } - }; + // Chain sync is SPV-only. + let show_connect_button = true; if show_connect_button { let connect_button = egui::Button::new( @@ -625,36 +352,17 @@ impl NetworkChooserScreen { .min_size(egui::vec2(120.0, 36.0)); if ui.add(connect_button).clicked() { - if current_backend_mode == CoreBackendMode::Spv { - if let Err(err) = self.current_app_context().start_spv() { - app_action = - AppAction::Custom(format!("Failed to start SPV: {}", err)); - } - } else { - // Core mode connect - let settings = - self.current_app_context().get_settings().ok().flatten(); - let dash_qt_path = settings - .and_then(|s| s.dash_qt_path) - .or_else(|| self.custom_dash_qt_path.clone()); - if let Some(path) = dash_qt_path { - app_action = AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::StartDashQT( - self.current_network, - path, - self.overwrite_dash_conf, - ), - )); - } - } + // The update loop owns the `TaskResult` sender the + // backend-wiring step needs, so it lazily wires the + // backend then starts chain sync. A click during the + // brief not-yet-wired boot window no longer fast-fails. + app_action = AppAction::StartSpv; } } - } }); - if FeatureGate::SpvBackend.is_available(self.current_app_context()) - && let Some(snap) = snapshot.as_ref() + if let Some(snap) = snapshot.as_ref() && (snap.status == SpvStatus::Syncing || snap.status == SpvStatus::Starting) { ui.add_space(10.0); @@ -669,45 +377,7 @@ impl NetworkChooserScreen { ui.add_space(10.0); ui.vertical(|ui| { - if current_backend_mode == CoreBackendMode::Rpc { - ui.horizontal(|ui| { - ui.label("Core RPC:"); - let rpc_color = if rpc_online { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - let rpc_label = if rpc_online { - "Connected".to_string() - } else if let Some(ref err) = rpc_last_error { - format!("Error: {err}") - } else { - "Disconnected".to_string() - }; - ui.colored_label(rpc_color, &rpc_label); - }); - - ui.horizontal(|ui| { - ui.label("ZMQ:"); - if disable_zmq { - ui.colored_label( - DashColors::text_secondary(dark_mode), - "Disabled", - ); - } else { - let zmq_color = if zmq_connected { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - let zmq_label = - if zmq_connected { "Connected" } else { "Disconnected" }; - ui.colored_label(zmq_color, zmq_label); - } - }); - } - - if current_backend_mode == CoreBackendMode::Spv { + { ui.horizontal(|ui| { ui.label("SPV:"); let color = if spv_connected { @@ -715,15 +385,20 @@ impl NetworkChooserScreen { } else { DashColors::ERROR }; - let label = if spv_status == SpvStatus::Error { - spv_error_detail - .as_ref() - .map(|e| format!("Error: {e}")) - .unwrap_or_else(|| "Error".to_string()) + if spv_status == SpvStatus::Error { + // Fixed, jargon-free label. The raw upstream sync + // error is offered only as a hover tooltip so the + // status line never renders internal error text. + let response = ui.colored_label( + color, + "Sync error — open Settings for details", + ); + if let Some(detail) = spv_error_detail.as_ref() { + response.on_hover_text(detail); + } } else { - spv_status.to_string() - }; - ui.colored_label(color, label); + ui.colored_label(color, spv_status.to_string()); + } }); } @@ -926,151 +601,6 @@ impl NetworkChooserScreen { }); }); - // Dash-QT Path - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.label( - egui::RichText::new("Dash Core Executable Path") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - if ui.button("Select File").clicked() - && let Some(path) = rfd::FileDialog::new().pick_file() - { - let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); - let file_name = path.file_name().and_then(|f| f.to_str()); - if let Some(file_name) = file_name { - // Handle macOS .app bundles - let resolved_path = if cfg!(target_os = "macos") - && path.extension().and_then(|s| s.to_str()) == Some("app") - { - path.join("Contents").join("MacOS").join("Dash-Qt") - } else { - path.clone() - }; - - // Check if the resolved path exists and is valid - let is_valid = if cfg!(target_os = "windows") { - file_name.to_ascii_lowercase().ends_with("dash-qt.exe") - } else if cfg!(target_os = "macos") { - file_name.eq_ignore_ascii_case("dash-qt") - || (file_name.to_ascii_lowercase().ends_with(".app") - && resolved_path.exists()) - } else { - file_name.eq_ignore_ascii_case("dash-qt") - }; - - if is_valid { - self.custom_dash_qt_path = Some(resolved_path); - if let Err(e) = self.save() { - tracing::warn!("Failed to save Dash-Qt path setting: {}", e); - MessageBanner::set_global( - ui.ctx(), - "Failed to save Dash-Qt path setting. Please try again.", - MessageType::Error, - ); - self.custom_dash_qt_path = previous_custom_dash_qt_path; - } - } else { - let required_file_name = if cfg!(target_os = "windows") { - "dash-qt.exe" - } else if cfg!(target_os = "macos") { - "Dash-Qt or Dash-Qt.app" - } else { - "dash-qt" - }; - MessageBanner::set_global( - ui.ctx(), - format!( - "Invalid file: Please select a valid '{}'.", - required_file_name - ), - MessageType::Error, - ); - } - } - } - - if self.custom_dash_qt_path.is_some() && ui.button("Clear").clicked() { - let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); - self.custom_dash_qt_path = Some(PathBuf::new()); - if let Err(e) = self.save() { - tracing::warn!("Failed to save cleared Dash-Qt path setting: {}", e); - MessageBanner::set_global( - ui.ctx(), - "Failed to clear Dash-Qt path setting. Please try again.", - MessageType::Error, - ); - self.custom_dash_qt_path = previous_custom_dash_qt_path; - } - } - }); - - if let Some(ref file) = self.custom_dash_qt_path - && !file.as_os_str().is_empty() - { - ui.horizontal(|ui| { - ui.label("Path:"); - ui.label( - egui::RichText::new(format_path_for_display(file)) - .color(DashColors::SUCCESS) - .italics(), - ); - }); - } - - // Configuration Options - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - ui.label( - egui::RichText::new("Configuration Options") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - let previous_overwrite_dash_conf = self.overwrite_dash_conf; - if StyledCheckbox::new(&mut self.overwrite_dash_conf, "Overwrite dash.conf") - .show(ui) - .clicked() - && let Err(e) = self.save() - { - tracing::warn!("Failed to save overwrite_dash_conf setting: {}", e); - MessageBanner::set_global( - ui.ctx(), - "Failed to save overwrite dash.conf setting. Please try again.", - MessageType::Error, - ); - self.overwrite_dash_conf = previous_overwrite_dash_conf; - } - ui.label( - egui::RichText::new("Auto-configure required settings") - .color(DashColors::TEXT_SECONDARY) - .italics(), - ); - }); - - // Disable ZMQ toggle (requires restart) - ui.add_space(6.0); - ui.horizontal(|ui| { - if StyledCheckbox::new(&mut self.disable_zmq, "Disable ZMQ (requires restart)") - .show(ui) - .clicked() - { - // Persist immediately via context - let _ = self - .current_app_context() - .update_disable_zmq(self.disable_zmq); - } - }); - ui.add_space(8.0); ui.horizontal(|ui| { @@ -1082,9 +612,14 @@ impl NetworkChooserScreen { ) .clicked() { - for ctx in self.network_contexts.values() { - ctx.enable_developer_mode(self.developer_mode); - } + // Expert Mode is a single app-global flag shared by every + // per-network context, so toggling it on one updates all. + self.current_app_context() + .enable_developer_mode(self.developer_mode); + // Re-render the nav immediately: enabling Expert Mode also + // disables animations, which stops continuous repaints, so + // request one so the Masternodes nav entry appears now. + ui.ctx().request_repaint(); // Persist to config file (non-blocking for UI) if let Ok(mut config) = Config::load_from(&self.data_dir) { @@ -1113,8 +648,36 @@ impl NetworkChooserScreen { ui.horizontal(|ui| { if ui.button("Clear Platform Addresses").clicked() { - // Clear from database + // TODO(C10): consolidate wallet_addresses + per-wallet k/v + // clearing once the wallet table itself migrates out of data.db. let current_context = self.current_app_context(); + let wallet_hashes: Vec<_> = current_context + .wallets + .read() + .map(|guard| guard.keys().copied().collect()) + .unwrap_or_default(); + // Drop each wallet's pushed sync cursor so the + // "Addresses synced" label reverts to "never synced" + // until the next coordinator pass repopulates it. + for hash in &wallet_hashes { + current_context.clear_platform_sync_info(hash); + } + // Clear the in-memory wallet maps so the UI never + // stays inconsistent with a half-completed clear. + if let Ok(wallets) = current_context.wallets.read() { + for wallet_arc in wallets.values() { + if let Ok(mut wallet) = wallet_arc.write() { + wallet.platform_address_info.clear(); + wallet.known_addresses.retain(|_, path| { + !path.is_platform_payment(current_context.network) + }); + wallet.watched_addresses.retain(|path, _| { + !path.is_platform_payment(current_context.network) + }); + } + } + } + match current_context .db .clear_all_platform_addresses(&current_context.network) @@ -1124,43 +687,14 @@ impl NetworkChooserScreen { "Cleared {} platform addresses from database", count ); - // Also clear from in-memory wallets - if let Ok(wallets) = current_context.wallets.read() { - for wallet_arc in wallets.values() { - if let Ok(mut wallet) = wallet_arc.write() { - // Clear platform address info - wallet.platform_address_info.clear(); - - // Remove platform addresses from known_addresses - wallet.known_addresses.retain(|_, path| { - !path.is_platform_payment(current_context.network) - }); - - // Remove platform addresses from watched_addresses - wallet.watched_addresses.retain(|path, _| { - !path.is_platform_payment(current_context.network) - }); - - // Remove platform addresses from address_balances - let platform_addrs: Vec<_> = wallet - .address_balances - .keys() - .filter(|addr| { - // Check if this address was a platform address - // by seeing if it's not in known_addresses anymore - !wallet.known_addresses.contains_key(*addr) - }) - .cloned() - .collect(); - for addr in platform_addrs { - wallet.address_balances.remove(&addr); - } - } - } - } } Err(e) => { - tracing::error!("Failed to clear platform addresses: {}", e); + MessageBanner::set_global( + ui.ctx(), + "Could not clear the saved Platform addresses. Restart the application and try again.", + MessageType::Error, + ) + .with_details(e); } } } @@ -1172,102 +706,9 @@ impl NetworkChooserScreen { }); } - ui.add_space(8.0); - - ui.horizontal(|ui| { - if StyledCheckbox::new( - &mut self.close_dash_qt_on_exit, - "Close Dash-Qt when DET exits", - ) - .show(ui) - .clicked() - { - // Save to database - match self - .db - .update_close_dash_qt_on_exit(self.close_dash_qt_on_exit) - { - Ok(_) => { - tracing::debug!( - "close_dash_qt_on_exit setting saved: {}", - self.close_dash_qt_on_exit - ); - } - Err(e) => { - tracing::error!( - "Failed to save close_dash_qt_on_exit setting: {:?}", - e - ); - } - } - } - ui.label( - egui::RichText::new(if self.close_dash_qt_on_exit { - "Dash-Qt will close automatically" - } else { - "Dash-Qt will keep running" - }) - .color(DashColors::TEXT_SECONDARY) - .italics(), - ); - }); - // Advanced SPV peer source configuration is Expert-only — // fresh-install users get auto-discovery, which is the correct default. if self.developer_mode { - ui.add_space(12.0); - ui.separator(); - ui.add_space(12.0); - - // SPV Peer Source - ui.label( - egui::RichText::new("SPV Peer Source") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(6.0); - ui.label( - egui::RichText::new( - "Choose how SPV finds peers for blockchain sync on mainnet/testnet.", - ) - .color(DashColors::text_secondary(dark_mode)), - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - if StyledCheckbox::new(&mut self.use_local_spv_node, "Use local Dash Core node") - .show(ui) - .clicked() - { - // Save to database - let _ = self - .db - .update_use_local_spv_node(self.use_local_spv_node); - - for ctx in self.network_contexts.values() { - ctx.spv_manager().set_use_local_node(self.use_local_spv_node); - } - } - ui.label( - egui::RichText::new(if self.use_local_spv_node { - "Connect to local node at 127.0.0.1" - } else { - "Use DNS seed discovery (default)" - }) - .color(DashColors::TEXT_SECONDARY) - .italics(), - ); - }); - ui.add_space(4.0); - ui.label( - egui::RichText::new( - "Note: Changes take effect on next SPV sync start. Devnet/local networks always use configured host.", - ) - .size(11.0) - .color(DashColors::text_secondary(dark_mode)) - .italics(), - ); - // Auto-start SPV on startup ui.add_space(12.0); ui.separator(); @@ -1292,9 +733,9 @@ impl NetworkChooserScreen { .show(ui) .clicked() { - // Save to database + // Save to the shared app k/v store let _ = self - .db + .current_app_context() .update_auto_start_spv(self.auto_start_spv); } ui.label( @@ -1382,14 +823,16 @@ impl NetworkChooserScreen { // diagnostic tools that can destroy wallet sync state and should not // be exposed to fresh-install users. if self.developer_mode { - let current_backend_mode = self.current_app_context().core_backend_mode(); - if current_backend_mode == CoreBackendMode::Spv { - let snapshot = self.current_app_context().spv_manager().status(); - ui.add_space(12.0); - ui.separator(); - ui.add_space(12.0); - app_action |= self.render_spv_maintenance_controls(ui, &snapshot); - } + // Chain sync is owned by upstream platform-wallet; the + // EventBridge feeds live status into ConnectionStatus. + let snapshot = self + .current_app_context() + .connection_status() + .spv_status_snapshot(); + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + app_action |= self.render_spv_maintenance_controls(ui, &snapshot); } }); }); @@ -1501,7 +944,7 @@ impl NetworkChooserScreen { } } - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::glass_white(dark_mode)) @@ -1605,7 +1048,7 @@ impl NetworkChooserScreen { snapshot: &SpvStatusSnapshot, ) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( egui::RichText::new("SPV Maintenance") @@ -1711,10 +1154,9 @@ impl NetworkChooserScreen { ))); } Err(err) => { - self.spv_clear_message = Some(SpvClearMessage::Error(format!( - "Failed to clear SPV data: {}", - err - ))); + tracing::error!(error = ?err, "Failed to clear SPV data"); + self.spv_clear_message = + Some(SpvClearMessage::Error(err.to_string())); } } } @@ -1769,6 +1211,36 @@ impl NetworkChooserScreen { } } + /// Fraction in [0,1] of the download window from `stage_start` (default `current`) to `target`. + /// + /// Windowing makes checkpoint-resumed syncs start near 0% instead of jumping ahead. Pass + /// `Some(0)` for a plain `current / target` ratio. + fn window_fraction(stage_start: Option<u32>, current: u32, target: u32) -> f32 { + if target == 0 { + return 0.0; + } + let start = stage_start.unwrap_or(current).min(target); + let span = target.saturating_sub(start); + if span == 0 { + return if current >= target { 1.0 } else { 0.0 }; + } + (current.saturating_sub(start) as f32 / span as f32).clamp(0.0, 1.0) + } + + /// Progress in [0,1] for a sync stage, from its state and windowed height range. + fn stage_progress( + state: SyncState, + stage_start: Option<u32>, + current: u32, + target: u32, + ) -> f32 { + match state { + SyncState::Syncing => Self::window_fraction(stage_start, current, target), + SyncState::Synced => 1.0, + SyncState::WaitingForConnections | SyncState::WaitForEvents | SyncState::Error => 0.0, + } + } + fn calculate_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { let Some(progress) = &snapshot.sync_progress else { return 0.0; @@ -1776,33 +1248,12 @@ impl NetworkChooserScreen { let Ok(headers) = progress.headers() else { return 0.0; }; - match headers.state() { - SyncState::Syncing => { - let target = headers.target_height(); - if target == 0 { - return 0.0; - } - // Use download window to show progress relative to remaining work, - // so checkpoint-resumed syncs start near 0% rather than jumping ahead. - let start = self - .headers_stage_start - .unwrap_or(headers.current_height()) - .min(target); - let span = target.saturating_sub(start); - if span == 0 { - if headers.current_height() >= target { - 1.0 - } else { - 0.0 - } - } else { - let done = headers.current_height().saturating_sub(start); - (done as f32 / span as f32).clamp(0.0, 1.0) - } - } - SyncState::Synced => 1.0, - SyncState::WaitingForConnections | SyncState::WaitForEvents | SyncState::Error => 0.0, - } + Self::stage_progress( + headers.state(), + self.headers_stage_start, + headers.current_height(), + headers.target_height(), + ) } fn calculate_filter_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { @@ -1812,31 +1263,12 @@ impl NetworkChooserScreen { let Ok(fh) = progress.filter_headers() else { return 0.0; }; - match fh.state() { - SyncState::Syncing => { - let target = fh.target_height(); - if target == 0 { - return 0.0; - } - let start = self - .filter_headers_stage_start - .unwrap_or(fh.current_height()) - .min(target); - let span = target.saturating_sub(start); - if span == 0 { - if fh.current_height() >= target { - 1.0 - } else { - 0.0 - } - } else { - let done = fh.current_height().saturating_sub(start); - (done as f32 / span as f32).clamp(0.0, 1.0) - } - } - SyncState::Synced => 1.0, - SyncState::WaitingForConnections | SyncState::WaitForEvents | SyncState::Error => 0.0, - } + Self::stage_progress( + fh.state(), + self.filter_headers_stage_start, + fh.current_height(), + fh.target_height(), + ) } fn calculate_filters_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { @@ -1846,34 +1278,12 @@ impl NetworkChooserScreen { let Ok(filters) = progress.filters() else { return 0.0; }; - match filters.state() { - SyncState::Syncing => { - let target = filters.target_height(); - if target == 0 { - return 0.0; - } - // Use windowed progress so checkpoint-resumed syncs start near 0%. - // current_height is the storage tip (not downloaded() which is a - // session-level count). - let start = self - .filters_stage_start - .unwrap_or(filters.current_height()) - .min(target); - let span = target.saturating_sub(start); - if span == 0 { - if filters.current_height() >= target { - 1.0 - } else { - 0.0 - } - } else { - let done = filters.current_height().saturating_sub(start); - (done as f32 / span as f32).clamp(0.0, 1.0) - } - } - SyncState::Synced => 1.0, - SyncState::WaitingForConnections | SyncState::WaitForEvents | SyncState::Error => 0.0, - } + Self::stage_progress( + filters.state(), + self.filters_stage_start, + filters.current_height(), + filters.target_height(), + ) } fn calculate_validating_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { @@ -1886,17 +1296,8 @@ impl NetworkChooserScreen { let Ok(mn) = progress.masternodes() else { return 0.0; }; - match mn.state() { - SyncState::Syncing => { - let target = mn.target_height(); - if target == 0 { - return 0.0; - } - (mn.current_height() as f32 / target as f32).clamp(0.0, 1.0) - } - SyncState::Synced => 1.0, - SyncState::WaitingForConnections | SyncState::WaitForEvents | SyncState::Error => 0.0, - } + // Masternode sync reports a plain current/target ratio, not a resume window. + Self::stage_progress(mn.state(), Some(0), mn.current_height(), mn.target_height()) } fn calculate_blocks_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { @@ -1912,32 +1313,18 @@ impl NetworkChooserScreen { if blocks.state() == SyncState::Synced { return 1.0; } - // Use last_processed height relative to the tracked target height. - // Don't branch on SyncState — blocks can transiently leave Syncing - // (e.g. WaitForEvents between batches) while still making progress. - let target = self.blocks_target_height; - if target == 0 { - return 0.0; - } - let current = blocks.last_processed(); - let start = self.blocks_stage_start.unwrap_or(current).min(target); - let span = target.saturating_sub(start); - if span == 0 { - if current >= target { 1.0 } else { 0.0 } - } else { - let done = current.saturating_sub(start); - (done as f32 / span as f32).clamp(0.0, 1.0) - } + // Track last_processed against blocks_target_height regardless of state: blocks can + // transiently leave Syncing (e.g. WaitForEvents between batches) while still progressing. + Self::window_fraction( + self.blocks_stage_start, + blocks.last_processed(), + self.blocks_target_height, + ) } fn any_rpc_backend(&self) -> bool { - self.backend_modes - .iter() - .any(|(network, mode)| *mode == CoreBackendMode::Rpc && self.has_context_for(*network)) - } - - fn has_context_for(&self, network: Network) -> bool { - self.network_contexts.contains_key(&network) + // Chain sync is SPV-only; the RPC wallet backend was removed. + false } fn spv_status_detail(&self, snapshot: &SpvStatusSnapshot) -> Option<String> { @@ -2026,33 +1413,26 @@ impl ScreenLike for NetworkChooserScreen { // This ensures dropdowns are closed when navigating back self.should_reset_collapsing_states = true; - // Reload settings from database to ensure we have the latest values - if let Ok(Some(settings)) = self.current_app_context().get_settings() { - self.custom_dash_qt_path = settings.dash_qt_path; - self.overwrite_dash_conf = settings.overwrite_dash_conf; - self.theme_preference = settings.theme_mode; - } - - for (&network, ctx) in &self.network_contexts { - self.backend_modes.insert(network, ctx.core_backend_mode()); - } + // Reload settings from the shared app k/v store to ensure we have the latest values + let settings = self.current_app_context().get_app_settings(); + self.theme_preference = settings.theme_mode; } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, self.current_app_context(), vec![("Networks", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, self.current_app_context(), RootScreenType::RootScreenNetworkChooser, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| self.render_network_table(ui)) @@ -2091,28 +1471,9 @@ impl ScreenLike for NetworkChooserScreen { } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - // Handle CoreClientReinitialized (from RPC password save) - if matches!( - &backend_task_success_result, - BackendTaskSuccessResult::CoreClientReinitialized - ) { - self.reinit_banner.take_and_clear(); - let save_failed = std::mem::take(&mut self.config_save_failed); - - if save_failed { - MessageBanner::set_global( - self.current_app_context().egui_ctx(), - "Could not save the configuration file. Your changes will apply for this session only.", - MessageType::Warning, - ); - } else { - MessageBanner::set_global( - self.current_app_context().egui_ctx(), - "Core RPC password saved successfully.", - MessageType::Success, - ); - } - } + // The post-DAPI-discovery SDK reinit (`CoreClientReinitialized`) needs + // no banner of its own — the discovery result already confirmed the + // refresh ("Updated to N node addresses."). // Handle DapiNodesDiscovered (from "Refresh DAPI endpoints" button) if let BackendTaskSuccessResult::DapiNodesDiscovered { @@ -2155,8 +1516,6 @@ impl ScreenLike for NetworkChooserScreen { } fn display_message(&mut self, _msg: &str, msg_type: MessageType) { - self.reinit_banner.take_and_clear(); - self.config_save_failed = false; // Only reset discovery state on errors — other message types may be unrelated if matches!(msg_type, MessageType::Error) && self.discovery_in_progress { self.discovery_in_progress = false; diff --git a/src/ui/wallets/account_summary.rs b/src/ui/state/account_summary.rs similarity index 52% rename from src/ui/wallets/account_summary.rs rename to src/ui/state/account_summary.rs index 9f1e7edc8..6f004b36e 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/state/account_summary.rs @@ -1,10 +1,9 @@ use std::collections::BTreeMap; -use dash_sdk::dpp::balances::credits::Credits; -use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::{Address, Network}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; -use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference, Wallet}; +use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum AccountCategory { @@ -184,6 +183,8 @@ pub(crate) fn categorize_account_path( AccountCategory::Bip32 } else if path.is_bip44(network) { AccountCategory::Bip44 + } else if path.is_platform_payment(network) { + AccountCategory::PlatformPayment } else { AccountCategory::from_reference(reference) }; @@ -201,8 +202,6 @@ pub struct AccountSummary { pub category: AccountCategory, pub index: Option<u32>, pub confirmed_balance: u64, - /// Platform credits balance for Platform Payment addresses - pub platform_credits: Credits, } #[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] @@ -214,7 +213,6 @@ struct AccountKey { struct AccountSummaryBuilder { key: AccountKey, confirmed_balance: u64, - platform_credits: Credits, } impl AccountSummaryBuilder { @@ -222,13 +220,11 @@ impl AccountSummaryBuilder { Self { key: AccountKey { category, index }, confirmed_balance: 0, - platform_credits: 0, } } - fn add_address(&mut self, balance: u64, platform_credits: Credits) { + fn add_address(&mut self, balance: u64) { self.confirmed_balance += balance; - self.platform_credits += platform_credits; } fn build(self) -> AccountSummary { @@ -236,37 +232,65 @@ impl AccountSummaryBuilder { category: self.key.category, index: self.key.index, confirmed_balance: self.confirmed_balance, - platform_credits: self.platform_credits, } } } -pub fn collect_account_summaries(wallet: &Wallet, network: Network) -> Vec<AccountSummary> { +/// Build per-account Core (chain) balance summaries for the wallet, sourced +/// entirely from the display-only `WalletBackend` snapshot — never the legacy +/// in-memory `Wallet` model. Platform credits are not part of this breakdown: +/// the Platform tab reads the single authoritative total +/// [`AppContext::platform_balance_duffs`](crate::context::AppContext::platform_balance_duffs) +/// directly, so its figure cannot diverge from the wallet header total. +/// +/// Two passes feed the per-category chain totals: +/// +/// 1. **Generated addresses** (`address_paths`) — every address the upstream +/// wallet has generated, categorized by derivation-path shape. This seeds the +/// per-category structure (so an unfunded account still renders a +/// zero-balance tab) and folds in each address's chain balance from +/// `address_balances`. +/// 2. **Authoritative funded addresses** — every funded address in +/// `address_balances` that `address_paths` does not cover (funds past DET's +/// bookkeeping window), bucketed as `Other(Unknown)`. This closes the desync +/// where such funds were dropped from the per-category totals. Each chain +/// balance is counted exactly once (pass 2 skips addresses pass 1 covered). +pub fn collect_account_summaries( + network: Network, + address_balances: &BTreeMap<Address, u64>, + address_paths: &BTreeMap<Address, DerivationPath>, +) -> Vec<AccountSummary> { let mut builders: BTreeMap<AccountKey, AccountSummaryBuilder> = BTreeMap::new(); - for (path, info) in &wallet.watched_addresses { - let (category, index) = categorize_account_path(path, network, info.path_reference); - - let balance = wallet - .address_balances - .get(&info.address) - .cloned() - .unwrap_or_default(); - - // Get Platform credits balance for Platform Payment addresses - // Use canonical lookup to handle potential Address key mismatches - let platform_credits = wallet - .get_platform_address_info(&info.address) - .map(|info| info.balance) - .unwrap_or_default(); - + // Pass 1: generated addresses seed the per-category structure and chain + // balances, categorized by derivation-path shape. + for (address, path) in address_paths { + let (category, index) = + categorize_account_path(path, network, DerivationPathReference::Unknown); + let balance = address_balances.get(address).copied().unwrap_or_default(); builders .entry(AccountKey { category: category.clone(), index, }) .or_insert_with(|| AccountSummaryBuilder::new(category, index)) - .add_address(balance, platform_credits); + .add_address(balance); + } + + // Pass 2: funded addresses the generated-path set does not cover, so no + // funded chain balance is dropped from the per-category totals. + for (address, &balance) in address_balances { + if balance == 0 || address_paths.contains_key(address) { + continue; + } + let category = AccountCategory::Other(DerivationPathReference::Unknown); + builders + .entry(AccountKey { + category: category.clone(), + index: None, + }) + .or_insert_with(|| AccountSummaryBuilder::new(category, None)) + .add_address(balance); } let mut summaries: Vec<_> = builders @@ -336,4 +360,200 @@ mod tests { categorize_account_path(&path, Network::Testnet, DerivationPathReference::Unknown); assert_ne!(category, AccountCategory::Bip44); } + + /// A P2PKH testnet address derived from a raw secret-key byte — a distinct, + /// valid address to stand in for a funded-but-unindexed chain address. + fn addr_from_byte(b: u8) -> Address { + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::secp256k1::{ + PublicKey as SecpPublicKey, Secp256k1, SecretKey, + }; + let secp = Secp256k1::new(); + let mut sk_bytes = [0u8; 32]; + sk_bytes[0] = if b == 0 { 1 } else { b }; + let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); + let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&secp, &sk).serialize()) + .expect("valid pubkey"); + Address::p2pkh(&pubkey, Network::Testnet) + } + + #[test] + fn platform_payment_path_shape_is_categorized_without_reference() { + use crate::model::wallet::DerivationPathHelpers; + // Snapshot-sourced paths carry no DET reference — the DIP-17 shape alone + // must categorize them, or a Core-funded platform address buckets wrong. + let path = DerivationPath::platform_payment_path(Network::Testnet, 0, 0, 3); + let (category, index) = + categorize_account_path(&path, Network::Testnet, DerivationPathReference::Unknown); + assert_eq!(category, AccountCategory::PlatformPayment); + assert_eq!(index, None); + } + + /// A BIP-44 external path `m/44'/1'/0'/0/idx` on testnet. + fn bip44_path(idx: u32) -> DerivationPath { + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: idx }, + ]) + } + + #[test] + fn funded_generated_addresses_sum_into_their_category() { + let a0 = addr_from_byte(41); + let a5 = addr_from_byte(42); + + let mut address_paths = BTreeMap::new(); + address_paths.insert(a0.clone(), bip44_path(0)); + address_paths.insert(a5.clone(), bip44_path(5)); + + let mut address_balances = BTreeMap::new(); + address_balances.insert(a0, 1_000u64); + address_balances.insert(a5, 5_000u64); + + let summaries = + collect_account_summaries(Network::Testnet, &address_balances, &address_paths); + + let core_total: u64 = summaries.iter().map(|s| s.confirmed_balance).sum(); + assert_eq!(core_total, 6_000); + + let bip44 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44) + .expect("bip44 summary"); + assert_eq!(bip44.confirmed_balance, 6_000); + } + + #[test] + fn funded_address_missing_from_paths_is_counted_once_as_other() { + // Funded, generated (in address_paths) address. + let generated = addr_from_byte(50); + // Funded address the generated-path set has not indexed yet. + let unindexed = addr_from_byte(51); + + let mut address_paths = BTreeMap::new(); + address_paths.insert(generated.clone(), bip44_path(0)); + + let mut address_balances = BTreeMap::new(); + address_balances.insert(generated, 1_000u64); + address_balances.insert(unindexed, 5_000u64); + + let summaries = + collect_account_summaries(Network::Testnet, &address_balances, &address_paths); + + // Neither balance is dropped, and the generated address is not + // double-counted by the authoritative pass. + let core_total: u64 = summaries.iter().map(|s| s.confirmed_balance).sum(); + assert_eq!(core_total, 6_000); + + let other = summaries + .iter() + .find(|s| s.category == AccountCategory::Other(DerivationPathReference::Unknown)) + .expect("other summary for the unindexed funded address"); + assert_eq!(other.confirmed_balance, 5_000); + } + + #[test] + fn generated_address_present_in_both_maps_is_counted_exactly_once() { + let shared = addr_from_byte(60); + + let mut address_paths = BTreeMap::new(); + address_paths.insert(shared.clone(), bip44_path(0)); + + let mut address_balances = BTreeMap::new(); + address_balances.insert(shared, 4_000u64); + + let summaries = + collect_account_summaries(Network::Testnet, &address_balances, &address_paths); + + let total: u64 = summaries.iter().map(|s| s.confirmed_balance).sum(); + assert_eq!( + total, 4_000, + "the shared address is counted once, not twice" + ); + } + + #[test] + fn two_funded_bip44_accounts_keep_distinct_per_account_totals() { + // Account #0: two receive addresses summing to 3.5 DASH. + let a0 = addr_from_byte(80); + let a1 = addr_from_byte(81); + // Account #1: one address holding 0.5 DASH. + let b0 = addr_from_byte(82); + let acct1_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ]); + + let mut address_paths = BTreeMap::new(); + address_paths.insert(a0.clone(), bip44_path(0)); + address_paths.insert(a1.clone(), bip44_path(1)); + address_paths.insert(b0.clone(), acct1_path); + + let mut address_balances = BTreeMap::new(); + address_balances.insert(a0, 250_000_000u64); + address_balances.insert(a1, 100_000_000u64); + address_balances.insert(b0, 50_000_000u64); + + let summaries = + collect_account_summaries(Network::Testnet, &address_balances, &address_paths); + + let acct0 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44 && s.index == Some(0)) + .expect("account #0 summary"); + assert_eq!( + acct0.confirmed_balance, 350_000_000, + "3.5 DASH in account #0" + ); + + let acct1 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44 && s.index == Some(1)) + .expect("account #1 summary"); + assert_eq!( + acct1.confirmed_balance, 50_000_000, + "0.5 DASH in account #1" + ); + + // The per-account Core totals sum to 4.0 DASH. This pins the + // per-account *arithmetic* of `collect_account_summaries` only — that it + // splits funds by account and drops nothing. Agreement with the real + // wallet-header total (`WalletBackend::wallet_balance().total`) is proven + // separately, against the real accessor, in + // `snapshot::tests::header_total_reconciles_with_core_tab_breakdown_through_real_accessors`. + let core_total: u64 = summaries.iter().map(|s| s.confirmed_balance).sum(); + assert_eq!(core_total, 400_000_000); + } + + #[test] + fn unfunded_generated_account_still_gets_a_zero_balance_summary() { + // A generated but unfunded BIP-44 account #1 must still surface a tab. + let acct1_addr = addr_from_byte(70); + let acct1_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ]); + + let mut address_paths = BTreeMap::new(); + address_paths.insert(acct1_addr, acct1_path); + let address_balances = BTreeMap::new(); + + let summaries = + collect_account_summaries(Network::Testnet, &address_balances, &address_paths); + + let acct1 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44 && s.index == Some(1)) + .expect("unfunded BIP44 account #1 summary"); + assert_eq!(acct1.confirmed_balance, 0); + } } diff --git a/src/ui/state/avatar_cache.rs b/src/ui/state/avatar_cache.rs new file mode 100644 index 000000000..758df5d5d --- /dev/null +++ b/src/ui/state/avatar_cache.rs @@ -0,0 +1,249 @@ +//! Per-screen cache of contact/profile avatar images, keyed by URL. +//! +//! Avatar images are fetched over the network (and served from the DET avatar +//! disk cache) behind an async accessor that must not run on the egui frame +//! loop. Screens fetch them through the App Task System +//! ([`DashPayTask::FetchAvatar`]) and render from this cache via the +//! [`Avatar`](crate::ui::components::avatar::Avatar) component. +//! +//! Each URL moves through `NotRequested → Loading → Fetched → Ready`, with +//! `Failed` as the terminal error state. A fetch is dispatched only from +//! `NotRequested`, so neither a slow fetch, a broken URL, nor a decode failure +//! re-dispatches every frame. The `Fetched → Ready` step is the UI thread +//! decoding the bytes into a GPU texture on first paint; the raw bytes are +//! dropped once the texture exists. +//! +//! [`DashPayTask::FetchAvatar`]: crate::backend_task::dashpay::DashPayTask::FetchAvatar + +use crate::backend_task::BackendTask; +use crate::backend_task::dashpay::DashPayTask; +use egui::TextureHandle; +use std::collections::BTreeMap; + +/// Per-URL fetch state. The absence of an entry is the implicit `NotRequested` +/// state — only `NotRequested` dispatches a fetch. +enum FetchState { + /// A fetch has been dispatched and no result has arrived yet. + Loading, + /// The fetch completed; holds the validated image bytes awaiting decode. + Fetched(Vec<u8>), + /// The bytes were decoded and uploaded; the raw bytes have been dropped. + Ready(TextureHandle), + /// The fetch or decode failed. Does not auto-redispatch; the user re-arms + /// it explicitly via [`AvatarCache::invalidate`]. + Failed, +} + +/// Cached avatar images keyed by URL, fetched via backend tasks and decoded to +/// GPU textures on demand. +/// +/// A fetch is dispatched only from `NotRequested` (the retry-storm guard): an +/// empty, failed, or slow fetch never re-dispatches every frame. +/// [`Self::invalidate`] returns every URL to `NotRequested` so an explicit +/// refresh re-fetches. +#[derive(Default)] +pub struct AvatarCache { + states: BTreeMap<String, FetchState>, +} + +impl AvatarCache { + pub fn new() -> Self { + Self::default() + } + + /// Returns the backend task to dispatch when `url` is `NotRequested`, or + /// `None` once it is `Loading`, `Fetched`, `Ready`, or `Failed`. The caller + /// dispatches the returned task as an + /// [`AppAction::BackendTask`](crate::app::AppAction::BackendTask). + pub fn ensure_requested(&mut self, url: &str) -> Option<BackendTask> { + if url.is_empty() || self.states.contains_key(url) { + return None; + } + self.states.insert(url.to_string(), FetchState::Loading); + Some(BackendTask::DashPayTask(Box::new( + DashPayTask::FetchAvatar { + url: url.to_string(), + }, + ))) + } + + /// Seed the cache with already-known bytes (e.g. a profile's locally-stored + /// avatar), moving `url` straight to `Fetched` so no network fetch is + /// dispatched. No-op if the URL is already tracked. + pub fn seed(&mut self, url: &str, bytes: Vec<u8>) { + if url.is_empty() { + return; + } + self.states + .entry(url.to_string()) + .or_insert(FetchState::Fetched(bytes)); + } + + /// Record a completed fetch: `Some(bytes) → Fetched`, `None → Failed`. A + /// URL already `Ready` keeps its uploaded texture (a duplicate result never + /// discards a live texture). + pub fn store(&mut self, url: String, bytes: Option<Vec<u8>>) { + if matches!(self.states.get(&url), Some(FetchState::Ready(_))) { + return; + } + let state = match bytes { + Some(bytes) => FetchState::Fetched(bytes), + None => FetchState::Failed, + }; + self.states.insert(url, state); + } + + /// The fetched-but-not-yet-decoded bytes for `url`, present only in the + /// `Fetched` state. The [`Avatar`](crate::ui::components::avatar::Avatar) + /// component decodes these and calls [`Self::set_ready`] / [`Self::mark_failed`]. + pub fn fetched_bytes(&self, url: &str) -> Option<&[u8]> { + match self.states.get(url) { + Some(FetchState::Fetched(bytes)) => Some(bytes), + _ => None, + } + } + + /// Promote `url` to `Ready` with its uploaded texture, dropping the raw bytes. + pub fn set_ready(&mut self, url: &str, texture: TextureHandle) { + self.states + .insert(url.to_string(), FetchState::Ready(texture)); + } + + /// The uploaded texture for `url` once `Ready`, else `None`. + pub fn ready_texture(&self, url: &str) -> Option<&TextureHandle> { + match self.states.get(url) { + Some(FetchState::Ready(texture)) => Some(texture), + _ => None, + } + } + + /// Move `url` to `Failed` (fetch produced undecodable bytes). Does not + /// re-dispatch until [`Self::invalidate`] re-arms it. + pub fn mark_failed(&mut self, url: &str) { + self.states.insert(url.to_string(), FetchState::Failed); + } + + /// Whether `url`'s fetch failed. Drives the fallback avatar. + pub fn is_failed(&self, url: &str) -> bool { + matches!(self.states.get(url), Some(FetchState::Failed)) + } + + /// Return every URL to `NotRequested` so the next render re-fetches. Used by + /// an explicit refresh and the identity-change reset — a changed avatar at a + /// stable URL repaints because its texture is dropped here. + pub fn invalidate(&mut self) { + self.states.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const URL_A: &str = "https://example.com/a.png"; + const URL_B: &str = "https://example.com/b.png"; + + /// The fetch fires exactly once per URL: the first `ensure_requested` yields + /// a task, and every later call for the same URL yields `None` — even before + /// a result arrives and even after a failure. This is the retry-storm guard. + #[test] + fn ensure_requested_fires_once_per_url() { + let mut cache = AvatarCache::new(); + + assert!( + cache.ensure_requested(URL_A).is_some(), + "first request dispatches" + ); + assert!( + cache.ensure_requested(URL_A).is_none(), + "a second request before the result must not re-dispatch" + ); + + cache.store(URL_A.to_string(), None); // failed + assert!(cache.is_failed(URL_A)); + assert!( + cache.ensure_requested(URL_A).is_none(), + "a failed fetch must not trigger a retry storm" + ); + } + + /// An empty URL never dispatches — there is nothing to fetch. + #[test] + fn empty_url_never_dispatches() { + let mut cache = AvatarCache::new(); + assert!(cache.ensure_requested("").is_none()); + } + + /// Distinct URLs fetch independently. + #[test] + fn urls_fetch_independently() { + let mut cache = AvatarCache::new(); + cache.ensure_requested(URL_A); + cache.store(URL_A.to_string(), Some(vec![1, 2, 3])); + + assert!( + cache.ensure_requested(URL_B).is_some(), + "a different URL must dispatch its own fetch" + ); + assert_eq!( + cache.fetched_bytes(URL_A), + Some(&[1, 2, 3][..]), + "the first URL's bytes must remain available" + ); + } + + /// A successful result exposes its bytes for decoding. + #[test] + fn store_some_exposes_fetched_bytes() { + let mut cache = AvatarCache::new(); + cache.ensure_requested(URL_A); + cache.store(URL_A.to_string(), Some(vec![9, 9])); + assert_eq!(cache.fetched_bytes(URL_A), Some(&[9, 9][..])); + assert!(!cache.is_failed(URL_A)); + } + + /// `seed` moves a URL straight to `Fetched`, suppressing the network fetch. + #[test] + fn seed_suppresses_fetch() { + let mut cache = AvatarCache::new(); + cache.seed(URL_A, vec![7]); + assert_eq!(cache.fetched_bytes(URL_A), Some(&[7][..])); + assert!( + cache.ensure_requested(URL_A).is_none(), + "a seeded URL must not dispatch a fetch" + ); + } + + /// `mark_failed` moves a fetched URL to the terminal failed state. + #[test] + fn mark_failed_is_terminal_until_invalidate() { + let mut cache = AvatarCache::new(); + cache.ensure_requested(URL_A); + cache.store(URL_A.to_string(), Some(vec![0])); + cache.mark_failed(URL_A); // e.g. undecodable bytes + assert!(cache.is_failed(URL_A)); + assert!(cache.fetched_bytes(URL_A).is_none()); + assert!( + cache.ensure_requested(URL_A).is_none(), + "a failed URL must not auto-redispatch" + ); + + cache.invalidate(); + assert!( + cache.ensure_requested(URL_A).is_some(), + "invalidate must re-arm the fetch" + ); + } + + /// A duplicate `store` after a texture is `Ready` must not clobber it. + /// (`set_ready` needs an egui context, so this asserts the guard via the + /// public state machine: once fetched bytes are gone, a repeat success with + /// new bytes still replaces them — but a Ready entry is protected.) + #[test] + fn store_after_fetched_replaces_bytes() { + let mut cache = AvatarCache::new(); + cache.store(URL_A.to_string(), Some(vec![1])); + cache.store(URL_A.to_string(), Some(vec![2, 2])); + assert_eq!(cache.fetched_bytes(URL_A), Some(&[2, 2][..])); + } +} diff --git a/src/ui/state/global_nav.rs b/src/ui/state/global_nav.rs new file mode 100644 index 000000000..816fe44de --- /dev/null +++ b/src/ui/state/global_nav.rs @@ -0,0 +1,272 @@ +//! Page-scoped global-nav model: which breadcrumb pills a root page composes, +//! how each pill participates, and the page-scoped object selection kept +//! distinct from the app-global user-identity selection. +//! +//! Renders nothing (module-placement discriminator → `ui/state`). The +//! `global_nav_switcher` component reads a [`PageNavSpec`] and renders it. +//! +//! FR-6 boundary: [`IdentityPillScope::PageScopedObject`] carries its own +//! selection and never writes `AppContext::selected_identity_id` — the switcher +//! maps it to a distinct `SelectPageObject` effect, not `SelectIdentity`. + +use crate::ui::RootScreenType; +use dash_sdk::platform::Identifier; + +/// One selectable object in a page-scoped pill dropdown, e.g. a loaded +/// masternode/evonode identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageObjectItem { + /// Identity id of the object (the masternode/evonode identity). + pub id: Identifier, + /// Display label for the dropdown row and the pill when this item is active. + pub label: String, +} + +/// How a breadcrumb pill participates on a given page (FR-GLOBAL-NAV-2 rule 3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PillConsumption { + /// The page consumes this selection — render interactive, two-way bound. + Consumed, + /// Not yet wired on this page — render subdued (dimmed, no caret, no text + /// tag) with a hover tooltip telling the user how to change the selection. + Unwired { tooltip: String }, +} + +impl PillConsumption { + /// Whether the page consumes this selection (interactive pill). + pub fn is_consumed(&self) -> bool { + matches!(self, Self::Consumed) + } + + /// The how-to-change tooltip for an unwired pill; `None` when consumed. + pub fn tooltip(&self) -> Option<&str> { + match self { + Self::Unwired { tooltip } => Some(tooltip), + Self::Consumed => None, + } + } +} + +/// What the third breadcrumb pill represents on a page (FR-GLOBAL-NAV-3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityPillScope { + /// The app-global User identity (`AppContext::selected_identity_id`). The + /// switcher reads/writes the app-scoped selection for this variant. + AppGlobalUser, + /// A page-scoped object in view (the masternode/evonode on the Masternodes + /// page). This variant **never** writes `AppContext::selected_identity_id`; + /// the switcher maps its selection to `SelectPageObject`. This is the + /// structural FR-6 boundary in code. + PageScopedObject { + /// Label shown when nothing is selected, e.g. `(no masternode yet)`. + placeholder: String, + /// The objects the dropdown offers. + items: Vec<PageObjectItem>, + /// The currently selected object, if any. + selected: Option<Identifier>, + }, +} + +impl IdentityPillScope { + /// Build a page-scoped object scope with the given placeholder and items. + pub fn page_scoped_object( + placeholder: impl Into<String>, + items: Vec<PageObjectItem>, + selected: Option<Identifier>, + ) -> Self { + Self::PageScopedObject { + placeholder: placeholder.into(), + items, + selected, + } + } + + /// Whether this scope is page-scoped (and therefore never touches the + /// app-global identity selection). + pub fn is_page_scoped(&self) -> bool { + matches!(self, Self::PageScopedObject { .. }) + } + + /// The page-scoped selection, if any. Always `None` for [`AppGlobalUser`], + /// which resolves its selection from `AppContext`, not from this scope — a + /// page-scoped object is never the app-global identity (FR-6 boundary). + /// + /// [`AppGlobalUser`]: IdentityPillScope::AppGlobalUser + pub fn page_scoped_selection(&self) -> Option<Identifier> { + match self { + Self::PageScopedObject { selected, .. } => *selected, + Self::AppGlobalUser => None, + } + } +} + +/// Per-page global-nav composition: the page-aware segment-1 (label + link +/// target) and which pills the page shows / how each participates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageNavSpec { + segment1_label: String, + segment1_target: RootScreenType, + wallet_pill: Option<PillConsumption>, + identity_pill: Option<(IdentityPillScope, PillConsumption)>, +} + +impl PageNavSpec { + /// A spec with the given page-aware segment-1 and no pills yet — compose + /// pills via [`with_wallet_pill`](Self::with_wallet_pill) and + /// [`with_identity_pill`](Self::with_identity_pill). + pub fn new(segment1_label: impl Into<String>, segment1_target: RootScreenType) -> Self { + Self { + segment1_label: segment1_label.into(), + segment1_target, + wallet_pill: None, + identity_pill: None, + } + } + + /// A fully-unwired everyday-page spec (the Phase-A rollout default): both + /// the wallet pill and the app-global identity pill render subdued with + /// how-to-change tooltips. Pages with no identity context use + /// [`new`](Self::new) + [`with_wallet_pill`](Self::with_wallet_pill) alone. + pub fn unwired_everyday( + segment1_label: impl Into<String>, + segment1_target: RootScreenType, + wallet_tooltip: impl Into<String>, + identity_tooltip: impl Into<String>, + ) -> Self { + Self::new(segment1_label, segment1_target) + .with_wallet_pill(PillConsumption::Unwired { + tooltip: wallet_tooltip.into(), + }) + .with_identity_pill( + IdentityPillScope::AppGlobalUser, + PillConsumption::Unwired { + tooltip: identity_tooltip.into(), + }, + ) + } + + /// Add the wallet pill with the given participation mode. + pub fn with_wallet_pill(mut self, consumption: PillConsumption) -> Self { + self.wallet_pill = Some(consumption); + self + } + + /// Add the third (identity/object) pill with its scope and participation. + pub fn with_identity_pill( + mut self, + scope: IdentityPillScope, + consumption: PillConsumption, + ) -> Self { + self.identity_pill = Some((scope, consumption)); + self + } + + /// The page-aware segment-1 label (e.g. `Masternodes`). + pub fn segment1_label(&self) -> &str { + &self.segment1_label + } + + /// The root screen segment-1 links to. + pub fn segment1_target(&self) -> RootScreenType { + self.segment1_target + } + + /// The wallet-pill participation, or `None` if the page shows no wallet pill. + pub fn wallet_pill(&self) -> Option<&PillConsumption> { + self.wallet_pill.as_ref() + } + + /// The third-pill scope + participation, or `None` if the page shows no + /// identity/object pill (per-page composition — FR-GLOBAL-NAV-2 rule 4). + pub fn identity_pill(&self) -> Option<&(IdentityPillScope, PillConsumption)> { + self.identity_pill.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::new([byte; 32]) + } + + /// Foundation for TC-NAV-01 — segment-1 is page-driven (label + target). + #[test] + fn segment1_is_page_aware() { + let spec = PageNavSpec::new("Masternodes", RootScreenType::RootScreenIdentityHub); + assert_eq!(spec.segment1_label(), "Masternodes"); + assert_eq!( + spec.segment1_target(), + RootScreenType::RootScreenIdentityHub + ); + } + + /// TC-NAV-15 — a page with no identity/object context shows only the wallet + /// pill; there is no third segment at all. + #[test] + fn wallet_only_page_has_no_identity_pill() { + let spec = PageNavSpec::new("Wallets", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed); + assert!(spec.wallet_pill().is_some()); + assert!(spec.identity_pill().is_none()); + } + + /// TC-NAV-13 / TC-NAV-14 — an unwired pill's participation carries a + /// non-empty how-to-change tooltip; a consumed pill carries none. + #[test] + fn unwired_pill_carries_nonempty_tooltip() { + let unwired = PillConsumption::Unwired { + tooltip: "Change the active wallet from the Wallets tab.".to_string(), + }; + assert!(!unwired.is_consumed()); + assert_eq!( + unwired.tooltip(), + Some("Change the active wallet from the Wallets tab.") + ); + + let consumed = PillConsumption::Consumed; + assert!(consumed.is_consumed()); + assert!(consumed.tooltip().is_none()); + } + + /// TC-FR6-07 foundation — the page-scoped object selection is isolated from + /// the app-global identity: `PageScopedObject` exposes its own selection, + /// `AppGlobalUser` never does. + #[test] + fn page_scoped_object_is_isolated_from_app_global() { + let scope = IdentityPillScope::page_scoped_object( + "(no masternode yet)", + vec![PageObjectItem { + id: id(7), + label: "mn-east-01".to_string(), + }], + Some(id(7)), + ); + assert!(scope.is_page_scoped()); + assert_eq!(scope.page_scoped_selection(), Some(id(7))); + + let app_global = IdentityPillScope::AppGlobalUser; + assert!(!app_global.is_page_scoped()); + assert_eq!(app_global.page_scoped_selection(), None); + } + + /// The Phase-A rollout default composes both pills subdued with tooltips. + #[test] + fn unwired_everyday_composes_both_subdued() { + let spec = PageNavSpec::unwired_everyday( + "Contracts", + RootScreenType::RootScreenDocumentQuery, + "Change the active wallet from the Wallets tab.", + "Change the active identity from the Identity Hub.", + ); + let wallet = spec.wallet_pill().expect("wallet pill present"); + assert!(!wallet.is_consumed()); + assert!(wallet.tooltip().is_some_and(|t| !t.is_empty())); + + let (scope, consumption) = spec.identity_pill().expect("identity pill present"); + assert_eq!(*scope, IdentityPillScope::AppGlobalUser); + assert!(!consumption.is_consumed()); + assert!(consumption.tooltip().is_some_and(|t| !t.is_empty())); + } +} diff --git a/src/ui/state/hub_selection.rs b/src/ui/state/hub_selection.rs new file mode 100644 index 000000000..30a704f0e --- /dev/null +++ b/src/ui/state/hub_selection.rs @@ -0,0 +1,133 @@ +//! Identities-hub view state that renders nothing. +//! +//! The active identity itself is app-scoped (`AppContext::selected_identity_id`), +//! so this holds only the within-session view concerns the breadcrumb switcher +//! owns: whether the user explicitly opened the picker, and the inline-search +//! buffers for the wallet / identity dropdowns. Per the module-placement +//! discriminator (renders nothing → `ui/state`). + +/// Which hub surface to render, derived from the loaded-identity count, the +/// explicit app-scoped selection, and the picker override. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HubView { + /// No identities loaded — first-run onboarding. + Onboarding, + /// Identity picker grid (choose-an-identity). + Picker, + /// A single identity's home (the resolved active identity). + Home, +} + +/// Resolve the hub surface. `has_explicit_active` is whether the app-scoped +/// selection points at a still-loaded identity (the *raw* selection, not the +/// first-identity fallback); `picker_override` is set when the user clicked the +/// `Identities` crumb to deliberately open the picker. +pub fn effective_view( + loaded_count: usize, + has_explicit_active: bool, + picker_override: bool, +) -> HubView { + if loaded_count == 0 { + HubView::Onboarding + } else if picker_override { + HubView::Picker + } else if has_explicit_active || loaded_count == 1 { + // Explicit choice, or a lone identity to auto-select. + HubView::Home + } else { + // Two or more identities and none chosen — choose one first. + HubView::Picker + } +} + +/// Within-session hub view state. Renders nothing. +#[derive(Default)] +pub struct HubSelection { + /// The user explicitly opened the picker (via the `Identities` crumb) even + /// though an identity is active. Cleared when an identity is chosen. + picker_override: bool, + /// Inline-search buffer for the wallet dropdown; live only while open. + wallet_search: String, + /// Inline-search buffer for the identity dropdown; live only while open. + identity_search: String, +} + +impl HubSelection { + /// Whether the user has explicitly opened the picker. + pub fn picker_override(&self) -> bool { + self.picker_override + } + + /// Open the picker (the `Identities` crumb click). + pub fn open_picker(&mut self) { + self.picker_override = true; + } + + /// Clear the picker override — call when an identity is chosen. + pub fn clear_picker_override(&mut self) { + self.picker_override = false; + } + + pub fn wallet_search_mut(&mut self) -> &mut String { + &mut self.wallet_search + } + + pub fn identity_search_mut(&mut self) -> &mut String { + &mut self.identity_search + } + + pub fn wallet_search(&self) -> &str { + &self.wallet_search + } + + pub fn identity_search(&self) -> &str { + &self.identity_search + } + + /// Clear both inline-search buffers (on popup close). + pub fn clear_searches(&mut self) { + self.wallet_search.clear(); + self.identity_search.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_picker_sets_and_clear_resets_override() { + let mut s = HubSelection::default(); + assert!(!s.picker_override()); + s.open_picker(); + assert!(s.picker_override()); + s.clear_picker_override(); + assert!(!s.picker_override()); + } + + #[test] + fn clear_searches_empties_both_buffers() { + let mut s = HubSelection::default(); + s.wallet_search_mut().push_str("abc"); + s.identity_search_mut().push_str("xyz"); + s.clear_searches(); + assert!(s.wallet_search().is_empty()); + assert!(s.identity_search().is_empty()); + } + + #[test] + fn effective_view_state_machine() { + // No identities → onboarding regardless of the other inputs. + assert_eq!(effective_view(0, false, false), HubView::Onboarding); + assert_eq!(effective_view(0, true, true), HubView::Onboarding); + // Explicit picker override wins when identities exist. + assert_eq!(effective_view(1, true, true), HubView::Picker); + assert_eq!(effective_view(3, true, true), HubView::Picker); + // One identity, none explicitly chosen → auto Home. + assert_eq!(effective_view(1, false, false), HubView::Home); + // An explicit active identity → Home. + assert_eq!(effective_view(3, true, false), HubView::Home); + // Two or more, none chosen → Picker. + assert_eq!(effective_view(2, false, false), HubView::Picker); + } +} diff --git a/src/ui/state/masternodes_view.rs b/src/ui/state/masternodes_view.rs new file mode 100644 index 000000000..c0973360d --- /dev/null +++ b/src/ui/state/masternodes_view.rs @@ -0,0 +1,55 @@ +//! 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). +//! +//! 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. + +use crate::ui::RootScreenType; +use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; + +/// 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 { + PageNavSpec::new("Masternodes", RootScreenType::RootScreenMasternodes) + .with_wallet_pill(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. + #[test] + fn spec_has_segment1_and_wallet_pill_but_no_object_pill() { + let spec = masternodes_page_nav_spec(); + 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. + assert!( + spec.identity_pill().is_none(), + "the Masternodes breadcrumb must carry no object/identity pill", + ); + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs new file mode 100644 index 000000000..0d6a64f97 --- /dev/null +++ b/src/ui/state/mod.rs @@ -0,0 +1,16 @@ +//! Non-widget UI state: per-screen view-models and async fetch-state caches. +//! +//! Modules here own no egui rendering surface — they are neither `Component` +//! nor `ComponentResponse`. Screens own them and may dispatch `BackendTask` +//! through them; the module placement policy (P14) keeps these out of +//! `ui/components/`, which is reserved for renderable widget types. + +pub mod account_summary; +pub mod avatar_cache; +pub mod global_nav; +pub mod hub_selection; +pub mod masternodes_view; +pub mod tracked_asset_lock_cache; + +pub use avatar_cache::AvatarCache; +pub use tracked_asset_lock_cache::TrackedAssetLockCache; diff --git a/src/ui/state/tracked_asset_lock_cache.rs b/src/ui/state/tracked_asset_lock_cache.rs new file mode 100644 index 000000000..e1f9203ab --- /dev/null +++ b/src/ui/state/tracked_asset_lock_cache.rs @@ -0,0 +1,298 @@ +//! Per-screen cache of wallets' tracked asset locks. +//! +//! Tracked asset locks live behind the wallet backend's async accessor, which +//! must not be driven from the egui frame loop. Screens fetch them through the +//! App Task System ([`WalletTask::ListTrackedAssetLocks`]) and render from this +//! cache. Entries are keyed by `WalletSeedHash`, so a screen that lists several +//! wallets (e.g. the top-up funding-method gate) fetches each independently. +//! +//! [`WalletTask::ListTrackedAssetLocks`]: crate::backend_task::wallet::WalletTask::ListTrackedAssetLocks + +use crate::backend_task::BackendTask; +use crate::backend_task::wallet::WalletTask; +use crate::model::wallet::WalletSeedHash; +use platform_wallet::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use std::collections::BTreeMap; + +/// Per-wallet fetch state. The absence of an entry is the implicit +/// `NotRequested` state — only `NotRequested` dispatches a fetch. +enum FetchState { + /// A fetch has been dispatched and no result has arrived yet. + Loading, + /// The fetch completed; holds the wallet's tracked asset locks. + Loaded(Vec<TrackedAssetLock>), + /// The fetch failed. Does not auto-redispatch; the user retries explicitly. + Failed, +} + +/// Cached tracked asset locks keyed by wallet, fetched via backend tasks. +/// +/// Each wallet moves through `NotRequested → Loading → Loaded | Failed`. A fetch +/// is dispatched only from `NotRequested`, so neither an empty result, a slow +/// fetch, nor a failure re-dispatches every frame (the F94 retry-storm guard). +/// [`Self::invalidate`] / [`Self::invalidate_one`] return a wallet to +/// `NotRequested` to re-arm a fetch (refresh and the Retry affordance). +#[derive(Default)] +pub struct TrackedAssetLockCache { + states: BTreeMap<WalletSeedHash, FetchState>, +} + +impl TrackedAssetLockCache { + pub fn new() -> Self { + Self::default() + } + + /// Returns the backend task to dispatch when this wallet is `NotRequested`, + /// or `None` once it is `Loading`, `Loaded`, or `Failed`. The caller + /// dispatches the returned task as an + /// [`AppAction::BackendTask`](crate::app::AppAction::BackendTask). + pub fn ensure_requested(&mut self, seed_hash: WalletSeedHash) -> Option<BackendTask> { + if self.states.contains_key(&seed_hash) { + return None; + } + self.states.insert(seed_hash, FetchState::Loading); + Some(BackendTask::WalletTask(WalletTask::ListTrackedAssetLocks { + seed_hash, + })) + } + + /// Marks every `NotRequested` wallet in `seed_hashes` as `Loading` and + /// returns one fetch task per newly-requested wallet. Use this when a screen + /// reads several wallets at once (e.g. a funding-method gate) so all fetches + /// dispatch together as a single + /// [`AppAction::BackendTasks`](crate::app::AppAction::BackendTasks) — a loop + /// of `ensure_requested` would lose all but the last task because + /// `AppAction`'s `|=` keeps only the most recent value. + pub fn ensure_requested_many( + &mut self, + seed_hashes: impl IntoIterator<Item = WalletSeedHash>, + ) -> Vec<BackendTask> { + seed_hashes + .into_iter() + .filter_map(|seed_hash| self.ensure_requested(seed_hash)) + .collect() + } + + /// Record a completed fetch for one wallet (`→ Loaded`). + pub fn store(&mut self, seed_hash: WalletSeedHash, locks: Vec<TrackedAssetLock>) { + self.states.insert(seed_hash, FetchState::Loaded(locks)); + } + + /// Move every wallet currently `Loading` to `Failed`. The error + /// `TaskResult` carries no `seed_hash`, so the screen's error handler routes + /// here; a single lock fetch is normally in flight, so attribution is + /// correct. Worst case an unrelated error flips a concurrent fetch to a + /// retryable "couldn't load" state — graceful, no data loss. + pub fn mark_loading_failed(&mut self) { + for state in self.states.values_mut() { + if matches!(state, FetchState::Loading) { + *state = FetchState::Failed; + } + } + } + + /// Return one wallet to `NotRequested` so the next render re-dispatches its + /// fetch (the Retry affordance). + pub fn invalidate_one(&mut self, seed_hash: &WalletSeedHash) { + self.states.remove(seed_hash); + } + + /// Return all wallets to `NotRequested` so the next render re-fetches every + /// wallet (explicit refresh). + pub fn invalidate(&mut self) { + self.states.clear(); + } + + /// Whether the wallet's fetch is in flight (`Loading`). Drives the + /// "Loading asset locks…" state. + pub fn is_loading(&self, seed_hash: &WalletSeedHash) -> bool { + matches!(self.states.get(seed_hash), Some(FetchState::Loading)) + } + + /// Whether the wallet's fetch failed (`Failed`). Drives the retryable + /// "couldn't load" state. + pub fn is_failed(&self, seed_hash: &WalletSeedHash) -> bool { + matches!(self.states.get(seed_hash), Some(FetchState::Failed)) + } + + /// The cached locks for `seed_hash` once `Loaded`, or `None` in every other + /// state. + pub fn get(&self, seed_hash: &WalletSeedHash) -> Option<&[TrackedAssetLock]> { + match self.states.get(seed_hash) { + Some(FetchState::Loaded(locks)) => Some(locks), + _ => None, + } + } + + /// Whether the wallet has at least one still-actionable tracked asset lock + /// (any status other than `Consumed`). Returns `false` in every state but + /// `Loaded`. + pub fn has_unused(&self, seed_hash: &WalletSeedHash) -> bool { + self.get(seed_hash).is_some_and(|locks| { + locks + .iter() + .any(|l| !matches!(l.status, AssetLockStatus::Consumed)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED_A: WalletSeedHash = [1u8; 32]; + const SEED_B: WalletSeedHash = [2u8; 32]; + + /// The fetch fires exactly once per wallet: the first `ensure_requested` + /// yields a task, and every subsequent call for the same wallet yields + /// `None` — even before any result arrives and even after an empty result. + /// This is the F94 retry-storm guard: an empty or failed fetch must not + /// re-dispatch every frame. + #[test] + fn ensure_requested_fires_once_per_wallet() { + let mut cache = TrackedAssetLockCache::new(); + + assert!( + cache.ensure_requested(SEED_A).is_some(), + "first request for a wallet must dispatch" + ); + assert!( + cache.is_loading(&SEED_A), + "no result yet means the loading state holds" + ); + assert!( + cache.ensure_requested(SEED_A).is_none(), + "a second request before the result must not re-dispatch" + ); + + // An empty result (the common case) must stop further dispatches. + cache.store(SEED_A, Vec::new()); + assert!( + cache.ensure_requested(SEED_A).is_none(), + "an empty result must not trigger a retry storm" + ); + assert!(!cache.is_loading(&SEED_A), "stored result clears loading"); + assert!(cache.get(&SEED_A).is_some_and(|l| l.is_empty())); + } + + /// Distinct wallets fetch independently — one being cached does not satisfy + /// or suppress the fetch of another. + #[test] + fn wallets_fetch_independently() { + let mut cache = TrackedAssetLockCache::new(); + cache.ensure_requested(SEED_A); + cache.store(SEED_A, Vec::new()); + + assert!( + cache.ensure_requested(SEED_B).is_some(), + "a different wallet must dispatch its own fetch" + ); + assert!( + cache.is_loading(&SEED_B), + "the second wallet is still loading until its result arrives" + ); + assert!( + cache.get(&SEED_A).is_some(), + "the first wallet's cache must remain available" + ); + } + + /// `ensure_requested_many` returns one task per not-yet-requested wallet and + /// marks them all, so a multi-wallet screen dispatches every fetch in one + /// batch. Already-requested wallets and duplicates are skipped. + #[test] + fn ensure_requested_many_batches_unrequested_wallets() { + let mut cache = TrackedAssetLockCache::new(); + cache.ensure_requested(SEED_A); // already requested + + let tasks = cache.ensure_requested_many([SEED_A, SEED_B, SEED_B]); + assert_eq!( + tasks.len(), + 1, + "only the new, de-duplicated wallet (SEED_B) yields a task" + ); + assert!( + cache.ensure_requested_many([SEED_A, SEED_B]).is_empty(), + "a second pass over the same wallets dispatches nothing" + ); + } + + /// `invalidate` clears every wallet's state so an explicit refresh re-fetches. + #[test] + fn invalidate_allows_refetch() { + let mut cache = TrackedAssetLockCache::new(); + cache.ensure_requested(SEED_A); + cache.store(SEED_A, Vec::new()); + assert!(cache.ensure_requested(SEED_A).is_none()); + + cache.invalidate(); + assert!( + cache.ensure_requested(SEED_A).is_some(), + "invalidate must allow the same wallet to re-fetch" + ); + } + + /// A failed fetch must not strand the screen on an infinite spinner. + /// From `Loading`, `mark_loading_failed` moves to `Failed`; the wallet then + /// reports failed (not loading) and does NOT re-dispatch — so the screen can + /// render a retryable "couldn't load" state instead of a stuck spinner. + #[test] + fn failed_fetch_is_terminal_until_retry() { + let mut cache = TrackedAssetLockCache::new(); + cache.ensure_requested(SEED_A); + assert!( + cache.is_loading(&SEED_A), + "precondition: the fetch is loading" + ); + + cache.mark_loading_failed(); + assert!( + cache.is_failed(&SEED_A), + "a failed fetch must report failed" + ); + assert!( + !cache.is_loading(&SEED_A), + "a failed fetch must NOT still report loading (no stuck spinner)" + ); + assert!( + cache.ensure_requested(SEED_A).is_none(), + "a failed fetch must not auto-redispatch (no retry storm)" + ); + + // The explicit Retry affordance re-arms the fetch. + cache.invalidate_one(&SEED_A); + assert!( + cache.ensure_requested(SEED_A).is_some(), + "invalidate_one must re-arm a failed wallet so Retry re-fetches" + ); + assert!( + cache.is_loading(&SEED_A), + "the retry puts the wallet back to loading" + ); + } + + /// `mark_loading_failed` only touches in-flight fetches: an already-`Loaded` + /// wallet keeps its data when an unrelated error fires. + #[test] + fn mark_loading_failed_spares_loaded_wallets() { + let mut cache = TrackedAssetLockCache::new(); + cache.ensure_requested(SEED_A); + cache.store(SEED_A, Vec::new()); // SEED_A is Loaded + cache.ensure_requested(SEED_B); // SEED_B is Loading + + cache.mark_loading_failed(); + + assert!( + cache.get(&SEED_A).is_some(), + "a Loaded wallet must keep its data when an unrelated fetch fails" + ); + assert!( + !cache.is_failed(&SEED_A), + "a Loaded wallet must not flip to failed" + ); + assert!( + cache.is_failed(&SEED_B), + "the in-flight fetch flips to failed" + ); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index eb971706c..716926a2a 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,14 +1,22 @@ use egui::{ Button, Color32, CursorIcon, FontFamily, FontId, RichText, Stroke, Ui, Vec2, WidgetText, }; +use std::sync::atomic::{AtomicBool, Ordering}; -/// Theme mode enumeration -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ThemeMode { - Light, - Dark, - #[default] - System, +pub use crate::model::settings::ThemeMode; + +use crate::model::qualified_identity::IdentityStatus; + +impl From<IdentityStatus> for Color32 { + fn from(value: IdentityStatus) -> Self { + match value { + IdentityStatus::Active => Color32::from_rgb(0, 128, 0), // Green + IdentityStatus::Unknown => Color32::from_rgb(128, 128, 128), // Gray + IdentityStatus::PendingCreation => Color32::from_rgb(255, 165, 0), // Orange + IdentityStatus::NotFound => Color32::from_rgb(255, 0, 0), // Red + IdentityStatus::FailedCreation => Color32::from_rgb(255, 0, 0), // Red + } + } } /// Detect system theme preference @@ -20,16 +28,35 @@ pub fn detect_system_theme() -> Result<ThemeMode, String> { } } +/// Latches so a persistent detection failure (e.g. no XDG portal on +/// headless Linux) is logged once instead of on every poll. Cleared on the +/// next successful detection so a later failure logs again. +static THEME_DETECTION_FAILURE_LOGGED: AtomicBool = AtomicBool::new(false); + +/// Returns `true` the first time it's called since the latch was last reset +/// by a successful detection, `false` on every subsequent call. +fn should_log_theme_detection_failure() -> bool { + !THEME_DETECTION_FAILURE_LOGGED.swap(true, Ordering::Relaxed) +} + /// Detect system theme, returning `None` only on detection errors. /// Use this for polling: a `None` means "keep the previous theme" rather than /// flipping to an arbitrary default. `Unspecified` maps to Light (common on /// Linux where `dark_light` often can't determine the theme). pub fn try_detect_system_theme() -> Option<ThemeMode> { match dark_light::detect() { - Ok(dark_light::Mode::Dark) => Some(ThemeMode::Dark), - Ok(dark_light::Mode::Light | dark_light::Mode::Unspecified) => Some(ThemeMode::Light), + Ok(dark_light::Mode::Dark) => { + THEME_DETECTION_FAILURE_LOGGED.store(false, Ordering::Relaxed); + Some(ThemeMode::Dark) + } + Ok(dark_light::Mode::Light | dark_light::Mode::Unspecified) => { + THEME_DETECTION_FAILURE_LOGGED.store(false, Ordering::Relaxed); + Some(ThemeMode::Light) + } Err(e) => { - tracing::debug!("OS theme detection failed: {e}"); + if should_log_theme_detection_failure() { + tracing::debug!("OS theme detection failed: {e}"); + } None } } @@ -46,7 +73,6 @@ pub fn resolve_theme_mode(preference: ThemeMode) -> ThemeMode { /// Dash brand colors according to official guidelines pub struct DashColors; -#[allow(dead_code)] impl DashColors { /// Primary Dash Blue (#008de4) pub const DASH_BLUE: Color32 = Color32::from_rgb(0, 141, 228); @@ -550,10 +576,20 @@ impl DashColors { } } +/// User-facing network label, stable across all screens. +pub fn network_label(network: dash_sdk::dashcore_rpc::dashcore::Network) -> &'static str { + use dash_sdk::dashcore_rpc::dashcore::Network; + match network { + Network::Mainnet => "Mainnet", + Network::Testnet => "Testnet", + Network::Devnet => "Devnet", + Network::Regtest => "Regtest", + } +} + /// Typography scale and font configuration pub struct Typography; -#[allow(dead_code)] impl Typography { pub const SCALE_XS: f32 = 12.0; pub const SCALE_SM: f32 = 14.0; @@ -616,7 +652,6 @@ impl Typography { /// Spacing constants for consistent layout pub struct Spacing; -#[allow(dead_code)] impl Spacing { pub const XXS: f32 = 2.0; pub const XS: f32 = 4.0; @@ -643,7 +678,6 @@ impl Spacing { /// Border radius and shape constants pub struct Shape; -#[allow(dead_code)] impl Shape { pub const RADIUS_NONE: u8 = 0; pub const RADIUS_SM: u8 = 6; @@ -659,7 +693,6 @@ impl Shape { /// Modern shadow definitions for depth and visual appeal pub struct Shadow; -#[allow(dead_code)] impl Shadow { pub fn small() -> egui::Shadow { egui::Shadow { @@ -722,7 +755,6 @@ impl Shadow { /// Component style definitions pub struct ComponentStyles; -#[allow(dead_code)] impl ComponentStyles { /// Standard minimum size for dialog buttons (width × height) pub const DIALOG_BUTTON_MIN_SIZE: Vec2 = Vec2::new(96.0, 36.0); @@ -811,7 +843,7 @@ impl ComponentStyles { .clone() .strong() .color(Self::primary_button_text()), - // INTENTIONAL(CMT-010): LayoutJob/Galley variants not used by any callsite + // LayoutJob/Galley variants are not used by any callsite. other => RichText::new(other.text().to_string()) .strong() .color(Self::primary_button_text()), @@ -833,7 +865,7 @@ impl ComponentStyles { .clone() .strong() .color(Self::secondary_button_text(dark_mode)), - // INTENTIONAL(CMT-010): LayoutJob/Galley variants not used by any callsite + // LayoutJob/Galley variants are not used by any callsite. other => RichText::new(other.text().to_string()) .strong() .color(Self::secondary_button_text(dark_mode)), @@ -855,7 +887,7 @@ impl ComponentStyles { .clone() .strong() .color(Self::danger_button_text()), - // INTENTIONAL(CMT-010): LayoutJob/Galley variants not used by any callsite + // LayoutJob/Galley variants are not used by any callsite. other => RichText::new(other.text().to_string()) .strong() .color(Self::danger_button_text()), @@ -890,14 +922,14 @@ impl ComponentStyles { ui.add(Self::primary_button(label)) .on_hover_cursor(CursorIcon::PointingHand) } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let text = match label.into() { WidgetText::RichText(rt) => rt .as_ref() .clone() .strong() .color(Self::button_disabled_text(dark_mode)), - // INTENTIONAL(CMT-010): LayoutJob/Galley variants not used by any callsite + // LayoutJob/Galley variants are not used by any callsite. other => RichText::new(other.text().to_string()) .strong() .color(Self::button_disabled_text(dark_mode)), @@ -953,7 +985,7 @@ impl ComponentStyles { pub fn toolbar_button(label: impl Into<WidgetText>, fill: egui::Color32) -> Button<'static> { let text = match label.into() { WidgetText::RichText(rt) => rt.as_ref().clone().color(DashColors::WHITE), - // INTENTIONAL(CMT-010): LayoutJob/Galley variants not used by any callsite + // LayoutJob/Galley variants are not used by any callsite. other => RichText::new(other.text().to_string()).color(DashColors::WHITE), }; Button::new(text) @@ -1087,7 +1119,7 @@ pub fn apply_theme(ctx: &egui::Context, theme_mode: ThemeMode) { // Apply the custom visuals first ctx.set_visuals(visuals); - let mut style = (*ctx.style()).clone(); + let mut style = (*ctx.global_style()).clone(); // Configure modern visuals with gradients and glass effects // Override all background colors again to ensure they stick @@ -1175,5 +1207,35 @@ pub fn apply_theme(ctx: &egui::Context, theme_mode: ThemeMode) { // Don't override extreme_bg_color here - it should remain as input_background for TextEdit widgets style.visuals.faint_bg_color = DashColors::background(dark_mode); - ctx.set_style(style); + ctx.set_global_style(style); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_detection_failure_logs_once_until_reset() { + THEME_DETECTION_FAILURE_LOGGED.store(false, Ordering::Relaxed); + + assert!( + should_log_theme_detection_failure(), + "first failure should log" + ); + assert!( + !should_log_theme_detection_failure(), + "repeated failure should be suppressed" + ); + assert!( + !should_log_theme_detection_failure(), + "still suppressed while failure persists" + ); + + // A successful detection resets the latch. + THEME_DETECTION_FAILURE_LOGGED.store(false, Ordering::Relaxed); + assert!( + should_log_theme_detection_failure(), + "failure after a success should log again" + ); + } } diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index fb0dd4e70..0a3d46dc0 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -8,13 +8,13 @@ use dash_sdk::dpp::data_contract::associated_token::token_configuration_conventi use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::DataContract; use dash_sdk::platform::Identifier; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Ui}; use crate::ui::theme::ComponentStyles; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::contract::ContractTask; -use crate::database::contracts::InsertTokensToo; +use crate::model::qualified_contract::InsertTokensToo; use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -27,11 +27,21 @@ use crate::{ ui::{MessageType, ScreenLike, components::top_panel::add_top_panel}, }; +/// Which lookup is in flight, so `display_task_result` can disambiguate a +/// `ContractNotFound`: fall back to a token-ID search vs. report a genuine +/// missing-contract error. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum SearchKind { + ByContractId, + ByTokenId, + Saving, +} + /// UI state during the add-token flow. -#[derive(PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone)] enum AddTokenStatus { Idle, - Searching(u32), + Searching(SearchKind, u32), FoundSingle(Box<TokenInfo>), FoundMultiple(Vec<TokenInfo>), Error, @@ -79,7 +89,7 @@ impl AddTokenByIdScreen { .clicked() { let now = Utc::now().timestamp() as u32; - self.status = AddTokenStatus::Searching(now); + self.status = AddTokenStatus::Searching(SearchKind::ByContractId, now); if !self.contract_or_token_id_input.is_empty() { // Try to parse as identifier @@ -139,7 +149,8 @@ impl AddTokenByIdScreen { let insert_mode = InsertTokensToo::SomeTokensShouldBeAdded(vec![tok.token_position]); // Set status to show we're processing - self.status = AddTokenStatus::Searching(chrono::Utc::now().timestamp() as u32); + self.status = + AddTokenStatus::Searching(SearchKind::Saving, Utc::now().timestamp() as u32); // None for alias; change if you allow user alias input return AppAction::BackendTasks( @@ -259,32 +270,14 @@ impl AddTokenByIdScreen { } impl ScreenLike for AddTokenByIdScreen { - fn display_message(&mut self, msg: &str, msg_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. + fn display_message(&mut self, _msg: &str, msg_type: MessageType) { + // Status transitions are driven by typed results in `display_task_result`. + // React only to the message *type* here, never to its text. match msg_type { - MessageType::Success => { - if msg.contains("DataContract successfully saved") { - self.status = AddTokenStatus::Complete; - } else if msg.contains("Contract not found") { - // Contract not found, try as token ID - if let Ok(_identifier) = - Identifier::from_string(&self.contract_or_token_id_input, Encoding::Base58) - { - // We'll initiate a token ID search - self.try_token_id_next = true; - } else { - self.status = AddTokenStatus::Error; - } - } else if msg.contains("Token not found") - || msg.contains("Error fetching contracts") - { - self.status = AddTokenStatus::Error; - } - } MessageType::Error | MessageType::Warning => { self.status = AddTokenStatus::Error; } - MessageType::Info => {} + MessageType::Success | MessageType::Info => {} } } @@ -299,6 +292,36 @@ impl ScreenLike for AddTokenByIdScreen { ) => { self.handle_fetched_contract(contract, Some(token_position)); } + BackendTaskSuccessResult::ContractNotFound => { + if matches!( + self.status, + AddTokenStatus::Searching(SearchKind::ByContractId, _) + ) { + // The input was not a contract ID; try it as a token ID next + // frame (the search is dispatched from `ui()`). + self.try_token_id_next = true; + } else { + // A token was found but its contract is missing — a genuine + // data inconsistency, so we do not loop back into a search. + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This token was found, but its contract could not be loaded right now. Please try again in a little while.", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; + } + } + BackendTaskSuccessResult::TokenNotFound => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No token or contract was found for that ID. Double-check the ID and try again.", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; + } + BackendTaskSuccessResult::SavedContract => { + self.status = AddTokenStatus::Complete; + } _ => {} } } @@ -307,9 +330,9 @@ impl ScreenLike for AddTokenByIdScreen { // nothing to refresh } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -320,15 +343,15 @@ impl ScreenLike for AddTokenByIdScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { // If we are in the "Complete" status, just show success screen if self.status == AddTokenStatus::Complete { return self.show_success_screen(ui); @@ -349,7 +372,7 @@ impl ScreenLike for AddTokenByIdScreen { Identifier::from_string(&self.contract_or_token_id_input, Encoding::Base58) { let now = Utc::now().timestamp() as u32; - self.status = AddTokenStatus::Searching(now); + self.status = AddTokenStatus::Searching(SearchKind::ByTokenId, now); inner_action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::FetchTokenByTokenId(identifier), ))); @@ -360,12 +383,11 @@ impl ScreenLike for AddTokenByIdScreen { let search_action = self.render_search_inputs(ui); inner_action |= search_action; - if let AddTokenStatus::Searching(start_time) = self.status { + if let AddTokenStatus::Searching(kind, start_time) = self.status { ui.add_space(10.0); let elapsed_seconds = Utc::now().timestamp() as u32 - start_time; - // Show different messages based on whether we have a token selected - if self.selected_token.is_some() { + if matches!(kind, SearchKind::Saving) { ui.label(format!( "Adding token... {} seconds elapsed", elapsed_seconds @@ -387,3 +409,128 @@ impl ScreenLike for AddTokenByIdScreen { action } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::AppState; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + fn data_dir_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) + } + + /// Runs `f` in a unique temp data dir with a Tokio runtime in context, so + /// `AppState::new()` neither touches the real user data dir nor races other + /// test threads on `DASH_EVO_DATA_DIR`. + fn with_isolated_dir<R>(f: impl FnOnce() -> R) -> R { + let lock = data_dir_lock(); + let tmp = tempfile::tempdir().expect("create temp data dir"); + let prior = std::env::var("DASH_EVO_DATA_DIR").ok(); + // Safety: serialized by `lock`; env var is restored below before it drops. + unsafe { + std::env::set_var("DASH_EVO_DATA_DIR", tmp.path()); + } + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + let result = f(); + drop(_guard); + drop(rt); + // Safety: serialized by `lock`. + unsafe { + match &prior { + Some(v) => std::env::set_var("DASH_EVO_DATA_DIR", v), + None => std::env::remove_var("DASH_EVO_DATA_DIR"), + } + } + drop(lock); + drop(tmp); + result + } + + fn make_ctx() -> Arc<AppContext> { + let app = AppState::new(egui::Context::default()).expect("AppState builds"); + app.current_app_context().clone() + } + + fn screen_with(ctx: &Arc<AppContext>, status: AddTokenStatus) -> AddTokenByIdScreen { + let mut screen = AddTokenByIdScreen::new(ctx); + screen.status = status; + screen + } + + #[test] + fn display_task_result_drives_status_from_typed_variants() { + with_isolated_dir(|| { + let ctx = make_ctx(); + + // Saving the contract completes the import. + let mut screen = screen_with(&ctx, AddTokenStatus::Searching(SearchKind::Saving, 0)); + screen.display_task_result(BackendTaskSuccessResult::SavedContract); + assert_eq!(screen.status, AddTokenStatus::Complete); + + // A missing contract during a contract-ID lookup retries as a token ID. + let mut screen = + screen_with(&ctx, AddTokenStatus::Searching(SearchKind::ByContractId, 0)); + screen.display_task_result(BackendTaskSuccessResult::ContractNotFound); + assert!( + screen.try_token_id_next, + "contract-ID miss must fall back to a token-ID search" + ); + assert!( + matches!( + screen.status, + AddTokenStatus::Searching(SearchKind::ByContractId, _) + ), + "the fallback keeps searching; it must not flip to Error" + ); + + // A missing contract behind a found token is a genuine error, not a retry. + let mut screen = screen_with(&ctx, AddTokenStatus::Searching(SearchKind::ByTokenId, 0)); + screen.display_task_result(BackendTaskSuccessResult::ContractNotFound); + assert_eq!(screen.status, AddTokenStatus::Error); + assert!( + !screen.try_token_id_next, + "a found token's missing contract must not loop back into a search" + ); + + // Nothing matched the ID at all. + let mut screen = screen_with(&ctx, AddTokenStatus::Searching(SearchKind::ByTokenId, 0)); + screen.display_task_result(BackendTaskSuccessResult::TokenNotFound); + assert_eq!(screen.status, AddTokenStatus::Error); + + // A background balance refresh must not disturb a completed import. + let mut screen = screen_with(&ctx, AddTokenStatus::Complete); + screen.display_task_result(BackendTaskSuccessResult::FetchedTokenBalances); + assert_eq!(screen.status, AddTokenStatus::Complete); + }); + } + + #[test] + fn display_message_reacts_to_type_not_text() { + with_isolated_dir(|| { + let ctx = make_ctx(); + + // Any error banner puts the screen into the Error state. + let mut screen = + screen_with(&ctx, AddTokenStatus::Searching(SearchKind::ByContractId, 0)); + screen.display_message("anything at all", MessageType::Error); + assert_eq!(screen.status, AddTokenStatus::Error); + + // The old code flipped to Complete on this exact success text; it must not now. + let mut screen = + screen_with(&ctx, AddTokenStatus::Searching(SearchKind::ByContractId, 0)); + screen.display_message("DataContract successfully saved", MessageType::Success); + assert!( + matches!( + screen.status, + AddTokenStatus::Searching(SearchKind::ByContractId, _) + ), + "success banner text must no longer drive status transitions" + ); + }); + } +} diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index aa69d2e94..906a0914e 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,699 +1,150 @@ +use super::token_action_screen::{StepCounter, TokenAction, TokenActionCtx, TokenActionScreen}; +use super::tokens_screen::{IdentityTokenIdentifier, IdentityTokenInfo}; +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::BackendTask; +use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::model::fee_estimation::format_credits_as_dash; +use crate::context::AppContext; +use crate::model::amount::Amount; +use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::{BannerHandle, Component, ComponentResponse, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; -use crate::ui::tokens::validate_signing_key; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Ui}; -use eframe::egui::{Frame, Margin}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; - -use crate::app::{AppAction, BackendTasksExecutionMode}; -use crate::backend_task::BackendTask; -use crate::backend_task::tokens::TokenTask; -use crate::context::AppContext; -use crate::model::amount::Amount; -use crate::model::wallet::Wallet; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, Screen, ScreenLike}; - -use super::tokens_screen::IdentityTokenInfo; +use dash_sdk::platform::IdentityPublicKey; +use eframe::egui::Ui; +use std::sync::Arc; -/// Internal states for the burn process. -#[derive(PartialEq)] -pub enum BurnTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -pub struct BurnTokensScreen { - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<IdentityPublicKey>, - show_advanced_options: bool, - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, +/// Burns a chosen amount of the caller's tokens. +pub type BurnTokensScreen = TokenActionScreen<BurnAction>; - // The user chooses how many tokens to burn +pub struct BurnAction { pub amount: Option<Amount>, amount_input: Option<AmountInput>, - max_amount: Option<u64>, // Maximum amount the user can burn based on their balance - pub public_note: Option<String>, - - status: BurnTokensStatus, - - // Basic references - pub app_context: Arc<AppContext>, - - // Confirmation popup - confirmation_dialog: Option<ConfirmationDialog>, - - // For password-based wallet unlocking, if needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, + /// Maximum amount the user can burn based on their balance. + max_amount: Option<u64>, } -impl BurnTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let token_balance = match app_context.identity_token_balances() { - Ok(identity_token_balances) => { - let itb = identity_token_balances; - let key = IdentityTokenIdentifier { - identity_id: identity_token_info.identity.identity.id(), - token_id: identity_token_info.token_id, - }; - itb.get(&key).map(|itb| itb.balance) - } - Err(_) => None, - }; - - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); - - let group = match identity_token_info - .token_config - .manual_burning_rules() - .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to burn this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); - - Self { - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - amount: None, - amount_input: None, - max_amount: token_balance, - public_note: None, - status: BurnTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - completed_fee_result: None, - refresh_banner: None, - } - } - - /// Renders a text input for the user to specify an amount to burn - fn render_amount_input(&mut self, ui: &mut egui::Ui) { +impl BurnAction { + fn render_amount_input(&mut self, ui: &mut Ui, ctx: &TokenActionCtx) { + let max_amount = self.max_amount; let amount_input = self.amount_input.get_or_insert_with(|| { - let token_amount = Amount::from_token(&self.identity_token_info, 0); + let token_amount = Amount::from_token(ctx.info, 0); let mut input = AmountInput::new(token_amount).with_label("Amount:"); - - if self.max_amount.is_some() { - input.set_show_max_button(self.max_amount.is_some()); - input.set_max_amount(self.max_amount); + if max_amount.is_some() { + input.set_show_max_button(true); + input.set_max_amount(max_amount); } - input }); - let amount_response = amount_input.show(ui).inner; - // Update the amount based on user input amount_response.update(&mut self.amount); - // errors are handled inside AmountInput } +} - /// Renders a confirm popup with the final "Are you sure?" step - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let amount = match self.amount.as_ref() { - Some(amount) if amount.value() > 0 => amount, - _ => { - self.status = BurnTokensStatus::Error; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Please enter a valid amount greater than 0.", - MessageType::Error, - ); - self.confirmation_dialog = None; - return AppAction::None; - } - }; - - let dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new( - "Confirm Burn".to_string(), - format!("Are you sure you want to burn {}?", amount), - ) - .danger_mode(true) // Burning tokens is destructive - }); - - match dialog.show(ui).inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - - // Validate signing key before transitioning to waiting state - let Some(signing_key) = - validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = BurnTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Burning tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); +impl TokenAction for BurnAction { + const VERB: &'static str = "Burn"; + const PAGE_HEADING: &'static str = "Burn Tokens"; + const PROGRESS: &'static str = "Burning tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Burn"; + const DANGER: bool = true; - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) + fn new(info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + let max_amount = match app_context.identity_token_balances() { + Ok(balances) => { + let key = IdentityTokenIdentifier { + identity_id: info.identity.identity.id(), + token_id: info.token_id, }; - - // Dispatch the actual backend burn action - AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::BurnTokens { - owner_identity: self.identity_token_info.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - amount: amount.value(), - group_info, - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ) + balances.get(&key).map(|itb| itb.balance) } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, - } - } - - /// Renders a simple "Success!" screen after completion - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Burn", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) - } -} - -impl ScreenLike for BurnTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = BurnTokensStatus::Error; + Err(_) => None, + }; + BurnAction { + amount: None, + amount_input: None, + max_amount, } } - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::BurnedTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = BurnTokensStatus::Complete; - } + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config + .manual_burning_rules() + .authorized_to_make_change_action_takers() } - fn refresh(&mut self) { - // If you need to reload local identity data or re-check keys - if let Ok(all_identities) = self.app_context.load_local_user_identities() - && let Some(updated_identity) = all_identities - .into_iter() - .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + format!( + "Are you sure you want to burn {}?", + self.amount.clone().unwrap_or(Amount::new(0, 0)) + ) } - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Burn", AppAction::None), - ], - vec![], + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let n = step.advance(); + ui.heading(format!("{}. Amount to burn", n)); + ui.add_space(5.0); + if ctx.is_group_signing { + ui.label( + "You are signing an existing group Burn so you are not allowed to choose the amount.", ); + ui.add_space(5.0); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Burn", AppAction::None), - ], - vec![], - ); + self.render_amount_input(ui, ctx); } + } - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - let central_panel_action = island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - // If we are in the "Complete" status, just show success screen - if self.status == BurnTokensStatus::Complete { - return self.show_success_screen(ui); - } - - ui.heading("Burn Tokens"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - DashColors::error_color(dark_mode), - format!( - "No authentication keys found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), - ); - ui.add_space(10.0); - - // Show "Add key" or "Check keys" option - let first_key = self - .identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity_token_info.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity_token_info.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return AppAction::None; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Burn Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Burn transaction"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity_token_info.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Amount to burn - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Amount to burn", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Burn so you are not allowed to choose the amount.", - ); - ui.add_space(5.0); - ui.label(format!( - "Amount: {}", - self.amount - .as_ref() - .map(|a| a.to_string()) - .unwrap_or_default() - )); - } else { - self.render_amount_input(ui); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Burn so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):").info_tooltip( - "A note about the transaction that can be seen by the public.", - ); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut txt).changed() { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Burn", - &self.group_action_id, + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + let amount = match self.amount.as_ref() { + Some(amount) if amount.value() > 0 => amount.value(), + _ => { + MessageBanner::set_global( + ctx.app_context.egui_ctx(), + "Please enter a valid amount greater than 0.", + MessageType::Error, ); - - // Display estimated fee before action button - let estimated_fee = fee_estimator.estimate_token_transition(); - ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - egui::Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated Fee:") - .color(DashColors::text_secondary(dark_mode)), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - }); - }); - - // Burn button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() { - // Create confirmation dialog on button click - if self.confirmation_dialog.is_none() { - let amount = match self.amount.as_ref() { - Some(amount) if amount.value() > 0 => amount, - _ => return AppAction::None, - }; - - self.confirmation_dialog = Some( - ConfirmationDialog::new( - "Confirm Burn".to_string(), - format!("Are you sure you want to burn {}?", amount), - ) - .danger_mode(true), - ); - } - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - // Show in-progress or error messages - ui.add_space(10.0); - match &self.status { - BurnTokensStatus::NotStarted => { - // no-op - } - BurnTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - BurnTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - BurnTokensStatus::Complete => { - // handled above - } - } + return None; } + }; - AppAction::None - }); - - action |= central_panel_action; + Some(AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(Box::new(TokenTask::BurnTokens { + owner_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, + signing_key, + public_note, + amount, + group_info, + })), + BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), + ], + BackendTasksExecutionMode::Sequential, + )) + } - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::BurnedTokens(fee) => Some(fee.clone()), + _ => None, } - - action } } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 8963a2889..204d1bc2f 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -21,7 +21,7 @@ use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution use dash_sdk::dpp::data_contract::TokenConfiguration; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; +use eframe::egui::{self, Color32, Frame, Margin, Ui}; use egui::RichText; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; @@ -304,9 +304,11 @@ impl ScreenLike for ClaimTokensScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -321,15 +323,15 @@ impl ScreenLike for ClaimTokensScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { if self.status == ClaimTokensStatus::Complete { action |= self.show_success_screen(ui); return; @@ -399,13 +401,14 @@ impl ScreenLike for ClaimTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global( + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + let handle = MessageBanner::set_global( self.app_context.egui_ctx(), "Unable to open wallet. Please unlock it and try again.", MessageType::Error, - ) - .with_details(e); + ); + handle.with_details(e); + handle.disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -563,7 +566,7 @@ impl ScreenLike for ClaimTokensScreen { let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index a3726b3ba..3b8029f73 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -1,649 +1,120 @@ +use super::token_action_screen::{StepCounter, TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::component_trait::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Frame, Margin, Ui}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use eframe::egui::Ui; +use std::sync::Arc; -/// Represents possible states in the “destroy frozen funds” flow -#[derive(PartialEq)] -pub enum DestroyFrozenFundsStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -/// A screen for destroying frozen funds of a particular token contract -pub struct DestroyFrozenFundsScreen { - /// Identity that is authorized to destroy - identity: QualifiedIdentity, - - /// Info on which token contract we're dealing with - pub identity_token_info: IdentityTokenInfo, - - /// The key used to sign the operation - selected_key: Option<IdentityPublicKey>, - show_advanced_options: bool, - - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, +/// Destroys the frozen funds of a target identity for a contract. +pub type DestroyFrozenFundsScreen = TokenActionScreen<DestroyFrozenFundsAction>; - /// Optional public note - pub public_note: Option<String>, - - /// The user must specify the identity ID whose frozen funds are to be destroyed - /// Typically some Identity that has been frozen by the system or a group +pub struct DestroyFrozenFundsAction { + /// Identity whose frozen funds are to be destroyed (Base58 or hex). pub frozen_identity_id: String, - - /// All frozen identities that can be selected - /// TODO: We should filter them by frozen status, right now we just show all known identities frozen_identities: Vec<QualifiedIdentity>, - - status: DestroyFrozenFundsStatus, - - /// Basic references - pub app_context: Arc<AppContext>, - - /// Confirmation dialog - confirmation_dialog: Option<ConfirmationDialog>, - - /// If password-based wallet unlocking is needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - /// Fee result from completed operation - completed_fee_result: Option<FeeResult>, - /// Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, } -impl DestroyFrozenFundsScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); - - let group = match identity_token_info - .token_config - .destroy_frozen_funds_rules() - .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Destroying frozen funds is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to destroy frozen funds on this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to destroy frozen funds on this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); +impl TokenAction for DestroyFrozenFundsAction { + const VERB: &'static str = "Destroy"; + const PAGE_HEADING: &'static str = "Destroy Frozen Funds"; + const PROGRESS: &'static str = "Destroying frozen funds..."; + const CONFIRM_TITLE: &'static str = "Confirm Destroy Frozen Funds"; + const DANGER: bool = true; - let all_identities = super::load_identities_with_banner(app_context); - - Self { - identity: identity_token_info.identity.clone(), + fn new(_info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + DestroyFrozenFundsAction { frozen_identity_id: String::new(), - frozen_identities: all_identities, - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - public_note: None, - status: DestroyFrozenFundsStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - completed_fee_result: None, - refresh_banner: None, + frozen_identities: super::load_identities_with_banner(app_context), } } - /// Renders the text input for specifying the "frozen identity" - fn render_frozen_identity_input(&mut self, ui: &mut Ui) { - ui.add( - IdentitySelector::new( - "frozen_identity_selector", - &mut self.frozen_identity_id, - &self.frozen_identities, - ) - .label("Frozen Identity ID:"), - ); + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config + .destroy_frozen_funds_rules() + .authorized_to_make_change_action_takers() } - /// Confirmation popup - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let msg = format!( - "Are you sure you want to destroy frozen funds for identity {}? This action cannot be undone.", + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + format!( + "Are you sure you want to destroy the frozen funds of identity {}?", self.frozen_identity_id - ); - - let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new("Confirm Destroy Frozen Funds", msg) - .confirm_text(Some("Destroy")) - .cancel_text(Some("Cancel")) - .danger_mode(true) - }); + ) + } - let response = confirmation_dialog.show(ui); - match response.inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - self.confirmation_ok() - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let n = step.advance(); + ui.heading(format!( + "{}. Enter the identity ID whose frozen funds to destroy", + n + )); + ui.add_space(5.0); + if ctx.is_group_signing { + ui.label( + "You are signing an existing group Destroy Frozen Funds so you are not allowed to choose the identity.", + ); + ui.add_space(5.0); + ui.label(format!("Identity: {}", self.frozen_identity_id)); + } else { + ui.add( + IdentitySelector::new( + "destroy_frozen_identity_selector", + &mut self.frozen_identity_id, + &self.frozen_identities, + ) + .label("Frozen Identity ID:") + .width(300.0), + ); } } - fn confirmation_ok(&mut self) -> AppAction { - let Ok(frozen_id) = Identifier::from_string_try_encodings( + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + let Ok(frozen_identity) = Identifier::from_string_try_encodings( &self.frozen_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], + &[Encoding::Base58, Encoding::Hex], ) else { - self.status = DestroyFrozenFundsStatus::Error; MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid frozen identity format", + ctx.app_context.egui_ctx(), + "Please enter a valid identity ID.", MessageType::Error, ); - return AppAction::None; + return None; }; - // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = DestroyFrozenFundsStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Destroying frozen funds...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - AppAction::BackendTask(BackendTask::TokenTask(Box::new( + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::DestroyFrozenFunds { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, + actor_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - frozen_identity: frozen_id, + public_note, + frozen_identity, group_info, }, - ))) - } - /// Simple "Success" screen - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Destroy Frozen Funds", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) - } -} - -impl ScreenLike for DestroyFrozenFundsScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = DestroyFrozenFundsStatus::Error; - } - } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = - backend_task_success_result - { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = DestroyFrozenFundsStatus::Complete; - } - } - - fn refresh(&mut self) { - // Reload the identity data if needed - if let Ok(all_identities) = self.app_context.load_local_user_identities() - && let Some(updated_identity) = all_identities - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } + )))) } - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Destroy Frozen Funds", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Destroy Frozen Funds", AppAction::None), - ], - vec![], - ); + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::DestroyedFrozenFunds(fee) => Some(fee.clone()), + _ => None, } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - if self.status == DestroyFrozenFundsStatus::Complete { - action |= self.show_success_screen(ui); - return; - } - - ui.heading("Destroy Frozen Funds"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), - ); - ui.add_space(10.0); - - // Show "Add key" or "Check keys" option - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Destroy Frozen Funds"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Destroy operation"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Frozen identity - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!( - "{}. Frozen identity to destroy funds from", - step_num - )); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Destroy so you are not allowed to choose the identity.", - ); - ui.add_space(5.0); - ui.label(format!("Identity: {}", self.frozen_identity_id)); - } else { - self.render_frozen_identity_input(ui); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Destroy so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):").info_tooltip( - "A note about the transaction that can be seen by the public.", - ); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut txt).changed() { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Destroy Frozen Funds", - &self.group_action_id, - ); - - // Destroy button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() { - // Initialize confirmation dialog when button is clicked - let msg = format!( - "Are you sure you want to destroy frozen funds for identity {}? This action cannot be undone.", - self.frozen_identity_id - ); - self.confirmation_dialog = Some( - ConfirmationDialog::new("Confirm Destroy Frozen Funds", msg) - .confirm_text(Some("Destroy")) - .cancel_text(Some("Cancel")) - .danger_mode(true), - ); - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - // Show in-progress or error messages - ui.add_space(10.0); - match &self.status { - DestroyFrozenFundsStatus::NotStarted => { - // no-op - } - DestroyFrozenFundsStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - DestroyFrozenFundsStatus::Error => { - // Error display is handled by the global MessageBanner - } - DestroyFrozenFundsStatus::Complete => { - // handled above - } - } - } - }); - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } - } - - action } } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 1c43b2e42..c0f64f5f8 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -6,7 +6,7 @@ use dash_sdk::dpp::data_contract::associated_token::token_configuration::accesso use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Ui}; use egui::RichText; use super::tokens_screen::IdentityTokenInfo; @@ -176,7 +176,7 @@ impl PurchaseTokenScreen { if let Some(pricing_schedule) = &self.fetched_pricing_schedule { ui.add_space(5.0); ui.label("Current pricing:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; match pricing_schedule { TokenPricingSchedule::SinglePrice(price_per_unit) => { @@ -397,10 +397,12 @@ impl ScreenLike for PurchaseTokenScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // Build a top panel let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -412,16 +414,16 @@ impl ScreenLike for PurchaseTokenScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); - island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + island_central_panel(ui, |ui| { + let dark_mode = ui.style().visuals.dark_mode; // If we are in the "Complete" status, just show success screen if self.status == PurchaseTokensStatus::Complete { action |= self.show_success_screen(ui); @@ -491,8 +493,9 @@ impl ScreenLike for PurchaseTokenScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -570,7 +573,7 @@ impl ScreenLike for PurchaseTokenScreen { // Display estimated fee before action button let estimated_fee = self.app_context.fee_estimator().estimate_token_transition(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(egui::Margin::symmetric(10, 8)) diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index fea1288af..4931a82f2 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -1,651 +1,116 @@ +use super::token_action_screen::{StepCounter, TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::component_trait::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use eframe::egui::Ui; +use std::sync::Arc; -/// Internal states for the freeze operation -#[derive(PartialEq)] -pub enum FreezeTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -/// A UI Screen that allows freezing an identity’s tokens for a particular contract -pub struct FreezeTokensScreen { - identity: QualifiedIdentity, - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<IdentityPublicKey>, - show_advanced_options: bool, - pub public_note: Option<String>, +/// Freezes a target identity's tokens for a contract. +pub type FreezeTokensScreen = TokenActionScreen<FreezeAction>; - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, - known_identities: Vec<QualifiedIdentity>, - - /// The identity we want to freeze +pub struct FreezeAction { + /// Identity to freeze (Base58 or hex). pub freeze_identity_id: String, - - status: FreezeTokensStatus, - - // Basic references - pub app_context: Arc<AppContext>, - - // Confirmation dialog - confirmation_dialog: Option<ConfirmationDialog>, - - // If password-based wallet unlocking is needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, + known_identities: Vec<QualifiedIdentity>, } -impl FreezeTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let known_identities = super::load_identities_with_banner(app_context); - - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); +impl TokenAction for FreezeAction { + const VERB: &'static str = "Freeze"; + const PAGE_HEADING: &'static str = "Freeze Identity’s Tokens"; + const PROGRESS: &'static str = "Freezing tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Freeze"; - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); - - let group = match identity_token_info - .token_config - .freeze_rules() - .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Freezing is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to freeze this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to freeze this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); - - Self { - identity: identity_token_info.identity.clone(), - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - public_note: None, + fn new(_info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + FreezeAction { freeze_identity_id: String::new(), - status: FreezeTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - known_identities, - completed_fee_result: None, - refresh_banner: None, + known_identities: super::load_identities_with_banner(app_context), } } - /// Renders text input for the identity to freeze - fn render_freeze_identity_input(&mut self, ui: &mut Ui) { - let _response = ui.add( - IdentitySelector::new( - "freeze_identity_selector", - &mut self.freeze_identity_id, - &self.known_identities, - ) - .label("Freeze Identity ID:") - .width(300.0), - ); + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config + .freeze_rules() + .authorized_to_make_change_action_takers() } - /// Confirmation popup - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let msg = format!( + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + format!( "Are you sure you want to freeze identity {}?", self.freeze_identity_id - ); - - let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new("Confirm Freeze", msg) - .confirm_text(Some("Confirm")) - .cancel_text(Some("Cancel")) - }); + ) + } - let response = confirmation_dialog.show(ui); - match response.inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - self.confirmation_ok() - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let n = step.advance(); + ui.heading(format!("{}. Enter the identity ID to freeze", n)); + ui.add_space(5.0); + if ctx.is_group_signing { + ui.label( + "You are signing an existing group Freeze so you are not allowed to choose the identity.", + ); + ui.add_space(5.0); + ui.label(format!("Identity: {}", self.freeze_identity_id)); + } else { + ui.add( + IdentitySelector::new( + "freeze_identity_selector", + &mut self.freeze_identity_id, + &self.known_identities, + ) + .label("Freeze Identity ID:") + .width(300.0), + ); } } - /// Handle confirmation OK action - fn confirmation_ok(&mut self) -> AppAction { - // Validate user input - let Ok(freeze_id) = Identifier::from_string_try_encodings( + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + let Ok(freeze_identity) = Identifier::from_string_try_encodings( &self.freeze_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], + &[Encoding::Base58, Encoding::Hex], ) else { - self.status = FreezeTokensStatus::Error; MessageBanner::set_global( - self.app_context.egui_ctx(), + ctx.app_context.egui_ctx(), "Please enter a valid identity ID.", MessageType::Error, ); - return AppAction::None; - }; - - // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; + return None; }; - self.status = FreezeTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Freezing tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - // Grab the data contract for this token from the app context - let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch to backend - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::FreezeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::FreezeTokens { + actor_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, + signing_key, + public_note, + freeze_identity, + group_info, }, - freeze_identity: freeze_id, - group_info, - }))) + )))) } - /// Success screen - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Freeze", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) - } -} - -impl ScreenLike for FreezeTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = FreezeTokensStatus::Error; + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::FrozeTokens(fee) => Some(fee.clone()), + _ => None, } } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::FrozeTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = FreezeTokensStatus::Complete; - } - } - - fn refresh(&mut self) { - // Reload identity if needed - if let Ok(all_identities) = self.app_context.load_local_user_identities() - && let Some(updated_identity) = all_identities - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Freeze", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Freeze", AppAction::None), - ], - vec![], - ); - } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - let central_panel_action = island_central_panel(ctx, |ui| { - if self.status == FreezeTokensStatus::Complete { - return self.show_success_screen(ui); - } - - ui.heading("Freeze Identity’s Tokens"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), - ); - ui.add_space(10.0); - - // Show "Add key" or "Check keys" option - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return AppAction::None; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Freeze Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Freeze transition"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - } - - // Identity to freeze - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Enter the identity ID to freeze", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Freeze so you are not allowed to choose the identity.", - ); - ui.add_space(5.0); - ui.label(format!("Identity: {}", self.freeze_identity_id)); - } else { - self.render_freeze_identity_input(ui); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Freeze so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):").info_tooltip( - "A note about the transaction that can be seen by the public.", - ); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut txt).changed() { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Freeze", - &self.group_action_id, - ); - - // Display estimated fee before action button - let estimated_fee = fee_estimator.estimate_token_transition(); - ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - egui::Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated Fee:") - .color(DashColors::text_secondary(dark_mode)), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - }); - }); - - // Freeze button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() { - // Initialize confirmation dialog when button is clicked - self.confirmation_dialog = None; // Reset for fresh dialog - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - // Show in-progress or error messages - ui.add_space(10.0); - match &self.status { - FreezeTokensStatus::NotStarted => { - // no-op - } - FreezeTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - FreezeTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - FreezeTokensStatus::Complete => { - // handled above - } - } - } - - AppAction::None - }); - - action |= central_panel_action; - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } - } - - action - } } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index f54a868d2..7d3e4c455 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -1,240 +1,49 @@ +use super::token_action_screen::{StepCounter, TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Ui}; -use eframe::egui::{Frame, Margin}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; -/// Internal states for the mint process. -#[derive(PartialEq)] -pub enum MintTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -/// A UI Screen for minting tokens from an existing token contract -pub struct MintTokensScreen { - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<IdentityPublicKey>, - show_advanced_options: bool, - pub public_note: Option<String>, - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, - known_identities: Vec<QualifiedIdentity>, +use eframe::egui::Ui; +use std::sync::Arc; - recipient_identity_id: String, +/// Mints new tokens to a recipient (defaulting to the issuer). +pub type MintTokensScreen = TokenActionScreen<MintAction>; +pub struct MintAction { pub amount: Option<Amount>, amount_input: Option<AmountInput>, - status: MintTokensStatus, - - /// Basic references - pub app_context: Arc<AppContext>, - - /// Confirmation popup - confirmation_dialog: Option<ConfirmationDialog>, - - // If needed for password-based wallet unlocking: - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, + recipient_identity_id: String, + known_identities: Vec<QualifiedIdentity>, } -impl MintTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let known_identities = super::load_identities_with_banner(app_context); - - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); - - let group = match identity_token_info - .token_config - .manual_minting_rules() - .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Minting is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to mint this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to mint this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); - - Self { - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - public_note: None, - group, - is_unilateral_group_member, - group_action_id: None, - known_identities, - recipient_identity_id: "".to_string(), - amount: None, - amount_input: None, - status: MintTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - completed_fee_result: None, - refresh_banner: None, - } - } - - /// Renders an amount input for the user to specify an amount to mint - fn render_amount_input(&mut self, ui: &mut Ui) { - // Lazy initialization with proper token configuration +impl MintAction { + fn render_amount_input(&mut self, ui: &mut Ui, ctx: &TokenActionCtx) { let amount_input = self.amount_input.get_or_insert_with(|| { - // Create appropriate Amount based on token configuration - let token_amount = Amount::from_token(&self.identity_token_info, 0); + let token_amount = Amount::from_token(ctx.info, 0); AmountInput::new(token_amount).with_label("Amount to Mint:") }); - - // Check if input should be disabled when operation is in progress - let enabled = match self.status { - MintTokensStatus::WaitingForResult | MintTokensStatus::Complete => false, - MintTokensStatus::NotStarted | MintTokensStatus::Error => true, - }; - - let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; - - response.inner.update(&mut self.amount); - // errors are handled inside AmountInput + let response = amount_input.show(ui).inner; + response.update(&mut self.amount); } - /// Renders an optional text input for the user to specify a "Recipient Identity" - fn render_recipient_input(&mut self, ui: &mut Ui) { - let _response = ui.add( + fn render_recipient_input(&mut self, ui: &mut Ui, ctx: &TokenActionCtx) { + ui.add( IdentitySelector::new( "mint_recipient_selector", &mut self.recipient_identity_id, @@ -242,496 +51,129 @@ impl MintTokensScreen { ) .width(300.0) .label("Recipient:") - .exclude(&[self.identity_token_info.identity.identity.id()]), + .exclude(&[ctx.info.identity.identity.id()]), ); + } +} - // If empty, minted tokens go to the 'issuer' identity (self.identity). +impl TokenAction for MintAction { + const VERB: &'static str = "Mint"; + const PAGE_HEADING: &'static str = "Mint Tokens"; + const PROGRESS: &'static str = "Minting tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Mint"; + + fn new(_info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + MintAction { + amount: None, + amount_input: None, + recipient_identity_id: String::new(), + known_identities: super::load_identities_with_banner(app_context), + } + } + + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config + .manual_minting_rules() + .authorized_to_make_change_action_takers() } - /// Renders a confirm popup with the final "Are you sure?" step - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let msg = format!( + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + format!( "Are you sure you want to mint {} tokens to {}?", self.amount.clone().unwrap_or(Amount::new(0, 0)), self.recipient_identity_id - ); + ) + } - let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new("Confirm Mint", msg) - .confirm_text(Some("Mint")) - .cancel_text(Some("Cancel")) - }); + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let n = step.advance(); + ui.heading(format!("{}. Amount to mint", n)); + ui.add_space(5.0); + if ctx.is_group_signing { + ui.label( + "You are signing an existing group Mint so you are not allowed to choose the amount.", + ); + ui.add_space(5.0); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); + } else { + self.render_amount_input(ui, ctx); + } - let response = confirmation_dialog.show(ui); - match response.inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - self.confirmation_ok() - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None + let distribution_rules = ctx.info.token_config.distribution_rules(); + if distribution_rules.minting_allow_choosing_destination() + || ctx.app_context.is_developer_mode() + { + let destination_optional = distribution_rules + .new_tokens_destination_identity() + .is_some(); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + let n = step.advance(); + if destination_optional { + ui.heading(format!("{}. Recipient identity (optional)", n)); + } else { + ui.heading(format!("{}. Recipient identity (required)", n)); } - None => AppAction::None, + ui.add_space(5.0); + self.render_recipient_input(ui, ctx); } } - fn confirmation_ok(&mut self) -> AppAction { + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { - self.status = MintTokensStatus::Error; MessageBanner::set_global( - self.app_context.egui_ctx(), + ctx.app_context.egui_ctx(), "Invalid amount", MessageType::Error, ); - return AppAction::None; + return None; } - let Ok(receiver_id) = Identifier::from_string_try_encodings( + let Ok(recipient_id) = Identifier::from_string_try_encodings( &self.recipient_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], + &[Encoding::Base58, Encoding::Hex], ) else { - self.status = MintTokensStatus::Error; MessageBanner::set_global( - self.app_context.egui_ctx(), + ctx.app_context.egui_ctx(), "Invalid receiver", MessageType::Error, ); - return AppAction::None; - }; - - // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = MintTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Minting tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) + return None; }; - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::MintTokens { - sending_identity: self.identity_token_info.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::MintTokens { + sending_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, + signing_key, + public_note, + recipient_id: Some(recipient_id), + amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), + group_info, }, - recipient_id: Some(receiver_id), - amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), - group_info, - }))) - } - /// Renders a simple "Success!" screen after completion - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Mint", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) - } -} - -impl ScreenLike for MintTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = MintTokensStatus::Error; - } - } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = MintTokensStatus::Complete; - } - } - - fn refresh(&mut self) { - // If you need to reload local identity data or re-check keys: - if let Ok(all_identities) = self.app_context.load_local_user_identities() - && let Some(updated_identity) = all_identities - .into_iter() - .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + )))) } - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Mint", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Mint", AppAction::None), - ], - vec![], - ); + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::MintedTokens(fee) => Some(fee.clone()), + _ => None, } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - let central_panel_action = island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - // If we are in the "Complete" status, just show success screen - if self.status == MintTokensStatus::Complete { - return self.show_success_screen(ui); - } - - ui.heading("Mint Tokens"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), - ); - ui.add_space(10.0); - - // Show "Add key" or "Check keys" option - let first_key = self - .identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity_token_info.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity_token_info.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario (similar to TransferTokens) - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return AppAction::None; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Mint Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Mint transaction"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity_token_info.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Amount to mint - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Amount to mint", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Mint so you are not allowed to choose the amount.", - ); - ui.add_space(5.0); - ui.label(format!( - "Amount: {}", - self.amount - .as_ref() - .map(|a| a.to_string()) - .unwrap_or_default() - )); - } else { - self.render_amount_input(ui); - } - - if self - .identity_token_info - .token_config - .distribution_rules() - .minting_allow_choosing_destination() - || self.app_context.is_developer_mode() - { - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - if self - .identity_token_info - .token_config - .distribution_rules() - .new_tokens_destination_identity() - .is_some() - { - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Recipient identity (optional)", step_num)); - } else { - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Recipient identity (required)", step_num)); - } - ui.add_space(5.0); - self.render_recipient_input(ui); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 4 } else { 3 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Mint so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):").info_tooltip( - "A note about the transaction that can be seen by the public.", - ); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut txt).changed() { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Mint", - &self.group_action_id, - ); - - // Display estimated fee before action button - let estimated_fee = fee_estimator.estimate_token_transition(); - ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - egui::Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated Fee:") - .color(DashColors::text_secondary(dark_mode)), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - }); - }); - - // Mint button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() { - let msg = format!( - "Are you sure you want to mint {} tokens to {}?", - self.amount.clone().unwrap_or(Amount::new(0, 0)), - self.recipient_identity_id - ); - self.confirmation_dialog = Some( - ConfirmationDialog::new("Confirm Mint", msg) - .confirm_text(Some("Mint")) - .cancel_text(Some("Cancel")), - ); - } - } - - // If the user pressed "Mint," show a popup - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - // Show in-progress or error messages - ui.add_space(10.0); - match &self.status { - MintTokensStatus::NotStarted => {} - MintTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - MintTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - MintTokensStatus::Complete => { - // handled above - } - } - } - - AppAction::None - }); - - action |= central_panel_action; - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } - } - - action } } diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 66ee31426..3b60a9d75 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -8,6 +8,7 @@ pub mod mint_tokens_screen; pub mod pause_tokens_screen; pub mod resume_tokens_screen; pub mod set_token_price_screen; +pub mod token_action_screen; pub mod tokens_screen; pub mod transfer_tokens_screen; pub mod unfreeze_tokens_screen; diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 5a37f5948..3206fe740 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -1,562 +1,64 @@ +use super::token_action_screen::{TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::platform::Identifier; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; +use dash_sdk::platform::IdentityPublicKey; +use std::sync::Arc; -/// Represents states for the pause flow -#[derive(PartialEq)] -pub enum PauseTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -/// A UI screen that allows pausing all token-related actions for a contract -pub struct PauseTokensScreen { - identity: QualifiedIdentity, - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<dash_sdk::platform::IdentityPublicKey>, - show_advanced_options: bool, - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, - pub public_note: Option<String>, +/// Pauses all token transfers for a contract (emergency action). +pub type PauseTokensScreen = TokenActionScreen<PauseAction>; - status: PauseTokensStatus, +pub struct PauseAction; - // Basic references - pub app_context: Arc<AppContext>, +impl TokenAction for PauseAction { + const VERB: &'static str = "Pause"; + const PAGE_HEADING: &'static str = "Pause Token Contract"; + const PROGRESS: &'static str = "Pausing tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Pause"; - // Confirmation popup - confirmation_dialog: Option<ConfirmationDialog>, - - // If password-based wallet unlocking is needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, -} - -impl PauseTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); + fn new(_info: &IdentityTokenInfo, _app_context: &Arc<AppContext>) -> Self { + PauseAction + } - let group = match identity_token_info - .token_config + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config .emergency_action_rules() .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Pausing is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to pause this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to pause this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); - - Self { - identity: identity_token_info.identity.clone(), - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - public_note: None, - status: PauseTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - completed_fee_result: None, - refresh_banner: None, - } } - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new( - "Confirm Pause".to_string(), - "Are you sure you want to pause token transfers for this contract?".to_string(), - ) - }); - - match dialog.show(ui).inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - - // Validate signing key before transitioning to waiting state - let Some(signing_key) = - validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = PauseTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Pausing tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::PauseTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - group_info, - }))) - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, - } + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + "Are you sure you want to pause token transfers for this contract?".to_string() } - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Pause", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::PauseTokens { + actor_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, + signing_key, + public_note, + group_info, + }, + )))) } -} -impl ScreenLike for PauseTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = PauseTokensStatus::Error; + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::PausedTokens(fee) => Some(fee.clone()), + _ => None, } } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::PausedTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = PauseTokensStatus::Complete; - } - } - - fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_user_identities() - && let Some(updated) = all - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated; - } - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Pause", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Pause", AppAction::None), - ], - vec![], - ); - } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - let central_panel_action = island_central_panel(ctx, |ui| { - if self.status == PauseTokensStatus::Complete { - return self.show_success_screen(ui); - } - - ui.heading("Pause Token Contract"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), - ); - ui.add_space(10.0); - - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return AppAction::None; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Pause Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Pause transition"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Pause so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):"); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui - .text_edit_singleline(&mut txt) - .info_tooltip( - "A note about the transaction that can be seen by the public.", - ) - .changed() - { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Pause", - &self.group_action_id, - ); - - // Pause button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() - && self.confirmation_dialog.is_none() - { - self.confirmation_dialog = Some(ConfirmationDialog::new( - "Confirm Pause".to_string(), - "Are you sure you want to pause token transfers for this contract?" - .to_string(), - )); - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - ui.add_space(10.0); - match &self.status { - PauseTokensStatus::NotStarted => {} - PauseTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - PauseTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - PauseTokensStatus::Complete => {} - } - } - - AppAction::None - }); - - action |= central_panel_action; - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } - } - - action - } } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 71261a0c5..76077ef9d 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -1,558 +1,64 @@ +use super::token_action_screen::{TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::platform::Identifier; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; +use dash_sdk::platform::IdentityPublicKey; +use std::sync::Arc; -/// States for the resume flow -#[derive(PartialEq)] -pub enum ResumeTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -pub struct ResumeTokensScreen { - identity: QualifiedIdentity, - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<dash_sdk::platform::IdentityPublicKey>, - show_advanced_options: bool, - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, - pub public_note: Option<String>, +/// Resumes token transfers for a paused contract (emergency action). +pub type ResumeTokensScreen = TokenActionScreen<ResumeAction>; - status: ResumeTokensStatus, +pub struct ResumeAction; - // Basic references - pub app_context: Arc<AppContext>, +impl TokenAction for ResumeAction { + const VERB: &'static str = "Resume"; + const PAGE_HEADING: &'static str = "Resume Token Contract"; + const PROGRESS: &'static str = "Resuming tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Resume"; - // Confirmation popup - confirmation_dialog: Option<ConfirmationDialog>, - - // If password-based wallet unlocking is needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, -} - -impl ResumeTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); + fn new(_info: &IdentityTokenInfo, _app_context: &Arc<AppContext>) -> Self { + ResumeAction + } - let group = match identity_token_info - .token_config + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config .emergency_action_rules() .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Resuming is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to resume this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to resume this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); - - Self { - identity: identity_token_info.identity.clone(), - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - public_note: None, - status: ResumeTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - completed_fee_result: None, - refresh_banner: None, - } - } - - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new( - "Confirm Resume".to_string(), - "Are you sure you want to resume normal token actions for this contract?" - .to_string(), - ) - }); - - match dialog.show(ui).inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - - // Validate signing key before transitioning to waiting state - let Some(signing_key) = - validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = ResumeTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Resuming tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::ResumeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - group_info, - }))) - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, - } - } - - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Resume", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) } -} -impl ScreenLike for ResumeTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = ResumeTokensStatus::Error; - } + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + "Are you sure you want to resume normal token actions for this contract?".to_string() } - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::ResumedTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = ResumeTokensStatus::Complete; - } + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::ResumeTokens { + actor_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, + signing_key, + public_note, + group_info, + }, + )))) } - fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_user_identities() - && let Some(updated) = all - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated; + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::ResumedTokens(fee) => Some(fee.clone()), + _ => None, } } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Resume", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Resume", AppAction::None), - ], - vec![], - ); - } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - island_central_panel(ctx, |ui| { - if self.status == ResumeTokensStatus::Complete { - action |= self.show_success_screen(ui); - return; - } - - ui.heading("Resume Token Contract"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), - ); - ui.add_space(10.0); - - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Resume Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Resume transition"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Resume so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):"); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui - .text_edit_singleline(&mut txt) - .info_tooltip( - "A note about the transaction that can be seen by the public.", - ) - .changed() - { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Resume", - &self.group_action_id, - ); - - // Resume button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() - && self.confirmation_dialog.is_none() - { - self.confirmation_dialog = Some(ConfirmationDialog::new( - "Confirm Resume".to_string(), - "Are you sure you want to resume normal token actions for this contract?".to_string(), - )); - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - ui.add_space(10.0); - match &self.status { - ResumeTokensStatus::NotStarted => {} - ResumeTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - ResumeTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - ResumeTokensStatus::Complete => {} - } - } - }); - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } - } - - action - } } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 3228eea5f..41f20ddfc 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -40,7 +40,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; +use eframe::egui::{self, Color32, Frame, Margin, Ui}; use egui::RichText; use egui_extras::{Column, TableBuilder}; use std::collections::HashSet; @@ -81,7 +81,12 @@ pub enum SetTokenPriceStatus { Complete, } -/// A UI Screen for minting tokens from an existing token contract +/// A UI Screen for setting a token's direct-purchase price. +// +// TODO(det): fold into the shared `TokenActionScreen<A>` scaffold like the other +// single-shot token actions. Deferred because the pricing form (single price vs. +// schedule) is large and the verb-based authorization message template does not +// fit "set/change price" grammatically. pub struct SetTokenPriceScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option<IdentityPublicKey>, @@ -409,7 +414,7 @@ impl SetTokenPriceScreen { } } PricingType::TieredPricing => { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let text_primary = DashColors::text_primary(dark_mode); ui.label("Add pricing tiers to offer volume discounts"); ui.add_space(10.0); @@ -855,13 +860,15 @@ impl ScreenLike for SetTokenPriceScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action; // Build a top panel if self.group_action_id.is_some() { action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -872,7 +879,7 @@ impl ScreenLike for SetTokenPriceScreen { ); } else { action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -885,15 +892,15 @@ impl ScreenLike for SetTokenPriceScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { // If we are in the "Complete" status, just show success screen if self.status == SetTokenPriceStatus::Complete { @@ -964,8 +971,9 @@ impl ScreenLike for SetTokenPriceScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -1099,7 +1107,7 @@ impl ScreenLike for SetTokenPriceScreen { let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs new file mode 100644 index 000000000..b4400d1a8 --- /dev/null +++ b/src/ui/tokens/token_action_screen.rs @@ -0,0 +1,598 @@ +//! Shared scaffold for single-shot token action screens. +//! +//! Pause, resume, freeze, unfreeze, destroy-frozen-funds, burn and mint all share +//! the same shell: authorization resolution, wallet unlock, an advanced key chooser, +//! a per-action form section, a public note, a fee estimate, a confirmation dialog +//! and a success screen. [`TokenActionScreen`] implements that shell once; each +//! action supplies only its differences through the [`TokenAction`] trait. + +use super::tokens_screen::IdentityTokenInfo; +use crate::app::AppAction; +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; +use crate::context::AppContext; +use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::ui::helpers::{ + TransactionType, add_key_chooser, check_token_authorization, render_group_action_text, +}; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use crate::ui::tokens::validate_signing_key; +use crate::ui::{MessageType, Screen, ScreenLike}; +use dash_sdk::dpp::data_contract::GroupContractPosition; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; +use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; +use dash_sdk::dpp::data_contract::group::Group; +use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use eframe::egui::{self, Color32, Frame, Margin, RichText, Ui}; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +/// Sequential heading numbers ("1.", "2.", …) shared between the scaffold and forms. +pub struct StepCounter(usize); + +impl StepCounter { + fn new() -> Self { + StepCounter(0) + } + + /// Advances to and returns the next step index (1-based). + pub fn advance(&mut self) -> usize { + self.0 += 1; + self.0 + } +} + +/// Lifecycle of a single-shot token action. +#[derive(PartialEq)] +pub enum TokenActionStatus { + NotStarted, + WaitingForResult, + Error, + Complete, +} + +/// Context handed to a [`TokenAction`] while rendering or dispatching. +pub struct TokenActionCtx<'a> { + pub info: &'a IdentityTokenInfo, + pub app_context: &'a Arc<AppContext>, + /// True when co-signing an existing group action (form inputs are read-only). + pub is_group_signing: bool, +} + +impl TokenActionCtx<'_> { + /// Shared `Arc` over the token's data contract for backend dispatch. + pub fn data_contract(&self) -> Arc<dash_sdk::platform::DataContract> { + Arc::new(self.info.data_contract.contract.clone()) + } +} + +/// The per-action differences between the otherwise-identical token action screens. +pub trait TokenAction: Sized + 'static { + /// Verb used in breadcrumbs, headings and buttons (e.g. `"Pause"`). + const VERB: &'static str; + /// Central-panel page heading (e.g. `"Mint Tokens"`). + const PAGE_HEADING: &'static str; + /// Info-banner text shown while the operation runs (e.g. `"Minting tokens..."`). + const PROGRESS: &'static str; + /// Confirmation dialog title (e.g. `"Confirm Mint"`). + const CONFIRM_TITLE: &'static str; + /// Whether the confirmation dialog is styled as destructive. + const DANGER: bool = false; + + /// Builds the action-specific state. + fn new(info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self; + + /// The change-control rules that gate this action. + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers; + + /// Confirmation dialog body; may reflect the action's current inputs. + fn confirm_message(&self, ctx: &TokenActionCtx) -> String; + + /// Renders the per-action form section between the key chooser and the note. + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let _ = (ui, ctx, step); + } + + /// Validates inputs and builds the dispatch action. Returns `None` after showing + /// an error banner when the inputs are invalid. + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction>; + + /// Extracts the fee from this action's success result variant. + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult>; +} + +/// State shared by every token action screen, independent of the concrete action. +pub struct TokenActionCommon { + pub identity_token_info: IdentityTokenInfo, + pub group_action_id: Option<Identifier>, + pub public_note: Option<String>, + + selected_key: Option<IdentityPublicKey>, + show_advanced_options: bool, + group: Option<(GroupContractPosition, Group)>, + is_unilateral_group_member: bool, + status: TokenActionStatus, + confirmation_dialog: Option<ConfirmationDialog>, + selected_wallet: Option<Arc<RwLock<Wallet>>>, + wallet_unlock_popup: WalletUnlockPopup, + wallet_open_attempted: bool, + completed_fee_result: Option<FeeResult>, + refresh_banner: Option<BannerHandle>, + app_context: Arc<AppContext>, +} + +impl TokenActionCommon { + fn group_info(&self) -> Option<GroupStateTransitionInfoStatus> { + if let Some(action_id) = self.group_action_id { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id, + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + } + } +} + +/// A single-shot token action screen parameterised by its [`TokenAction`]. +pub struct TokenActionScreen<A: TokenAction> { + pub common: TokenActionCommon, + pub action: A, +} + +impl<A: TokenAction> TokenActionScreen<A> { + pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + let possible_key = identity_token_info + .identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + let takers = A::authorized_takers(&identity_token_info.token_config); + let auth = check_token_authorization(&takers, &identity_token_info, A::VERB); + if let Some(msg) = auth.error_message { + super::set_error_banner(app_context, &msg); + } + + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + super::set_error_banner(app_context, &e); + None + }); + + let action = A::new(&identity_token_info, app_context); + + Self { + common: TokenActionCommon { + identity_token_info, + group_action_id: None, + public_note: None, + selected_key: possible_key, + show_advanced_options: false, + group: auth.group, + is_unilateral_group_member: auth.is_unilateral_group_member, + status: TokenActionStatus::NotStarted, + confirmation_dialog: None, + selected_wallet, + wallet_unlock_popup: WalletUnlockPopup::new(), + wallet_open_attempted: false, + completed_fee_result: None, + refresh_banner: None, + app_context: app_context.clone(), + }, + action, + } + } + + /// Rebinds the shared app context (used on network switch). + pub fn set_app_context(&mut self, app_context: Arc<AppContext>) { + self.common.app_context = app_context; + } + + fn ctx(&self) -> TokenActionCtx<'_> { + TokenActionCtx { + info: &self.common.identity_token_info, + app_context: &self.common.app_context, + is_group_signing: self.common.group_action_id.is_some(), + } + } + + fn show_success_screen(&self, ui: &mut Ui) -> AppAction { + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + A::VERB, + self.common.group_action_id.is_some(), + self.common.is_unilateral_group_member, + self.common.group.is_some(), + &self.common.app_context, + None, + ) + } + + /// Renders the missing-key banner with "Check Keys" / "Add key" shortcuts. + fn render_no_keys(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let identity = &self.common.identity_token_info.identity; + let dark_mode = ui.style().visuals.dark_mode; + + ui.colored_label( + DashColors::error_color(dark_mode), + format!( + "No authentication keys with CRITICAL security level found for this {} identity.", + identity.identity_type, + ), + ); + ui.add_space(10.0); + + let first_key = identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + identity.clone(), + key.clone(), + None, + &self.common.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + identity.clone(), + &self.common.app_context, + ))); + } + + action + } + + /// Renders the public-note step (editable, or read-only while group-signing). + fn render_public_note(&mut self, ui: &mut Ui, step: usize) { + ui.heading(format!("{}. Public note (optional)", step)); + ui.add_space(5.0); + if self.common.group_action_id.is_some() { + ui.label(format!( + "You are signing an existing group {} so you are not allowed to put a note.", + A::VERB + )); + ui.add_space(5.0); + ui.label(format!( + "Note: {}", + self.common + .public_note + .clone() + .unwrap_or("None".to_string()) + )); + } else { + ui.horizontal(|ui| { + ui.label("Public note (optional):") + .info_tooltip("A note about the transaction that can be seen by the public."); + ui.add_space(10.0); + let mut txt = self.common.public_note.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut txt).changed() { + self.common.public_note = if !txt.is_empty() { Some(txt) } else { None }; + } + }); + } + } + + fn render_fee_estimate(&self, ui: &mut Ui) { + let estimated_fee = self + .common + .app_context + .fee_estimator() + .estimate_document_batch(1); + let dark_mode = ui.style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + let Some(dialog) = self.common.confirmation_dialog.as_mut() else { + return AppAction::None; + }; + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.common.confirmation_dialog = None; + self.submit() + } + Some(ConfirmationStatus::Canceled) => { + self.common.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, + } + } + + fn submit(&mut self) -> AppAction { + let Some(signing_key) = + validate_signing_key(&self.common.app_context, self.common.selected_key.as_ref()) + else { + return AppAction::None; + }; + + let group_info = self.common.group_info(); + let public_note = if self.common.group_action_id.is_some() { + None + } else { + self.common.public_note.clone() + }; + + let ctx = TokenActionCtx { + info: &self.common.identity_token_info, + app_context: &self.common.app_context, + is_group_signing: self.common.group_action_id.is_some(), + }; + + match self + .action + .build_action(&ctx, signing_key, public_note, group_info) + { + Some(dispatch) => { + self.common.status = TokenActionStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.common.app_context.egui_ctx(), + A::PROGRESS, + MessageType::Info, + ); + handle.with_elapsed(); + self.common.refresh_banner = Some(handle); + dispatch + } + None => { + self.common.status = TokenActionStatus::Error; + AppAction::None + } + } + } +} + +impl<A: TokenAction> ScreenLike for TokenActionScreen<A> { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.common.refresh_banner.take_and_clear(); + self.common.status = TokenActionStatus::Error; + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let Some(fee_result) = A::success_fee(&backend_task_success_result) { + self.common.refresh_banner.take_and_clear(); + self.common.completed_fee_result = Some(fee_result); + self.common.status = TokenActionStatus::Complete; + } + } + + fn refresh(&mut self) { + if let Ok(all) = self.common.app_context.load_local_user_identities() + && let Some(updated) = all.into_iter().find(|id| { + id.identity.id() == self.common.identity_token_info.identity.identity.id() + }) + { + self.common.identity_token_info.identity = updated; + } + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; + + let breadcrumbs = if self.common.group_action_id.is_some() { + vec![ + ("Contracts", AppAction::GoToMainScreen), + ("Group Actions", AppAction::PopScreen), + (A::VERB, AppAction::None), + ] + } else { + vec![ + ("Tokens", AppAction::GoToMainScreen), + ( + self.common.identity_token_info.token_alias.as_str(), + AppAction::PopScreen, + ), + (A::VERB, AppAction::None), + ] + }; + let mut action = add_top_panel(ui, &self.common.app_context, breadcrumbs, vec![]); + + action |= add_left_panel( + ui, + &self.common.app_context, + crate::ui::RootScreenType::RootScreenMyTokenBalances, + ); + action |= add_tokens_subscreen_chooser_panel(ui, &self.common.app_context); + + let central_panel_action = island_central_panel(ui, |ui| { + if self.common.status == TokenActionStatus::Complete { + return self.show_success_screen(ui); + } + + ui.heading(A::PAGE_HEADING); + ui.add_space(10.0); + + let identity = &self.common.identity_token_info.identity; + let has_keys = if self.common.app_context.is_developer_mode() { + !identity.identity.public_keys().is_empty() + } else { + !identity + .available_authentication_keys_with_critical_security_level() + .is_empty() + }; + + if !has_keys { + return self.render_no_keys(ui); + } + + let mut inner_action = AppAction::None; + + // Handle a locked wallet before showing the form. + if let Some(wallet) = &self.common.selected_wallet { + if !self.common.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(&self.common.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); + } + self.common.wallet_open_attempted = true; + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.common.wallet_unlock_popup.open(); + } + return AppAction::None; + } + } + + let mut step = StepCounter::new(); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.common.show_advanced_options, "Advanced Options"); + }); + ui.add_space(10.0); + + if self.common.show_advanced_options { + let n = step.advance(); + ui.heading(format!( + "{}. Select the key to sign the {} transition", + n, + A::VERB + )); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.common.app_context, + &self.common.identity_token_info.identity, + &mut self.common.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + { + let action_ctx = TokenActionCtx { + info: &self.common.identity_token_info, + app_context: &self.common.app_context, + is_group_signing: self.common.group_action_id.is_some(), + }; + self.action.render_form(ui, &action_ctx, &mut step); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + let note_step = step.advance(); + self.render_public_note(ui, note_step); + + ui.add_space(10.0); + self.render_fee_estimate(ui); + + let button_text = render_group_action_text( + ui, + &self.common.group, + &self.common.identity_token_info, + A::VERB, + &self.common.group_action_id, + ); + + if self.common.app_context.is_developer_mode() || !button_text.contains("Test") { + ui.add_space(10.0); + if ComponentStyles::add_primary_button(ui, button_text).clicked() { + let message = self.action.confirm_message(&self.ctx()); + let mut dialog = ConfirmationDialog::new(A::CONFIRM_TITLE, message) + .confirm_text(Some(A::VERB)) + .cancel_text(Some("Cancel")); + if A::DANGER { + dialog = dialog.danger_mode(true); + } + self.common.confirmation_dialog = Some(dialog); + } + } + + if self.common.confirmation_dialog.is_some() { + inner_action |= self.show_confirmation_popup(ui); + } + + ui.add_space(10.0); + inner_action + }); + + action |= central_panel_action; + + if self.common.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.common.selected_wallet + { + let _ = self + .common + .wallet_unlock_popup + .show(ctx, wallet, &self.common.app_context); + } + + action + } +} diff --git a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs index ca724bca5..d3f3a1201 100644 --- a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs +++ b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs @@ -40,7 +40,7 @@ impl TokensScreen { }) .show(ui.ctx(), |ui| { // Display the JSON in a multiline text box - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(10.0); ui.label( egui::RichText::new("Below is the data contract JSON:") @@ -66,7 +66,7 @@ impl TokensScreen { // Close button — right-aligned to match ConfirmationDialog pattern ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { self.show_json_popup = false; } diff --git a/src/ui/tokens/tokens_screen/distributions.rs b/src/ui/tokens/tokens_screen/distributions.rs index 907517af8..6a0563031 100644 --- a/src/ui/tokens/tokens_screen/distributions.rs +++ b/src/ui/tokens/tokens_screen/distributions.rs @@ -1004,6 +1004,7 @@ Emits tokens in fixed amounts for specific intervals. "Perpetual Distribution Rules", None, &mut self.token_creator_perpetual_distribution_rules_expanded, + None, ); }); diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 59e402e5a..015913bde 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -8,6 +8,7 @@ use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, ContractSearchStatus, TokensScreen, }; +use crate::wallet_backend::poison::MutexRecover; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::emath::Align; use egui::{RichText, Ui}; @@ -59,7 +60,7 @@ impl TokensScreen { if go_clicked || enter_pressed { // Clear old results, set status - self.search_results.lock().unwrap().clear(); + self.search_results.lock_recover().clear(); self.contract_search_status = ContractSearchStatus::WaitingForResult; self.operation_banner.take_and_clear(); let handle = MessageBanner::set_global( @@ -86,7 +87,7 @@ impl TokensScreen { // Clear the search input self.token_search_query = Some("".to_string()); // Clear the search results - self.search_results.lock().unwrap().clear(); + self.search_results.lock_recover().clear(); // Reset the search status self.contract_search_status = ContractSearchStatus::NotStarted; // Clear pagination state @@ -116,7 +117,7 @@ impl TokensScreen { } ContractSearchStatus::Complete => { // Show the results - let results = self.search_results.lock().unwrap().clone(); + let results = self.search_results.lock_recover().clone(); if results.is_empty() { ui.label("No tokens match your keyword."); } else { diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index b477c23a3..d9318957e 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -11,6 +11,7 @@ pub use structs::*; pub use groups::*; +use crate::wallet_backend::poison::MutexRecover; use std::collections::BTreeMap; use std::sync::{Arc, Mutex, RwLock}; use tracing::error; @@ -45,15 +46,16 @@ use dash_sdk::dpp::prelude::TimestampMillisInterval; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use dash_sdk::query_types::IndexMap; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Ui}; use crate::ui::theme::{DashColors, ResponseExt}; use egui::{Checkbox, ColorImage, ComboBox, Response, RichText, TextEdit, TextureHandle}; use enum_iterator::Sequence; use image::ImageReader; use crate::app::BackendTasksExecutionMode; use crate::backend_task::contract::ContractTask; +use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; -use crate::backend_task::{BackendTask, NO_IDENTITIES_FOUND}; +use crate::backend_task::BackendTask; use crate::app::{AppAction, DesiredAppAction}; use crate::context::AppContext; @@ -239,8 +241,28 @@ impl From<ChangeControlRulesV0> for ChangeControlRulesUI { } } +/// Extra "minting destination" controls appended below the manual-mint rule grid. +/// +/// Passed to [`ChangeControlRulesUI::render_control_change_rules_ui`] only for the +/// Manual Mint action; every other action passes `None`. +pub struct MintRecipientSection<'a> { + pub should_default_to_contract_owner: &'a mut bool, + pub destination_identity_enabled: &'a mut bool, + pub allow_choosing_destination: &'a mut bool, + pub destination_identity_rules: &'a mut ChangeControlRulesUI, + pub destination_identity: &'a mut String, + pub allow_choosing_destination_rules: &'a mut ChangeControlRulesUI, + pub destination_expanded: &'a mut bool, + pub allow_choosing_expanded: &'a mut bool, +} + impl ChangeControlRulesUI { /// Renders the UI for a single action’s configuration (mint, burn, freeze, etc.) + /// + /// `special_case_option` carries the Freeze-only "allow transfers to frozen + /// identities" toggle; `mint_section` carries the Manual-Mint-only destination + /// controls. Both are `None` for every other action. + #[allow(clippy::too_many_arguments)] pub fn render_control_change_rules_ui( &mut self, ui: &mut Ui, @@ -248,6 +270,7 @@ impl ChangeControlRulesUI { action_name: &str, special_case_option: Option<&mut bool>, is_expanded: &mut bool, + mint_section: Option<MintRecipientSection>, ) { ui.horizontal(|ui| { // +/- button @@ -331,7 +354,7 @@ impl ChangeControlRulesUI { self.authorized_identity.get_or_insert_with(String::new); if let Some(ref mut id_str) = self.authorized_identity { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_sized( [300.0, 22.0], TextEdit::singleline(id_str) @@ -414,7 +437,7 @@ impl ChangeControlRulesUI { self.admin_identity.get_or_insert_with(String::new); if let Some(ref mut id_str) = self.admin_identity { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_sized( [300.0, 22.0], TextEdit::singleline(id_str) @@ -478,246 +501,16 @@ impl ChangeControlRulesUI { }); ui.end_row(); } - }); - }); - } - } - - #[allow(clippy::too_many_arguments)] - pub fn render_mint_control_change_rules_ui( - &mut self, - ui: &mut Ui, - current_groups: &[GroupConfigUI], - new_tokens_destination_identity_should_default_to_contract_owner: &mut bool, - new_tokens_destination_identity_enabled: &mut bool, - minting_allow_choosing_destination: &mut bool, - new_tokens_destination_identity_rules: &mut ChangeControlRulesUI, - new_tokens_destination_identity: &mut String, - minting_allow_choosing_destination_rules: &mut ChangeControlRulesUI, - is_expanded: &mut bool, - new_tokens_destination_expanded: &mut bool, - minting_allow_choosing_expanded: &mut bool, - ) { - ui.horizontal(|ui| { - // +/- button - let button_text = if *is_expanded { "−" } else { "+" }; - let button_response = ui.add( - egui::Button::new( - RichText::new(button_text) - .size(20.0) - .color(DashColors::DASH_BLUE), - ) - .fill(Color32::TRANSPARENT) - .stroke(egui::Stroke::NONE), - ); - if button_response.clicked() { - *is_expanded = !*is_expanded; - } - ui.label("Manual Mint"); - }); - - if *is_expanded { - ui.indent("manual_mint_content", |ui| { - egui::Grid::new("manual_mint_grid") - .num_columns(2) - .spacing([16.0, 8.0]) // Horizontal, vertical spacing - .show(ui, |ui| { - // Authorized action takers - ui.horizontal(|ui| { - ui.label("Authorized to perform action:"); - ComboBox::from_id_salt(format!("Authorized Manual Mint {}", current_groups.len())) - .selected_text(match self.rules.authorized_to_make_change { - AuthorizedActionTakers::NoOne => "No One".to_string(), - AuthorizedActionTakers::ContractOwner => "Contract Owner".to_string(), - AuthorizedActionTakers::Identity(id) => { - if id == Identifier::default() { - "Identity".to_string() - } else { - format!("Identity({})", id) - } - }, - AuthorizedActionTakers::MainGroup => "Main Group".to_string(), - AuthorizedActionTakers::Group(position) => format!("Group {}", position), - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.rules.authorized_to_make_change, - AuthorizedActionTakers::NoOne, - "No One", - ); - ui.selectable_value( - &mut self.rules.authorized_to_make_change, - AuthorizedActionTakers::ContractOwner, - "Contract Owner", - ); - ui.selectable_value( - &mut self.rules.authorized_to_make_change, - AuthorizedActionTakers::Identity(Identifier::default()), - "Identity", - ); - if current_groups.is_empty() { - ui.horizontal(|ui| { - ui.label(RichText::new("(No Groups Added Yet)").color(Color32::GRAY)); - }); - } else { - ui.selectable_value( - &mut self.rules.authorized_to_make_change, - AuthorizedActionTakers::MainGroup, - "Main Group", - ); - } - for (group_position, _group) in current_groups.iter().enumerate() { - ui.selectable_value( - &mut self.rules.authorized_to_make_change, - AuthorizedActionTakers::Group(group_position as GroupContractPosition), - format!("Group {}", group_position), - ); - } - }); - - // If user selected Identity or Group, show text edit - if let AuthorizedActionTakers::Identity(_) = &mut self.rules.authorized_to_make_change { - self.authorized_identity.get_or_insert_with(String::new); - if let Some(ref mut id_str) = self.authorized_identity { - ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add_sized( - [300.0, 22.0], - TextEdit::singleline(id_str) - .hint_text("Enter base58 id") - .text_color(DashColors::text_primary(dark_mode)) - .background_color(DashColors::input_background(dark_mode)), - ); - - if !id_str.is_empty() { - let is_valid = Identifier::from_string(id_str.as_str(), Encoding::Base58).is_ok(); - - let (symbol, color) = if is_valid { - ("✔", Color32::DARK_GREEN) - } else { - ("×", Color32::DARK_RED) - }; - - ui.label(RichText::new(symbol).color(color).strong()); - } - }); - } - } - }); - ui.end_row(); - - // Admin action takers - ui.horizontal(|ui| { - ui.label("Authorized to change rules:"); - ComboBox::from_id_salt(format!("Admin Manual Mint {}", current_groups.len())) - .selected_text(match self.rules.admin_action_takers { - AuthorizedActionTakers::NoOne => "No One".to_string(), - AuthorizedActionTakers::ContractOwner => "Contract Owner".to_string(), - AuthorizedActionTakers::Identity(id) => { - if id == Identifier::default() { - "Identity".to_string() - } else { - format!("Identity({})", id) - } - }, - AuthorizedActionTakers::MainGroup => "Main Group".to_string(), - AuthorizedActionTakers::Group(position) => format!("Group {}", position), - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.rules.admin_action_takers, - AuthorizedActionTakers::NoOne, - "No One", - ); - ui.selectable_value( - &mut self.rules.admin_action_takers, - AuthorizedActionTakers::ContractOwner, - "Contract Owner", - ); - ui.selectable_value( - &mut self.rules.admin_action_takers, - AuthorizedActionTakers::Identity(Identifier::default()), - "Identity", - ); - if current_groups.is_empty() { - ui.horizontal(|ui| { - ui.label(RichText::new("(No Groups Added Yet)").color(Color32::GRAY)); - }); - } else { - ui.selectable_value( - &mut self.rules.admin_action_takers, - AuthorizedActionTakers::MainGroup, - "Main Group", - ); - } - for (group_position, _group) in current_groups.iter().enumerate() { - ui.selectable_value( - &mut self.rules.admin_action_takers, - AuthorizedActionTakers::Group(group_position as GroupContractPosition), - format!("Group {}", group_position), - ); - } - }); - - if let AuthorizedActionTakers::Identity(_) = &mut self.rules.admin_action_takers { - self.admin_identity.get_or_insert_with(String::new); - if let Some(ref mut id_str) = self.admin_identity { - ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add_sized( - [300.0, 22.0], - TextEdit::singleline(id_str) - .hint_text("Enter base58 id") - .text_color(DashColors::text_primary(dark_mode)) - .background_color(DashColors::input_background(dark_mode)), - ); - - if !id_str.is_empty() { - let is_valid = Identifier::from_string(id_str.as_str(), Encoding::Base58).is_ok(); - - let (symbol, color) = if is_valid { - ("✔", Color32::DARK_GREEN) - } else { - ("×", Color32::DARK_RED) - }; - - ui.label(RichText::new(symbol).color(color).strong()); - } - }); - } - } - }); - ui.end_row(); - - // Booleans - ui.checkbox( - &mut self - .rules - .changing_authorized_action_takers_to_no_one_allowed, - "Changing authorized action takers to no one allowed", - ); - ui.end_row(); - - ui.checkbox( - &mut self.rules.changing_admin_action_takers_to_no_one_allowed, - "Changing admin action takers to no one allowed", - ); - ui.end_row(); - - ui.checkbox( - &mut self.rules.self_changing_admin_action_takers_allowed, - "Self-changing admin action takers allowed", - ); - ui.end_row(); - - if self.rules.authorized_to_make_change != AuthorizedActionTakers::NoOne { + if let Some(mint) = mint_section + && self.rules.authorized_to_make_change != AuthorizedActionTakers::NoOne + { let mut default_to_owner_clicked = false; let mut default_to_identity_clicked = false; if ui .checkbox( - new_tokens_destination_identity_should_default_to_contract_owner, + mint.should_default_to_contract_owner, "Newly minted tokens should default to going to contract owner", ) .clicked() @@ -727,7 +520,7 @@ impl ChangeControlRulesUI { if ui .checkbox( - new_tokens_destination_identity_enabled, + mint.destination_identity_enabled, "Use a default identity to receive newly minted tokens", ) .clicked() @@ -737,42 +530,53 @@ impl ChangeControlRulesUI { // Apply exclusivity if default_to_owner_clicked { - *new_tokens_destination_identity_enabled = false; + *mint.destination_identity_enabled = false; } if default_to_identity_clicked { - *new_tokens_destination_identity_should_default_to_contract_owner = false; + *mint.should_default_to_contract_owner = false; } - if *new_tokens_destination_identity_enabled { + if *mint.destination_identity_enabled { ui.end_row(); ui.label("Default Destination Identity (Base58):"); - ui.text_edit_singleline(new_tokens_destination_identity); + ui.text_edit_singleline(mint.destination_identity); ui.end_row(); - new_tokens_destination_identity_rules.render_control_change_rules_ui(ui, current_groups,"New Tokens Destination Identity Rules", None, new_tokens_destination_expanded); + mint.destination_identity_rules.render_control_change_rules_ui( + ui, + current_groups, + "New Tokens Destination Identity Rules", + None, + mint.destination_expanded, + None, + ); } ui.end_row(); - // MINTING ALLOW CHOOSING DESTINATION ui.checkbox( - minting_allow_choosing_destination, + mint.allow_choosing_destination, "Allow user to pick a destination identity on each mint", ); - - if *minting_allow_choosing_destination { + if *mint.allow_choosing_destination { ui.end_row(); - minting_allow_choosing_destination_rules.render_control_change_rules_ui(ui, current_groups, "Minting Allow Choosing Destination Rules", None, minting_allow_choosing_expanded); + mint.allow_choosing_destination_rules.render_control_change_rules_ui( + ui, + current_groups, + "Minting Allow Choosing Destination Rules", + None, + mint.allow_choosing_expanded, + None, + ); } ui.end_row(); - // Destination Identity Mode Enforcement - let none_selected = !*new_tokens_destination_identity_enabled - && !*new_tokens_destination_identity_should_default_to_contract_owner - && !*minting_allow_choosing_destination; + let none_selected = !*mint.destination_identity_enabled + && !*mint.should_default_to_contract_owner + && !*mint.allow_choosing_destination; if none_selected { ui.colored_label( @@ -1147,16 +951,52 @@ pub struct TokenBuildArgs { pub distribution_rules: TokenDistributionRules, pub groups: BTreeMap<u16, Group>, pub document_schemas: Option<BTreeMap<String, serde_json::Value>>, - pub marketplace_trade_mode: u8, pub marketplace_rules: ChangeControlRules, pub change_direct_purchase_pricing_rules: ChangeControlRules, } +impl TokenBuildArgs { + /// Converts parsed UI inputs into the backend token-contract parameters + /// consumed by `AppContext::build_data_contract_v1_with_one_token`. + pub fn into_contract_params(self) -> crate::backend_task::tokens::TokenContractParams { + crate::backend_task::tokens::TokenContractParams { + token_names: self.token_names, + contract_keywords: self.contract_keywords, + token_description: self.token_description, + should_capitalize: self.should_capitalize, + decimals: self.decimals, + base_supply: self.base_supply, + max_supply: self.max_supply, + start_paused: self.start_paused, + allow_transfers_to_frozen_identities: self.allow_transfers_to_frozen_identities, + keeps_history: self.keeps_history, + main_control_group: self.main_control_group, + manual_minting_rules: self.manual_minting_rules, + manual_burning_rules: self.manual_burning_rules, + freeze_rules: self.freeze_rules, + unfreeze_rules: self.unfreeze_rules, + destroy_frozen_funds_rules: self.destroy_frozen_funds_rules, + emergency_action_rules: self.emergency_action_rules, + max_supply_change_rules: self.max_supply_change_rules, + conventions_change_rules: self.conventions_change_rules, + main_control_group_change_authorized: self.main_control_group_change_authorized, + distribution_rules: self.distribution_rules, + groups: self.groups, + document_schemas: self.document_schemas, + marketplace_rules: self.marketplace_rules, + } + } +} + pub type TokenSearchable = bool; /// The main, combined TokensScreen: /// - Displays token balances or a search UI /// - Allows reordering of tokens if desired +/// +// TODO(det): this god-struct still mixes state for every subscreen (My Tokens, +// Search, Token Creator). Split per-subscreen state into dedicated structs behind +// a thin shell; the kittest reads `selected_identity` directly, so update it too. pub struct TokensScreen { pub app_context: Arc<AppContext>, pub tokens_subscreen: TokensSubscreen, @@ -1238,7 +1078,7 @@ pub struct TokensScreen { selected_token_preset: Option<TokenConfigurationPresetFeatures>, show_pop_up_info: Option<String>, identity_id_string: String, - selected_identity: Option<QualifiedIdentity>, + pub selected_identity: Option<QualifiedIdentity>, selected_key: Option<IdentityPublicKey>, selected_wallet: Option<Arc<RwLock<Wallet>>>, wallet_open_attempted: bool, @@ -1279,7 +1119,6 @@ pub struct TokensScreen { main_control_group_change_authorized_group: Option<String>, // Marketplace rules - marketplace_trade_mode: u8, // 0 = NotTradeable, future values for other modes marketplace_rules: ChangeControlRulesUI, change_direct_purchase_pricing_rules: ChangeControlRulesUI, @@ -1515,8 +1354,7 @@ impl TokensScreen { .map(|qi| (qi.identity.id(), qi)) .collect(); let all_known_tokens = app_context - .db - .get_all_known_tokens_with_data_contract(app_context) + .get_all_known_tokens_with_data_contract() .unwrap_or_default(); let my_tokens = my_tokens( @@ -1643,7 +1481,6 @@ impl TokensScreen { main_control_group_change_authorized_group: None, // Marketplace rules - marketplace_trade_mode: 0, // NotTradeable marketplace_rules: ChangeControlRulesUI::default(), change_direct_purchase_pricing_rules: ChangeControlRulesUI::default(), @@ -1774,11 +1611,25 @@ impl TokensScreen { document_schemas_error: None, }; - if let Ok(saved_ids) = screen.app_context.db.load_token_order() { + if let Ok(saved_ids) = screen.app_context.load_token_order() { screen.reorder_vec_to(saved_ids); screen.use_custom_order = true; } + // Seed selected_identity from the app-scoped selection (W4 SYNC); fall back to first. + if let Some(preferred_id) = screen.app_context.selected_identity_id() + && let Some(qi) = screen.identities.get(&preferred_id) + { + screen.selected_identity = Some(qi.clone()); + screen.identity_id_string = preferred_id.to_string(Encoding::Base58); + } + if screen.selected_identity.is_none() + && let Some((id, qi)) = screen.identities.iter().next() + { + screen.selected_identity = Some(qi.clone()); + screen.identity_id_string = id.to_string(Encoding::Base58); + } + screen } @@ -1822,7 +1673,6 @@ impl TokensScreen { .collect::<Vec<_>>(); self.app_context - .db .save_token_order(all_ids) .map_err(|e| { error!("Error saving token order: {}", e); @@ -2407,7 +2257,6 @@ impl TokensScreen { self.authorized_main_control_group_change = AuthorizedActionTakers::NoOne; self.main_control_group_change_authorized_identity = None; self.main_control_group_change_authorized_group = None; - self.marketplace_trade_mode = 0; self.marketplace_rules = ChangeControlRulesUI::default(); self.change_direct_purchase_pricing_rules = ChangeControlRulesUI::default(); self.main_control_group_input = "".to_string(); @@ -2584,13 +2433,13 @@ impl TokensScreen { AppAction::None } - fn show_remove_identity_token_balance_popup(&mut self, ui: &mut Ui) { + fn show_remove_identity_token_balance_popup(&mut self, ui: &mut Ui) -> AppAction { // If no token is set, nothing to confirm let token_to_remove = match &self.identity_token_balance_to_remove { Some(token) => token.clone(), None => { self.confirm_remove_identity_token_balance_popup = false; - return; + return AppAction::None; } }; @@ -2613,32 +2462,21 @@ impl TokensScreen { // Show the dialog and handle the response let response = confirmation_dialog.show(ui).inner; + let mut action = AppAction::None; if let Some(status) = response.dialog_response { - match status { - ConfirmationStatus::Confirmed => { - if let Err(e) = self - .app_context - .remove_token_balance(token_to_remove.token_id, token_to_remove.identity_id) - { - MessageBanner::set_global( - self.app_context.egui_ctx(), - format!("Error removing token balance: {}", e), - MessageType::Error, - ); - } else { - self.refresh(); - } - self.confirm_remove_identity_token_balance_popup = false; - self.identity_token_balance_to_remove = None; - self.remove_identity_token_balance_confirmation_dialog = None; - } - ConfirmationStatus::Canceled => { - self.confirm_remove_identity_token_balance_popup = false; - self.identity_token_balance_to_remove = None; - self.remove_identity_token_balance_confirmation_dialog = None; - } + if let ConfirmationStatus::Confirmed = status { + action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::StopTrackingTokenBalance(IdentityTokenIdentifier { + identity_id: token_to_remove.identity_id, + token_id: token_to_remove.token_id, + }), + ))); } + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + self.remove_identity_token_balance_confirmation_dialog = None; } + action } fn show_remove_token_popup(&mut self, ui: &mut Ui) { @@ -2677,11 +2515,7 @@ impl TokensScreen { if let Some(status) = response.dialog_response { match status { ConfirmationStatus::Confirmed => { - if let Err(e) = self - .app_context - .db - .remove_token(&token_to_remove, &self.app_context) - { + if let Err(e) = self.app_context.remove_token(&token_to_remove) { MessageBanner::set_global( self.app_context.egui_ctx(), format!("Error removing token balance: {}", e), @@ -2751,8 +2585,7 @@ impl ScreenLike for TokensScreen { fn refresh(&mut self) { self.all_known_tokens = self .app_context - .db - .get_all_known_tokens_with_data_contract(&self.app_context) + .get_all_known_tokens_with_data_contract() .unwrap_or_default(); self.identities = self @@ -2775,7 +2608,7 @@ impl ScreenLike for TokensScreen { &self.token_pricing_data, ); - match self.app_context.db.load_token_order() { + match self.app_context.load_token_order() { Ok(saved_ids) => { self.reorder_vec_to(saved_ids); @@ -2793,8 +2626,7 @@ impl ScreenLike for TokensScreen { self.all_known_tokens = self .app_context - .db - .get_all_known_tokens_with_data_contract(&self.app_context) + .get_all_known_tokens_with_data_contract() .unwrap_or_default(); self.identities = self .app_context @@ -2817,7 +2649,9 @@ impl ScreenLike for TokensScreen { ); } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; // Build top-right buttons @@ -2847,7 +2681,7 @@ impl ScreenLike for TokensScreen { .unwrap_or_else(|| token_id.to_string(Encoding::Base58)); action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::Custom("Back to tokens".to_string())), @@ -2866,7 +2700,7 @@ impl ScreenLike for TokensScreen { ); action |= add_top_panel( - ctx, + ui, &self.app_context, vec![ ( @@ -2879,7 +2713,7 @@ impl ScreenLike for TokensScreen { ); } else { action |= add_top_panel( - ctx, + ui, &self.app_context, vec![("Tokens", AppAction::None)], right_buttons.clone(), @@ -2890,21 +2724,18 @@ impl ScreenLike for TokensScreen { match self.tokens_subscreen { TokensSubscreen::MyTokens => { action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenMyTokenBalances, ); } TokensSubscreen::SearchTokens => { - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenTokenSearch, - ); + action |= + add_left_panel(ui, &self.app_context, RootScreenType::RootScreenTokenSearch); } TokensSubscreen::TokenCreator => { action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenTokenCreator, ); @@ -2912,10 +2743,10 @@ impl ScreenLike for TokensScreen { } // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tokens_subscreen_chooser_panel(ui, self.app_context.as_ref()); // Main panel - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { egui::ScrollArea::vertical() .show(ui, |ui| { let mut inner_action = AppAction::None; @@ -2925,11 +2756,8 @@ impl ScreenLike for TokensScreen { inner_action |= self.render_my_tokens_subscreen(ui); } TokensSubscreen::SearchTokens => { - if self.selected_contract_id.is_some() { - inner_action |= self.render_contract_details( - ui, - &self.selected_contract_id.unwrap(), - ); + if let Some(contract_id) = self.selected_contract_id { + inner_action |= self.render_contract_details(ui, &contract_id); // Render the JSON popup if needed if self.show_json_popup { self.render_data_contract_json_popup(ui); @@ -2946,7 +2774,7 @@ impl ScreenLike for TokensScreen { // Elapsed display for refreshing is handled by the global MessageBanner if self.confirm_remove_identity_token_balance_popup { - self.show_remove_identity_token_balance_popup(ui); + inner_action |= self.show_remove_identity_token_balance_popup(ui); } if self.confirm_remove_token_popup { self.show_remove_token_popup(ui); @@ -3052,7 +2880,6 @@ impl ScreenLike for TokensScreen { if msg.contains("Successfully fetched token balances") || msg.contains("Failed to fetch token balances") || msg.contains("Failed to get estimated rewards") - || msg.eq(NO_IDENTITIES_FOUND) { // Clear adding status on any error if msg.contains("Failed") { @@ -3086,13 +2913,25 @@ impl ScreenLike for TokensScreen { } } + fn display_task_error(&mut self, error: &TaskError) -> bool { + // A token-balance refresh with no local identities is a normal empty + // state, not an in-flight operation. Clear the refresh indicator and let + // AppState show the informational banner. + if matches!(error, TaskError::NoIdentitiesFound) + && self.tokens_subscreen == TokensSubscreen::MyTokens + { + self.refreshing_status = RefreshingStatus::NotRefreshing; + } + false + } + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { // Clear any active operation banner self.operation_banner.take_and_clear(); match backend_task_success_result { BackendTaskSuccessResult::DescriptionsByKeyword(descriptions, next_cursor) => { - let mut sr = self.search_results.lock().unwrap(); + let mut sr = self.search_results.lock_recover(); *sr = descriptions; self.search_has_next_page = next_cursor.is_some(); if let Some(cursor) = next_cursor { @@ -3103,8 +2942,9 @@ impl ScreenLike for TokensScreen { } BackendTaskSuccessResult::ContractsWithDescriptions(contracts_with_descriptions) => { let default_info = (None, vec![]); - let info = contracts_with_descriptions - .get(&self.selected_contract_id.unwrap()) + let info = self + .selected_contract_id + .and_then(|id| contracts_with_descriptions.get(&id)) .unwrap_or(&default_info); self.selected_contract_description = info.0.clone(); @@ -3158,7 +2998,6 @@ impl ScreenLike for TokensScreen { #[cfg(test)] mod tests { - use std::path::Path; use std::sync::Once; use crate::app_dir::copy_env_file_if_not_exists; @@ -3224,17 +3063,30 @@ mod tests { let db_file_path = "test_db_token_creator"; let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); - db.initialize(Path::new(&db_file_path)).unwrap(); + // Force legacy wallet-family schema for tests — `initialize` + // gates these out for truly-fresh installs post-T-DEV-01. + db.create_tables(true).unwrap(); + db.set_default_version().unwrap(); ensure_test_env(); + // The upstream SQLite persister that backs `app_kv` is not safe + // to re-open concurrently from multiple tests on the same file, + // so each test gets its own scratch directory. Production opens + // a single persister per process via `AppContext::open_app_kv`. + let kv_tmp = tempfile::tempdir().expect("kv tmpdir"); + let app_kv = AppContext::open_app_kv(kv_tmp.path()).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(kv_tmp.path()).expect("open secret store"); + let data_dir = crate::app_dir::app_user_data_dir_path().unwrap(); let app_context = AppContext::new( - crate::app_dir::app_user_data_dir_path().unwrap(), + data_dir, Network::Regtest, db, - None, Default::default(), Default::default(), egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3260,6 +3112,7 @@ mod tests { }, dpns_names: vec![], associated_wallets: BTreeMap::new(), + secret_access: None, wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, @@ -3378,35 +3231,9 @@ mod tests { let build_args = token_creator_ui .parse_token_build_args() .expect("parse_token_build_args should succeed"); + let owner_id = build_args.identity_id; let data_contract = app_context - .build_data_contract_v1_with_one_token( - build_args.identity_id, - build_args.token_names, - build_args.contract_keywords, - build_args.token_description, - build_args.should_capitalize, - build_args.decimals, - build_args.base_supply, - build_args.max_supply, - build_args.start_paused, - build_args.allow_transfers_to_frozen_identities, - build_args.keeps_history, - build_args.main_control_group, - build_args.manual_minting_rules, - build_args.manual_burning_rules, - build_args.freeze_rules, - build_args.unfreeze_rules, - build_args.destroy_frozen_funds_rules, - build_args.emergency_action_rules, - build_args.max_supply_change_rules, - build_args.conventions_change_rules, - build_args.main_control_group_change_authorized, - build_args.distribution_rules, - build_args.groups, - build_args.document_schemas, - build_args.marketplace_trade_mode, - build_args.marketplace_rules, - ) + .build_data_contract_v1_with_one_token(owner_id, build_args.into_contract_params()) .expect("Contract build failed"); // ------------------------------------------------- @@ -3538,17 +3365,30 @@ mod tests { let db_file_path = "test_db_distribution_random"; let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); - db.initialize(Path::new(&db_file_path)).unwrap(); + // Force legacy wallet-family schema for tests — `initialize` + // gates these out for truly-fresh installs post-T-DEV-01. + db.create_tables(true).unwrap(); + db.set_default_version().unwrap(); ensure_test_env(); + // The upstream SQLite persister that backs `app_kv` is not safe + // to re-open concurrently from multiple tests on the same file, + // so each test gets its own scratch directory. Production opens + // a single persister per process via `AppContext::open_app_kv`. + let kv_tmp = tempfile::tempdir().expect("kv tmpdir"); + let app_kv = AppContext::open_app_kv(kv_tmp.path()).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(kv_tmp.path()).expect("open secret store"); + let data_dir = crate::app_dir::app_user_data_dir_path().unwrap(); let app_context = AppContext::new( - crate::app_dir::app_user_data_dir_path().unwrap(), + data_dir, Network::Regtest, db, - None, Default::default(), Default::default(), egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3574,6 +3414,7 @@ mod tests { }, dpns_names: vec![], associated_wallets: BTreeMap::new(), + secret_access: None, wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, @@ -3608,35 +3449,9 @@ mod tests { let build_args = token_creator_ui .parse_token_build_args() .expect("Should parse"); + let owner_id = build_args.identity_id; let data_contract = app_context - .build_data_contract_v1_with_one_token( - build_args.identity_id, - build_args.token_names, - build_args.contract_keywords, - build_args.token_description, - build_args.should_capitalize, - build_args.decimals, - build_args.base_supply, - build_args.max_supply, - build_args.start_paused, - build_args.allow_transfers_to_frozen_identities, - build_args.keeps_history, - build_args.main_control_group, - build_args.manual_minting_rules, - build_args.manual_burning_rules, - build_args.freeze_rules, - build_args.unfreeze_rules, - build_args.destroy_frozen_funds_rules, - build_args.emergency_action_rules, - build_args.max_supply_change_rules, - build_args.conventions_change_rules, - build_args.main_control_group_change_authorized, - build_args.distribution_rules, - build_args.groups, - build_args.document_schemas, - build_args.marketplace_trade_mode, - build_args.marketplace_rules, - ) + .build_data_contract_v1_with_one_token(owner_id, build_args.into_contract_params()) .expect("Should build successfully"); let contract_v1 = data_contract.as_v1().expect("Expected DataContract::V1"); @@ -3666,17 +3481,30 @@ mod tests { let db_file_path = "test_db_empty_token_name"; let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); - db.initialize(Path::new(&db_file_path)).unwrap(); + // Force legacy wallet-family schema for tests — `initialize` + // gates these out for truly-fresh installs post-T-DEV-01. + db.create_tables(true).unwrap(); + db.set_default_version().unwrap(); ensure_test_env(); + // The upstream SQLite persister that backs `app_kv` is not safe + // to re-open concurrently from multiple tests on the same file, + // so each test gets its own scratch directory. Production opens + // a single persister per process via `AppContext::open_app_kv`. + let kv_tmp = tempfile::tempdir().expect("kv tmpdir"); + let app_kv = AppContext::open_app_kv(kv_tmp.path()).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(kv_tmp.path()).expect("open secret store"); + let data_dir = crate::app_dir::app_user_data_dir_path().unwrap(); let app_context = AppContext::new( - crate::app_dir::app_user_data_dir_path().unwrap(), + data_dir, Network::Regtest, db, - None, Default::default(), Default::default(), egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3702,6 +3530,7 @@ mod tests { }, dpns_names: vec![], associated_wallets: BTreeMap::new(), + secret_access: None, wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 618f98fae..f015d5b1d 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -177,7 +177,7 @@ impl TokensScreen { if let Some(token_info) = self.all_known_tokens.get(&token_id).cloned() { let mut is_open = true; let mut close_popup = false; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let window_response = egui::Window::new("Token Configuration Details") .resizable(true) @@ -195,7 +195,7 @@ impl TokensScreen { self.render_token_info_popup_content(ui, &token_info); ui.separator(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -230,7 +230,7 @@ impl TokensScreen { } fn render_no_owned_tokens(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) @@ -623,7 +623,7 @@ impl TokensScreen { } ui.separator(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -673,7 +673,7 @@ impl TokensScreen { let mut pos = 0; let mut action = AppAction::None; ui.spacing_mut().item_spacing.x = 5.0; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if range.contains(&pos) { if itb.available_actions.can_transfer { diff --git a/src/ui/tokens/tokens_screen/structs.rs b/src/ui/tokens/tokens_screen/structs.rs index d7414c7f1..514ade961 100644 --- a/src/ui/tokens/tokens_screen/structs.rs +++ b/src/ui/tokens/tokens_screen/structs.rs @@ -1,5 +1,6 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::tokens::tokens_screen::validate_perpetual_distribution_recipient; @@ -7,6 +8,7 @@ use dash_sdk::dpp::balances::credits::TokenAmount; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::methods::v0::TokenPerpetualDistributionV0Accessors; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; @@ -171,38 +173,6 @@ impl IdentityTokenInfo { token_position: *token_position, }) } - // Allow dead_code: This constructor creates token info from identity balances with lookup, - // useful for token management screens requiring detailed context resolution - #[allow(dead_code)] - pub fn try_from_identity_token_balance_with_actions_with_lookup( - identity_token_balance: &IdentityTokenBalanceWithActions, - app_context: &AppContext, - ) -> Result<Self, TaskError> { - let IdentityTokenBalanceWithActions { - token_id, - token_alias, - token_config, - identity_id, - data_contract_id, - token_position, - .. - } = identity_token_balance; - let identity = app_context - .get_identity_by_id(identity_id)? - .ok_or(TaskError::IdentityNotFoundLocally)?; - let data_contract = app_context - .get_contract_by_id(data_contract_id)? - .ok_or(TaskError::DataContractNotFound)?; - Ok(Self { - token_id: *token_id, - token_alias: token_alias.clone(), - identity, - data_contract, - token_config: token_config.clone(), - token_position: *token_position, - }) - } - pub fn try_from_identity_token_maybe_balance_with_actions_with_lookup( identity_token_balance: &IdentityTokenMaybeBalanceWithActions, app_context: &AppContext, @@ -555,3 +525,40 @@ pub fn get_available_token_actions_for_identity( can_update_config, } } + +impl Amount { + /// Creates an [`Amount`] for a token, taking decimals and unit name from + /// the token configuration. + pub fn from_token(token_info: &IdentityTokenInfo, value: TokenAmount) -> Self { + let decimal_places = token_info.token_config.conventions().decimals(); + Self::new(value, decimal_places).with_unit_name(&token_info.token_alias) + } +} + +impl From<&IdentityTokenBalance> for Amount { + /// Uses the token configuration's decimals and the token alias as the unit name. + fn from(token_balance: &IdentityTokenBalance) -> Self { + let decimal_places = token_balance.token_config.conventions().decimals(); + Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) + } +} + +impl From<IdentityTokenBalance> for Amount { + fn from(token_balance: IdentityTokenBalance) -> Self { + Self::from(&token_balance) + } +} + +impl From<&IdentityTokenBalanceWithActions> for Amount { + /// Uses the token configuration's decimals and the token alias as the unit name. + fn from(token_balance: &IdentityTokenBalanceWithActions) -> Self { + let decimal_places = token_balance.token_config.conventions().decimals(); + Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) + } +} + +impl From<IdentityTokenBalanceWithActions> for Amount { + fn from(token_balance: IdentityTokenBalanceWithActions) -> Self { + Self::from(&token_balance) + } +} diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 1192c493f..2ab7ae222 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -25,7 +25,10 @@ use crate::ui::MessageType; use crate::ui::helpers::{add_identity_key_chooser, TransactionType}; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use crate::ui::tokens::tokens_screen::{TokenBuildArgs, TokenCreatorStatus, TokenNameLanguage, TokensScreen, ChangeControlRulesUI}; +use crate::ui::tokens::tokens_screen::{ + ChangeControlRulesUI, MintRecipientSection, TokenBuildArgs, TokenCreatorStatus, + TokenNameLanguage, TokensScreen, +}; impl TokensScreen { pub(super) fn render_token_creator(&mut self, context: &Context, ui: &mut Ui) -> AppAction { @@ -77,7 +80,7 @@ impl TokensScreen { } }; if all_identities.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) .corner_radius(5.0) @@ -143,7 +146,7 @@ impl TokensScreen { ui.heading("1. Select an identity:"); ui.add_space(5.0); - // Use IdentitySelector for simple mode + // Use IdentitySelector for simple mode — SYNC: write-back via syncing_global. let response = ui.add( IdentitySelector::new( "simple_identity_selector", @@ -154,7 +157,8 @@ impl TokensScreen { .expect("selected_identity should not fail") .other_option(false) .label("Identity:") - .width(300.0), + .width(300.0) + .syncing_global(self.app_context.clone()), ); // Auto-select the first eligible key when: @@ -219,7 +223,12 @@ impl TokensScreen { ui.heading("1. Select an identity and key to register the token contract with:"); ui.add_space(5.0); - // Use the helper function for identity and key selection + // Use the helper function for identity and key selection. + // SYNC (W4): snapshot before, write-back after on change. + let before_id = self + .selected_identity + .as_ref() + .map(|qi| qi.identity.id()); add_identity_key_chooser( ui, &self.app_context, @@ -228,6 +237,14 @@ impl TokensScreen { &mut self.selected_key, TransactionType::RegisterContract, ); + let after_id = self + .selected_identity + .as_ref() + .map(|qi| qi.identity.id()); + if before_id != after_id { + self.app_context + .set_selected_identity(after_id); + } ui.add_space(5.0); @@ -640,30 +657,6 @@ impl TokensScreen { crate::ui::helpers::info_icon_button(ui, "The decimal places of the token, for example Dash and Bitcoin use 8. The minimum indivisible amount is a Duff or a Satoshi respectively. If you put a value greater than 0 this means that it is indicated that the consensus is that 10^(number entered) is what represents 1 full unit of the token."); }); ui.end_row(); - - // Marketplace Trade Mode - ui.horizontal(|ui| { - ui.label("Marketplace Trade Mode:"); - ComboBox::from_id_salt("marketplace_trade_mode_selector") - .selected_text("Not Tradeable") - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.marketplace_trade_mode, - 0, - "Not Tradeable", - ); - // Future trade modes can be added here when SDK supports them - }); - - crate::ui::helpers::info_icon_button(ui, - "Currently, all tokens are created as 'Not Tradeable'. \ - Future updates will add more trade mode options.\n\n\ - IMPORTANT: If you want to enable marketplace trading in the future, \ - make sure to set the 'Marketplace Trade Mode Change' rules in the Action Rules \ - section to something other than 'No One'. Otherwise, trading can never be enabled." - ); - }); - ui.end_row(); }); }); } @@ -749,16 +742,40 @@ impl TokensScreen { ui.horizontal(|ui| { ui.add_space(20.0); // Indentation for action rules ui.vertical(|ui| { - self.manual_minting_rules.render_mint_control_change_rules_ui(ui, &self.groups_ui, &mut self.new_tokens_destination_identity_should_default_to_contract_owner, &mut self.new_tokens_destination_other_identity_enabled, &mut self.minting_allow_choosing_destination, &mut self.new_tokens_destination_identity_rules, &mut self.new_tokens_destination_other_identity, &mut self.minting_allow_choosing_destination_rules, &mut self.token_creator_manual_mint_expanded, &mut self.token_creator_new_tokens_destination_expanded, &mut self.token_creator_minting_allow_choosing_expanded); - self.manual_burning_rules.render_control_change_rules_ui(ui, &self.groups_ui,"Manual Burn", None, &mut self.token_creator_manual_burn_expanded); - self.freeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Freeze", Some(&mut self.allow_transfers_to_frozen_identities), &mut self.token_creator_freeze_expanded); - self.unfreeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Unfreeze", None, &mut self.token_creator_unfreeze_expanded); - self.destroy_frozen_funds_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Destroy Frozen Funds", None, &mut self.token_creator_destroy_frozen_expanded); - self.emergency_action_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Emergency Action", None, &mut self.token_creator_emergency_action_expanded); - self.max_supply_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Max Supply Change", None, &mut self.token_creator_max_supply_change_expanded); - self.conventions_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Conventions Change", None, &mut self.token_creator_conventions_change_expanded); - self.marketplace_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Marketplace Trade Mode Change", None, &mut self.token_creator_marketplace_expanded); - self.change_direct_purchase_pricing_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Direct Purchase Pricing Change", None, &mut self.token_creator_direct_purchase_pricing_expanded); + self.manual_minting_rules.render_control_change_rules_ui( + ui, + &self.groups_ui, + "Manual Mint", + None, + &mut self.token_creator_manual_mint_expanded, + Some(MintRecipientSection { + should_default_to_contract_owner: &mut self + .new_tokens_destination_identity_should_default_to_contract_owner, + destination_identity_enabled: &mut self + .new_tokens_destination_other_identity_enabled, + allow_choosing_destination: &mut self + .minting_allow_choosing_destination, + destination_identity_rules: &mut self + .new_tokens_destination_identity_rules, + destination_identity: &mut self + .new_tokens_destination_other_identity, + allow_choosing_destination_rules: &mut self + .minting_allow_choosing_destination_rules, + destination_expanded: &mut self + .token_creator_new_tokens_destination_expanded, + allow_choosing_expanded: &mut self + .token_creator_minting_allow_choosing_expanded, + }), + ); + self.manual_burning_rules.render_control_change_rules_ui(ui, &self.groups_ui,"Manual Burn", None, &mut self.token_creator_manual_burn_expanded, None); + self.freeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Freeze", Some(&mut self.allow_transfers_to_frozen_identities), &mut self.token_creator_freeze_expanded, None); + self.unfreeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Unfreeze", None, &mut self.token_creator_unfreeze_expanded, None); + self.destroy_frozen_funds_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Destroy Frozen Funds", None, &mut self.token_creator_destroy_frozen_expanded, None); + self.emergency_action_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Emergency Action", None, &mut self.token_creator_emergency_action_expanded, None); + self.max_supply_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Max Supply Change", None, &mut self.token_creator_max_supply_change_expanded, None); + self.conventions_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Conventions Change", None, &mut self.token_creator_conventions_change_expanded, None); + self.marketplace_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Marketplace Trade Mode Change", None, &mut self.token_creator_marketplace_expanded, None); + self.change_direct_purchase_pricing_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Direct Purchase Pricing Change", None, &mut self.token_creator_direct_purchase_pricing_expanded, None); }); }); @@ -891,33 +908,10 @@ impl TokensScreen { // We have the parsed token creation arguments // We can now call build_data_contract_v1_with_one_token using `args` self.cached_build_args = Some(args.clone()); + let owner_id = args.identity_id; let data_contract = match self.app_context.build_data_contract_v1_with_one_token( - args.identity_id, - args.token_names, - args.contract_keywords, - args.token_description, - args.should_capitalize, - args.decimals, - args.base_supply, - args.max_supply, - args.start_paused, - args.allow_transfers_to_frozen_identities, - args.keeps_history, - args.main_control_group, - args.manual_minting_rules, - args.manual_burning_rules, - args.freeze_rules, - args.unfreeze_rules, - args.destroy_frozen_funds_rules, - args.emergency_action_rules, - args.max_supply_change_rules, - args.conventions_change_rules, - args.main_control_group_change_authorized, - args.distribution_rules, - args.groups, - args.document_schemas, - args.marketplace_trade_mode, - args.marketplace_rules, + owner_id, + args.into_contract_params(), ) { Ok(dc) => dc, Err(e) => { @@ -993,8 +987,9 @@ impl TokensScreen { }; if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } @@ -1187,7 +1182,6 @@ impl TokensScreen { distribution_rules: TokenDistributionRules::V0(distribution_rules), groups, document_schemas: self.parsed_document_schemas.clone(), - marketplace_trade_mode: self.marketplace_trade_mode, marketplace_rules, change_direct_purchase_pricing_rules, }) @@ -1420,10 +1414,9 @@ impl TokensScreen { self.estimate_registration_cost() as f64 / 100_000_000_000.0 )); - // Check if marketplace is locked to NotTradeable forever + // Tokens are always created NotTradeable; warn if that can never change. let mut is_danger_mode = false; if let Some(args) = &self.cached_build_args { - let is_not_tradeable = args.marketplace_trade_mode == 0; let marketplace_rules_locked = matches!( args.marketplace_rules, ChangeControlRules::V0(ChangeControlRulesV0 { @@ -1433,7 +1426,7 @@ impl TokensScreen { }) ); - if is_not_tradeable && marketplace_rules_locked { + if marketplace_rules_locked { confirmation_message.push_str("\n\nWARNING: This token will be permanently set to NotTradeable and can NEVER be made tradeable in the future!"); is_danger_mode = true; } @@ -1488,35 +1481,7 @@ impl TokensScreen { BackendTask::TokenTask(Box::new(TokenTask::RegisterTokenContract { identity, signing_key: Box::new(signing_key), - - token_names: args.token_names, - contract_keywords: args.contract_keywords, - token_description: args.token_description, - should_capitalize: args.should_capitalize, - decimals: args.decimals, - base_supply: args.base_supply, - max_supply: args.max_supply, - start_paused: args.start_paused, - allow_transfers_to_frozen_identities: args - .allow_transfers_to_frozen_identities, - keeps_history: args.keeps_history, - main_control_group: args.main_control_group, - - manual_minting_rules: args.manual_minting_rules, - manual_burning_rules: args.manual_burning_rules, - freeze_rules: args.freeze_rules, - unfreeze_rules: Box::new(args.unfreeze_rules), - destroy_frozen_funds_rules: Box::new(args.destroy_frozen_funds_rules), - emergency_action_rules: Box::new(args.emergency_action_rules), - max_supply_change_rules: Box::new(args.max_supply_change_rules), - conventions_change_rules: Box::new(args.conventions_change_rules), - main_control_group_change_authorized: args - .main_control_group_change_authorized, - distribution_rules: args.distribution_rules, - groups: args.groups, - document_schemas: args.document_schemas, - marketplace_trade_mode: args.marketplace_trade_mode, - marketplace_rules: args.marketplace_rules, + params: Box::new(args.into_contract_params()), })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), ]; @@ -1579,7 +1544,7 @@ impl TokensScreen { ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let schemas_response = ui.add_sized( [ui.available_width(), 120.0], TextEdit::multiline(&mut self.document_schemas_input) @@ -1599,8 +1564,8 @@ impl TokensScreen { Color32::DARK_RED, format!("Schema validation error: {}", error), ); - } else if self.parsed_document_schemas.is_some() { - let schema_count = self.parsed_document_schemas.as_ref().unwrap().len(); + } else if let Some(parsed_document_schemas) = self.parsed_document_schemas.as_ref() { + let schema_count = parsed_document_schemas.len(); if schema_count > 0 { ui.colored_label( Color32::DARK_GREEN, diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 36b075ea1..6a95eba26 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -27,7 +27,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Ui}; use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; @@ -347,11 +347,7 @@ impl ScreenLike for TransferTokensScreen { } } if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { - match self - .app_context - .db - .get_identity_token_balances(&self.app_context) - { + match self.app_context.identity_token_balances() { Ok(token_balances) => { self.max_amount = token_balances .values() @@ -378,9 +374,11 @@ impl ScreenLike for TransferTokensScreen { } /// Renders the UI components for the withdrawal screen - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -395,16 +393,16 @@ impl ScreenLike for TransferTokensScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); - let central_panel_action = island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let central_panel_action = island_central_panel(ui, |ui| { + let dark_mode = ui.style().visuals.dark_mode; // Show the success screen if the transfer was successful if self.transfer_tokens_status == TransferTokensStatus::Complete { @@ -481,8 +479,9 @@ impl ScreenLike for TransferTokensScreen { } else { if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 43e894e15..2ba01a587 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -1,636 +1,116 @@ +use super::token_action_screen::{StepCounter, TokenAction, TokenActionCtx, TokenActionScreen}; use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; -use crate::ui::components::component_trait::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; -use crate::ui::identities::get_selected_wallet; -use crate::ui::identities::keys::add_key_screen::AddKeyScreen; -use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::tokens::validate_signing_key; -use crate::ui::{MessageType, Screen, ScreenLike}; -use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; -use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; -use dash_sdk::dpp::group::GroupStateTransitionInfo; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; -use egui::RichText; -use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use eframe::egui::Ui; +use std::sync::Arc; -/// The states for the unfreeze flow -#[derive(PartialEq)] -pub enum UnfreezeTokensStatus { - NotStarted, - WaitingForResult, - Error, - Complete, -} - -/// A screen that allows unfreezing a previously frozen identity's tokens for a specific contract -pub struct UnfreezeTokensScreen { - identity: QualifiedIdentity, - pub identity_token_info: IdentityTokenInfo, - selected_key: Option<IdentityPublicKey>, - show_advanced_options: bool, - pub public_note: Option<String>, +/// Unfreezes a previously frozen identity's tokens for a contract. +pub type UnfreezeTokensScreen = TokenActionScreen<UnfreezeAction>; - group: Option<(GroupContractPosition, Group)>, - is_unilateral_group_member: bool, - pub group_action_id: Option<Identifier>, - /// A list of identities that are frozen and can be unfrozen. - /// - /// TODO: Right now it is just a list of all identities, but it should be filtered to only show frozen ones. - frozen_identities: Vec<QualifiedIdentity>, - - /// The identity we want to freeze +pub struct UnfreezeAction { + /// Identity to unfreeze (Base58 or hex). pub unfreeze_identity_id: String, - - status: UnfreezeTokensStatus, - - // Basic references - pub app_context: Arc<AppContext>, - - // Confirmation dialog - confirmation_dialog: Option<ConfirmationDialog>, - - // If password-based wallet unlocking is needed - selected_wallet: Option<Arc<RwLock<Wallet>>>, - wallet_unlock_popup: WalletUnlockPopup, - wallet_open_attempted: bool, - // Fee result from completed operation - completed_fee_result: Option<FeeResult>, - // Banner handle for elapsed time display - refresh_banner: Option<BannerHandle>, + frozen_identities: Vec<QualifiedIdentity>, } -impl UnfreezeTokensScreen { - pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { - // TODO: filter to include only frozen identities - let frozen_identities = super::load_identities_with_banner(app_context); - - let possible_key = identity_token_info - .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned(); - - let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); - - let group = match identity_token_info - .token_config - .unfreeze_rules() - .authorized_to_make_change_action_takers() - { - AuthorizedActionTakers::NoOne => { - set_error_banner("Unfreezing is not allowed on this token"); - None - } - AuthorizedActionTakers::ContractOwner => { - if identity_token_info.data_contract.contract.owner_id() - != identity_token_info.identity.identity.id() - { - set_error_banner( - "You are not allowed to unfreeze this token. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != &identity_token_info.identity.identity.id() { - set_error_banner("You are not allowed to unfreeze this token"); - } - None - } - AuthorizedActionTakers::MainGroup => { - match identity_token_info.token_config.main_control_group() { - None => { - set_error_banner( - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match identity_token_info - .data_contract - .contract - .expected_group(*group_pos) - { - Ok(group) => Some((*group_pos, group.clone())), - Err(e) => { - set_error_banner(&format!("Invalid contract: {}", e)); - None - } - } - } - }; - - let mut is_unilateral_group_member = false; - if group.is_some() - && let Some((_, group)) = group.clone() - { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - is_unilateral_group_member = true; - } - }; - - // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - set_error_banner(&e); - None - }); +impl TokenAction for UnfreezeAction { + const VERB: &'static str = "Unfreeze"; + const PAGE_HEADING: &'static str = "Unfreeze Identity’s Tokens"; + const PROGRESS: &'static str = "Unfreezing tokens..."; + const CONFIRM_TITLE: &'static str = "Confirm Unfreeze"; - Self { - identity: identity_token_info.identity.clone(), - identity_token_info, - selected_key: possible_key, - show_advanced_options: false, - group, - is_unilateral_group_member, - group_action_id: None, - public_note: None, + fn new(_info: &IdentityTokenInfo, app_context: &Arc<AppContext>) -> Self { + UnfreezeAction { unfreeze_identity_id: String::new(), - status: UnfreezeTokensStatus::NotStarted, - app_context: app_context.clone(), - confirmation_dialog: None, - selected_wallet, - wallet_unlock_popup: WalletUnlockPopup::new(), - wallet_open_attempted: false, - frozen_identities, - completed_fee_result: None, - refresh_banner: None, + frozen_identities: super::load_identities_with_banner(app_context), } } - fn render_unfreeze_identity_input(&mut self, ui: &mut Ui) { - let _response = ui.add( - IdentitySelector::new( - "unfreeze_identity_selector", - &mut self.unfreeze_identity_id, - &self.frozen_identities, - ) - .width(300.0) - .label("Identity ID to unfreeze:"), - ); + fn authorized_takers(config: &TokenConfiguration) -> AuthorizedActionTakers { + *config + .unfreeze_rules() + .authorized_to_make_change_action_takers() } - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let msg = format!( + fn confirm_message(&self, _ctx: &TokenActionCtx) -> String { + format!( "Are you sure you want to unfreeze identity {}?", self.unfreeze_identity_id - ); - - let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new("Confirm Unfreeze", msg) - .confirm_text(Some("Confirm")) - .cancel_text(Some("Cancel")) - }); + ) + } - let response = confirmation_dialog.show(ui); - match response.inner.dialog_response { - Some(ConfirmationStatus::Confirmed) => { - self.confirmation_dialog = None; - self.confirmation_ok() - } - Some(ConfirmationStatus::Canceled) => { - self.confirmation_dialog = None; - AppAction::None - } - None => AppAction::None, + fn render_form(&mut self, ui: &mut Ui, ctx: &TokenActionCtx, step: &mut StepCounter) { + let n = step.advance(); + ui.heading(format!("{}. Enter the identity ID to unfreeze", n)); + ui.add_space(5.0); + if ctx.is_group_signing { + ui.label( + "You are signing an existing group Unfreeze so you are not allowed to choose the identity.", + ); + ui.add_space(5.0); + ui.label(format!("Identity: {}", self.unfreeze_identity_id)); + } else { + ui.add( + IdentitySelector::new( + "unfreeze_identity_selector", + &mut self.unfreeze_identity_id, + &self.frozen_identities, + ) + .label("Unfreeze Identity ID:") + .width(300.0), + ); } } - fn confirmation_ok(&mut self) -> AppAction { - // Validate user input - let Ok(unfreeze_id) = Identifier::from_string_try_encodings( + fn build_action( + &mut self, + ctx: &TokenActionCtx, + signing_key: IdentityPublicKey, + public_note: Option<String>, + group_info: Option<GroupStateTransitionInfoStatus>, + ) -> Option<AppAction> { + let Ok(unfreeze_identity) = Identifier::from_string_try_encodings( &self.unfreeze_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], + &[Encoding::Base58, Encoding::Hex], ) else { - self.status = UnfreezeTokensStatus::Error; MessageBanner::set_global( - self.app_context.egui_ctx(), + ctx.app_context.egui_ctx(), "Please enter a valid identity ID.", MessageType::Error, ); - return AppAction::None; + return None; }; - // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) - else { - return AppAction::None; - }; - - self.status = UnfreezeTokensStatus::WaitingForResult; - let handle = MessageBanner::set_global( - self.app_context.egui_ctx(), - "Unfreezing tokens...", - MessageType::Info, - ); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - - // Grab the data contract for this token from the app context - let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if let Some(action_id) = self.group_action_id { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id, - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch to backend - AppAction::BackendTask(BackendTask::TokenTask(Box::new( + Some(AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::UnfreezeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, + actor_identity: ctx.info.identity.clone(), + data_contract: ctx.data_contract(), + token_position: ctx.info.token_position, signing_key, - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - unfreeze_identity: unfreeze_id, + public_note, + unfreeze_identity, group_info, }, - ))) - } - - fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - crate::ui::helpers::show_group_token_success_screen_with_fee( - ui, - "Unfreeze", - self.group_action_id.is_some(), - self.is_unilateral_group_member, - self.group.is_some(), - &self.app_context, - None, - ) - } -} - -impl ScreenLike for UnfreezeTokensScreen { - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); - self.status = UnfreezeTokensStatus::Error; - } + )))) } - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::UnfrozeTokens(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); - self.completed_fee_result = Some(fee_result); - self.status = UnfreezeTokensStatus::Complete; - } - } - - fn refresh(&mut self) { - if let Ok(all_identities) = self.app_context.load_local_user_identities() - && let Some(updated_identity) = all_identities - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action; - - // Build a top panel - if self.group_action_id.is_some() { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Group Actions", AppAction::PopScreen), - ("Unfreeze", AppAction::None), - ], - vec![], - ); - } else { - action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Tokens", AppAction::GoToMainScreen), - (&self.identity_token_info.token_alias, AppAction::PopScreen), - ("Unfreeze", AppAction::None), - ], - vec![], - ); - } - - // Left panel - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenMyTokenBalances, - ); - - // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); - - island_central_panel(ctx, |ui| { - if self.status == UnfreezeTokensStatus::Complete { - action |= self.show_success_screen(ui); - return; - } - - ui.heading("Unfreeze a Frozen Identity’s Tokens"); - ui.add_space(10.0); - - // Check if user has any auth keys - let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), - ); - ui.add_space(10.0); - - // Show "Add key" or "Check keys" option - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - None, - &self.app_context, - ))); - } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return; - } - } - - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Unfreeze Tokens"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the Unfreeze transition"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - // Identity to unfreeze - let step_num = if self.show_advanced_options { 2 } else { 1 }; - ui.heading(format!("{}. Enter the identity ID to unfreeze", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Unfreeze so you are not allowed to choose the identity.", - ); - ui.add_space(5.0); - ui.label(format!("Identity: {}", self.unfreeze_identity_id)); - } else { - self.render_unfreeze_identity_input(ui); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Render text input for the public note - let step_num = if self.show_advanced_options { 3 } else { 2 }; - ui.heading(format!("{}. Public note (optional)", step_num)); - ui.add_space(5.0); - if self.group_action_id.is_some() { - ui.label( - "You are signing an existing group Mint so you are not allowed to put a note.", - ); - ui.add_space(5.0); - ui.label(format!( - "Note: {}", - self.public_note.clone().unwrap_or("None".to_string()) - )); - } else { - ui.horizontal(|ui| { - ui.label("Public note (optional):").info_tooltip( - "A note about the transaction that can be seen by the public.", - ); - ui.add_space(10.0); - let mut txt = self.public_note.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut txt).changed() { - self.public_note = if !txt.is_empty() { Some(txt) } else { None }; - } - }); - } - - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions - - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Estimated fee:") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.label( - RichText::new(format_credits_as_dash(estimated_fee)) - .color(DashColors::text_primary(dark_mode)) - .size(14.0), - ); - }); - }); - - let button_text = render_group_action_text( - ui, - &self.group, - &self.identity_token_info, - "Unfreeze", - &self.group_action_id, - ); - - // Unfreeze button - if self.app_context.is_developer_mode() || !button_text.contains("Test") { - ui.add_space(10.0); - if ComponentStyles::add_primary_button(ui, button_text).clicked() { - // Initialize confirmation dialog when button is clicked - let msg = format!( - "Are you sure you want to unfreeze identity {}?", - self.unfreeze_identity_id - ); - self.confirmation_dialog = Some( - ConfirmationDialog::new("Confirm Unfreeze", msg) - .confirm_text(Some("Confirm")) - .cancel_text(Some("Cancel")), - ); - } - } - - // Show confirmation dialog if it exists - if self.confirmation_dialog.is_some() { - action |= self.show_confirmation_popup(ui); - } - - // Show in-progress or error messages - ui.add_space(10.0); - match &self.status { - UnfreezeTokensStatus::NotStarted => { - // no-op - } - UnfreezeTokensStatus::WaitingForResult => { - // Elapsed display is handled by the global MessageBanner - } - UnfreezeTokensStatus::Error => { - // Error display is handled by the global MessageBanner - } - UnfreezeTokensStatus::Complete => { - // handled above - } - } - } - }); - - // Show wallet unlock popup if open - if self.wallet_unlock_popup.is_open() - && let Some(wallet) = &self.selected_wallet - { - let result = self - .wallet_unlock_popup - .show(ctx, wallet, &self.app_context); - if result == WalletUnlockResult::Unlocked { - // Wallet unlocked successfully - } + fn success_fee(result: &BackendTaskSuccessResult) -> Option<FeeResult> { + match result { + BackendTaskSuccessResult::UnfrozeTokens(fee) => Some(fee.clone()), + _ => None, } - - action } } diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 8fd41aa03..041b72274 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -14,7 +14,9 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::components::{MessageBanner, OptionBannerExt, ResultBannerExt}; -use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; +use crate::ui::helpers::{ + TransactionType, add_key_chooser, check_token_authorization, render_group_action_text, +}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -22,7 +24,6 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; @@ -30,13 +31,12 @@ use dash_sdk::dpp::data_contract::associated_token::token_configuration_item::To use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use dash_sdk::dpp::data_contract::group::Group; -use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -134,96 +134,16 @@ impl UpdateTokenConfigScreen { .token_config .authorized_action_takers_for_configuration_item(&self.change_item); - let group = match authorized_action_takers { - AuthorizedActionTakers::NoOne => { - super::set_error_banner( - &self.app_context, - "This action is not allowed on this token", - ); - None - } - AuthorizedActionTakers::ContractOwner => { - if self.identity_token_info.data_contract.contract.owner_id() - != self.identity_token_info.identity.identity.id() - { - super::set_error_banner( - &self.app_context, - "You are not allowed to perform this action. Only the contract owner is.", - ); - } - None - } - AuthorizedActionTakers::Identity(identifier) => { - if identifier != self.identity_token_info.identity.identity.id() { - super::set_error_banner( - &self.app_context, - "You are not allowed to perform this action", - ); - } - None - } - AuthorizedActionTakers::MainGroup => { - match self.identity_token_info.token_config.main_control_group() { - None => { - super::set_error_banner( - &self.app_context, - "Invalid contract: No main control group, though one should exist", - ); - None - } - Some(group_pos) => { - match self - .identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - super::set_error_banner( - &self.app_context, - &format!("Invalid contract: {}", e), - ); - None - } - } - } - } - } - AuthorizedActionTakers::Group(group_pos) => { - match self - .identity_token_info - .data_contract - .contract - .expected_group(group_pos) - { - Ok(group) => Some((group_pos, group.clone())), - Err(e) => { - super::set_error_banner( - &self.app_context, - &format!("Invalid contract: {}", e), - ); - None - } - } - } - }; - - self.group = group; - - // Update is_unilateral_group_member based on new group - self.is_unilateral_group_member = false; - if let Some((_, group)) = &self.group { - let your_power = group - .members() - .get(&self.identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power - && your_power >= &group.required_power() - { - self.is_unilateral_group_member = true; - } + let auth = check_token_authorization( + &authorized_action_takers, + &self.identity_token_info, + "Update", + ); + if let Some(msg) = auth.error_message { + super::set_error_banner(&self.app_context, &msg); } + self.group = auth.group; + self.is_unilateral_group_member = auth.is_unilateral_group_member; } fn render_token_config_updater(&mut self, ui: &mut Ui) -> AppAction { @@ -719,7 +639,7 @@ impl UpdateTokenConfigScreen { // Display estimated fee before action button let estimated_fee = self.app_context.fee_estimator().estimate_token_transition(); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; egui::Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(egui::Margin::symmetric(10, 8)) @@ -865,7 +785,7 @@ impl UpdateTokenConfigScreen { authorized_identity_input.get_or_insert_with(String::new); if let Some(id_str) = authorized_identity_input { ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_sized( [300.0, 22.0], egui::TextEdit::singleline(id_str) @@ -875,17 +795,16 @@ impl UpdateTokenConfigScreen { ); if !id_str.is_empty() { - let is_valid = - Identifier::from_string(id_str, Encoding::Base58).is_ok(); - let (symbol, color) = if is_valid { + let parsed = Identifier::from_string(id_str, Encoding::Base58); + let (symbol, color) = if parsed.is_ok() { ("✔", Color32::DARK_GREEN) } else { ("×", Color32::RED) }; ui.label(RichText::new(symbol).color(color).strong()); - if is_valid { - *id = Identifier::from_string(id_str, Encoding::Base58).unwrap(); + if let Ok(parsed_id) = parsed { + *id = parsed_id; } } }); @@ -946,13 +865,15 @@ impl ScreenLike for UpdateTokenConfigScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action; // Build a top panel if self.group_action_id.is_some() { action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), @@ -963,7 +884,7 @@ impl ScreenLike for UpdateTokenConfigScreen { ); } else { action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -976,16 +897,16 @@ impl ScreenLike for UpdateTokenConfigScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); // Central panel - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { if self.update_status == UpdateTokenConfigStatus::Complete { action |= self.show_success_screen(ui); @@ -1045,8 +966,9 @@ impl ScreenLike for UpdateTokenConfigScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); } self.wallet_open_attempted = true; } diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index 54c6ce1c3..a5376aa40 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -12,9 +12,8 @@ use crate::ui::{MessageType, ScreenLike}; use chrono::{DateTime, Utc}; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::Value; -use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery}; -use egui::Context; use std::sync::Arc; use super::tokens_screen::IdentityTokenBasicInfo; @@ -41,6 +40,7 @@ impl ViewTokenClaimsScreen { Self { identity_token_basic_info: identity_token_basic_info.clone(), new_claims_query: DocumentQuery { + select: SelectProjection::documents(), data_contract: app_context.token_history_contract.clone(), document_type_name: "claim".to_string(), where_clauses: vec![ @@ -55,6 +55,8 @@ impl ViewTokenClaimsScreen { value: Value::Identifier(identity_token_basic_info.identity_id.into()), }, ], + group_by: Vec::new(), + having: Vec::new(), order_by_clauses: vec![], limit: 0, start: None, @@ -94,10 +96,10 @@ impl ScreenLike for ViewTokenClaimsScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { // Top panel let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Tokens", AppAction::GoToMainScreen), @@ -117,16 +119,16 @@ impl ScreenLike for ViewTokenClaimsScreen { // Left panel action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenMyTokenBalances, ); // Subscreen chooser - action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tokens_subscreen_chooser_panel(ui, &self.app_context); // Central panel - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { ui.heading("View Token Claims"); ui.add_space(10.0); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index ef85636fe..aa04d5f86 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -8,7 +8,7 @@ use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, ScreenLike}; -use eframe::egui::{self, Context, ScrollArea, TextEdit, Ui}; +use eframe::egui::{self, ScrollArea, TextEdit, Ui}; use std::sync::Arc; pub struct AddressBalanceScreen { @@ -153,22 +153,22 @@ impl ScreenLike for AddressBalanceScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenToolsAddressBalanceScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, &self.app_context); + action |= add_tools_subscreen_chooser_panel(ui, &self.app_context); - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { ScrollArea::vertical().show(ui, |ui| { action |= self.render_input(ui); self.render_result(ui); diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index ecc7a6915..ea3003f7d 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -9,7 +9,7 @@ use crate::ui::theme::DashColors; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; use dash_sdk::platform::DataContract; -use eframe::egui::{Color32, Context, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; +use eframe::egui::{Color32, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; use std::sync::Arc; // ======================= 1. Data & helpers ======================= @@ -30,12 +30,6 @@ pub struct ContractVisualizerScreen { // ---- parsed output ------- parsed_json: Option<String>, parse_status: ContractParseStatus, - - // ---- helper for chooser search ---- - // Allow dead_code: This field provides search functionality for contract selection, - // useful for filtering contracts in the visualizer interface - #[allow(dead_code)] - contract_search_term: String, } impl ContractVisualizerScreen { @@ -47,8 +41,6 @@ impl ContractVisualizerScreen { parsed_json: None, parse_status: ContractParseStatus::NotStarted, - - contract_search_term: String::new(), } } @@ -121,7 +113,7 @@ impl ContractVisualizerScreen { fn show_input(&mut self, ui: &mut Ui) { ui.label("Enter hex, base64, or comma-separated integers for Contract:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let resp = ui.add( TextEdit::multiline(&mut self.input_data_hex) .desired_rows(4) @@ -142,7 +134,7 @@ impl ContractVisualizerScreen { ScrollArea::vertical().show(ui, |ui| match &self.parse_status { ContractParseStatus::Complete => { - ui.monospace(self.parsed_json.as_ref().unwrap()); + ui.monospace(self.parsed_json.as_deref().unwrap_or_default()); } ContractParseStatus::Error(msg) => { let error_color = DashColors::ERROR; @@ -178,22 +170,22 @@ impl crate::ui::ScreenLike for ContractVisualizerScreen { // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenToolsContractVisualizerScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); /* ---------- central panel ---------- */ - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { self.show_input(ui); self.show_output(ui); AppAction::None diff --git a/src/ui/tools/document_visualizer_screen.rs b/src/ui/tools/document_visualizer_screen.rs index bd2bc5633..6bcc339b8 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -12,7 +12,7 @@ use crate::ui::theme::DashColors; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dash_sdk::dpp::{data_contract::document_type::DocumentType, document::Document}; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, TextEdit, Ui}; +use eframe::egui::{self, Color32, Frame, Margin, RichText, TextEdit, Ui}; use std::sync::Arc; // ======================= 1. Data & helpers ======================= @@ -140,7 +140,7 @@ impl DocumentVisualizerScreen { fn show_input(&mut self, ui: &mut Ui) { ui.label("Enter hex, base64, or comma-separated integers for Document:"); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let resp = ui.add( TextEdit::multiline(&mut self.input_data_hex) .desired_rows(4) @@ -161,7 +161,7 @@ impl DocumentVisualizerScreen { egui::ScrollArea::both().show(ui, |ui| match &self.parse_status { DocumentParseStatus::Complete => { - ui.monospace(self.parsed_json.as_ref().unwrap()); + ui.monospace(self.parsed_json.as_deref().unwrap_or_default()); } DocumentParseStatus::WaitingForSelection => { ui.colored_label(Color32::GRAY, "Select a contract and document type."); @@ -200,22 +200,22 @@ impl crate::ui::ScreenLike for DocumentVisualizerScreen { // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenToolsDocumentVisualizerScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); /* ---------- central panel ---------- */ - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { /* ---------- simple dual-combo chooser ---------- */ //todo cache the contracts add_contract_doc_type_chooser_with_filtering( diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index a1e6944a9..417547b0c 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -17,7 +17,7 @@ use dash_sdk::dpp::identity::{ Identity, IdentityPublicKey, KeyType, Purpose, accessors::IdentityGettersV0, }; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use egui::{Button, ComboBox, Context, Frame, Grid, Margin, RichText, ScrollArea, TextEdit, Ui}; +use egui::{Button, ComboBox, Frame, Grid, Margin, RichText, ScrollArea, TextEdit, Ui}; use std::sync::Arc; use std::time::Duration; @@ -50,7 +50,7 @@ pub struct GroveSTARKScreen { mode: ProofMode, // Generation fields - selected_identity: Option<String>, + pub selected_identity: Option<String>, selected_key: Option<IdentityPublicKey>, selected_contract: Option<String>, selected_document_type: Option<String>, @@ -91,7 +91,7 @@ impl GroveSTARKScreen { // Load initial contracts (exclude system contracts) let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; - let all_contracts = app_context.get_contracts(None, None).unwrap_or_default(); + let all_contracts = app_context.get_contracts().unwrap_or_default(); tracing::info!( "ZK Proofs screen found {} total contracts", @@ -128,10 +128,32 @@ impl GroveSTARKScreen { available_contracts.len() ); + // Seed selected_identity from the app-scoped id if it is in the EdDSA-filtered list + // (READ-only R4: no syncing_global — a developer tool must not push an EdDSA-only + // identity as the global selection). + let eddsa_filter = |qi: &&QualifiedIdentity| { + qi.identity.public_keys().iter().any(|(_, key)| { + matches!(key.key_type(), KeyType::EDDSA_25519_HASH160) + && (key.purpose() == Purpose::AUTHENTICATION + || key.purpose() == Purpose::TRANSFER) + }) + }; + let selected_identity: Option<String> = { + let preferred_id = app_context.selected_identity_id(); + preferred_id + .and_then(|id| { + qualified_identities + .iter() + .find(|qi| eddsa_filter(qi) && qi.identity.id() == id) + }) + .or_else(|| qualified_identities.iter().find(|qi| eddsa_filter(qi))) + .map(|qi| qi.identity.id().to_string(Encoding::Base58)) + }; + Self { app_context: app_context.clone(), mode: ProofMode::Generate, - selected_identity: None, + selected_identity, selected_key: None, selected_contract: None, selected_document_type: None, @@ -167,6 +189,21 @@ impl GroveSTARKScreen { .iter() .map(|qualified_identity| qualified_identity.identity.clone()) .collect(); + + // Re-seed selected_identity from the app-scoped id iff it is in the + // refreshed EdDSA-filtered list (READ-only R4: no syncing_global). + let preferred_id = app_context.selected_identity_id(); + let new_selection = preferred_id + .and_then(|id| { + self.qualified_identities + .iter() + .find(|qi| qi.identity.id() == id) + }) + .or_else(|| self.qualified_identities.first()) + .map(|qi| qi.identity.id().to_string(Encoding::Base58)); + if self.selected_identity.is_none() { + self.selected_identity = new_selection; + } } fn get_qualified_identity(&self, identity_id_str: &str) -> Option<&QualifiedIdentity> { @@ -205,7 +242,7 @@ impl GroveSTARKScreen { fn refresh_contracts(&mut self, app_context: &AppContext) { let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; - let all_contracts = app_context.get_contracts(None, None).unwrap_or_default(); + let all_contracts = app_context.get_contracts().unwrap_or_default(); self.available_contracts = all_contracts .into_iter() @@ -235,7 +272,7 @@ impl GroveSTARKScreen { self.available_document_types.clear(); self.selected_document_type = None; - if let Ok(contracts) = app_context.get_contracts(None, None) { + if let Ok(contracts) = app_context.get_contracts() { for contract in contracts { let id = contract .contract @@ -371,72 +408,25 @@ impl GroveSTARKScreen { } }; - // Get the private key from the qualified identity - let private_key = match self.get_qualified_identity(&identity_id) { - Some(qualified_identity) => { - // Get the wallets for resolving encrypted keys - let wallets = app_context.wallets.read().unwrap(); - let wallet_vec: Vec<_> = wallets.values().cloned().collect(); - - // Try to get the private key - match qualified_identity.private_keys.get_resolve( - &( - PrivateKeyTarget::PrivateKeyOnMainIdentity, - selected_key.id(), - ), - &wallet_vec, - app_context.network, - ) { - Ok(Some((_, private_key_bytes))) => private_key_bytes, - Ok(None) => { - MessageBanner::set_global( - app_context.egui_ctx(), - "Private key not found in storage", - MessageType::Error, - ); - self.is_generating = false; - return AppAction::None; - } - Err(e) => { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to get private key: {}", e), - MessageType::Error, - ); - self.is_generating = false; - return AppAction::None; - } - } - } - None => { - MessageBanner::set_global( - app_context.egui_ctx(), - "Qualified identity not found", - MessageType::Error, - ); - self.is_generating = false; - return AppAction::None; - } - }; - - // For EDDSA_25519_HASH160, the key data is only 20 bytes (the hash) - // We need to derive the public key from the private key - let public_key = { - use ed25519_dalek::SigningKey; - let signing_key = SigningKey::from_bytes(&private_key); - let verifying_key = signing_key.verifying_key(); - *verifying_key.as_bytes() + // The backend resolves the signing key and derives its public key + // through the JIT chokepoint — the seed never enters the UI. Carry the + // qualified identity (which holds the chokepoint handle) into the task. + let Some(qualified_identity) = self.get_qualified_identity(&identity_id).cloned() else { + MessageBanner::set_global( + app_context.egui_ctx(), + "Qualified identity not found", + MessageType::Error, + ); + self.is_generating = false; + return AppAction::None; }; - // Use fixed parameters for simplicity and consistency let task = BackendTask::GroveSTARKTask(GroveSTARKTask::GenerateProof { - identity_id, + identity: Box::new(qualified_identity), contract_id, document_type, document_id, key_id: selected_key.id(), - private_key, - public_key, }); AppAction::BackendTask(task) @@ -522,7 +512,7 @@ impl GroveSTARKScreen { } fn render_generation_ui(&mut self, ui: &mut Ui, app_context: &AppContext) -> Option<AppAction> { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let debug_build = cfg!(debug_assertions); ui.label( @@ -864,7 +854,7 @@ impl GroveSTARKScreen { ui: &mut Ui, app_context: &AppContext, ) -> Option<AppAction> { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let debug_build = cfg!(debug_assertions); ui.label( @@ -1059,12 +1049,12 @@ impl ScreenLike for GroveSTARKScreen { // Pop on success if needed } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = AppAction::None; // Add top panel with breadcrumb action |= add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], @@ -1072,21 +1062,21 @@ impl ScreenLike for GroveSTARKScreen { // Add left panel action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenToolsGroveSTARKScreen, ); // Add tools subscreen chooser panel - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); // Add central panel with the main UI - let panel_action = island_central_panel(ctx, |ui| { + let panel_action = island_central_panel(ui, |ui| { ui.label( RichText::new("GroveSTARK Zero-Knowledge Proofs") .size(Typography::SCALE_XL) .strong() - .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), + .color(DashColors::text_primary(ui.style().visuals.dark_mode)), ); ui.add_space(5.0); @@ -1094,7 +1084,7 @@ impl ScreenLike for GroveSTARKScreen { ui.label( RichText::new("WARNING: GroveSTARK is a research project. It has not been audited and may contain bugs and security flaws. This feature is NOT ready for production usage.") .size(Typography::SCALE_XS) - .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)) + .color(DashColors::text_primary(ui.style().visuals.dark_mode)) ); ui.add_space(Spacing::SM); ui.separator(); @@ -1108,11 +1098,11 @@ impl ScreenLike for GroveSTARKScreen { RichText::new("Mode:") .size(Typography::SCALE_LG) .strong() - .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), + .color(DashColors::text_primary(ui.style().visuals.dark_mode)), ); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Generate button let generate_selected = self.mode == ProofMode::Generate; diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs deleted file mode 100644 index 62ef28793..000000000 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ /dev/null @@ -1,4460 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::core::CoreItem; -use crate::backend_task::mnlist::MnListTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::components::core_p2p_handler::CoreP2PHandler; -use crate::context::AppContext; -use crate::ui::components::component_trait::Component; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::{ConfirmationDialog, ConfirmationStatus, island_central_panel}; -use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::{DashColors, ResponseExt}; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dashcore_rpc::json::QuorumType; -use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; -use dash_sdk::dpp::dashcore::consensus::serialize as serialize2; -use dash_sdk::dpp::dashcore::consensus::{Decodable, deserialize, serialize}; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::network::constants::NetworkExt; -use dash_sdk::dpp::dashcore::network::message_qrinfo::{QRInfo, QuorumSnapshot}; -use dash_sdk::dpp::dashcore::network::message_sml::MnListDiff; -use dash_sdk::dpp::dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; -use dash_sdk::dpp::dashcore::sml::llmq_type::LLMQType; -use dash_sdk::dpp::dashcore::sml::masternode_list::MasternodeList; -use dash_sdk::dpp::dashcore::sml::masternode_list_engine::{ - MasternodeListEngine, MasternodeListEngineBlockContainer, WORK_DIFF_DEPTH, -}; -use dash_sdk::dpp::dashcore::sml::masternode_list_entry::EntryMasternodeType; -use dash_sdk::dpp::dashcore::sml::masternode_list_entry::qualified_masternode_list_entry::QualifiedMasternodeListEntry; -use dash_sdk::dpp::dashcore::sml::quorum_entry::qualified_quorum_entry::{ - QualifiedQuorumEntry, VerifyingChainLockSignaturesType, -}; -use dash_sdk::dpp::dashcore::transaction::special_transaction::quorum_commitment::QuorumEntry; -use dash_sdk::dpp::dashcore::{ - Block, BlockHash as BlockHash2, ChainLock, InstantLock, Transaction, -}; -use dash_sdk::dpp::dashcore::{ - BlockHash, ChainLock as ChainLock2, InstantLock as InstantLock2, Network, ProTxHash, QuorumHash, -}; -use dash_sdk::dpp::prelude::CoreBlockHeight; -use eframe::egui::{self, Context, ScrollArea, Ui}; -use egui::{Align, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; -use itertools::Itertools; -use rfd::FileDialog; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::fs; -use std::path::Path; -use std::sync::Arc; - -type HeightHash = (u32, BlockHash); - -enum SelectedQRItem { - SelectedSnapshot(QuorumSnapshot), - MNListDiff(Box<MnListDiff>), - QuorumEntry(Box<QualifiedQuorumEntry>), -} - -/// User-entered inputs and transient filters. -#[derive(Default)] -struct InputState { - base_block_height: String, - end_block_height: String, - search_term: Option<String>, -} - -/// UI presentation state (tabs, banners, dialogs). -#[derive(Default)] -struct UiState { - selected_tab: usize, - masternode_engine_confirm_dialog: Option<ConfirmationDialog>, - error: Option<String>, -} - -/// Backend task state and sync toggles. -#[derive(Default)] -struct TaskState { - syncing: bool, - pending: Option<PendingTask>, - queued_task: Option<BackendTask>, -} - -/// Domain data for the masternode list diff tool. -struct MnListData { - masternode_list_engine: MasternodeListEngine, - mnlist_diffs: BTreeMap<(CoreBlockHeight, CoreBlockHeight), MnListDiff>, - qr_infos: BTreeMap<BlockHash, QRInfo>, -} - -impl MnListData { - fn new(app_context: &Arc<AppContext>) -> Self { - let mut mnlist_diffs = BTreeMap::new(); - let masternode_list_engine = match app_context.network { - Network::Mainnet => { - use std::env; - tracing::debug!( - "Current working directory: {:?}", - env::current_dir().unwrap() - ); - let file_path = "artifacts/mn_list_diff_0_2227096.bin"; - // Attempt to load and parse the MNListDiff file - if Path::new(file_path).exists() { - match fs::read(file_path) { - Ok(bytes) => { - let diff: MnListDiff = - deserialize(bytes.as_slice()).expect("expected to deserialize"); - mnlist_diffs.insert((0, 2227096), diff.clone()); - MasternodeListEngine::initialize_with_diff_to_height( - diff, - 2227096, - Network::Mainnet, - ) - .expect("expected to start engine") - } - Err(e) => { - tracing::error!("Failed to read MNListDiff file: {}", e); - MasternodeListEngine::default_for_network(Network::Mainnet) - } - } - } else { - tracing::warn!("MNListDiff file not found: {}", file_path); - MasternodeListEngine::default_for_network(Network::Mainnet) - } - } - Network::Testnet => { - let file_path = "artifacts/mn_list_diff_testnet_0_1296600.bin"; - // Attempt to load and parse the MNListDiff file - if Path::new(file_path).exists() { - match fs::read(file_path) { - Ok(bytes) => { - let diff: MnListDiff = - deserialize(bytes.as_slice()).expect("expected to deserialize"); - mnlist_diffs.insert((0, 1296600), diff.clone()); - MasternodeListEngine::initialize_with_diff_to_height( - diff, - 1296600, - Network::Testnet, - ) - .expect("expected to start engine") - } - Err(e) => { - tracing::error!("Failed to read MNListDiff file: {}", e); - MasternodeListEngine::default_for_network(Network::Testnet) - } - } - } else { - tracing::warn!("MNListDiff file not found: {}", file_path); - MasternodeListEngine::default_for_network(Network::Testnet) - } - } - _ => MasternodeListEngine::default_for_network(app_context.network), - }; - - Self { - masternode_list_engine, - mnlist_diffs, - qr_infos: Default::default(), - } - } -} - -/// Derived caches to avoid repeated lookups or recomputation. -#[derive(Default)] -struct CacheState { - masternode_lists_with_all_quorum_heights_known: BTreeSet<CoreBlockHeight>, - dml_diffs_with_cached_quorum_heights: HashSet<(CoreBlockHeight, CoreBlockHeight)>, - block_height_cache: BTreeMap<BlockHash, CoreBlockHeight>, - block_hash_cache: BTreeMap<CoreBlockHeight, BlockHash>, - masternode_list_quorum_hash_cache: - BTreeMap<BlockHash, BTreeMap<LLMQType, Vec<(CoreBlockHeight, QualifiedQuorumEntry)>>>, - chain_lock_sig_cache: BTreeMap<(CoreBlockHeight, BlockHash), Option<BLSSignature>>, - chain_lock_reversed_sig_cache: BTreeMap<BLSSignature, BTreeSet<(CoreBlockHeight, BlockHash)>>, -} - -/// User selection state for lists and detail panes. -#[derive(Default)] -struct SelectionState { - selected_dml_diff_key: Option<(CoreBlockHeight, CoreBlockHeight)>, - selected_dml_height_key: Option<CoreBlockHeight>, - selected_option_index: Option<usize>, - selected_quorum_in_diff_index: Option<usize>, - selected_masternode_in_diff_index: Option<usize>, - selected_quorum_hash_in_mnlist_diff: Option<(LLMQType, QuorumHash)>, - selected_quorum_type_in_quorum_viewer: Option<LLMQType>, - selected_quorum_hash_in_quorum_viewer: Option<QuorumHash>, - selected_masternode_pro_tx_hash: Option<ProTxHash>, - selected_qr_field: Option<String>, - selected_qr_list_index: Option<String>, - selected_core_item: Option<(CoreItem, bool)>, - selected_qr_item: Option<SelectedQRItem>, -} - -/// Incoming core items received via ZMQ or backend tasks. -#[derive(Default)] -struct IncomingState { - chain_locked_blocks: BTreeMap<CoreBlockHeight, (Block, ChainLock, bool)>, - instant_send_transactions: Vec<(Transaction, InstantLock, bool)>, -} - -/// Screen for viewing MNList diffs (diffs in the masternode list and quorums) -pub struct MasternodeListDiffScreen { - pub app_context: Arc<AppContext>, - input: InputState, - ui_state: UiState, - task: TaskState, - data: MnListData, - cache: CacheState, - selection: SelectionState, - incoming: IncomingState, -} - -impl MasternodeListDiffScreen { - /// Create a new MNListDiffScreen - pub fn new(app_context: &Arc<AppContext>) -> Self { - let data = MnListData::new(app_context); - Self { - app_context: app_context.clone(), - input: InputState::default(), - ui_state: UiState::default(), - task: TaskState::default(), - data, - cache: CacheState::default(), - selection: SelectionState::default(), - incoming: IncomingState::default(), - } - } - - fn selected_dml(&self) -> Option<&MnListDiff> { - self.selection - .selected_dml_diff_key - .and_then(|key| self.data.mnlist_diffs.get(&key)) - } - - fn selected_mn_list(&self) -> Option<&MasternodeList> { - self.selection.selected_dml_height_key.and_then(|height| { - self.data - .masternode_list_engine - .masternode_lists - .get(&height) - }) - } - - fn known_block_hashes_with_base(&self, base_hash: BlockHash) -> Vec<BlockHash> { - let mut known_block_hashes: Vec<_> = self - .data - .mnlist_diffs - .values() - .map(|mn_list_diff| mn_list_diff.block_hash) - .collect(); - known_block_hashes.push(base_hash); - known_block_hashes - } - - fn get_height_or_error_as_string(&self, block_hash: &BlockHash) -> String { - match self.get_height(block_hash) { - Ok(height) => height.to_string(), - Err(e) => format!("Failed to get height for {}: {}", block_hash, e), - } - } - - /// Build a backend task that fetches the extra diffs needed to validate non-rotating quorums. - /// Returns None if requirements cannot be computed. - fn build_validation_diffs_task(&mut self) -> Option<BackendTask> { - // Determine hashes we need to validate - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_non_rotating_quorum_hashes( - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - true, - ); - if hashes.is_empty() { - return None; - } - - // Compute target validation heights (h-8) - let mut heights: BTreeSet<u32> = BTreeSet::new(); - for quorum_hash in &hashes { - if let Ok(h) = self.get_height_and_cache(quorum_hash) - && h >= 8 - { - heights.insert(h - 8); - } - } - if heights.is_empty() { - return None; - } - - let client = self.app_context.core_client.read().unwrap(); - let mut chain: Vec<(u32, BlockHash, u32, BlockHash)> = Vec::new(); - - // Determine base starting point similar to previous logic - let (first_engine_height, first_engine_hash_opt) = self - .data - .masternode_list_engine - .masternode_lists - .first_key_value() - .map(|(h, l)| (*h, Some(l.block_hash))) - .unwrap_or((0, None)); - - let oldest_needed = *heights.first().unwrap(); - let mut base_height: u32; - let mut base_hash: BlockHash; - if first_engine_height != 0 && first_engine_height < oldest_needed { - base_height = first_engine_height; - base_hash = first_engine_hash_opt.unwrap(); - } else { - // Use genesis as base - base_height = 0; - let Ok(genesis) = client.get_block_hash(0) else { - return None; - }; - base_hash = BlockHash::from_byte_array(genesis.to_byte_array()); - } - - for h in heights { - let Ok(bh) = client.get_block_hash(h) else { - continue; - }; - let bh = BlockHash::from_byte_array(bh.to_byte_array()); - chain.push((base_height, base_hash, h, bh)); - base_height = h; - base_hash = bh; - } - - if chain.is_empty() { - return None; - } - Some(BackendTask::MnListTask(MnListTask::FetchDiffsChain { - chain, - })) - } - - fn get_height(&self, block_hash: &BlockHash) -> Result<CoreBlockHeight, String> { - let Some(height) = self - .data - .masternode_list_engine - .block_container - .get_height(block_hash) - else { - let Some(height) = self.cache.block_height_cache.get(block_hash) else { - tracing::debug!( - "Asking core for height no cache {} ({})", - block_hash, - block_hash.reverse() - ); - return match self - .app_context - .core_client - .read() - .unwrap() - .get_block_header_info( - &(BlockHash2::from_byte_array(block_hash.to_byte_array())), - ) { - Ok(block_hash) => Ok(block_hash.height as CoreBlockHeight), - Err(e) => Err(e.to_string()), - }; - }; - return Ok(*height); - }; - Ok(height) - } - - #[allow(dead_code)] - fn get_height_and_cache_or_error_as_string(&mut self, block_hash: &BlockHash) -> String { - match self.get_height_and_cache(block_hash) { - Ok(height) => height.to_string(), - Err(e) => format!("Failed to get height for {}: {}", block_hash, e), - } - } - - /// Resolve a block hash to its height without mutating the engine. - /// - /// Reads in order from: engine `block_container`, `block_height_cache`, then Core RPC - /// (populating `block_height_cache` on success). Unlike [`Self::get_height_and_cache`], - /// this helper deliberately does **not** call `feed_block_height` — callers that need - /// to stage pre-commit lookups (e.g. `build_qr_info_block_data`) use this so a partial - /// failure leaves the engine untouched. - fn resolve_height(&mut self, block_hash: &BlockHash) -> Result<CoreBlockHeight, String> { - if let Some(height) = self - .data - .masternode_list_engine - .block_container - .get_height(block_hash) - { - return Ok(height); - } - if let Some(height) = self.cache.block_height_cache.get(block_hash) { - return Ok(*height); - } - tracing::debug!( - "Asking core for height {} ({})", - block_hash, - block_hash.reverse() - ); - match self - .app_context - .core_client - .read() - .unwrap() - .get_block_header_info(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) - { - Ok(result) => { - let height = result.height as CoreBlockHeight; - self.cache.block_height_cache.insert(*block_hash, height); - Ok(height) - } - Err(e) => Err(e.to_string()), - } - } - - fn get_height_and_cache(&mut self, block_hash: &BlockHash) -> Result<CoreBlockHeight, String> { - let height = self.resolve_height(block_hash)?; - self.data - .masternode_list_engine - .block_container - .feed_block_height(height, *block_hash); - Ok(height) - } - - /// Resolve every block hash referenced by a QRInfo to its height so the engine's - /// `block_container` can be pre-populated before [`MasternodeListEngine::feed_qr_info`]. - /// - /// Walks the hash set the engine enumerates from the QRInfo and resolves each from - /// our local cache first, then via Core RPC (populating the cache on success). For - /// every referenced hash we additionally fetch the cycle-boundary block at - /// `height + WORK_DIFF_DEPTH` (the engine needs it to compute rotated quorum keys). - /// Returns a flat list of `(height, hash)` pairs the caller feeds into - /// `engine.block_container.feed_block_height`. - /// - /// This is a pure "build" step: it uses [`Self::resolve_height`] (which populates - /// the local height cache but never touches the engine) so a mid-loop failure does - /// not leave the engine partially populated. The caller commits the returned entries - /// to the engine only after the full set resolves successfully. - /// - /// If a cycle-boundary block is not yet available from Core RPC, the referenced-hash - /// entry is still kept and the boundary lookup is skipped with a warning. The engine - /// may still succeed in `feed_qr_info`; if it does not, the error surfaces from that - /// call where it belongs. - fn build_qr_info_block_data( - &mut self, - qr_info: &QRInfo, - ) -> Result<Vec<(CoreBlockHeight, BlockHash)>, String> { - let mut entries = Vec::new(); - - for hash in MasternodeListEngine::qr_info_referenced_block_hashes(qr_info) { - if hash.as_byte_array() == &[0; 32] { - continue; - } - let height = self.resolve_height(&hash)?; - entries.push((height, hash)); - - let cycle_boundary_height = height + WORK_DIFF_DEPTH; - match self.get_block_hash_and_cache(cycle_boundary_height) { - Ok(cycle_boundary_hash) => { - entries.push((cycle_boundary_height, cycle_boundary_hash)); - } - Err(e) => { - tracing::warn!( - referenced_hash = %hash, - referenced_height = height, - cycle_boundary_height, - error = %e, - "cycle-boundary block not yet available; skipping and letting feed_qr_info decide" - ); - continue; - } - } - } - - Ok(entries) - } - - #[allow(dead_code)] - fn get_chain_lock_sig_and_cache( - &mut self, - block_hash: &BlockHash, - ) -> Result<Option<BLSSignature>, String> { - let height = self.get_height_and_cache(block_hash)?; - if !self - .cache - .chain_lock_sig_cache - .contains_key(&(height, *block_hash)) - { - let block = self - .app_context - .core_client - .read() - .unwrap() - .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) - .map_err(|e| e.to_string())?; - let Some(coinbase) = block - .coinbase() - .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) - .and_then(|payload| payload.clone().to_coinbase_payload().ok()) - else { - return Err(format!("coinbase not found on block hash {}", block_hash)); - }; - //todo clean up - self.cache.chain_lock_sig_cache.insert( - (height, *block_hash), - coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()), - ); - if let Some(sig) = coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()) { - self.cache - .chain_lock_reversed_sig_cache - .entry(sig) - .or_default() - .insert((height, *block_hash)); - } - } - - Ok(*self - .cache - .chain_lock_sig_cache - .get(&(height, *block_hash)) - .unwrap()) - } - - fn get_chain_lock_sig(&self, block_hash: &BlockHash) -> Result<Option<BLSSignature>, String> { - let height = self.get_height(block_hash)?; - if !self - .cache - .chain_lock_sig_cache - .contains_key(&(height, *block_hash)) - { - let block = self - .app_context - .core_client - .read() - .unwrap() - .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) - .map_err(|e| e.to_string())?; - let Some(coinbase) = block - .coinbase() - .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) - .and_then(|payload| payload.clone().to_coinbase_payload().ok()) - else { - return Err(format!("coinbase not found on block hash {}", block_hash)); - }; - Ok(coinbase.best_cl_signature.map(|sig| sig.to_bytes().into())) - } else { - Ok(*self - .cache - .chain_lock_sig_cache - .get(&(height, *block_hash)) - .unwrap()) - } - } - - fn get_block_hash(&self, height: CoreBlockHeight) -> Result<BlockHash, String> { - let Some(block_hash) = self - .data - .masternode_list_engine - .block_container - .get_hash(&height) - else { - let Some(block_hash) = self.cache.block_hash_cache.get(&height) else { - // println!("Asking core for hash of {}", height); - return match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(height) - { - Ok(block_hash) => Ok(BlockHash::from_byte_array(block_hash.to_byte_array())), - Err(e) => Err(e.to_string()), - }; - }; - return Ok(*block_hash); - }; - Ok(*block_hash) - } - - #[allow(dead_code)] - fn get_block_hash_and_cache(&mut self, height: CoreBlockHeight) -> Result<BlockHash, String> { - // First, try to get the hash from masternode_list_engine's block_container. - if let Some(block_hash) = self - .data - .masternode_list_engine - .block_container - .get_hash(&height) - { - return Ok(*block_hash); - } - - // Then, check the cache. - if let Some(cached_hash) = self.cache.block_hash_cache.get(&height) { - return Ok(*cached_hash); - } - - // If not cached, retrieve from core client and insert into cache. - // println!("Asking core for hash of {} and caching it", height); - match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(height) - { - Ok(core_block_hash) => { - let block_hash = BlockHash::from_byte_array(core_block_hash.to_byte_array()); - self.cache.block_hash_cache.insert(height, block_hash); - Ok(block_hash) - } - Err(e) => Err(e.to_string()), - } - } - // - // fn feed_qr_info_cl_sigs(&mut self, qr_info: &QRInfo) { - // let heights = match self.data.masternode_list_engine.required_cl_sig_heights(qr_info) { - // Ok(heights) => heights, - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // for height in heights { - // let block_hash = match self.get_block_hash(height) { - // Ok(block_hash) => block_hash, - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // let maybe_chain_lock_sig = match self - // .app_context - // .core_client - // .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) - // { - // Ok(block) => { - // let Some(coinbase) = block - // .coinbase() - // .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) - // .and_then(|payload| payload.clone().to_coinbase_payload().ok()) - // else { - // self.ui_state.error = - // Some(format!("coinbase not found on block hash {}", block_hash)); - // return; - // }; - // coinbase.best_cl_signature - // } - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // if let Some(maybe_chain_lock_sig) = maybe_chain_lock_sig { - // self.data.masternode_list_engine.feed_chain_lock_sig( - // block_hash, - // BLSSignature::from(maybe_chain_lock_sig.to_bytes()), - // ); - // } - // } - // } - - #[allow(dead_code)] - fn feed_qr_info_block_heights(&mut self, qr_info: &QRInfo) { - let mn_list_diffs = [ - &qr_info.mn_list_diff_tip, - &qr_info.mn_list_diff_h, - &qr_info.mn_list_diff_at_h_minus_c, - &qr_info.mn_list_diff_at_h_minus_2c, - &qr_info.mn_list_diff_at_h_minus_3c, - ]; - - // If h-4c exists, add it to the list - if let Some((_, mn_list_diff_h_minus_4c)) = - &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c - { - mn_list_diffs.iter().for_each(|&mn_list_diff| { - self.feed_mn_list_diff_heights(mn_list_diff); - }); - - // Feed h-4c separately - self.feed_mn_list_diff_heights(mn_list_diff_h_minus_4c); - } else { - mn_list_diffs.iter().for_each(|&mn_list_diff| { - self.feed_mn_list_diff_heights(mn_list_diff); - }); - } - - // Process `last_commitment_per_index` quorum hashes - qr_info - .last_commitment_per_index - .iter() - .for_each(|quorum_entry| { - self.feed_quorum_entry_height(quorum_entry); - }); - - // Process `mn_list_diff_list` (extra diffs) - qr_info.mn_list_diff_list.iter().for_each(|mn_list_diff| { - self.feed_mn_list_diff_heights(mn_list_diff); - }); - } - - /// **Helper function:** Feeds the base and block hash heights of an `MnListDiff` - fn feed_mn_list_diff_heights(&mut self, mn_list_diff: &MnListDiff) { - // Feed base block hash height - if let Ok(base_height) = self.get_height(&mn_list_diff.base_block_hash) { - tracing::debug!("feeding {} {}", base_height, mn_list_diff.base_block_hash); - self.data - .masternode_list_engine - .block_container - .feed_block_height(base_height, mn_list_diff.base_block_hash); - } else { - self.ui_state.error = Some(format!( - "Failed to get height for base block hash: {}", - mn_list_diff.base_block_hash - )); - } - - // Feed block hash height - if let Ok(block_height) = self.get_height(&mn_list_diff.block_hash) { - tracing::debug!("feeding {} {}", block_height, mn_list_diff.block_hash); - self.data - .masternode_list_engine - .block_container - .feed_block_height(block_height, mn_list_diff.block_hash); - } else { - self.ui_state.error = Some(format!( - "Failed to get height for block hash: {}", - mn_list_diff.block_hash - )); - } - } - - /// **Helper function:** Feeds the quorum hash height of a `QuorumEntry` - fn feed_quorum_entry_height(&mut self, quorum_entry: &QuorumEntry) { - if let Ok(height) = self.get_height(&quorum_entry.quorum_hash) { - self.data - .masternode_list_engine - .block_container - .feed_block_height(height, quorum_entry.quorum_hash); - } else { - self.ui_state.error = Some(format!( - "Failed to get height for quorum hash: {}", - quorum_entry.quorum_hash - )); - } - } - - fn parse_heights(&mut self) -> Result<(HeightHash, HeightHash), String> { - let base = if self.input.base_block_height.is_empty() { - self.input.base_block_height = "0".to_string(); - match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(0) - { - Ok(block_hash) => (0, BlockHash::from_byte_array(block_hash.to_byte_array())), - Err(e) => { - return Err(e.to_string()); - } - } - } else { - match self.input.base_block_height.trim().parse() { - Ok(start) => match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(start) - { - Ok(block_hash) => ( - start, - BlockHash::from_byte_array(block_hash.to_byte_array()), - ), - Err(e) => { - return Err(e.to_string()); - } - }, - Err(e) => { - return Err(e.to_string()); - } - } - }; - let end = if self.input.end_block_height.is_empty() { - match self - .app_context - .core_client - .read() - .unwrap() - .get_best_block_hash() - { - Ok(block_hash) => { - match self - .app_context - .core_client - .read() - .unwrap() - .get_block_header_info(&block_hash) - { - Ok(header) => { - self.input.end_block_height = format!("{}", header.height); - ( - header.height as u32, - BlockHash::from_byte_array(block_hash.to_byte_array()), - ) - } - Err(e) => { - return Err(e.to_string()); - } - } - } - Err(e) => { - return Err(e.to_string()); - } - } - } else { - match self.input.end_block_height.trim().parse() { - Ok(end) => match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(end) - { - Ok(block_hash) => (end, BlockHash::from_byte_array(block_hash.to_byte_array())), - Err(e) => { - return Err(e.to_string()); - } - }, - Err(e) => { - return Err(e.to_string()); - } - } - }; - Ok((base, end)) - } - - fn serialize_masternode_list_engine(&self) -> Result<String, String> { - match bincode::encode_to_vec( - &self.data.masternode_list_engine, - bincode::config::standard(), - ) { - Ok(encoded_bytes) => Ok(hex::encode(encoded_bytes)), // Convert to hex string - Err(e) => Err(format!("Serialization failed: {}", e)), - } - } - - fn insert_mn_list_diff(&mut self, mn_list_diff: &MnListDiff) { - let base_block_hash = mn_list_diff.base_block_hash; - let base_height = match self.get_height_and_cache(&base_block_hash) { - Ok(height) => height, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - let block_hash = mn_list_diff.block_hash; - let height = match self.get_height_and_cache(&block_hash) { - Ok(height) => height, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - - self.data - .mnlist_diffs - .insert((base_height, height), mn_list_diff.clone()); - } - - fn fetch_rotated_quorum_info( - &mut self, - p2p_handler: &mut CoreP2PHandler, - base_block_hash: BlockHash, - block_hash: BlockHash, - ) -> Option<QRInfo> { - let known_block_hashes = self.known_block_hashes_with_base(base_block_hash); - tracing::debug!( - "requesting with known_block_hashes {}", - known_block_hashes - .iter() - .map(|bh| bh.to_string()) - .join(", ") - ); - let qr_info = match p2p_handler.get_qr_info(known_block_hashes, block_hash) { - Ok(list_diff) => list_diff, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return None; - } - }; - self.insert_mn_list_diff(&qr_info.mn_list_diff_tip); - self.insert_mn_list_diff(&qr_info.mn_list_diff_h); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_c); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_2c); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_3c); - if let Some((_, mn_list_diff_at_h_minus_4c)) = - &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c - { - self.insert_mn_list_diff(mn_list_diff_at_h_minus_4c); - } - for diff in &qr_info.mn_list_diff_list { - self.insert_mn_list_diff(diff) - } - self.data.qr_infos.insert(block_hash, qr_info.clone()); - Some(qr_info) - } - - fn fetch_diffs_with_hashes( - &mut self, - p2p_handler: &mut CoreP2PHandler, - hashes: BTreeSet<QuorumHash>, - ) { - let mut hashes_needed_to_validate = BTreeMap::new(); - for quorum_hash in hashes { - let height = match self.get_height_and_cache(&quorum_hash) { - Ok(height) => height, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - let validation_hash = match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(height - 8) - { - Ok(block_hash) => block_hash, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - hashes_needed_to_validate.insert( - height - 8, - BlockHash::from_byte_array(validation_hash.to_byte_array()), - ); - } - - if let Some((oldest_needed_height, _)) = hashes_needed_to_validate.first_key_value() { - let (first_engine_height, first_masternode_list) = self - .data - .masternode_list_engine - .masternode_lists - .first_key_value() - .unwrap(); - let (mut base_block_height, mut base_block_hash) = if *first_engine_height - < *oldest_needed_height - { - (*first_engine_height, first_masternode_list.block_hash) - } else { - let known_genesis_block_hash = match self - .data - .masternode_list_engine - .network - .known_genesis_block_hash() - { - None => match self - .app_context - .core_client - .read() - .unwrap() - .get_block_hash(0) - { - Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }, - Some(known_genesis_block_hash) => known_genesis_block_hash, - }; - (0, known_genesis_block_hash) - }; - - for (core_block_height, block_hash) in hashes_needed_to_validate { - self.fetch_single_dml( - p2p_handler, - base_block_hash, - base_block_height, - block_hash, - core_block_height, - false, - ); - base_block_hash = block_hash; - base_block_height = core_block_height; - } - } - } - - fn fetch_single_dml( - &mut self, - p2p_handler: &mut CoreP2PHandler, - base_block_hash: BlockHash, - base_block_height: u32, - block_hash: BlockHash, - block_height: u32, - validate_quorums: bool, - ) { - let list_diff = match p2p_handler.get_dml_diff(base_block_hash, block_hash) { - Ok(list_diff) => list_diff, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - - if base_block_height == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() { - self.data.masternode_list_engine = - match MasternodeListEngine::initialize_with_diff_to_height( - list_diff.clone(), - block_height, - self.app_context.network, - ) { - Ok(masternode_list_engine) => masternode_list_engine, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - } - } else if let Err(e) = self.data.masternode_list_engine.apply_diff( - list_diff.clone(), - Some(block_height), - false, - None, - ) { - self.ui_state.error = Some(e.to_string()); - return; - } - - if validate_quorums && !self.data.masternode_list_engine.masternode_lists.is_empty() { - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_non_rotating_quorum_hashes( - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - true, - ); - self.fetch_diffs_with_hashes(p2p_handler, hashes); - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_rotating_quorum_hashes(&[]); - for hash in &hashes { - let height = match self.get_height_and_cache(hash) { - Ok(height) => height, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - self.cache.block_height_cache.insert(*hash, height); - } - - if let Err(e) = self - .data - .masternode_list_engine - .verify_non_rotating_masternode_list_quorums( - block_height, - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - ) - { - self.ui_state.error = Some(e.to_string()); - } - } - - self.data - .mnlist_diffs - .insert((base_block_height, block_height), list_diff); - } - - // fn fetch_range_dml(&mut self, step: u32, include_at_minus_8: bool, count: u32) { - // let ((base_block_height, base_block_hash), (block_height, block_hash)) = - // match self.parse_heights() { - // Ok(a) => a, - // Err(e) => { - // self.ui_state.error = Some(e); - // return; - // } - // }; - // - // let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { - // Ok(p2p_handler) => p2p_handler, - // Err(e) => { - // self.ui_state.error = Some(e); - // return; - // } - // }; - // - // let rem = block_height % 24; - // - // let intermediate_block_height = (block_height - rem).saturating_sub(count * step); - // - // let intermediate_block_hash = match self - // .app_context - // .core_client - // .get_block_hash(intermediate_block_height) - // { - // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // - // self.fetch_single_dml( - // &mut p2p_handler, - // base_block_hash, - // base_block_height, - // intermediate_block_hash, - // intermediate_block_height, - // false, - // ); - // - // let mut last_height = intermediate_block_height; - // let mut last_block_hash = intermediate_block_hash; - // - // for _i in 0..count { - // if include_at_minus_8 { - // let end_height = last_height + step - 8; - // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { - // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // self.fetch_single_dml( - // &mut p2p_handler, - // last_block_hash, - // last_height, - // end_block_hash, - // end_height, - // ); - // last_height = end_height; - // last_block_hash = end_block_hash; - // - // let end_height = last_height + 8; - // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { - // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // self.fetch_single_dml( - // &mut p2p_handler, - // last_block_hash, - // last_height, - // end_block_hash, - // end_height, - // ); - // last_height = end_height; - // last_block_hash = end_block_hash; - // } else { - // let end_height = last_height + step; - // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { - // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // self.fetch_single_dml( - // &mut p2p_handler, - // last_block_hash, - // last_height, - // end_block_hash, - // end_height, - // ); - // last_height = end_height; - // last_block_hash = end_block_hash; - // } - // } - // - // if rem != 0 { - // let end_height = last_height + rem; - // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { - // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), - // Err(e) => { - // self.ui_state.error = Some(e.to_string()); - // return; - // } - // }; - // self.fetch_single_dml( - // &mut p2p_handler, - // last_block_hash, - // last_height, - // end_block_hash, - // end_height, - // ); - // } - // - // // Reset selections when new data is loaded - // self.selection.selected_dml_diff_key = None; - // self.selection.selected_quorum_in_diff_index = None; - // } - - /// Clear all data and reset to initial state - pub(crate) fn clear(&mut self) { - self.data.masternode_list_engine = - MasternodeListEngine::default_for_network(self.app_context.network); - - // Clear cached data structures - self.data.mnlist_diffs.clear(); - self.data.qr_infos.clear(); - self.incoming.chain_locked_blocks.clear(); - self.incoming.instant_send_transactions.clear(); - self.cache.block_height_cache.clear(); - self.cache.block_hash_cache.clear(); - self.cache.masternode_list_quorum_hash_cache.clear(); - self.cache - .masternode_lists_with_all_quorum_heights_known - .clear(); - self.cache.dml_diffs_with_cached_quorum_heights.clear(); - self.cache.chain_lock_sig_cache.clear(); - self.cache.chain_lock_reversed_sig_cache.clear(); - - // Reset selections and UI state - self.selection.selected_dml_diff_key = None; - self.selection.selected_dml_height_key = None; - self.selection.selected_option_index = None; - self.selection.selected_quorum_in_diff_index = None; - self.selection.selected_masternode_in_diff_index = None; - self.selection.selected_quorum_hash_in_mnlist_diff = None; - self.selection.selected_masternode_pro_tx_hash = None; - self.selection.selected_qr_item = None; - self.selection.selected_core_item = None; - self.task.pending = None; - self.task.queued_task = None; - self.input.search_term = None; - self.ui_state.error = None; - } - - /// Clear all data except the oldest MNList diff starting from height 0 - fn clear_keep_base(&mut self) { - let (engine, start_end_diff) = - if let Some(((start, end), oldest_diff)) = self.data.mnlist_diffs.first_key_value() { - if start == &0 { - MasternodeListEngine::initialize_with_diff_to_height( - oldest_diff.clone(), - *end, - self.app_context.network, - ) - .map(|engine| (engine, Some(((*start, *end), oldest_diff.clone())))) - .unwrap_or(( - MasternodeListEngine::default_for_network(self.app_context.network), - None, - )) - } else { - ( - MasternodeListEngine::default_for_network(self.app_context.network), - None, - ) - } - } else { - ( - MasternodeListEngine::default_for_network(self.app_context.network), - None, - ) - }; - - self.data.masternode_list_engine = engine; - self.data.mnlist_diffs = Default::default(); - if let Some((key, oldest_diff)) = start_end_diff { - self.data.mnlist_diffs.insert(key, oldest_diff); - } - self.selection.selected_dml_diff_key = None; - self.selection.selected_dml_height_key = None; - self.selection.selected_option_index = None; - self.selection.selected_quorum_in_diff_index = None; - self.selection.selected_masternode_in_diff_index = None; - self.selection.selected_quorum_hash_in_mnlist_diff = None; - self.selection.selected_masternode_pro_tx_hash = None; - self.data.qr_infos = Default::default(); - // Clear chain lock signatures caches as these are independent of the retained base diff - self.cache.chain_lock_sig_cache.clear(); - self.cache.chain_lock_reversed_sig_cache.clear(); - } - - /// Fetch the MNList diffs between the given base and end block heights. - /// In a real implementation, you would replace the dummy function below with a call to - /// dash_core’s DB (or other data source) to retrieve the MNList diffs. - #[allow(dead_code)] - fn fetch_end_dml_diff(&mut self, validate_quorums: bool) { - let ((base_block_height, base_block_hash), (block_height, block_hash)) = - match self.parse_heights() { - Ok(a) => a, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - - let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { - Ok(p2p_handler) => p2p_handler, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - - self.fetch_single_dml( - &mut p2p_handler, - base_block_hash, - base_block_height, - block_hash, - block_height, - validate_quorums, - ); - - // Reset selections when new data is loaded - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - - #[allow(dead_code)] - fn fetch_end_qr_info(&mut self) { - let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { - Ok(a) => a, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - - let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { - Ok(p2p_handler) => p2p_handler, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - - self.fetch_rotated_quorum_info(&mut p2p_handler, base_block_hash, block_hash); - - // Reset selections when new data is loaded - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - - #[allow(dead_code)] - fn fetch_chain_locks(&mut self) { - let ((base_block_height, _base_block_hash), (block_height, _block_hash)) = - match self.parse_heights() { - Ok(a) => a, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - - let max_blocks = 2000; - - let loaded_list_height = match self.app_context.network { - Network::Mainnet => 2227096, - Network::Testnet => 1296600, - _ => 0, - }; - - let start_height = if base_block_height < loaded_list_height { - block_height - max_blocks - } else { - base_block_height - }; - - let end_height = std::cmp::min(start_height + max_blocks, block_height); - - for i in start_height..end_height { - if let Ok(block_hash) = self.get_block_hash_and_cache(i) { - self.get_chain_lock_sig_and_cache(&block_hash).ok(); - } - } - } - - #[allow(dead_code)] - fn sync(&mut self) { - if !self.task.syncing { - self.task.syncing = true; - self.fetch_end_qr_info_with_dmls(); - } - } - - #[allow(dead_code)] - fn fetch_end_qr_info_with_dmls(&mut self) { - let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { - Ok(a) => a, - Err(e) => { - self.ui_state.error = Some(e); - return; - } - }; - - let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { - Ok(p2p_handler) => p2p_handler, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - - let Some(qr_info) = - self.fetch_rotated_quorum_info(&mut p2p_handler, base_block_hash, block_hash) - else { - return; - }; - - self.feed_qr_info_and_get_dmls(qr_info, Some(p2p_handler)) - } - - fn feed_qr_info_and_get_dmls( - &mut self, - qr_info: QRInfo, - core_p2phandler: Option<CoreP2PHandler>, - ) { - let mut p2p_handler = match core_p2phandler { - None => match CoreP2PHandler::new(self.app_context.network, None) { - Ok(p2p_handler) => p2p_handler, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }, - Some(core_p2phandler) => core_p2phandler, - }; - - // Pre-populate hash↔height data the engine needs (cycle boundary hashes are - // not carried in the QRInfo message and must be resolved locally). - let block_data = match self.build_qr_info_block_data(&qr_info) { - Ok(data) => data, - Err(e) => { - self.ui_state.error = Some(e); - self.task.pending = None; - return; - } - }; - for (height, hash) in block_data { - self.data - .masternode_list_engine - .block_container - .feed_block_height(height, hash); - } - - if let Err(e) = self - .data - .masternode_list_engine - .feed_qr_info(qr_info, false, true) - { - self.ui_state.error = Some(e.to_string()); - self.task.pending = None; - return; - } - - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_non_rotating_quorum_hashes( - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - true, - ); - self.fetch_diffs_with_hashes(&mut p2p_handler, hashes); - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_rotating_quorum_hashes(&[]); - for hash in &hashes { - let height = match self.get_height_and_cache(hash) { - Ok(height) => height, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - self.cache.block_height_cache.insert(*hash, height); - } - - if let Some(latest_masternode_list) = - self.data.masternode_list_engine.latest_masternode_list() - && let Err(e) = self - .data - .masternode_list_engine - .verify_non_rotating_masternode_list_quorums( - latest_masternode_list.known_height, - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - ) - { - self.ui_state.error = Some(e.to_string()); - } - - // Reset selections when new data is loaded - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - - /// Render the input area at the top (base and end block height fields plus Get DMLs button) - fn render_input_area(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ScrollArea::horizontal() - .id_salt("dml_input_row_scroll") - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label("Base Block Height:"); - ui.add( - TextEdit::singleline(&mut self.input.base_block_height).desired_width(80.0), - ); - ui.label("End Block Height:"); - ui.add( - TextEdit::singleline(&mut self.input.end_block_height).desired_width(80.0), - ); - if ui.button("Get single end DML diff").clicked() - && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::DmlDiffSingle); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchEndDmlDiff { - base_block_height: base_h, - base_block_hash: base_hash, - block_height: h, - block_hash: hash, - validate_quorums: false, - }, - )); - } - if ui.button("Get single end QR info").clicked() - && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::QrInfo); - // Build known_block_hashes from current diffs + base hash (old UI behavior) - let known_block_hashes = self.known_block_hashes_with_base(base_hash); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchEndQrInfo { - known_block_hashes, - block_hash: hash, - }, - )); - } - if ui.button("Get DMLs w/o rotation").clicked() - && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::DmlDiffNoRotation); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchEndDmlDiff { - base_block_height: base_h, - base_block_hash: base_hash, - block_height: h, - block_hash: hash, - validate_quorums: true, - }, - )); - } - if ui.button("Get DMLs w/ rotation").clicked() - && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::QrInfoWithDmls); - // Build known_block_hashes from current diffs + base hash (old UI behavior) - let known_block_hashes = self.known_block_hashes_with_base(base_hash); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchEndQrInfoWithDmls { - known_block_hashes, - block_hash: hash, - }, - )); - } - if ui.button("Sync").clicked() - && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::QrInfoWithDmls); - // Build known_block_hashes from current diffs + base hash (old UI behavior) - let known_block_hashes = self.known_block_hashes_with_base(base_hash); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchEndQrInfoWithDmls { - known_block_hashes, - block_hash: hash, - }, - )); - } - if ui.button("Get chain locks").clicked() - && let Ok(((base_h, _), (h, _))) = self.parse_heights() - { - self.task.pending = Some(PendingTask::ChainLocks); - action = AppAction::BackendTask(BackendTask::MnListTask( - MnListTask::FetchChainLocks { - base_block_height: base_h, - block_height: h, - }, - )); - } - if ui - .button("Clear") - .clickable_tooltip("Clear all data and reset to initial state.") - .clicked() - { - self.clear(); - self.display_message("Cleared all data", MessageType::Success); - } - if ui - .button("Clear keep base") - .clickable_tooltip( - "Clear all data except the oldest MNList diff starting from height 0.", - ) - .clicked() - { - self.clear_keep_base(); - self.display_message( - "Cleared data and kept base diff", - MessageType::Success, - ); - } - }); - // Add bottom padding so the horizontal scrollbar doesn't overlap buttons - ui.add_space(12.0); - }); - action - } - - fn render_error_banner(&mut self, ui: &mut Ui) { - let Some(error_msg) = self.ui_state.error.clone() else { - return; - }; - - let message_color = DashColors::ERROR; - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(error_msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.ui_state.error = None; - } - }); - }); - }); - ui.add_space(10.0); - } - - fn render_pending_status(&self, ui: &mut Ui) { - let Some(pending) = self.task.pending else { - return; - }; - - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.scope(|ui| { - let style = ui.style_mut(); - // Force spinner (fg stroke) to Dash Blue - style.visuals.widgets.inactive.fg_stroke.color = DashColors::DASH_BLUE; - style.visuals.widgets.active.fg_stroke.color = DashColors::DASH_BLUE; - style.visuals.widgets.hovered.fg_stroke.color = DashColors::DASH_BLUE; - ui.add(egui::Spinner::new()); - }); - let label = match pending { - PendingTask::DmlDiffSingle => "Fetching DML diff…", - PendingTask::DmlDiffNoRotation => "Fetching DMLs (no rotation)…", - PendingTask::QrInfo => "Fetching QR info…", - PendingTask::QrInfoWithDmls => "Fetching QR info + DMLs…", - PendingTask::ChainLocks => "Fetching chain locks…", - }; - let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); - ui.colored_label(text_primary, label); - }); - ui.add_space(6.0); - } - - fn load_masternode_list_engine(&mut self) { - if let Some(path) = rfd::FileDialog::new() - .add_filter("Binary", &["dat"]) - .pick_file() - { - match std::fs::read(&path) { - Ok(bytes) => { - match bincode::decode_from_slice::<MasternodeListEngine, _>( - &bytes, - bincode::config::standard(), - ) { - Ok((engine, _)) => { - self.data.masternode_list_engine = engine; - } - Err(e) => { - tracing::error!("Failed to decode QRInfo: {}", e); - } - } - } - Err(e) => { - tracing::error!("Failed to read file: {:?}", e); - } - } - } - } - - fn save_masternode_list_engine(&mut self) { - // Serialize the masternode list engine - let serialized = match self.serialize_masternode_list_engine() { - Ok(serialized) => serialized, - Err(e) => { - self.ui_state.error = Some(format!("Serialization failed: {}", e)); - return; - } - }; - - // Open a file save dialog - if let Some(path) = FileDialog::new() - .set_title("Save Masternode List Engine") - .add_filter("JSON", &["hex"]) - .add_filter("Binary", &["bin"]) - .set_file_name("masternode_list_engine.hex") - .save_file() - { - // Attempt to write the serialized data to the selected file - match fs::write(&path, serialized) { - Ok(_) => { - tracing::info!("Masternode list engine saved to {:?}", path); - } - Err(e) => { - self.ui_state.error = Some(format!("Failed to save file: {}", e)); - } - } - } - } - - fn render_masternode_lists(&mut self, ui: &mut Ui) { - ui.heading("Masternode lists"); - ScrollArea::vertical() - .id_salt("dml_list_scroll_area") - .show(ui, |ui| { - for height in self.data.masternode_list_engine.masternode_lists.keys() { - let height_label = format!("{}", height); - - if ui - .selectable_label( - self.selection.selected_dml_height_key == Some(*height), - height_label, - ) - .clicked() - { - self.selection.selected_dml_diff_key = None; - self.selection.selected_dml_height_key = Some(*height); - self.selection.selected_quorum_in_diff_index = None; - } - } - }); - } - - /// Render MNList diffs list (block heights) - fn render_diff_list(&mut self, ui: &mut Ui) { - ui.heading("MNList Diffs"); - ScrollArea::vertical() - .id_salt("dml_list_scroll_area") - .show(ui, |ui| { - for (key, _dml) in self.data.mnlist_diffs.iter() { - let block_label = format!("Base: {} -> Block: {}", key.0, key.1); - - if ui - .selectable_label( - self.selection.selected_dml_diff_key == Some(*key), - block_label, - ) - .clicked() - { - self.selection.selected_dml_diff_key = Some(*key); - self.selection.selected_dml_height_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - } - }); - } - - /// Render the list of quorums for the selected DML - fn render_new_quorums(&mut self, ui: &mut Ui) { - ui.heading("New Quorums"); - - let Some(selected_key) = self.selection.selected_dml_diff_key else { - ui.label("Select a block height to show quorums."); - return; - }; - - let Some(dml) = self.data.mnlist_diffs.get(&selected_key) else { - ui.label("Select a block height to show quorums."); - return; - }; - - let should_get_heights = !self - .cache - .dml_diffs_with_cached_quorum_heights - .contains(&selected_key); - let new_quorums = dml.new_quorums.clone(); - let mut heights: HashMap<QuorumHash, CoreBlockHeight> = HashMap::new(); - for quorum in &new_quorums { - let height = if should_get_heights { - self.get_height_and_cache(&quorum.quorum_hash) - } else { - self.get_height(&quorum.quorum_hash) - } - .ok() - .unwrap_or_default(); - heights.insert(quorum.quorum_hash, height); - } - - ScrollArea::vertical() - .id_salt("quorum_list_scroll_area") - .show(ui, |ui| { - for (q_index, quorum) in new_quorums.iter().enumerate() { - let quorum_height = heights - .get(&quorum.quorum_hash) - .copied() - .unwrap_or_default(); - if ui - .selectable_label( - self.selection.selected_quorum_in_diff_index == Some(q_index), - format!( - "Quorum height {} [..]{}{} Type: {}", - quorum_height, - quorum.quorum_hash.to_string().as_str().split_at(58).1, - quorum - .quorum_index - .map(|i| format!(" (index {})", i)) - .unwrap_or_default(), - QuorumType::from(quorum.llmq_type as u32) - ), - ) - .clicked() - { - self.selection.selected_quorum_in_diff_index = Some(q_index); - self.selection.selected_masternode_in_diff_index = None; - } - } - }); - } - - fn render_selected_masternode_list_items(&mut self, ui: &mut Ui) { - ui.heading("Masternode List Explorer"); - - // Define available options for selection - let options = ["Quorums", "Masternodes"]; - let selected_index = self.selection.selected_option_index.unwrap_or(0); - - // Render the selection buttons - ui.horizontal(|ui| { - for (index, option) in options.iter().enumerate() { - if ui - .selectable_label(selected_index == index, *option) - .clicked() - { - self.selection.selected_option_index = Some(index); - } - } - }); - - ui.separator(); - - // Borrow mn_list separately to avoid multiple borrows of `self` - if self.selection.selected_dml_height_key.is_some() { - ScrollArea::vertical() - .id_salt("mnlist_items_scroll_area") - .show(ui, |ui| match selected_index { - 0 => self.render_quorums_in_masternode_list(ui), - 1 => self.render_masternodes_in_masternode_list(ui), - _ => (), - }); - } else { - ui.label("Select a block height to show details."); - } - } - - fn render_quorums_in_masternode_list(&mut self, ui: &mut Ui) { - let mut heights: BTreeMap<QuorumHash, CoreBlockHeight> = BTreeMap::new(); - let mut masternode_block_hash = None; - if let Some(selected_height) = self.selection.selected_dml_height_key { - if !self - .cache - .masternode_lists_with_all_quorum_heights_known - .contains(&selected_height) - { - if let Some(quorum_hashes) = self - .data - .masternode_list_engine - .masternode_lists - .get(&selected_height) - .map(|list| { - list.quorums - .values() - .flat_map(|quorums| quorums.keys()) - .copied() - .collect::<BTreeSet<_>>() - }) - { - for quorum_hash in quorum_hashes.iter() { - if let Ok(height) = self.get_height_and_cache(quorum_hash) { - heights.insert(*quorum_hash, height); - } - } - } - self.cache - .masternode_lists_with_all_quorum_heights_known - .insert(selected_height); - } - if let Some(mn_list) = self - .data - .masternode_list_engine - .masternode_lists - .get(&selected_height) - { - masternode_block_hash = Some(mn_list.block_hash); - for (llmq_type, quorum_map) in &mn_list.quorums { - if llmq_type == &LLMQType::Llmqtype50_60 - || llmq_type == &LLMQType::Llmqtype400_85 - { - continue; - } - for quorum_hash in quorum_map.keys() { - if let Ok(height) = self.get_height(quorum_hash) { - heights.insert(*quorum_hash, height); - } - } - } - self.cache - .masternode_list_quorum_hash_cache - .entry(mn_list.block_hash) - .or_insert_with(|| { - let mut btree_map = BTreeMap::new(); - for (llmq_type, quorum_map) in &mn_list.quorums { - let quorums_by_height = quorum_map - .iter() - .map(|(quorum_hash, quorum_entry)| { - ( - heights.get(quorum_hash).copied().unwrap_or_default(), - quorum_entry.clone(), - ) - }) - .collect(); - btree_map.insert(*llmq_type, quorums_by_height); - } - btree_map - }); - } - } - if let Some(quorums) = masternode_block_hash.and_then(|block_hash| { - self.cache - .masternode_list_quorum_hash_cache - .get(&block_hash) - }) { - ui.heading("Quorums in Masternode List"); - ui.label("(excluding 50_60 and 400_85)"); - ScrollArea::vertical() - .id_salt("quorum_list_scroll_area") - .show(ui, |ui| { - for (llmq_type, quorum_map) in quorums { - if llmq_type == &LLMQType::Llmqtype50_60 - || llmq_type == &LLMQType::Llmqtype400_85 - { - continue; - } - for (quorum_height, quorum_entry) in quorum_map.iter() { - if ui - .selectable_label( - self.selection.selected_quorum_hash_in_mnlist_diff - == Some(( - *llmq_type, - quorum_entry.quorum_entry.quorum_hash, - )), - format!( - "Quorum {} Type: {} Valid {}", - quorum_height, - QuorumType::from(*llmq_type as u32), - quorum_entry.verified - == LLMQEntryVerificationStatus::Verified - ), - ) - .clicked() - { - self.selection.selected_quorum_hash_in_mnlist_diff = - Some((*llmq_type, quorum_entry.quorum_entry.quorum_hash)); - self.selection.selected_masternode_pro_tx_hash = None; - self.selection.selected_dml_diff_key = None; - } - } - } - }); - } - } - - /// Filter masternodes based on the search term - fn filter_masternodes( - &self, - mn_list: &MasternodeList, - ) -> BTreeMap<ProTxHash, QualifiedMasternodeListEntry> { - // If no search term, return all masternodes - if let Some(search_term) = &self.input.search_term { - let search_term = search_term.to_lowercase(); - - if search_term.len() < 3 { - return mn_list.masternodes.clone(); // Require at least 3 characters to filter - } - - mn_list - .masternodes - .iter() - .filter(|(pro_tx_hash, mn_entry)| { - let masternode = &mn_entry.masternode_list_entry; - - // Convert fields to lowercase for case-insensitive search - let pro_tx_hash_str = pro_tx_hash.to_string().to_lowercase(); - let confirmed_hash_str = masternode - .confirmed_hash - .map(|h| h.to_string().to_lowercase()) - .unwrap_or_default(); - let service_ip = masternode.service_address.ip().to_string().to_lowercase(); - let operator_public_key = - masternode.operator_public_key.to_string().to_lowercase(); - let voting_key_id = masternode.key_id_voting.to_string().to_lowercase(); - - // Check reversed versions - let pro_tx_hash_reversed = pro_tx_hash.reverse().to_string().to_lowercase(); - let confirmed_hash_reversed = masternode - .confirmed_hash - .map(|h| h.reverse().to_string().to_lowercase()) - .unwrap_or_default(); - - // Match against search term - pro_tx_hash_str.contains(&search_term) - || confirmed_hash_str.contains(&search_term) - || service_ip.contains(&search_term) - || operator_public_key.contains(&search_term) - || voting_key_id.contains(&search_term) - || pro_tx_hash_reversed.contains(&search_term) - || confirmed_hash_reversed.contains(&search_term) - }) - .map(|(pro_tx_hash, entry)| (*pro_tx_hash, entry.clone())) - .collect() - } else { - mn_list.masternodes.clone() - } - } - - /// Render search bar - fn render_search_bar(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Search:"); - let mut search_term = self.input.search_term.clone().unwrap_or_default(); - let response = ui.add(TextEdit::singleline(&mut search_term).desired_width(200.0)); - - if response.changed() { - self.input.search_term = if search_term.trim().is_empty() { - None - } else { - Some(search_term) - }; - } - }); - } - - fn render_masternodes_in_masternode_list(&mut self, ui: &mut Ui) { - if self.selected_mn_list().is_some() { - ui.heading("Masternodes in List"); - self.render_search_bar(ui); - } - let Some(mn_list) = self.selected_mn_list() else { - return; - }; - - let filtered_masternodes = self.filter_masternodes(mn_list); - ScrollArea::vertical() - .id_salt("masternode_list_scroll_area") - .show(ui, |ui| { - for (pro_tx_hash, masternode) in filtered_masternodes.iter() { - if ui - .selectable_label( - self.selection.selected_masternode_pro_tx_hash == Some(*pro_tx_hash), - format!( - "{} {} {}", - if masternode.masternode_list_entry.mn_type - == EntryMasternodeType::Regular - { - "MN" - } else { - "EN" - }, - masternode.masternode_list_entry.service_address.ip(), - pro_tx_hash.to_string().as_str().split_at(5).0 - ), - ) - .clicked() - { - self.selection.selected_quorum_hash_in_mnlist_diff = None; - self.selection.selected_masternode_pro_tx_hash = Some(*pro_tx_hash); - } - } - }); - } - - fn render_masternode_list_page(&mut self, ui: &mut Ui) { - // Use a left-to-right layout that fills the available height so columns can expand fully - let full_w = ui.available_width(); - let full_h = ui.available_height(); - ui.allocate_ui_with_layout( - egui::Vec2::new(full_w, full_h), - Layout::left_to_right(Align::Min), - |ui| { - // Left column (Fixed width: 120px) - ui.allocate_ui_with_layout( - egui::Vec2::new(120.0, ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - self.render_masternode_lists(ui); - }, - ); - - ui.separator(); - - // Middle column (40% of the remaining space) - let mid_w = ui.available_width() * 0.4; - ui.allocate_ui_with_layout( - egui::Vec2::new(mid_w, ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - self.render_selected_masternode_list_items(ui); - }, - ); - - // Right column (Remaining space) - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width(), ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - if self.selection.selected_quorum_hash_in_mnlist_diff.is_some() { - self.render_quorum_details(ui); - } else if self.selection.selected_masternode_pro_tx_hash.is_some() { - self.render_mn_details(ui); - } - }, - ); - }, - ); - } - - fn render_selected_tab(&mut self, ui: &mut Ui) { - // Define available tabs - let mut tabs = vec![ - "Masternode Lists", - "Quorums", - "Diffs", - "QRInfo", - "Known Blocks", - "Known Chain Lock Sigs", - "Core Items", - "Save Masternode List Engine", - "Load Masternode List Engine", - ]; - - if self.task.syncing { - tabs.push("Stop Syncing"); - } - - // Render the selection buttons (scrollable horizontally) styled as buttons - ScrollArea::horizontal() - .id_salt("dml_tabs_scroll") - .show(ui, |ui| { - ui.horizontal(|ui| { - for (index, tab) in tabs.iter().enumerate() { - let is_selected = self.ui_state.selected_tab == index; - if is_selected { - // Match the selected look used under "Masternode List Explorer" - let _ = ui.selectable_label(true, *tab); - } else if ui.button(*tab).clicked() { - match index { - 7 => { - // Show the popup when "Masternode List Engine" is selected - self.ui_state.masternode_engine_confirm_dialog = Some( - ConfirmationDialog::new( - "Confirmation", - "This operation will take about 10 seconds. Are you sure you wish to continue?", - ) - .confirm_text(Some("Yes")) - .cancel_text(Some("Cancel")), - ); - } - 8 => { - self.load_masternode_list_engine(); - } - 9 => { - self.task.syncing = false; - } - index => self.ui_state.selected_tab = index, - } - } - } - }); - // Add bottom padding so the horizontal scrollbar doesn't overlap tabs - ui.add_space(12.0); - }); - - ui.separator(); - - // Scroll only the content below the tab row; for the Masternode Lists page, - // let its own columns manage scrolling independently. - if self.ui_state.selected_tab == 0 { - // Make the Masternode Lists section occupy remaining height - let full_w = ui.available_width(); - let full_h = ui.available_height(); - ui.allocate_ui_with_layout( - egui::Vec2::new(full_w, full_h), - Layout::top_down(Align::Min), - |ui| { - self.render_masternode_list_page(ui); - }, - ); - } else { - ScrollArea::vertical() - .auto_shrink([false; 2]) - .id_salt("dml_tab_content_scroll") - .show(ui, |ui| match self.ui_state.selected_tab { - 1 => self.render_quorums(ui), - 2 => self.render_diffs(ui), - 3 => self.render_qr_info(ui), - 4 => self.render_engine_known_blocks(ui), - 5 => self.render_known_chain_lock_sigs(ui), - 6 => self.render_core_items(ui), - _ => {} - }); - } - - // Render the confirmation popup if needed - if let Some(dialog) = self.ui_state.masternode_engine_confirm_dialog.as_mut() { - let response = dialog.show(ui); - if let Some(result) = response.inner.dialog_response { - self.ui_state.masternode_engine_confirm_dialog = None; - if result == ConfirmationStatus::Confirmed { - self.save_masternode_list_engine(); - } - } - } - } - - fn render_known_chain_lock_sigs(&mut self, ui: &mut Ui) { - ui.heading("Known Chain Lock Sigs"); - - ScrollArea::vertical() - .id_salt("known_chain_lock_sigs_scroll") - .show(ui, |ui| { - egui::Grid::new("known_chain_lock_sigs_grid") - .num_columns(3) // Two columns: Block Height | Block Hash | Sig - .striped(true) - .show(ui, |ui| { - ui.label("Block Height"); - ui.label("Block Hash"); - ui.label("Chain Lock Sig"); - ui.end_row(); - - for ((height, block_hash), sig) in &self.cache.chain_lock_sig_cache { - ui.label(format!("{}", height)); - ui.label(format!("{}", block_hash)); - if let Some(sig) = sig { - ui.label(format!("{}", sig)); - } else { - ui.label("None"); - } - - ui.end_row(); - } - }); - }); - } - - fn render_engine_known_blocks(&mut self, ui: &mut Ui) { - ui.heading("Known Blocks in Masternode List Engine"); - - // Add Save/Load functionality - ui.horizontal(|ui| { - if ui.button("Save Block Container").clicked() { - // Open native save dialog - if let Some(path) = FileDialog::new() - .set_file_name("block_container.dat") - .add_filter("Data Files", &["dat"]) - .save_file() - { - // Serialize and save the block container - let serialized_data = bincode::encode_to_vec( - &self.data.masternode_list_engine.block_container, - bincode::config::standard(), - ) - .expect("serialize container"); - if let Err(e) = std::fs::write(&path, serialized_data) { - tracing::error!("Failed to write file: {}", e); - } - } - } - }); - - ScrollArea::vertical() - .id_salt("known_blocks_scroll") - .show(ui, |ui| { - ui.label(format!( - "Total Known Blocks: {}", - self.data - .masternode_list_engine - .block_container - .known_block_count() - )); - - egui::Grid::new("known_blocks_grid") - .num_columns(2) // Two columns: Block Height | Block Hash - .striped(true) - .show(ui, |ui| { - ui.label("Block Height"); - ui.label("Block Hash"); - ui.end_row(); - - let MasternodeListEngineBlockContainer::BTreeMapContainer(map) = - &self.data.masternode_list_engine.block_container; - - // Sort block heights for ordered display - let mut known_blocks: Vec<_> = map.block_heights.iter().collect(); - known_blocks.sort_by_key(|(_, height)| *height); - - for (block_hash, height) in known_blocks { - ui.label(format!("{}", height)); - let hash_str = format!("{}", block_hash); - - if ui.selectable_label(false, hash_str.clone()).clicked() { - ui.ctx().copy_text(hash_str.clone()); - } - - ui.end_row(); - } - }); - }); - } - - fn render_diffs(&mut self, ui: &mut Ui) { - // Add Save/Load functionality - ui.horizontal(|ui| { - if ui.button("Save MN List Diffs").clicked() { - // Open native save dialog - if let Some(path) = FileDialog::new() - .set_file_name("mnlistdiffs.dat") - .add_filter("Data Files", &["dat"]) - .save_file() - { - // Serialize and save the block container - let serialized_data = bincode::encode_to_vec( - &self.data.mnlist_diffs, - bincode::config::standard(), - ) - .expect("serialize container"); - if let Err(e) = std::fs::write(&path, serialized_data) { - tracing::error!("Failed to write file: {}", e); - } - } - } - }); - // Create a three-column layout: - // - Left column: list of MNList Diffs (by block height) - // - Middle column: list of quorums for the selected DML - // - Right column: quorum details - ui.horizontal(|ui| { - ui.allocate_ui_with_layout( - egui::Vec2::new(150.0, 800.0), // Set fixed width for left column - Layout::top_down(Align::Min), - |ui| { - self.render_diff_list(ui); - }, - ); - - ui.separator(); // Optional: Adds a visual separator - - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width() * 0.4, 800.0), // Middle column - Layout::top_down(Align::Min), - |ui| { - self.render_selected_dml_items(ui); - }, - ); - - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width(), ui.available_height()), // Right column takes remaining space - Layout::top_down(Align::Min), - |ui| { - if self.selection.selected_quorum_in_diff_index.is_some() { - self.render_quorum_details(ui); - } else if self.selection.selected_masternode_in_diff_index.is_some() { - self.render_mn_details(ui); - } - }, - ); - }); - } - - fn render_masternode_changes(&mut self, ui: &mut Ui) { - ui.heading("Masternode changes"); - let Some(dml) = self.selected_dml() else { - ui.label("Select a block height to show quorums."); - return; - }; - let new_masternodes = dml.new_masternodes.clone(); - - ScrollArea::vertical() - .id_salt("quorum_list_scroll_area") - .show(ui, |ui| { - for (m_index, masternode) in new_masternodes.iter().enumerate() { - if ui - .selectable_label( - self.selection.selected_masternode_in_diff_index == Some(m_index), - format!( - "{} {} {}", - if masternode.mn_type == EntryMasternodeType::Regular { - "MN" - } else { - "EN" - }, - masternode.service_address.ip(), - masternode - .pro_reg_tx_hash - .to_string() - .as_str() - .split_at(5) - .0 - ), - ) - .clicked() - { - self.selection.selected_quorum_in_diff_index = None; - self.selection.selected_masternode_in_diff_index = Some(m_index); - } - } - }); - } - - fn render_mn_diff_chain_locks(&mut self, ui: &mut Ui) { - ui.heading("MN list diff chain locks"); - let Some(dml) = self.selected_dml() else { - return; - }; - - ScrollArea::vertical() - .id_salt("quorum_list_chain_locks_scroll_area") - .show(ui, |ui| { - for (index, sig) in dml.quorums_chainlock_signatures.iter().enumerate() { - ui.group(|ui| { - ui.label(format!("Signature #{}", index)); - ui.monospace(format!( - "Signature: {}", - hex::encode(sig.signature.as_bytes()) - )); - ui.label(format!("Index Set: {:?}", sig.index_set)); - }); - } - }); - } - - fn save_mn_list_diff(&mut self) { - let Some(selected_key) = self.selection.selected_dml_diff_key else { - self.ui_state.error = Some("No MNListDiff selected.".to_string()); - return; - }; - - let Some(mn_list_diff) = self.data.mnlist_diffs.get(&selected_key) else { - self.ui_state.error = Some("Failed to retrieve selected MNListDiff.".to_string()); - return; - }; - - // Extract block heights from the selected key - let (base_block_height, block_height) = selected_key; - - // Serialize the MNListDiff - let serialized = serialize(mn_list_diff); - - // Generate the dynamic filename - let file_name = format!("mn_list_diff_{}_{}.bin", base_block_height, block_height); - - // Open a file save dialog with the generated file name - if let Some(path) = FileDialog::new() - .set_title("Save MNListDiff") - .add_filter("Binary", &["bin"]) - .set_file_name(&file_name) // Set the dynamic filename - .save_file() - { - // Attempt to write the serialized data to the selected file - match fs::write(&path, serialized) { - Ok(_) => { - tracing::info!("MNListDiff saved to {:?}", path); - } - Err(e) => { - self.ui_state.error = Some(format!("Failed to save file: {}", e)); - } - } - } - } - - /// Render the list of items for the selected DML, with a selector at the top - fn render_selected_dml_items(&mut self, ui: &mut Ui) { - ui.heading("Masternode List Diff Explorer"); - - // Define available options for selection - let options = [ - "New Quorums", - "Masternode Changes", - "Chain Locks", - "Save Diff", - ]; - let selected_index = self.selection.selected_option_index.unwrap_or(0); - - // Render the selection buttons - ui.horizontal(|ui| { - for (index, option) in options.iter().enumerate() { - if ui - .selectable_label(selected_index == index, *option) - .clicked() - { - // If the user selects "Save MNListDiff", trigger save function - if index == 3 { - self.save_mn_list_diff(); - } else { - self.selection.selected_option_index = Some(index); - } - } - } - }); - - ui.separator(); - - // Determine the selected category and display corresponding information - if self.selected_dml().is_some() { - ScrollArea::vertical() - .id_salt("dml_items_scroll_area") - .show(ui, |ui| match selected_index { - 0 => self.render_new_quorums(ui), - 1 => self.render_masternode_changes(ui), - 2 => self.render_mn_diff_chain_locks(ui), - _ => (), - }); - } else { - ui.label("Select a block height to show details."); - } - } - - pub fn required_cl_sig_heights(&self, quorum: &QuorumEntry) -> BTreeSet<u32> { - let mut required_heights = BTreeSet::new(); - let Ok(quorum_block_height) = self.get_height(&quorum.quorum_hash) else { - return BTreeSet::new(); - }; - let llmq_params = quorum.llmq_type.params(); - let quorum_index = quorum_block_height % llmq_params.dkg_params.interval; - let cycle_base_height = quorum_block_height - quorum_index; - let cycle_length = llmq_params.dkg_params.interval; - for i in 0..=3 { - required_heights.insert(cycle_base_height - i * cycle_length - 8); - } - required_heights - } - - /// Render the details for the selected quorum - fn render_quorum_details(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let border = DashColors::border(dark_mode); - ui.heading("Quorum Details"); - if let Some(dml_key) = self.selection.selected_dml_diff_key { - let Some(dml) = self.data.mnlist_diffs.get(&dml_key) else { - return; - }; - let Some(q_index) = self.selection.selected_quorum_in_diff_index else { - ui.label("Select a quorum to view details."); - return; - }; - let Some(quorum) = dml.new_quorums.get(q_index) else { - return; - }; - - Frame::NONE - .stroke(Stroke::new(1.0, border)) - .show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - let height = self.get_height(&quorum.quorum_hash).ok(); - - // Build a vector of optional signatures with slots matching new_quorums length - let mut quorum_sig_lookup: Vec<Option<&BLSSignature>> = vec![None; dml.new_quorums.len()]; - - // Fill each slot with the corresponding signature - for quorum_sig_obj in &dml.quorums_chainlock_signatures { - for &index in &quorum_sig_obj.index_set { - if let Some(slot) = quorum_sig_lookup.get_mut(index as usize) { - *slot = Some(&quorum_sig_obj.signature); - } else { - return; - } - } - } - - // Verify all slots have been filled - if quorum_sig_lookup.iter().any(Option::is_none) { - return; - } - - let chain_lock_msg = if let Some(a) = quorum_sig_lookup.get(q_index) { - if let Some(b) = a { - hex::encode(b) - } else { - "Error a".to_string() - } - } else { - "Error b".to_string() - }; - - let expected_chain_lock_sig = if let Some(height) = height { - if let Ok(hash) = self.get_block_hash(height - 8) { - if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { - hex::encode(sig) - } else { - "Error (Did not find chain lock sig for hash)".to_string() - } - } else { - "Error (Did not find block hash of 8 blocks ago)".to_string() - } - } else { - "Error (Did not find quorum hash height)".to_string() - }; - if quorum.llmq_type.is_rotating_quorum_type() { - ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { - ui.label(format!( - "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nCycle Hash Height: {}\nQuorum Index: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", - quorum.version, - self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), - quorum.quorum_hash, - self.get_height(&quorum.quorum_hash).ok().and_then(|height| quorum.quorum_index.map(|index| format!("{}", height - index as CoreBlockHeight))).unwrap_or("Unknown".to_string()), - quorum.quorum_index.map(|quorum_index| quorum_index.to_string()).unwrap_or("Unknown".to_string()), - quorum.signers.iter().filter(|&&b| b).count(), - quorum.valid_members.iter().filter(|&&b| b).count(), - quorum.quorum_public_key, - chain_lock_msg, - expected_chain_lock_sig, - )); - }); - } else { - ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { - ui.label(format!( - "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", - quorum.version, - self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), - quorum.quorum_hash, - quorum.signers.iter().filter(|&&b| b).count(), - quorum.valid_members.iter().filter(|&&b| b).count(), - quorum.quorum_public_key, - chain_lock_msg, - expected_chain_lock_sig, - )); - }); - } - }); - return; - } - - if let Some(selected_height) = self.selection.selected_dml_height_key { - if let Some(mn_list) = self - .data - .masternode_list_engine - .masternode_lists - .get(&selected_height) - { - if let Some((llmq_type, quorum_hash)) = - self.selection.selected_quorum_hash_in_mnlist_diff - { - if let Some(quorum) = mn_list - .quorums - .get(&llmq_type) - .and_then(|quorums_by_type| quorums_by_type.get(&quorum_hash)) - { - let height = self.get_height(&quorum.quorum_entry.quorum_hash).ok(); - let chain_lock_sig = - if quorum.quorum_entry.llmq_type.is_rotating_quorum_type() { - let heights = self.required_cl_sig_heights(&quorum.quorum_entry); - format!( - "heights [{}]", - heights.iter().map(|h| h.to_string()).join(" | ") - ) - } else if let Some(height) = height { - if let Ok(hash) = self.get_block_hash(height - 8) { - if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { - hex::encode(sig) - } else { - "Error (Did not find chain lock sig for hash)".to_string() - } - } else { - "Error (Did not find block hash of 8 blocks ago)".to_string() - } - } else { - "Error (Did not find quorum hash height)".to_string() - }; - - let get_used_heights = |bls_signature: BLSSignature| { - let Some(used) = - self.cache.chain_lock_reversed_sig_cache.get(&bls_signature) - else { - return String::default(); - }; - if used.is_empty() { - String::default() - } else if used.len() == 1 { - format!(" [height: {}]", used.iter().next().unwrap().0) - } else { - format!( - " [height: {} to {}]", - used.iter().next().unwrap().0, - used.last().unwrap().0 - ) - } - }; - - let associated_chain_lock_sig = match quorum.verifying_chain_lock_signature - { - Some(VerifyingChainLockSignaturesType::NonRotating( - associated_chain_lock_sig, - )) => hex::encode(associated_chain_lock_sig), - Some(VerifyingChainLockSignaturesType::Rotating( - associated_chain_lock_sigs, - )) => { - format!( - "[\n-3: {}{}\n-2: {}{}\n-1: {}{}\n0: {}{}\n]", - hex::encode(associated_chain_lock_sigs[0]), - get_used_heights(associated_chain_lock_sigs[0]), - hex::encode(associated_chain_lock_sigs[1]), - get_used_heights(associated_chain_lock_sigs[1]), - hex::encode(associated_chain_lock_sigs[2]), - get_used_heights(associated_chain_lock_sigs[2]), - hex::encode(associated_chain_lock_sigs[3]), - get_used_heights(associated_chain_lock_sigs[3]) - ) - } - None => "None set".to_string(), - }; - - Frame::NONE - .stroke(Stroke::new(1.0, border)) - .show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - ScrollArea::vertical().id_salt("render_quorum_details_2").show(ui, |ui| { - ui.label(format!( - "Quorum Type: {}\nQuorum Height: {}\nQuorum Hash: {}\nCommitment Hash: {}\nCommitment Data: {}\nEntry Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nValidation Status: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", - QuorumType::from(quorum.quorum_entry.llmq_type as u32), - self.get_height(&quorum.quorum_entry.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), - quorum.quorum_entry.quorum_hash, - quorum.commitment_hash, - hex::encode(quorum.quorum_entry.commitment_data()), - quorum.entry_hash, - quorum.quorum_entry.signers.iter().filter(|&&b| b).count(), - quorum.quorum_entry.valid_members.iter().filter(|&&b| b).count(), - quorum.quorum_entry.quorum_public_key, - quorum.verified, - associated_chain_lock_sig, - chain_lock_sig, - )); - }); - }); - } - } else { - ui.label("Select a quorum to view details."); - } - } - } else { - ui.label("Select a block height and quorum."); - } - } - - /// Render the details for the selected Masternode - fn render_mn_details(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let border = DashColors::border(dark_mode); - ui.heading("Masternode Details"); - - if let Some(dml_key) = self.selection.selected_dml_diff_key { - if let Some(dml) = self.data.mnlist_diffs.get(&dml_key) { - if let Some(mn_index) = self.selection.selected_masternode_in_diff_index { - if let Some(masternode) = dml.new_masternodes.get(mn_index) { - Frame::NONE.stroke(Stroke::new(1.0, border)).show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - ScrollArea::vertical() - .id_salt("render_mn_details") - .show(ui, |ui| { - ui.label(format!( - "Version: {}\n\ - ProRegTxHash: {}\n\ - Confirmed Hash: {}\n\ - Service Address: {}:{}\n\ - Operator Public Key: {}\n\ - Voting Key ID: {}\n\ - Is Valid: {}\n\ - Masternode Type: {}", - masternode.version, - masternode.pro_reg_tx_hash.reverse(), - match masternode.confirmed_hash { - None => "No confirmed hash".to_string(), - Some(confirmed_hash) => - confirmed_hash.reverse().to_string(), - }, - masternode.service_address.ip(), - masternode.service_address.port(), - masternode.operator_public_key, - masternode.key_id_voting, - masternode.is_valid, - match masternode.mn_type { - EntryMasternodeType::Regular => "Regular".to_string(), - EntryMasternodeType::HighPerformance { - platform_http_port, - platform_node_id, - } => { - format!( - "High Performance (Port: {}, Node ID: {})", - platform_http_port, platform_node_id - ) - } - } - )); - }); - }); - } - } else { - ui.label("Select a Masternode to view details."); - } - } - } else if let Some(selected_height) = self.selection.selected_dml_height_key { - if let Some(mn_list) = self - .data - .masternode_list_engine - .masternode_lists - .get(&selected_height) - && let Some(selected_pro_tx_hash) = self.selection.selected_masternode_pro_tx_hash - && let Some(qualified_masternode) = mn_list.masternodes.get(&selected_pro_tx_hash) - { - let masternode = &qualified_masternode.masternode_list_entry; - Frame::NONE.stroke(Stroke::new(1.0, border)).show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - ScrollArea::vertical() - .id_salt("render_mn_details_2") - .show(ui, |ui| { - ui.label(format!( - "Version: {}\n\ - ProRegTxHash: {}\n\ - Confirmed Hash: {}\n\ - Service Address: {}:{}\n\ - Operator Public Key: {}\n\ - Voting Key ID: {}\n\ - Is Valid: {}\n\ - Masternode Type: {}\n\ - Entry Hash: {}\n\ - Confirmed Hash hashed with ProRegTx: {}\n", - masternode.version, - masternode.pro_reg_tx_hash.reverse(), - match masternode.confirmed_hash { - None => "No confirmed hash".to_string(), - Some(confirmed_hash) => confirmed_hash.reverse().to_string(), - }, - masternode.service_address.ip(), - masternode.service_address.port(), - masternode.operator_public_key, - masternode.key_id_voting, - masternode.is_valid, - match masternode.mn_type { - EntryMasternodeType::Regular => "Regular".to_string(), - EntryMasternodeType::HighPerformance { - platform_http_port, - platform_node_id, - } => { - format!( - "High Performance (Port: {}, Node ID: {})", - platform_http_port, platform_node_id - ) - } - }, - hex::encode(qualified_masternode.entry_hash), - if let Some(hash) = - qualified_masternode.confirmed_hash_hashed_with_pro_reg_tx - { - hash.reverse().to_string() - } else { - "None".to_string() - }, - )); - }); - }); - } - } else { - ui.label("Select a block height and Masternode."); - } - } - - fn render_selected_shapshot_details(ui: &mut Ui, snapshot: &QuorumSnapshot) { - ui.heading("Quorum Snapshot Details"); - - // Display Skip List Mode - ui.label(format!("Skip List Mode: {}", snapshot.skip_list_mode)); - - // Display Active Quorum Members (Bitset) - ui.label(format!( - "Active Quorum Members: {} members", - snapshot.active_quorum_members.len() - )); - - // Show active members in a scrollable area - ScrollArea::vertical() - .id_salt("render_snapshot_details") - .show(ui, |ui| { - ui.label("Active Quorum Members:"); - for (i, active) in snapshot.active_quorum_members.iter().enumerate() { - ui.label(format!( - "Member {}: {}", - i, - if *active { "Active" } else { "Inactive" } - )); - } - }); - - ui.separator(); - - // Display Skip List - ui.label(format!("Skip List: {} entries", snapshot.skip_list.len())); - - // Show skip list entries - ScrollArea::vertical() - .id_salt("render_snapshot_details_2") - .show(ui, |ui| { - ui.label("Skip List Entries:"); - for (i, skip_entry) in snapshot.skip_list.iter().enumerate() { - ui.label(format!("Entry {}: {}", i, skip_entry)); - } - }); - } - - fn render_qr_info(&mut self, ui: &mut Ui) { - ui.heading("QRInfo Viewer"); - - // Select the first available QRInfo if none is selected - let selected_qr_info = { - let Some((_, selected_qr_info)) = self.data.qr_infos.first_key_value() else { - ui.label("No QRInfo available."); - if ui.button("Load QR Info").clicked() - && let Some(path) = FileDialog::new() - .add_filter("Data Files", &["dat"]) - .pick_file() - { - match std::fs::read(&path) { - Ok(bytes) => { - // Let's first try consensus decode - match QRInfo::consensus_decode(&mut std::io::Cursor::new(&bytes)) { - Ok(qr_info) => { - let key = qr_info.mn_list_diff_tip.block_hash; - self.data.qr_infos.insert(key, qr_info.clone()); - self.feed_qr_info_and_get_dmls(qr_info, None); - } - Err(_) => { - match bincode::decode_from_slice::<QRInfo, _>( - &bytes, - bincode::config::standard(), - ) { - Ok((qr_info, _)) => { - let key = qr_info.mn_list_diff_tip.block_hash; - self.data.qr_infos.insert(key, qr_info); - } - Err(e) => { - tracing::error!("Failed to decode QRInfo: {}", e); - } - } - } - } - } - Err(e) => { - tracing::error!("Failed to read file: {}", e); - } - } - } - return; - }; - selected_qr_info.clone() - }; - - if let Ok(height) = self.get_height(&selected_qr_info.mn_list_diff_tip.block_hash) { - // Add Save/Load functionality - ui.horizontal(|ui| { - if ui.button("Save QR Info").clicked() { - // Open native save dialog - if let Some(path) = FileDialog::new() - .set_file_name(format!("qrinfo_{}.dat", height)) - .add_filter("Data Files", &["dat"]) - .save_file() - { - // Serialize and save the block container - let serialized_data = - bincode::encode_to_vec(&selected_qr_info, bincode::config::standard()) - .expect("serialize container"); - if let Err(e) = std::fs::write(&path, serialized_data) { - tracing::error!("Failed to write file: {}", e); - } - } - } - }); - } - - // Track user selections - if self.selection.selected_qr_field.is_none() { - self.selection.selected_qr_field = Some("Quorum Snapshots".to_string()); - } - - ui.horizontal(|ui| { - // Left Panel: Fields of QRInfo - ui.allocate_ui_with_layout( - egui::Vec2::new(180.0, ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - ui.label("QRInfo Fields:"); - let fields = [ - "Rotated Quorums At Index", - "Masternode List Diffs", - "Quorum Snapshots", - "Quorum Snapshot List", - "MN List Diff List", - ]; - - for field in &fields { - if ui - .selectable_label( - self.selection.selected_qr_field.as_deref() == Some(*field), - *field, - ) - .clicked() - { - self.selection.selected_qr_field = Some(field.to_string()); - self.selection.selected_qr_list_index = None; - self.selection.selected_qr_item = None; - } - } - }, - ); - - ui.separator(); - - // Center Panel: Items in the selected field - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width() * 0.5, ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - ui.heading("Selected Field Items"); - - match self.selection.selected_qr_field.as_deref() { - Some("Quorum Snapshots") => { - self.render_quorum_snapshots(ui, &selected_qr_info) - } - Some("Masternode List Diffs") => { - self.render_mn_list_diffs(ui, &selected_qr_info) - } - Some("Rotated Quorums At Index") => self.render_last_commitments( - ui, - selected_qr_info - .last_commitment_per_index - .first() - .map(|entry| entry.quorum_hash), - ), - Some("Quorum Snapshot List") => { - self.render_quorum_snapshot_list(ui, &selected_qr_info) - } - Some("MN List Diff List") => { - self.render_mn_list_diff_list(ui, &selected_qr_info) - } - _ => { - ui.label("Select a field to display."); - } - } - }, - ); - - ui.separator(); - - // Right Panel: Detailed View of Selected Item - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width(), ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - if let Some(selected_item) = &self.selection.selected_qr_item { - match selected_item { - SelectedQRItem::SelectedSnapshot(snapshot) => { - Self::render_selected_shapshot_details(ui, snapshot); - } - SelectedQRItem::MNListDiff(mn_list_diff) => { - self.render_selected_mn_list_diff(ui, mn_list_diff); - } - SelectedQRItem::QuorumEntry(quorum_entry) => { - Self::render_selected_quorum_entry(ui, quorum_entry); - } - } - } else { - ui.label("Select an item to view details."); - } - }, - ); - }); - } - fn render_selected_mn_list_diff(&self, ui: &mut Ui, mn_list_diff: &MnListDiff) { - ui.heading("MNListDiff Details"); - - // General MNListDiff Info - ui.label(format!( - "Version: {}\nBase Block Hash: {} ({})\nBlock Hash: {} ({})", - mn_list_diff.version, - mn_list_diff.base_block_hash, - self.get_height_or_error_as_string(&mn_list_diff.base_block_hash), - mn_list_diff.block_hash, - self.get_height_or_error_as_string(&mn_list_diff.block_hash) - )); - - ui.label(format!( - "Total Transactions: {}", - mn_list_diff.total_transactions - )); - - ui.separator(); - - // Merkle Tree Data - ui.heading("Merkle Tree"); - ui.label(format!( - "Merkle Hashes: {} entries", - mn_list_diff.merkle_hashes.len() - )); - ScrollArea::vertical() - .id_salt("render_selected_mn_list_diff") - .show(ui, |ui| { - for (i, merkle_hash) in mn_list_diff.merkle_hashes.iter().enumerate() { - ui.label(format!("{}: {}", i, merkle_hash)); - } - }); - - ui.separator(); - ui.label(format!( - "Merkle Flags ({} bytes)", - mn_list_diff.merkle_flags.len() - )); - - // Coinbase Transaction - ui.heading("Coinbase Transaction"); - ScrollArea::vertical() - .id_salt("render_selected_mn_list_diff_2") - .show(ui, |ui| { - ui.label(format!( - "Coinbase TXID: {}\nSize: {} bytes", - mn_list_diff.coinbase_tx.txid(), - mn_list_diff.coinbase_tx.size() - )); - }); - - ui.separator(); - - // Masternode Changes - ui.heading("Masternode Changes"); - ui.label(format!( - "New Masternodes: {}\nDeleted Masternodes: {}", - mn_list_diff.new_masternodes.len(), - mn_list_diff.deleted_masternodes.len(), - )); - - ScrollArea::vertical() - .id_salt("render_selected_mn_list_diff_3") - .show(ui, |ui| { - ui.heading("New Masternodes"); - for masternode in &mn_list_diff.new_masternodes { - ui.label(format!( - "{} {}:{}", - masternode.pro_reg_tx_hash, - masternode.service_address.ip(), - masternode.service_address.port(), - )); - } - - ui.separator(); - ui.heading("Removed Masternodes"); - for removed_pro_tx in &mn_list_diff.deleted_masternodes { - ui.label(removed_pro_tx.to_string()); - } - }); - - ui.separator(); - - // Quorum Changes - ui.heading("Quorum Changes"); - ui.label(format!( - "New Quorums: {}\nDeleted Quorums: {}", - mn_list_diff.new_quorums.len(), - mn_list_diff.deleted_quorums.len() - )); - - ScrollArea::vertical() - .id_salt("render_selected_mn_list_diff_4") - .show(ui, |ui| { - ui.heading("New Quorums"); - for quorum in &mn_list_diff.new_quorums { - ui.label(format!( - "Quorum {} Type: {}", - quorum.quorum_hash, - QuorumType::from(quorum.llmq_type as u32) - )); - } - - ui.separator(); - ui.heading("Removed Quorums"); - for deleted_quorum in &mn_list_diff.deleted_quorums { - ui.label(format!( - "Quorum {} Type: {}", - deleted_quorum.quorum_hash, - QuorumType::from(deleted_quorum.llmq_type as u32) - )); - } - }); - - ui.separator(); - - // Quorums ChainLock Signatures - ui.heading("Quorums ChainLock Signatures"); - ui.label(format!( - "Total ChainLock Signatures: {}", - mn_list_diff.quorums_chainlock_signatures.len() - )); - - ScrollArea::vertical() - .id_salt("render_selected_mn_list_diff_5") - .show(ui, |ui| { - for (i, cl_sig) in mn_list_diff.quorums_chainlock_signatures.iter().enumerate() { - ui.label(format!( - "Signature {}: {} for indexes [{}]", - i, - hex::encode(cl_sig.signature), - cl_sig - .index_set - .iter() - .map(|index| index.to_string()) - .collect::<Vec<_>>() - .join("-") - )); - } - }); - } - - fn render_quorum_snapshots(&mut self, ui: &mut Ui, qr_info: &QRInfo) { - let snapshots = [ - ("Quorum Snapshot h-c", &qr_info.quorum_snapshot_at_h_minus_c), - ( - "Quorum Snapshot h-2c", - &qr_info.quorum_snapshot_at_h_minus_2c, - ), - ( - "Quorum Snapshot h-3c", - &qr_info.quorum_snapshot_at_h_minus_3c, - ), - ]; - - if let Some((qs4c, _)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { - snapshots.iter().for_each(|(name, snapshot)| { - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(name.to_string()), - *name, - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(name.to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::SelectedSnapshot((*snapshot).clone())); - } - }); - - if ui - .selectable_label( - self.selection.selected_qr_list_index - == Some("Quorum Snapshot h-4c".to_string()), - "Quorum Snapshot h-4c", - ) - .clicked() - { - self.selection.selected_qr_list_index = Some("Quorum Snapshot h-4c".to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::SelectedSnapshot((*qs4c).clone())); - } - } - } - - fn render_selected_quorum_entry(ui: &mut Ui, qualified_quorum_entry: &QualifiedQuorumEntry) { - ui.heading("Quorum Entry Details"); - - // General Quorum Info - ui.label(format!( - "Version: {}\nQuorum Type: {}\nQuorum Hash: {}", - qualified_quorum_entry.quorum_entry.version, - QuorumType::from(qualified_quorum_entry.quorum_entry.llmq_type as u32), - qualified_quorum_entry.quorum_entry.quorum_hash - )); - - ui.label(format!( - "Quorum Index: {}", - qualified_quorum_entry - .quorum_entry - .quorum_index - .map_or("None".to_string(), |idx| idx.to_string()) - )); - - ui.separator(); - - // **Additional Qualified Quorum Entry Information** - ui.heading("Quorum Verification Details"); - let verification_symbol = match &qualified_quorum_entry.verified { - LLMQEntryVerificationStatus::Verified => "✔ Verified".to_string(), - LLMQEntryVerificationStatus::Invalid(reason) => format!("❌ Invalid ({})", reason), - LLMQEntryVerificationStatus::Unknown => "⬜ Unknown".to_string(), - LLMQEntryVerificationStatus::Skipped(reason) => format!("⬜ Skipped ({})", reason), - }; - ui.label(format!("Verification Status: {}", verification_symbol)); - - ui.separator(); - - ui.heading("Commitment & Entry Hashes"); - ScrollArea::vertical() - .id_salt("commitment_entry_hash") - .show(ui, |ui| { - ui.label(format!( - "Commitment Hash: {}", - qualified_quorum_entry.commitment_hash - )); - ui.label(format!("Entry Hash: {}", qualified_quorum_entry.entry_hash)); - }); - - ui.separator(); - - // Signers & Valid Members - ui.heading("Quorum Members"); - ui.label(format!( - "Total Signers: {}\nValid Members: {}", - qualified_quorum_entry - .quorum_entry - .signers - .iter() - .filter(|&&b| b) - .count(), - qualified_quorum_entry - .quorum_entry - .valid_members - .iter() - .filter(|&&b| b) - .count() - )); - - ScrollArea::vertical() - .id_salt("quorum_members_grid") - .show(ui, |ui| { - ui.label(format!( - "Total Signers: {}\nValid Members: {}", - qualified_quorum_entry - .quorum_entry - .signers - .iter() - .filter(|&&b| b) - .count(), - qualified_quorum_entry - .quorum_entry - .valid_members - .iter() - .filter(|&&b| b) - .count() - )); - - ui.separator(); - - ui.heading("Signers & Valid Members Grid"); - - egui::Grid::new("quorum_members_grid") - .num_columns(8) // Adjust based on UI width - .striped(true) - .show(ui, |ui| { - for (i, (is_signer, is_valid)) in qualified_quorum_entry - .quorum_entry - .signers - .iter() - .zip(qualified_quorum_entry.quorum_entry.valid_members.iter()) - .enumerate() - { - let text = match (*is_signer, *is_valid) { - (true, true) => "✔✔", - (true, false) => "✔❌", - (false, true) => "❌✔", - (false, false) => "❌❌", - }; - - let response = ui.label(text); - - // Tooltip on hover to show member index - if response.hovered() { - ui.ctx().debug_painter().text( - response.rect.center(), - egui::Align2::CENTER_CENTER, - format!("Member {}", i), - egui::FontId::proportional(14.0), - egui::Color32::BLUE, - ); - } - - // Create a new row every 8 members - if (i + 1) % 8 == 0 { - ui.end_row(); - } - } - }); - }); - - ui.separator(); - - // Quorum Public Key - ui.heading("Quorum Public Key"); - ScrollArea::vertical() - .id_salt("render_selected_quorum_entry_2") - .show(ui, |ui| { - ui.label(format!( - "Public Key: {}", - qualified_quorum_entry.quorum_entry.quorum_public_key - )); - }); - - ui.separator(); - - // Quorum Verification Vector Hash - ui.heading("Verification Vector Hash"); - ui.label(format!( - "Quorum VVec Hash: {}", - qualified_quorum_entry.quorum_entry.quorum_vvec_hash - )); - - ui.separator(); - - // Threshold Signature - ui.heading("Threshold Signature"); - ScrollArea::vertical() - .id_salt("render_selected_quorum_entry_3") - .show(ui, |ui| { - ui.label(format!( - "Signature: {}", - hex::encode(qualified_quorum_entry.quorum_entry.threshold_sig.to_bytes()) - )); - }); - - ui.separator(); - - // Aggregated Signature - ui.heading("All Commitment Aggregated Signature"); - ScrollArea::vertical() - .id_salt("render_selected_quorum_entry_4") - .show(ui, |ui| { - ui.label(format!( - "Signature: {}", - hex::encode( - qualified_quorum_entry - .quorum_entry - .all_commitment_aggregated_signature - .to_bytes() - ) - )); - }); - } - - fn show_mn_list_diff_heights_as_string( - &mut self, - mn_list_diff: &MnListDiff, - last_diff: Option<&MnListDiff>, - ) -> String { - let base_height_as_string = match self.get_height_and_cache(&mn_list_diff.base_block_hash) { - Ok(height) => height.to_string(), - Err(_) => "?".to_string(), - }; - - let height = self.get_height_and_cache(&mn_list_diff.block_hash).ok(); - - let height_as_string = match height { - Some(height) => height.to_string(), - None => "?".to_string(), - }; - - let extra_block_diff_info = height - .and_then(|height| { - last_diff.and_then(|diff| { - self.get_height(&diff.block_hash) - .ok() - .and_then(|start_height| { - height - .checked_sub(start_height) - .map(|diff| format!(" (+ {})", diff)) - }) - }) - }) - .unwrap_or_default(); - - format!( - "{} -> {}{}", - base_height_as_string, height_as_string, extra_block_diff_info - ) - } - - fn render_mn_list_diffs(&mut self, ui: &mut Ui, qr_info: &QRInfo) { - let mn_diffs = [ - ( - format!( - "MNListDiff h-3c {}", - self.show_mn_list_diff_heights_as_string( - &qr_info.mn_list_diff_at_h_minus_3c, - qr_info - .quorum_snapshot_and_mn_list_diff_at_h_minus_4c - .as_ref() - .map(|(_, diff)| diff) - ) - ), - &qr_info.mn_list_diff_at_h_minus_3c, - ), - ( - format!( - "MNListDiff h-2c {}", - self.show_mn_list_diff_heights_as_string( - &qr_info.mn_list_diff_at_h_minus_2c, - Some(&qr_info.mn_list_diff_at_h_minus_3c) - ) - ), - &qr_info.mn_list_diff_at_h_minus_2c, - ), - ( - format!( - "MNListDiff h-c {}", - self.show_mn_list_diff_heights_as_string( - &qr_info.mn_list_diff_at_h_minus_c, - Some(&qr_info.mn_list_diff_at_h_minus_2c) - ) - ), - &qr_info.mn_list_diff_at_h_minus_c, - ), - ( - format!( - "MNListDiff h {}", - self.show_mn_list_diff_heights_as_string( - &qr_info.mn_list_diff_h, - Some(&qr_info.mn_list_diff_at_h_minus_c) - ) - ), - &qr_info.mn_list_diff_h, - ), - ( - format!( - "MNListDiff Tip {}", - self.show_mn_list_diff_heights_as_string( - &qr_info.mn_list_diff_tip, - Some(&qr_info.mn_list_diff_h) - ) - ), - &qr_info.mn_list_diff_tip, - ), - ]; - if let Some((_, mn_diff4c)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { - let string = format!( - "MNListDiff h-4c {}", - self.show_mn_list_diff_heights_as_string(mn_diff4c, None) - ); - - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(string.clone()), - string.as_str(), - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(string); - self.selection.selected_qr_item = - Some(SelectedQRItem::MNListDiff(Box::new((*mn_diff4c).clone()))); - } - } - - mn_diffs.iter().for_each(|(name, diff)| { - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(name.to_string()), - name, - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(name.to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::MNListDiff(Box::new((*diff).clone()))); - } - }); - } - - fn render_last_commitments(&mut self, ui: &mut Ui, cycle_hash: Option<BlockHash>) { - let Some(cycle_hash) = cycle_hash else { - ui.label("QR Info had no rotated quorums. This should not happen."); - return; - }; - let Some(cycle_quorums) = self - .data - .masternode_list_engine - .rotated_quorums_per_cycle - .get(&cycle_hash) - else { - ui.label(format!( - "Engine does not know of cycle {} at height {}, we know of cycles [{}]", - cycle_hash, - self.get_height_or_error_as_string(&cycle_hash), - self.data - .masternode_list_engine - .rotated_quorums_per_cycle - .keys() - .map(|key| format!("{}, {}", self.get_height_or_error_as_string(key), key)) - .join(", ") - )); - return; - }; - if cycle_quorums.is_empty() { - ui.label(format!( - "Engine does not contain any rotated quorums for cycle {}", - cycle_hash - )); - } - for (quorum_index, commitment) in cycle_quorums.iter() { - // Determine the appropriate symbol based on verification status - let verification_symbol = match commitment.verified { - LLMQEntryVerificationStatus::Verified => "✔", // Checkmark - LLMQEntryVerificationStatus::Invalid(_) => "❌", // Cross - LLMQEntryVerificationStatus::Unknown | LLMQEntryVerificationStatus::Skipped(_) => { - "⬜" - } // Box - }; - - let label_text = format!("{} Quorum at Index {}", verification_symbol, quorum_index); - - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(quorum_index.to_string()), - label_text, - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(quorum_index.to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::QuorumEntry(Box::new(commitment.clone()))); - } - } - } - - fn render_quorum_snapshot_list(&mut self, ui: &mut Ui, qr_info: &QRInfo) { - for (index, snapshot) in qr_info.quorum_snapshot_list.iter().enumerate() { - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(index.to_string()), - format!("Snapshot {}", index), - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(index.to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::SelectedSnapshot(snapshot.clone())); - } - } - } - - fn render_mn_list_diff_list(&mut self, ui: &mut Ui, qr_info: &QRInfo) { - for (index, diff) in qr_info.mn_list_diff_list.iter().enumerate() { - if ui - .selectable_label( - self.selection.selected_qr_list_index == Some(index.to_string()), - format!("MNListDiff {}", index), - ) - .clicked() - { - self.selection.selected_qr_list_index = Some(index.to_string()); - self.selection.selected_qr_item = - Some(SelectedQRItem::MNListDiff(Box::new(diff.clone()))); - } - } - } - - fn render_quorums(&mut self, ui: &mut Ui) { - ui.heading("Quorum Viewer"); - - // Get all available quorum types - let quorum_types: Vec<LLMQType> = self - .data - .masternode_list_engine - .quorum_statuses - .keys() - .cloned() - .collect(); - - // Ensure a quorum type is selected - if self - .selection - .selected_quorum_type_in_quorum_viewer - .is_none() - { - self.selection.selected_quorum_type_in_quorum_viewer = quorum_types.first().copied(); - } - - // Render quorum type selection bar - ui.horizontal(|ui| { - for quorum_type in &quorum_types { - if ui - .selectable_label( - self.selection.selected_quorum_type_in_quorum_viewer == Some(*quorum_type), - quorum_type.to_string(), - ) - .clicked() - { - self.selection.selected_quorum_type_in_quorum_viewer = Some(*quorum_type); - self.selection.selected_quorum_hash_in_quorum_viewer = None; // Reset selected quorum when switching types - } - } - }); - - ui.separator(); - - let Some(selected_quorum_type) = self.selection.selected_quorum_type_in_quorum_viewer - else { - ui.label("No quorum types available."); - return; - }; - - let Some(quorum_map) = self - .data - .masternode_list_engine - .quorum_statuses - .get(&selected_quorum_type) - else { - ui.label("No quorums found for this type."); - return; - }; - - // Create a horizontal layout to align quorum hashes on the left and heights on the right - ui.horizontal(|ui| { - // Left Column: Quorum Hashes - ui.allocate_ui_with_layout( - egui::Vec2::new(500.0, 800.0), - Layout::top_down(Align::Min), - |ui| { - ui.heading(format!("Quorums of Type: {}", selected_quorum_type)); - - ScrollArea::vertical() - .id_salt("quorum_hashes_scroll") - .show(ui, |ui| { - egui::Grid::new("quorum_hashes_grid") - .num_columns(2) // Two columns: Quorum Hash | Status - .striped(true) - .show(ui, |ui| { - ui.label("Quorum Hash"); - ui.label("Status"); - ui.end_row(); - - for (quorum_hash, (_, _, status)) in quorum_map { - let hash_label = format!("{}", quorum_hash); - - // Display quorum hash as selectable - let hash_response = ui.selectable_label( - self.selection.selected_quorum_hash_in_quorum_viewer - == Some(*quorum_hash), - hash_label, - ); - - if hash_response.clicked() { - self.selection.selected_quorum_hash_in_quorum_viewer = - Some(*quorum_hash); - } - - // Determine status symbol - let (status_symbol, tooltip_text) = match status { - LLMQEntryVerificationStatus::Verified => ("✔", None), - LLMQEntryVerificationStatus::Invalid(reason) => { - ("❌", Some(reason.to_string())) - } - LLMQEntryVerificationStatus::Unknown => ("⬜", None), - LLMQEntryVerificationStatus::Skipped(reason) => { - ("⚠", Some(reason.to_string())) - } - }; - - // Display small status icon - let status_response = ui.label(status_symbol); - - // Show tooltip on hover if there's an error message - if let Some(tooltip) = tooltip_text - && status_response.hovered() - { - ui.ctx().debug_painter().text( - status_response.rect.center(), - egui::Align2::CENTER_CENTER, - tooltip, - egui::FontId::proportional(14.0), - egui::Color32::RED, - ); - } - - ui.end_row(); - } - }); - }); - }, - ); - - ui.separator(); - - // Right Column: Heights where selected quorum exists - ui.allocate_ui_with_layout( - Vec2::new(500.0, 800.0), - Layout::top_down(Align::Min), - |ui| { - ui.heading("Quorum Heights"); - - if let Some(selected_quorum_hash) = - self.selection.selected_quorum_hash_in_quorum_viewer - { - if let Some((heights, key, status)) = quorum_map.get(&selected_quorum_hash) - { - ui.label(format!("Public Key: {}", key)); - ui.label(format!("Verification Status: {}", status)); - ScrollArea::vertical() - .id_salt("quorum_heights_scroll") - .show(ui, |ui| { - for height in heights { - ui.label(format!("Height: {}", height)); - } - }); - } else { - ui.label("Selected quorum not found."); - } - } else { - ui.label("Select a quorum to see its heights."); - } - }, - ); - }); - } - - #[allow(dead_code)] - fn render_selected_item_details(&mut self, ui: &mut Ui, selected_item: String) { - ui.heading("Details"); - - ScrollArea::vertical().show(ui, |ui| { - ui.monospace(selected_item); - }); - } - - /// Render core items, including chain-locked blocks and instant send transactions. - fn render_core_items(&mut self, ui: &mut Ui) { - ui.heading("Core Items Viewer"); - - // Layout: Left (ChainLocked Blocks), Middle (InstantSend Transactions), Right (Details) - ui.horizontal(|ui| { - // Left Column: Chain Locked Blocks - ui.allocate_ui_with_layout( - Vec2::new(200.0, 1000.0), - Layout::top_down(Align::Min), - |ui| { - ui.heading("ChainLocked Blocks"); - - ScrollArea::vertical().id_salt("chain_locked_blocks_scroll").show(ui, |ui| { - for (block_height, (block, chain_lock, is_valid)) in - self.incoming.chain_locked_blocks.iter() - { - let label_text = format!( - "{} {} {}", - if *is_valid { "✔" } else { "❌" }, - block_height, - block.header.block_hash() - ); - - if ui - .selectable_label( - matches!(self.selection.selected_core_item, Some((CoreItem::ChainLockedBlock(_, ref l), _)) if l.block_height == *block_height), - label_text, - ) - .clicked() - { - self.selection.selected_core_item = Some((CoreItem::ChainLockedBlock(block.clone(), chain_lock.clone()), *is_valid)); - } - } - }); - }, - ); - - ui.separator(); - - // Middle Column: Instant Send Transactions - ui.allocate_ui_with_layout( - egui::Vec2::new(300.0, 1000.0), - Layout::top_down(Align::Min), - |ui| { - ui.heading("Instant Send Transactions"); - - ScrollArea::vertical().id_salt("instant_send_scroll").show(ui, |ui| { - for (transaction, instant_lock, is_valid) in - self.incoming.instant_send_transactions.iter() - { - let label_text = format!( - "{} TxID: {}", - if *is_valid { "✔" } else { "❌" }, - transaction.txid() - ); - - if ui - .selectable_label( - matches!(self.selection.selected_core_item, Some((CoreItem::InstantLockedTransaction(ref t, _, _), _)) if t == transaction), - label_text, - ) - .clicked() - { - self.selection.selected_core_item = Some((CoreItem::InstantLockedTransaction(transaction.clone(), vec![], instant_lock.clone()), *is_valid)); - } - } - }); - }, - ); - - ui.separator(); - - // Right Column: Details of the Selected Item - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width(), ui.available_height()), - Layout::top_down(Align::Min), - |ui| { - if let Some((selected_core_item, _)) = &self.selection.selected_core_item { - match selected_core_item { - CoreItem::ChainLockedBlock(..) => self.render_chain_lock_details(ui), - CoreItem::InstantLockedTransaction(..) => self.render_instant_send_details(ui), - _ => { - ui.label("Select an item to view details."); - }, - } - } else { - ui.label("Select an item to view details."); - } - }, - ); - }); - } - - /// Render details of a selected ChainLock - fn render_chain_lock_details(&mut self, ui: &mut Ui) { - ui.heading("ChainLock Details"); - - if let Some((CoreItem::ChainLockedBlock(block, chain_lock), is_valid)) = - &self.selection.selected_core_item - { - ui.label(format!( - "Block Height: {}\nBlock Hash: {}\nValid: {}", - chain_lock.block_height, - chain_lock.block_hash, - if *is_valid { "✔ Yes" } else { "❌ No" }, - )); - - ui.separator(); - - ui.heading("Block Transactions"); - ScrollArea::vertical() - .id_salt("block_tx_scroll") - .show(ui, |ui| { - if block.txdata.is_empty() { - ui.label("No transactions in this block."); - } else { - for transaction in &block.txdata { - ui.label(format!("TxID: {}", transaction.txid())); - } - } - }); - - ui.separator(); - ui.heading("Quorum Signature"); - ui.label(format!( - "Signature: {}", - hex::encode(chain_lock.signature.to_bytes()) - )); - - //todo clean this - let b = serialize2(chain_lock); - let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); - match self - .data - .masternode_list_engine - .chain_lock_potential_quorum_under(&chain_lock_2) - { - Ok(Some(quorum)) => { - ui.label(format!("Quorum Hash: {}", quorum.quorum_entry.quorum_hash,)); - ui.label(format!( - "Request Id: {}", - chain_lock.request_id().expect("expected request id") - )); - let sign_id = chain_lock_2 - .sign_id( - quorum.quorum_entry.llmq_type, - quorum.quorum_entry.quorum_hash, - None, - ) - .expect("expected sign id"); - ui.label(format!("Sign Hash (Sign ID): {}", sign_id)); - if let Err(e) = quorum - .verify_message_digest(sign_id.to_byte_array(), chain_lock_2.signature) - { - ui.label(format!("Signature Verification Error: {}", e)); - } - } - Ok(None) => { - ui.label("No quorum".to_string()); - } - Err(err) => { - ui.label(format!("Error finding quorum: {}", err)); - } - }; - - ui.separator(); - - ui.heading("Data"); - - ui.label(format!("Block Data {}", hex::encode(serialize2(block)),)); - - ui.label(format!("Lock Data {}", hex::encode(serialize2(chain_lock)),)); - - ui.separator(); - } else { - ui.label("No ChainLock selected."); - } - } - - /// Render details of a selected Instant Send transaction - fn render_instant_send_details(&mut self, ui: &mut Ui) { - ui.heading("Instant Send Details"); - - if let Some((CoreItem::InstantLockedTransaction(transaction, _, instant_lock), is_valid)) = - &self.selection.selected_core_item - { - ui.label(format!( - "TxID: {}\nValid: {}\nCycle Hash:{}", - transaction.txid(), - if *is_valid { "✔ Yes" } else { "❌ No" }, - instant_lock.cyclehash, - )); - - ui.separator(); - - ui.heading("Transaction Inputs"); - ScrollArea::vertical() - .id_salt("tx_inputs_scroll") - .show(ui, |ui| { - if transaction.input.is_empty() { - ui.label("No inputs."); - } else { - for txin in &transaction.input { - ui.label(format!( - "Input: {}:{}", - txin.previous_output.txid, txin.previous_output.vout - )); - } - } - }); - - ui.separator(); - ui.heading("Transaction Outputs"); - ScrollArea::vertical() - .id_salt("tx_outputs_scroll") - .show(ui, |ui| { - if transaction.output.is_empty() { - ui.label("No outputs."); - } else { - for txout in &transaction.output { - ui.label(format!( - "Output: {} sat -> {}", - txout.value, txout.script_pubkey - )); - } - } - }); - - ui.separator(); - ui.heading("Signing Info"); - - //todo clean this - let b = serialize2(instant_lock); - let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); - match self - .data - .masternode_list_engine - .is_lock_quorum(&instant_lock_2) - { - Ok((quorum, request_sign_id, index)) => { - ui.label(format!( - "Quorum Hash: {} at index {}", - quorum.quorum_entry.quorum_hash, index, - )); - ui.label(format!("Request Id: {}", request_sign_id)); - let sign_id = instant_lock_2 - .sign_id( - quorum.quorum_entry.llmq_type, - quorum.quorum_entry.quorum_hash, - Some(request_sign_id), - ) - .expect("expected sign id"); - ui.label(format!("Sign Hash (Sign ID): {}", sign_id)); - if let Err(e) = quorum - .verify_message_digest(sign_id.to_byte_array(), instant_lock_2.signature) - { - ui.label(format!("Signature Verification Error: {}", e)); - } - } - Err(err) => { - ui.label(format!("Error finding quorum: {}", err)); - } - }; - - ui.separator(); - ui.heading("Quorum Signature"); - ui.label(format!( - "Signature: {}", - hex::encode(instant_lock.signature.to_bytes()) - )); - - ui.separator(); - - ui.heading("Data"); - - ui.label(format!( - "Transaction Data {}", - hex::encode(serialize2(transaction)), - )); - - ui.label(format!( - "Lock Data {}", - hex::encode(serialize2(instant_lock)), - )); - } else { - ui.label("No Instant Send transaction selected."); - } - } - - fn attempt_verify_chain_lock(&self, chain_lock: &ChainLock) -> bool { - let b = serialize2(chain_lock); - let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); - self.data - .masternode_list_engine - .verify_chain_lock(&chain_lock_2) - .is_ok() - } - - fn attempt_verify_transaction_lock(&self, instant_lock: &InstantLock) -> bool { - let b = serialize2(instant_lock); - let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); - self.data - .masternode_list_engine - .verify_is_lock(&instant_lock_2) - .is_ok() - } - - fn received_new_block(&mut self, block: Block, chain_lock: ChainLock) { - let valid = self.attempt_verify_chain_lock(&chain_lock); - self.input.end_block_height = chain_lock.block_height.to_string(); - if self.task.syncing - && let Some((base_block_height, masternode_list)) = self - .data - .masternode_list_engine - .masternode_lists - .last_key_value() - && *base_block_height < chain_lock.block_height - { - let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { - Ok(p2p_handler) => p2p_handler, - Err(e) => { - self.ui_state.error = Some(e.to_string()); - return; - } - }; - - let Some(qr_info) = self.fetch_rotated_quorum_info( - &mut p2p_handler, - masternode_list.block_hash, - chain_lock.block_hash.to_byte_array().into(), - ) else { - return; - }; - - self.feed_qr_info_and_get_dmls(qr_info, Some(p2p_handler)); - - // self.fetch_single_dml( - // &mut p2p_handler, - // masternode_list.block_hash, - // *base_block_height, - // BlockHash::from_byte_array(chain_lock.block_hash.to_byte_array()), - // chain_lock.block_height, - // true, - // ); - - // Reset selections when new data is loaded - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - self.incoming - .chain_locked_blocks - .insert(chain_lock.block_height, (block, chain_lock, valid)); - } -} - -impl ScreenLike for MasternodeListDiffScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.task.pending = None; - self.ui_state.error = Some(message.to_string()); - } - } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::CoreItem(core_item) = backend_task_success_result { - // println!("received core item {:?}", core_item); - match core_item { - CoreItem::InstantLockedTransaction(transaction, _, instant_lock) => { - let valid = self.attempt_verify_transaction_lock(&instant_lock); - self.incoming.instant_send_transactions.push(( - transaction, - instant_lock, - valid, - )); - } - CoreItem::ChainLockedBlock(block, chain_lock) => { - self.received_new_block(block, chain_lock); - } - _ => {} - } - return; - } - match backend_task_success_result { - BackendTaskSuccessResult::MnListFetchedDiff { - base_height, - height, - diff, - } => { - // Apply to engine similarly to original UI method - if base_height == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() - { - match MasternodeListEngine::initialize_with_diff_to_height( - diff.clone(), - height, - self.app_context.network, - ) { - Ok(engine) => self.data.masternode_list_engine = engine, - Err(e) => self.ui_state.error = Some(e.to_string()), - } - } else if let Err(e) = self.data.masternode_list_engine.apply_diff( - diff.clone(), - Some(height), - false, - None, - ) { - self.ui_state.error = Some(e.to_string()); - } - self.data.mnlist_diffs.insert((base_height, height), diff); - // If this was the no-rotation path, queue the extra diffs needed for verification (restored behavior) - if matches!(self.task.pending, Some(PendingTask::DmlDiffNoRotation)) { - if let Some(task) = self.build_validation_diffs_task() { - self.task.queued_task = Some(task); - self.display_message( - "Fetched DMLs (no rotation); fetching validation diffs…", - MessageType::Info, - ); - } else if !self.data.masternode_list_engine.masternode_lists.is_empty() { - // Fallback: attempt verification directly - if let Err(e) = self - .data - .masternode_list_engine - .verify_non_rotating_masternode_list_quorums( - height, - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - ) - { - self.ui_state.error = Some(e.to_string()); - } - self.task.pending = None; - self.display_message("Fetched DMLs (no rotation)", MessageType::Success); - } else { - self.task.pending = None; - self.display_message("Fetched DMLs (no rotation)", MessageType::Success); - } - } else { - self.task.pending = None; - self.display_message("Fetched DML diff", MessageType::Success); - } - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - } - BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info } => { - // Pre-populate hash↔height data the engine needs (cycle boundary hashes - // are not carried in the QRInfo message and must be resolved locally). - // Build this BEFORE inserting diffs into the cache so a failure does not - // leave the cache partially populated. - let block_data = match self.build_qr_info_block_data(&qr_info) { - Ok(data) => data, - Err(e) => { - self.ui_state.error = Some(e); - self.task.pending = None; - return; - } - }; - for (height, hash) in block_data { - self.data - .masternode_list_engine - .block_container - .feed_block_height(height, hash); - } - - // Warm heights and cache diffs before feed_qr_info (replicates old flow) - self.insert_mn_list_diff(&qr_info.mn_list_diff_tip); - self.insert_mn_list_diff(&qr_info.mn_list_diff_h); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_c); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_2c); - self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_3c); - if let Some((_, d)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { - self.insert_mn_list_diff(d); - } - for d in &qr_info.mn_list_diff_list { - self.insert_mn_list_diff(d); - } - if let Err(e) = - self.data - .masternode_list_engine - .feed_qr_info(qr_info.clone(), false, true) - { - self.ui_state.error = Some(e.to_string()); - } - // Store full qr_info for the QR tab - let key = qr_info.mn_list_diff_tip.block_hash; - self.data.qr_infos.insert(key, qr_info); - self.selection.selected_dml_diff_key = None; - self.selection.selected_quorum_in_diff_index = None; - // Queue extra diffs required for verification (previous behavior) - if let Some(task) = self.build_validation_diffs_task() { - self.task.queued_task = Some(task); - self.display_message( - "Fetched QR info + DMLs; fetching validation diffs…", - MessageType::Info, - ); - } else { - self.task.pending = None; - self.display_message("Fetched QR info + DMLs", MessageType::Success); - } - } - BackendTaskSuccessResult::MnListFetchedDiffs { items } => { - // Apply returned diffs sequentially - for ((base_h, h), diff) in items { - if base_h == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() { - if let Ok(engine) = MasternodeListEngine::initialize_with_diff_to_height( - diff.clone(), - h, - self.app_context.network, - ) { - self.data.masternode_list_engine = engine; - } - } else { - let _ = self.data.masternode_list_engine.apply_diff( - diff.clone(), - Some(h), - false, - None, - ); - } - self.data.mnlist_diffs.insert((base_h, h), diff); - } - // Update rotating quorum heights cache (previous behavior) - let hashes = self - .data - .masternode_list_engine - .latest_masternode_list_rotating_quorum_hashes(&[]); - for hash in &hashes { - if let Ok(height) = self.get_height_and_cache(hash) { - self.cache.block_height_cache.insert(*hash, height); - } - } - // Verify non-rotating quorums as before - if let Some(latest_masternode_list) = - self.data.masternode_list_engine.latest_masternode_list() - && let Err(e) = self - .data - .masternode_list_engine - .verify_non_rotating_masternode_list_quorums( - latest_masternode_list.known_height, - &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], - ) - { - self.ui_state.error = Some(e.to_string()); - } - self.task.pending = None; - self.display_message( - "Fetched validation diffs and verified non-rotating quorums", - MessageType::Success, - ); - } - BackendTaskSuccessResult::MnListChainLockSigs { entries } => { - for ((h, bh), sig) in entries { - self.cache.chain_lock_sig_cache.insert((h, bh), sig); - if let Some(sig) = sig { - self.cache - .chain_lock_reversed_sig_cache - .entry(sig) - .or_default() - .insert((h, bh)); - } - } - self.task.pending = None; - self.display_message("Fetched chain lock signatures", MessageType::Success); - } - _ => {} - } - } - - fn refresh_on_arrival(&mut self) { - // Optionally refresh data when this screen is shown - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![("Tools", AppAction::None)], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenToolsMasternodeListDiffScreen, - ); - - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); - - // Styled central panel consistent with other tool screens; scroll only below tab row - action |= island_central_panel(ctx, |ui| { - // Top: input area (base/end block height + Get DMLs button) - let mut inner = AppAction::None; - inner |= self.render_input_area(ui); - // If we queued a backend task from a prior result processing, send it now - if let Some(task) = self.task.queued_task.take() { - inner |= AppAction::BackendTask(task); - } - - self.render_error_banner(ui); - self.render_pending_status(ui); - - ui.separator(); - - self.render_selected_tab(ui); - inner - }); - action - } -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PendingTask { - DmlDiffSingle, - DmlDiffNoRotation, - QrInfo, - QrInfoWithDmls, - ChainLocks, -} diff --git a/src/ui/tools/mod.rs b/src/ui/tools/mod.rs index 721ea7746..52d2b8c0b 100644 --- a/src/ui/tools/mod.rs +++ b/src/ui/tools/mod.rs @@ -2,8 +2,34 @@ pub mod address_balance_screen; pub mod contract_visualizer_screen; pub mod document_visualizer_screen; pub mod grovestark_screen; -pub mod masternode_list_diff_screen; pub mod platform_info_screen; -pub mod proof_log_screen; pub mod proof_visualizer_screen; pub mod transition_visualizer_screen; + +/// The tabs available in the Tools section, shown in the left-hand chooser panel. +#[derive(PartialEq)] +pub enum ToolsSubscreen { + PlatformInfo, + AddressBalance, + TransactionViewer, + DocumentViewer, + ProofViewer, + ContractViewer, + GroveSTARK, + DPNS, +} + +impl ToolsSubscreen { + pub fn display_name(&self) -> &'static str { + match self { + Self::PlatformInfo => "Platform info", + Self::AddressBalance => "Address balance", + Self::TransactionViewer => "Transaction deserializer", + Self::ProofViewer => "Proof deserializer", + Self::DocumentViewer => "Document deserializer", + Self::ContractViewer => "Contract deserializer", + Self::GroveSTARK => "ZK Proofs", + Self::DPNS => "DPNS", + } + } +} diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index 2237188a9..84ebd2926 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -10,7 +10,7 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; -use eframe::egui::{self, Context, ScrollArea, Ui}; +use eframe::egui::{self, ScrollArea, Ui}; use std::sync::Arc; pub struct PlatformInfoScreen { @@ -158,23 +158,23 @@ impl ScreenLike for PlatformInfoScreen { // Don't auto-refresh - let user trigger actions manually } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenToolsPlatformInfoScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); - let panel_action = island_central_panel(ctx, |ui| { + let panel_action = island_central_panel(ui, |ui| { ui.heading("Platform Information Tool"); ui.separator(); @@ -301,6 +301,10 @@ impl ScreenLike for PlatformInfoScreen { PlatformInfoTaskResult::AddressBalance { .. } => { // This result is handled by AddressBalanceScreen, not here } + PlatformInfoTaskResult::Withdrawals { .. } => { + // Structured withdrawals are a programmatic (MCP/CLI) result; + // this screen uses the text variants instead. + } } } } diff --git a/src/ui/tools/proof_log_screen.rs b/src/ui/tools/proof_log_screen.rs deleted file mode 100644 index 67b5b2e68..000000000 --- a/src/ui/tools/proof_log_screen.rs +++ /dev/null @@ -1,426 +0,0 @@ -use crate::app::AppAction; -use crate::context::AppContext; -use crate::model::proof_log_item::ProofLogItem; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::{DashColors, ResponseExt}; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::drive::grovedb::operations::proof::GroveDBProof; -use dash_sdk::drive::query::PathQuery; -use eframe::egui::{self, Context, Grid, ScrollArea, Ui}; -use egui::text::LayoutJob; -use egui::{Color32, FontId, Frame, Stroke, TextFormat, TextStyle, Vec2}; -use regex::Regex; -use std::ops::Range; -use std::sync::Arc; - -/// Screen to visualize proofs from the proof log. -pub struct ProofLogScreen { - pub(crate) app_context: Arc<AppContext>, - proof_items: Vec<ProofLogItem>, - selected_proof_index: Option<usize>, - show_errors_only: bool, - sort_column: ProofLogColumn, - sort_ascending: bool, - pagination_range: Range<u64>, - items_per_page: u64, - display_mode: DisplayMode, -} - -fn extract_hashes_from_error(error: &str) -> Vec<String> { - let re = Regex::new(r"[a-fA-F0-9]{64}").unwrap(); - re.find_iter(error) - .map(|mat| mat.as_str().to_string()) - .collect() -} - -#[derive(Clone, Copy)] -enum ProofLogColumn { - RequestType, - Height, - Time, - Error, -} - -#[derive(Clone, Copy, Eq, PartialEq)] -enum DisplayMode { - Hex, - Json, - PathQuery, -} - -impl ProofLogScreen { - /// Creates a new ProofViewerScreen instance. - pub fn new(app_context: &Arc<AppContext>) -> Self { - Self { - app_context: app_context.clone(), - proof_items: Vec::new(), - selected_proof_index: None, - show_errors_only: false, - sort_column: ProofLogColumn::Time, - sort_ascending: false, - pagination_range: 0..100, - items_per_page: 100, - display_mode: DisplayMode::Hex, - } - } - - /// Fetches proof log items from the database based on current settings. - fn fetch_proof_items(&mut self) { - let db = &self.app_context.db; - if let Ok(mut items) = - db.get_proof_log_items(self.show_errors_only, self.pagination_range.clone()) - { - // Sort items based on current sorting settings - items.sort_by(|a, b| { - let ordering = match self.sort_column { - ProofLogColumn::RequestType => a.request_type.cmp(&b.request_type), - ProofLogColumn::Height => a.height.cmp(&b.height), - ProofLogColumn::Time => a.time_ms.cmp(&b.time_ms), - ProofLogColumn::Error => a.error.cmp(&b.error), - }; - if self.sort_ascending { - ordering - } else { - ordering.reverse() - } - }); - self.proof_items = items; - } - } - - /// Renders the left side of the screen with the list of proofs. - fn render_proof_list(&mut self, ui: &mut Ui) { - // ui.horizontal(|ui| { - // if ui - // .checkbox(&mut self.show_errors_only, "Show Errors Only") - // .changed() - // { - // self.fetch_proof_items(); - // } - // ui.label("Items per page:"); - // ui.add(egui::DragValue::new(&mut self.items_per_page).range(10..=1000)); - // if ui.button("Refresh").clicked() { - // self.fetch_proof_items(); - // } - // }); - - // Scrollable area for the table - ScrollArea::both() - .id_salt("proof_list_scroll_area") - .show(ui, |ui| { - Grid::new("proof_log_table") - .num_columns(4) - .striped(false) - .show(ui, |ui| { - // Table headers with sorting - if ui - .button("Request Type") - .clickable_tooltip("Click to sort by Request Type") - .clicked() - { - self.sort_column = ProofLogColumn::RequestType; - self.sort_ascending = !self.sort_ascending; - self.fetch_proof_items(); - } - if ui - .button("Height") - .clickable_tooltip("Click to sort by Height") - .clicked() - { - self.sort_column = ProofLogColumn::Height; - self.sort_ascending = !self.sort_ascending; - self.fetch_proof_items(); - } - if ui - .button("Time") - .clickable_tooltip("Click to sort by Time") - .clicked() - { - self.sort_column = ProofLogColumn::Time; - self.sort_ascending = !self.sort_ascending; - self.fetch_proof_items(); - } - if ui - .button("Error") - .clickable_tooltip("Click to sort by Error") - .clicked() - { - self.sort_column = ProofLogColumn::Error; - self.sort_ascending = !self.sort_ascending; - self.fetch_proof_items(); - } - ui.end_row(); - - // Data rows - for (index, item) in self.proof_items.iter().enumerate() { - // First column: selectable label for Request Type - if ui - .selectable_label( - self.selected_proof_index == Some(index), - format!("{:?}", item.request_type), - ) - .clicked() - { - self.selected_proof_index = Some(index); - } - - // Second column: Height - ui.label(item.height.to_string()); - - // Third column: Time - ui.label(item.time_ms.to_string()); - - // Fourth column: Error (first 20 chars, full error on hover) - let error_text = - item.error.as_ref().map_or("No Error".to_string(), |e| { - if e.len() > 40 { - format!("{}...", &e[..40]) - } else { - e.clone() - } - }); - ui.label(error_text) - .info_tooltip(item.error.as_deref().unwrap_or("No Error")); - - ui.end_row(); - } - }); - }); - - // Pagination controls - ui.horizontal(|ui| { - if ui.button("Previous").clicked() && self.pagination_range.start >= self.items_per_page - { - self.pagination_range = (self.pagination_range.start - self.items_per_page) - ..(self.pagination_range.end - self.items_per_page); - self.fetch_proof_items(); - } - if ui.button("Next").clicked() { - self.pagination_range = (self.pagination_range.start + self.items_per_page) - ..(self.pagination_range.end + self.items_per_page); - self.fetch_proof_items(); - } - ui.label(format!( - "Showing items {} to {}", - self.pagination_range.start + 1, - self.pagination_range.end - )); - }); - } - - fn highlight_proof_text( - proof_text: &str, - hashes: &[String], - font_id: FontId, - text_color: Color32, - ) -> LayoutJob { - let mut job = LayoutJob::default(); - let mut remaining_text = proof_text; - - while !remaining_text.is_empty() { - let empty_string = String::new(); - // Find the earliest occurrence of any hash - let (earliest_pos, matched_hash) = hashes - .iter() - .filter_map(|hash| remaining_text.find(hash).map(|pos| (pos, hash))) - .min_by_key(|&(pos, _)| pos) - .unwrap_or((remaining_text.len(), &empty_string)); - - // Add text before the matched hash - if earliest_pos > 0 { - let before_text = &remaining_text[..earliest_pos]; - job.append( - before_text, - 0.0, - TextFormat { - font_id: font_id.clone(), - color: text_color, - ..Default::default() - }, - ); - } - - if !matched_hash.is_empty() { - // Add the matched hash with highlight - job.append( - matched_hash, - 0.0, - TextFormat { - font_id: font_id.clone(), - color: DashColors::HIGHLIGHT_GOLD, // Highlight color - ..Default::default() - }, - ); - // Move past the hash - remaining_text = &remaining_text[earliest_pos + matched_hash.len()..]; - } else { - break; - } - } - - job - } - - /// Renders the right side of the screen with proof details. - fn render_proof_details(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Display Mode:"); - ui.radio_value(&mut self.display_mode, DisplayMode::Hex, "Hex"); - ui.radio_value(&mut self.display_mode, DisplayMode::Json, "JSON"); - ui.radio_value(&mut self.display_mode, DisplayMode::PathQuery, "Path Query"); - }); - - if let Some(index) = self.selected_proof_index { - if let Some(proof_item) = self.proof_items.get(index) { - // Display basic information - ui.label(format!("Request Type: {:?}", proof_item.request_type)); - ui.label(format!("Height: {}", proof_item.height)); - ui.label(format!("Time: {}", proof_item.time_ms)); - if let Some(error) = &proof_item.error { - ui.label(format!("Error: {}", error)); - } else { - ui.label("Error: None"); - } - - // Display proof based on display mode - let (proof_display, hashes) = match self.display_mode { - DisplayMode::Hex => { - let encoded = hex::encode(&proof_item.proof_bytes); - // Extract hashes from the error message - let hashes = if let Some(error) = &proof_item.error { - extract_hashes_from_error(error) - } else { - Vec::new() - }; - (encoded, hashes) - } - DisplayMode::Json => { - let hashes = if let Some(error) = &proof_item.error { - extract_hashes_from_error(error) - } else { - Vec::new() - }; - let config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - let grovedb_proof: Result<GroveDBProof, _> = - bincode::decode_from_slice(&proof_item.proof_bytes, config) - .map(|(a, _)| a); - let text = match grovedb_proof { - Ok(proof) => format!("{}", proof), - Err(_) => "Invalid GroveDBProof".to_string(), - }; - (text, hashes) - } - DisplayMode::PathQuery => { - let config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - let verification_path_query: Result<PathQuery, _> = - bincode::decode_from_slice( - &proof_item.verification_path_query_bytes, - config, - ) - .map(|(a, _)| a); - let text = match verification_path_query { - Ok(path_query) => format!("{}", path_query), - Err(_) => "Invalid Path Query".to_string(), - }; - (text, vec![]) - } - }; - - // Create the layout job with highlighted hashes - let font_id = TextStyle::Monospace.resolve(ui.style()); - let dark_mode = ui.ctx().style().visuals.dark_mode; - let text_primary = DashColors::text_primary(dark_mode); - let border = DashColors::border(dark_mode); - let layout_job = - Self::highlight_proof_text(&proof_display, &hashes, font_id, text_primary); - - let frame = Frame::new() - .stroke(Stroke::new(1.0, border)) - .fill(Color32::TRANSPARENT) - .corner_radius(2.0); // Set margins to zero - - frame.show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - - ScrollArea::vertical() - .id_salt("proof_display_scroll_area") - .show(ui, |ui| { - ui.label(layout_job); - }); - }); - } - } else { - ui.label("No proof selected."); - } - } -} - -impl ScreenLike for ProofLogScreen { - fn display_message(&mut self, _message: &str, _message_type: MessageType) { - // Implement message display if needed - } - - fn refresh_on_arrival(&mut self) { - self.fetch_proof_items() - } - - /// Renders the UI components for the proof viewer screen. - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![("Tools", AppAction::None)], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenToolsProofLogScreen, - ); - - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); - - action |= island_central_panel(ctx, |ui| { - // Fetch proof items if not already fetched - if self.proof_items.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(10.0); - ui.heading("No proof items to display."); - }); - self.fetch_proof_items(); - return AppAction::None; - } - - ui.columns(2, |columns| { - // Left side: Proof list - columns[0].with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { - ScrollArea::vertical() - .id_salt("proof_list_scroll_area") - .show(ui, |ui| { - self.render_proof_list(ui); - }); - }); - - // Right side: Proof details - columns[1].with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { - ScrollArea::vertical() - .id_salt("proof_details_scroll_area") - .show(ui, |ui| { - self.render_proof_details(ui); - }); - }); - }); - AppAction::None - }); - - action - } -} diff --git a/src/ui/tools/proof_visualizer_screen.rs b/src/ui/tools/proof_visualizer_screen.rs index 39fe65fb7..558ac91b7 100644 --- a/src/ui/tools/proof_visualizer_screen.rs +++ b/src/ui/tools/proof_visualizer_screen.rs @@ -9,7 +9,7 @@ use crate::ui::{MessageType, RootScreenType, ScreenLike}; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::drive::grovedb::operations::proof::GroveDBProof; -use eframe::egui::{self, Context, ScrollArea, TextEdit, Ui}; +use eframe::egui::{self, ScrollArea, TextEdit, Ui}; use egui::Color32; use std::sync::Arc; @@ -79,7 +79,7 @@ impl ProofVisualizerScreen { fn show_input_field(&mut self, ui: &mut Ui) { ui.label("Enter hex, base64, or comma-separated integers for GroveDB proof:"); ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let response = ui.add( TextEdit::multiline(&mut self.input_data) .desired_rows(6) @@ -106,7 +106,7 @@ impl ProofVisualizerScreen { ScrollArea::vertical().show(ui, |ui| { if let Some(ref json) = self.proof_string { ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( TextEdit::multiline(&mut json.clone()) .desired_rows(10) @@ -119,7 +119,7 @@ impl ProofVisualizerScreen { ui.add_space(10.0); } else if let Some(ref error) = self.error { ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( TextEdit::multiline(&mut error.clone()) .desired_rows(10) @@ -145,23 +145,23 @@ impl ScreenLike for ProofVisualizerScreen { // Implement message display if needed } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenToolsProofVisualizerScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { self.show_input_field(ui); self.show_output(ui); AppAction::None diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index 9ecf07b25..71868d35e 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -16,7 +16,7 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::serialization::PlatformDeserializable; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::platform::Identifier; -use eframe::egui::{self, Color32, Context, ScrollArea, TextEdit, Ui, Window}; +use eframe::egui::{self, Color32, ScrollArea, TextEdit, Ui, Window}; use egui::RichText; use serde_json::Value; use std::sync::Arc; @@ -153,7 +153,7 @@ impl TransitionVisualizerScreen { fn show_input_field(&mut self, ui: &mut Ui) { ui.label("Enter hex, base64, or comma-separated integers for state transition:"); ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let response = ui.add( TextEdit::multiline(&mut self.input_data) .desired_rows(6) @@ -207,7 +207,7 @@ impl TransitionVisualizerScreen { ScrollArea::vertical().show(ui, |ui| { if let Some(ref json) = self.parsed_json { ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add( TextEdit::multiline(&mut json.clone()) .desired_rows(10) @@ -418,23 +418,25 @@ impl ScreenLike for TransitionVisualizerScreen { } } - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Tools", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenToolsTransitionVisualizerScreen, ); - action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + action |= add_tools_subscreen_chooser_panel(ui, self.app_context.as_ref()); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { self.show_input_field(ui); self.show_output(ui) }); @@ -459,7 +461,7 @@ impl ScreenLike for TransitionVisualizerScreen { // Check if contract already exists let contract_exists = self .app_context - .get_contracts(None, None) + .get_contracts() .unwrap_or_default() .iter() .any(|c| { diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 74ebdc34b..7687acc3f 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -1,11 +1,6 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::core::CoreTask; -use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::Wallet; -use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; use crate::ui::components::entropy_grid::U256EntropyGrid; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; @@ -65,7 +60,6 @@ pub struct AddNewWalletScreen { estimated_time_to_crack: String, error: Option<String>, pub app_context: Arc<AppContext>, - use_password_for_app: bool, wallet_created: bool, // Success screen state created_wallet_seed_hash: Option<[u8; 32]>, @@ -74,12 +68,6 @@ pub struct AddNewWalletScreen { receive_qr_texture: Option<TextureHandle>, show_receive_popup: bool, funds_received: bool, - /// Cached list of Core wallets (fetched asynchronously via backend task) - core_wallets: Option<Vec<String>>, - /// Whether the backend task to fetch Core wallets has been dispatched - core_wallets_loading: bool, - /// Index of selected Core wallet in the ComboBox - selected_core_wallet_index: usize, } impl AddNewWalletScreen { @@ -96,7 +84,6 @@ impl AddNewWalletScreen { estimated_time_to_crack: "".to_string(), error: None, app_context: app_context.clone(), - use_password_for_app: true, wallet_created: false, created_wallet_seed_hash: None, receive_address: None, @@ -104,17 +91,9 @@ impl AddNewWalletScreen { receive_qr_texture: None, show_receive_popup: false, funds_received: false, - core_wallets: None, - core_wallets_loading: false, - selected_core_wallet_index: 0, } } - pub fn reset_core_wallets_cache(&mut self) { - self.core_wallets = None; - self.core_wallets_loading = false; - } - /// Generate a new seed phrase based on the selected language and word count fn generate_seed_phrase(&mut self) { let full_entropy = self.entropy_grid.random_number_with_user_input(); @@ -131,15 +110,6 @@ impl AddNewWalletScreen { if let Some(mnemonic) = &self.seed_phrase { let seed = mnemonic.to_seed(""); - // Handle app-level password encryption (UI concern, separate from wallet) - if !self.password_input.is_empty() && self.use_password_for_app { - let (encrypted_message, salt, nonce) = - encrypt_message(DASH_SECRET_MESSAGE, self.password_input.text())?; - self.app_context - .update_main_password(&salt, &nonce, &encrypted_message) - .map_err(|e| e.to_string())?; - } - let password = if self.password_input.is_empty() { None } else { @@ -159,7 +129,7 @@ impl AddNewWalletScreen { self.alias_input.clone() }; - let mut wallet = Wallet::new_from_seed( + let wallet = Wallet::new_from_seed( seed, self.app_context.network, Some(wallet_alias), @@ -167,11 +137,6 @@ impl AddNewWalletScreen { ) .map_err(|e| e.to_string())?; - wallet.core_wallet_name = self - .core_wallets - .as_ref() - .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()); - // Extract first receive address for display before registering if let Some((address, _)) = wallet.known_addresses.first_key_value() { self.receive_address_string = Some(address.to_string()); @@ -180,7 +145,11 @@ impl AddNewWalletScreen { let (new_wallet_seed_hash, _wallet_arc) = self .app_context - .register_wallet(wallet) + .register_wallet( + wallet, + &seed, + crate::model::wallet::birth_height::WalletOrigin::Fresh, + ) .map_err(|e| e.to_string())?; // Set pending wallet selection so the wallet screen auto-selects this wallet @@ -198,16 +167,12 @@ impl AddNewWalletScreen { fn show_success(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; - // Check for incoming funds by looking at wallet balance - // Use total_balance_duffs() which falls back to max_balance() (from UTXOs) if SPV balance not set + // Check for incoming funds via the display-only WalletBackend snapshot. if !self.funds_received { if let Some(seed_hash) = &self.created_wallet_seed_hash - && let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.get(seed_hash) - && let Ok(wallet_guard) = wallet.read() - && wallet_guard.total_balance_duffs() > 0 + && self.app_context.snapshot_balance(seed_hash).total > 0 { self.funds_received = true; // Auto-close the popup when funds are received @@ -402,7 +367,7 @@ impl AddNewWalletScreen { } fn render_seed_phrase_input(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let surface = DashColors::surface(dark_mode); let border = DashColors::border(dark_mode); let text_primary = DashColors::text_primary(dark_mode); @@ -563,36 +528,13 @@ impl AddNewWalletScreen { } impl ScreenLike for AddNewWalletScreen { - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::CoreWalletsList(wallets) = backend_task_success_result { - self.selected_core_wallet_index = self - .selected_core_wallet_index - .min(wallets.len().saturating_sub(1)); - self.core_wallets = Some(wallets); - } - } - - fn display_task_error(&mut self, _error: &TaskError) -> bool { - self.core_wallets_loading = false; - self.core_wallets = Some(vec![]); - false - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut pending_action = AppAction::None; - if self.core_wallets.is_none() && !self.core_wallets_loading { - // SPV mode has no Dash Core RPC — skip listing Core wallets entirely. - if self.app_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv { - self.core_wallets = Some(vec![]); - } else { - self.core_wallets_loading = true; - pending_action = - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::ListCoreWallets)); - } - } + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; + let pending_action = AppAction::None; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Wallets", AppAction::GoToMainScreen), @@ -602,12 +544,12 @@ impl ScreenLike for AddNewWalletScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; let ctx = ui.ctx().clone(); @@ -742,43 +684,7 @@ impl ScreenLike for AddNewWalletScreen { ui.separator(); ui.add_space(10.0); - let save_step = if self - .core_wallets - .as_ref() - .is_some_and(|w| w.len() > 1) - { - let core_wallets = self.core_wallets.as_ref().unwrap(); - ui.heading( - "6. Select the Dash Core wallet to use for RPC operations.", - ); - ui.add_space(8.0); - - ui.horizontal(|ui| { - ui.label("Dash Core Wallet:"); - let selected_name = - &core_wallets[self.selected_core_wallet_index]; - ComboBox::from_id_salt("core_wallet_selector") - .selected_text(selected_name.as_str()) - .show_ui(ui, |ui| { - for (i, name) in core_wallets.iter().enumerate() { - ui.selectable_value( - &mut self.selected_core_wallet_index, - i, - name, - ); - } - }); - }); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - "7" - } else { - "6" - }; - - ui.heading(format!("{save_step}. Save the wallet.")); + ui.heading("6. Save the wallet."); ui.add_space(10.0); // Save Wallet button styled like Load Identity button @@ -810,7 +716,7 @@ impl ScreenLike for AddNewWalletScreen { .show(ctx, |ui| { ui.label(error_message); ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { self.error = None; } diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index e39d4f7e0..47a631e31 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -1,283 +1,285 @@ use crate::app::AppAction; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::secret::Secret; -use crate::model::wallet::Wallet; +use crate::model::fee_estimation::format_duffs_as_dash; use crate::ui::components::MessageBanner; 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; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::state::TrackedAssetLockCache; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dashcore_rpc::dashcore::{Address, InstantLock, Transaction}; -use dash_sdk::dpp::fee::Credits; +use dash_sdk::dpp::dashcore::OutPoint; use dash_sdk::dpp::prelude::AssetLockProof; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Ui}; use egui::{Color32, Frame, Margin, RichText}; -use std::sync::{Arc, RwLock}; +use platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock; +use std::sync::Arc; pub struct AssetLockDetailScreen { pub wallet_seed_hash: [u8; 32], - pub asset_lock_index: usize, + pub out_point: OutPoint, pub app_context: Arc<AppContext>, - wallet: Option<Arc<RwLock<Wallet>>>, - password_input: PasswordInput, - show_private_key_popup: bool, - private_key_wif: Option<Secret>, + asset_lock_cache: TrackedAssetLockCache, } impl AssetLockDetailScreen { pub fn new( wallet_seed_hash: [u8; 32], - asset_lock_index: usize, + out_point: OutPoint, app_context: &Arc<AppContext>, ) -> Self { - // Find the wallet by seed hash - let wallet = app_context - .wallets - .read() - .unwrap() - .values() - .find(|w| w.read().unwrap().seed_hash() == wallet_seed_hash) - .cloned(); - Self { wallet_seed_hash, - asset_lock_index, + out_point, app_context: app_context.clone(), - wallet, - password_input: PasswordInput::new().with_hint_text("Enter password"), - show_private_key_popup: false, - private_key_wif: None, + asset_lock_cache: TrackedAssetLockCache::default(), } } - #[allow(clippy::type_complexity)] - fn get_asset_lock_data( - &self, - ) -> Option<( - Transaction, - Address, - Credits, - Option<InstantLock>, - Option<AssetLockProof>, - )> { - self.wallet.as_ref().and_then(|wallet| { - let wallet = wallet.read().unwrap(); - wallet - .unused_asset_locks - .get(self.asset_lock_index) - .cloned() - }) + fn load_tracked_lock(&self) -> Option<TrackedAssetLock> { + self.asset_lock_cache + .get(&self.wallet_seed_hash)? + .iter() + .find(|t| t.out_point == self.out_point) + .cloned() } fn render_asset_lock_info(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - - if let Some((tx, address, amount, _islock, proof)) = self.get_asset_lock_data() { - Frame::new() - .fill(DashColors::surface(dark_mode)) - .corner_radius(5.0) - .inner_margin(Margin::same(15)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .show(ui, |ui| { - ui.heading(RichText::new("Asset Lock Details").color(DashColors::text_primary(dark_mode))); - ui.add_space(10.0); - - // Transaction Information - ui.label(RichText::new("Transaction Information").strong().color(DashColors::text_primary(dark_mode))); - ui.separator(); - ui.add_space(5.0); + let dark_mode = ui.style().visuals.dark_mode; + + let Some(lock) = self.load_tracked_lock() else { + if self.asset_lock_cache.is_failed(&self.wallet_seed_hash) { + let mut retry = false; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label( + RichText::new("Couldn't load asset lock.") + .size(16.0) + .color(Color32::GRAY), + ); + ui.add_space(8.0); + retry = ui.button("Retry").clicked(); + }); + if retry { + self.asset_lock_cache.invalidate_one(&self.wallet_seed_hash); + } + return; + } + + let message = if self.asset_lock_cache.is_loading(&self.wallet_seed_hash) { + "Loading asset lock…" + } else { + "Asset lock not found" + }; + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label(RichText::new(message).size(16.0).color(Color32::GRAY)); + }); + return; + }; + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(5.0) + .inner_margin(Margin::same(15)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .show(ui, |ui| { + ui.heading( + RichText::new("Asset Lock Details").color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Transaction ID:"); - ui.label(RichText::new(tx.txid().to_string()).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); + ui.label( + RichText::new("Transaction Information") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Transaction ID:"); + ui.label( + RichText::new(lock.out_point.txid.to_string()) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Output Index:"); + ui.label( + RichText::new(lock.out_point.vout.to_string()) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Amount:"); + ui.label( + RichText::new(format!( + "{} ({} duffs)", + format_duffs_as_dash(lock.amount), + lock.amount + )) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("Address:"); - ui.label(RichText::new(address.to_string()).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Status:"); + ui.label(format!("{:?}", lock.status)); + }); + ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("Amount:"); - let dash_amount = amount.to_string().parse::<u64>().unwrap_or(0) as f64 * 1e-8; - ui.label(RichText::new(format!("{:.8} DASH ({} duffs)", dash_amount, amount)) - .strong() - .color(DashColors::text_primary(dark_mode))); - }); - ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Funding Account Type:"); + ui.label(format!("{:?}", lock.funding_type)); + }); + ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("Asset Lock Proof Type:"); - let (proof_type, color) = match &proof { - Some(AssetLockProof::Instant(_)) => ("Instant Send Locked", DashColors::success_color(dark_mode)), - Some(AssetLockProof::Chain(_)) => ("Chain Locked", DashColors::success_color(dark_mode)), - None => ("Waiting for Lock", DashColors::warning_color(dark_mode)), - }; - ui.label(RichText::new(proof_type).color(color)); - }); - ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Identity Index:"); + ui.label(lock.identity_index.to_string()); + }); + ui.add_space(5.0); - // Asset Lock Proof Details - if let Some(proof) = &proof { - ui.add_space(15.0); - ui.label(RichText::new("Asset Lock Proof Details").strong().color(DashColors::text_primary(dark_mode))); - ui.separator(); - ui.add_space(5.0); - - // Show specific proof details based on type - match proof { - AssetLockProof::Instant(instant_proof) => { - ui.horizontal(|ui| { - ui.label("Type:"); - ui.label(RichText::new("Instant Send").font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - - // The instant lock is in the instant_proof - ui.horizontal(|ui| { - ui.label("InstantLock TxID:"); - ui.label(RichText::new(instant_proof.instant_lock.txid.to_string()).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - - ui.horizontal(|ui| { - ui.label("Output Index:"); - ui.label(RichText::new(instant_proof.output_index.to_string()).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - } - AssetLockProof::Chain(chain_proof) => { - ui.horizontal(|ui| { - ui.label("Type:"); - ui.label(RichText::new("Chain Lock").font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - - ui.horizontal(|ui| { - ui.label("Core Chain Locked Height:"); - ui.label(RichText::new(chain_proof.core_chain_locked_height.to_string()).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - - ui.horizontal(|ui| { - ui.label("OutPoint:"); - ui.label(RichText::new(format!("{}:{}", chain_proof.out_point.txid, chain_proof.out_point.vout)).font(egui::FontId::monospace(12.0))); - }); - ui.add_space(5.0); - } + ui.horizontal(|ui| { + ui.label("Asset Lock Proof Type:"); + let (proof_type, color) = match &lock.proof { + Some(AssetLockProof::Instant(_)) => { + ("Instant Send Locked", DashColors::success_color(dark_mode)) + } + Some(AssetLockProof::Chain(_)) => { + ("Chain Locked", DashColors::success_color(dark_mode)) } + None => ("Waiting for Lock", DashColors::warning_color(dark_mode)), + }; + ui.label(RichText::new(proof_type).color(color)); + }); + ui.add_space(5.0); - // Asset Lock Proof Hex - ui.add_space(10.0); - - // Serialize the proof to get hex - let proof_hex = match serde_json::to_vec(proof) { - Ok(bytes) => hex::encode(bytes), - Err(e) => format!("Error serializing proof: {}", e), - }; - - ui.horizontal(|ui| { - ui.label("Asset Lock Proof (hex):"); - if ui.small_button("Copy").clicked() { - ui.ctx().copy_text(proof_hex.clone()); - MessageBanner::set_global(ui.ctx(), "Asset lock proof copied to clipboard", MessageType::Success); - } - }); - ui.add_space(5.0); + if let Some(proof) = &lock.proof { + ui.add_space(15.0); + ui.label( + RichText::new("Asset Lock Proof Details") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + ui.add_space(5.0); - // Display hex in a scrollable area with monospace font - egui::ScrollArea::horizontal() - .id_salt("proof_hex") - .show(ui, |ui| { - ui.label(RichText::new(&proof_hex).font(egui::FontId::monospace(10.0)).color(DashColors::text_secondary(dark_mode))); + match proof { + AssetLockProof::Instant(instant_proof) => { + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label( + RichText::new("Instant Send") + .font(egui::FontId::monospace(12.0)), + ); }); - - ui.add_space(10.0); - ui.collapsing("View Raw Proof Details", |ui| { - ui.label(RichText::new(format!("{:#?}", proof)).font(egui::FontId::monospace(10.0))); - }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("InstantLock TxID:"); + ui.label( + RichText::new(instant_proof.instant_lock.txid.to_string()) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Output Index:"); + ui.label( + RichText::new(instant_proof.output_index.to_string()) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + } + AssetLockProof::Chain(chain_proof) => { + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label( + RichText::new("Chain Lock").font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Core Chain Locked Height:"); + ui.label( + RichText::new(chain_proof.core_chain_locked_height.to_string()) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("OutPoint:"); + ui.label( + RichText::new(format!( + "{}:{}", + chain_proof.out_point.txid, chain_proof.out_point.vout + )) + .font(egui::FontId::monospace(12.0)), + ); + }); + ui.add_space(5.0); + } } - // Private Key Section (requires wallet unlock) - ui.add_space(20.0); - ui.label(RichText::new("Private Key Information").strong().color(DashColors::text_primary(dark_mode))); - ui.separator(); - ui.add_space(5.0); + ui.add_space(10.0); + let proof_hex = match serde_json::to_vec(proof) { + Ok(bytes) => hex::encode(bytes), + Err(e) => format!("Error serializing proof: {}", e), + }; - let (needs_unlock, unlocked) = self.render_wallet_unlock_if_needed(ui); - - if (!needs_unlock || unlocked) - && let Some(wallet_arc) = self.wallet.clone() { - let wallet = wallet_arc.read().unwrap(); - - // Find the private key for this address - if let Some(derivation_path) = wallet.known_addresses.get(&address).cloned() { - drop(wallet); // Release the read lock before getting write lock - - ui.horizontal(|ui| { - ui.label("Private Key (WIF):"); - ui.label(RichText::new("••••••••••••••••••••").font(egui::FontId::monospace(12.0)).color(DashColors::text_secondary(dark_mode))); - if ui.small_button("View").clicked() { - // Retrieve the private key when View is clicked - let wallet = wallet_arc.write().unwrap(); - match wallet.private_key_at_derivation_path(&derivation_path, self.app_context.network) { - Ok(private_key) => { - self.private_key_wif = Some(Secret::new(private_key.to_wif())); - self.show_private_key_popup = true; - } - Err(e) => { - MessageBanner::set_global(ui.ctx(), format!("Error retrieving private key: {}", e), MessageType::Error); - } - } - } - }); - - ui.add_space(5.0); - ui.label(RichText::new("Warning: Keep this private key secure! Anyone with access to it can spend these funds.") - .color(DashColors::warning_color(dark_mode)) - .italics()); - } else { - ui.label(RichText::new("Private key not found for this address") - .color(DashColors::error_color(dark_mode))); - } + ui.horizontal(|ui| { + ui.label("Asset Lock Proof (hex):"); + if ui.small_button("Copy").clicked() { + ui.ctx().copy_text(proof_hex.clone()); + MessageBanner::set_global( + ui.ctx(), + "Asset lock proof copied to clipboard", + MessageType::Success, + ); } - }); - } else { - ui.vertical_centered(|ui| { - ui.add_space(50.0); - ui.label( - RichText::new("Asset lock not found") - .size(16.0) - .color(Color32::GRAY), - ); - }); - } - } -} - -impl ScreenWithWalletUnlock for AssetLockDetailScreen { - fn selected_wallet_ref(&self) -> &Option<Arc<RwLock<Wallet>>> { - &self.wallet - } + }); + ui.add_space(5.0); - fn password_input(&mut self) -> &mut PasswordInput { - &mut self.password_input - } + egui::ScrollArea::horizontal() + .id_salt("proof_hex") + .show(ui, |ui| { + ui.label( + RichText::new(&proof_hex) + .font(egui::FontId::monospace(10.0)) + .color(DashColors::text_secondary(dark_mode)), + ); + }); - fn app_context(&self) -> Arc<AppContext> { - self.app_context.clone() + ui.add_space(10.0); + ui.collapsing("View Raw Proof Details", |ui| { + ui.label( + RichText::new(format!("{:#?}", proof)) + .font(egui::FontId::monospace(10.0)), + ); + }); + } + }); } } impl ScreenLike for AssetLockDetailScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ( @@ -292,16 +294,24 @@ impl ScreenLike for AssetLockDetailScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + // Fetch the wallet's tracked locks once (off the UI thread); the lock + // for this screen is found by out_point in the cached list. + if let Some(task) = self + .asset_lock_cache + .ensure_requested(self.wallet_seed_hash) + { + action |= AppAction::BackendTask(task); + } + + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; - // Header with Back button (outside ScrollArea to avoid scrollbar overlap) ui.horizontal(|ui| { ui.heading( RichText::new("Asset Lock Information") @@ -323,72 +333,25 @@ impl ScreenLike for AssetLockDetailScreen { self.render_asset_lock_info(ui); }); - // Message display is handled by the global MessageBanner - inner_action }); - // Private key popup - if self.show_private_key_popup { - // Draw dark overlay behind the popup - let screen_rect = ctx.content_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("private_key_popup_overlay"), - )); - painter.rect_filled(screen_rect, 0.0, DashColors::modal_overlay()); - - egui::Window::new("Private Key") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.set_min_width(400.0); - - ui.add_space(10.0); - ui.label(RichText::new("⚠ Warning").color(DashColors::WARNING_BRIGHT).strong()); - ui.label("Keep this private key secure! Anyone with access to it can spend these funds."); - ui.add_space(15.0); - - ui.label("Private Key (WIF):"); - if let Some(ref wif) = self.private_key_wif { - ui.add(egui::TextEdit::multiline(&mut wif.expose_secret()) - .font(egui::FontId::monospace(12.0)) - .desired_width(f32::INFINITY) - .desired_rows(1)); + action + } - ui.add_space(10.0); - } + fn display_message(&mut self, _message: &str, _message_type: MessageType) {} - let mut close_popup = false; - ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - if ComponentStyles::add_primary_button(ui, "Copy").clicked() - && let Some(ref wif) = self.private_key_wif - { - // SECURITY: clipboard copy inherently exposes plaintext — user-initiated action - ui.ctx().copy_text(wif.expose_secret().to_string()); - MessageBanner::set_global(ctx, "Private key copied to clipboard", MessageType::Success); - } - if ComponentStyles::add_secondary_button(ui, "Close", dark_mode) - .clicked() - { - close_popup = true; - } - }); - if close_popup { - self.show_private_key_popup = false; - self.private_key_wif = None; - } - ui.add_space(10.0); - }); + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks } = result { + self.asset_lock_cache.store(seed_hash, locks); } - - action } - fn display_message(&mut self, _message: &str, _message_type: MessageType) { - // Error/success display is handled by the global MessageBanner. + fn display_task_error(&mut self, _error: &TaskError) -> bool { + // Flip the in-flight lock fetch to a retryable state so the screen shows + // a Retry button instead of a permanent "Loading…". + self.asset_lock_cache.mark_loading_failed(); + false } fn refresh_on_arrival(&mut self) {} diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 46ab006ef..d9adb12c5 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -1,25 +1,25 @@ use crate::app::AppAction; use crate::backend_task::core::{CoreItem, CoreTask}; -use crate::backend_task::error::TaskError; +use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; +use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::Component; use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; 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; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::identities::funding_common::{self, WalletFundedScreenStep, generate_qr_code_image}; +use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, try_open_wallet_no_password}; +use crate::ui::identities::funding_common::{WalletFundedScreenStep, generate_qr_code_image}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, TxOut}; -use eframe::egui::{self, Context, Ui}; +use crate::wallet_backend::poison::RwLockRecover; +use dash_sdk::dashcore_rpc::dashcore::Address; +use eframe::egui::{self, Ui}; use egui::{Button, RichText, Vec2}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -36,14 +36,18 @@ pub struct CreateAssetLockScreen { pub wallet: Arc<RwLock<Wallet>>, pub(crate) selected_wallet: Option<Arc<RwLock<Wallet>>>, pub app_context: Arc<AppContext>, - password_input: PasswordInput, + wallet_unlock_popup: WalletUnlockPopup, // Asset lock creation fields step: Arc<RwLock<WalletFundedScreenStep>>, amount_input: Option<AmountInput>, identity_index: u32, funding_address: Option<Address>, - funding_utxo: Option<(OutPoint, TxOut, Address)>, - core_has_funding_address: Option<bool>, + /// A queued "derive the deposit address" request the `ui()` loop drains into + /// a `WalletTask::GenerateReceiveAddress` backend task. The address comes + /// from the upstream SPV-watched pool so the deposit becomes a spendable, + /// visible UTXO. Carries the wallet's seed hash; the address returns via + /// `GeneratedReceiveAddress`. + pending_funding_address_request: Option<WalletSeedHash>, is_creating: bool, asset_lock_tx_id: Option<String>, @@ -61,7 +65,7 @@ impl CreateAssetLockScreen { // Calculate next unused identity index let identity_index = { - let wallet_guard = wallet.read().unwrap(); + let wallet_guard = wallet.read_recover(); wallet_guard .identities .keys() @@ -75,7 +79,7 @@ impl CreateAssetLockScreen { wallet, selected_wallet, app_context: app_context.clone(), - password_input: PasswordInput::new().with_hint_text("Enter password"), + wallet_unlock_popup: WalletUnlockPopup::new(), step: Arc::new(RwLock::new(WalletFundedScreenStep::WaitingOnFunds)), amount_input: Some( AmountInput::new(Amount::new_dash(0.5)) @@ -84,8 +88,7 @@ impl CreateAssetLockScreen { ), identity_index, funding_address: None, - funding_utxo: None, - core_has_funding_address: None, + pending_funding_address_request: None, is_creating: false, asset_lock_tx_id: None, asset_lock_purpose: None, @@ -96,45 +99,30 @@ impl CreateAssetLockScreen { } } - fn generate_funding_address(&mut self) -> Result<(), TaskError> { - let mut wallet = self.wallet.write().unwrap(); - - // Generate a new asset lock funding address - let receive_address = wallet - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| TaskError::WalletAddressDerivationFailed { detail: e })?; - let core_wallet_name = wallet.core_wallet_name.clone(); - drop(wallet); - - // Import address to core if needed - if let Some(has_address) = self.core_has_funding_address { - if !has_address { - self.app_context.ensure_address_imported( - &receive_address, - core_wallet_name.as_deref(), - Some("Managed by Dash Evo Tool - Asset Lock"), - )?; - } - self.funding_address = Some(receive_address); - } else { - self.app_context.ensure_address_imported( - &receive_address, - core_wallet_name.as_deref(), - Some("Managed by Dash Evo Tool - Asset Lock"), - )?; - self.funding_address = Some(receive_address); - self.core_has_funding_address = Some(true); + /// Queue a request to derive the deposit address from the SPV-watched pool. + /// + /// The derivation runs in the backend via `WalletTask::GenerateReceiveAddress` + /// (→ upstream `next_unused`), so the deposit address is always watched and + /// the incoming UTXO becomes visible and spendable by the asset-lock build. + /// Idempotent: a request already in flight, or an address already derived, + /// is not re-queued. + fn queue_funding_address_request(&mut self) { + if self.funding_address.is_some() || self.pending_funding_address_request.is_some() { + return; } - - Ok(()) + let Ok(seed_hash) = self.wallet.read().map(|w| w.seed_hash()) else { + return; + }; + self.pending_funding_address_request = Some(seed_hash); } - fn render_qr_code(&mut self, ui: &mut egui::Ui) -> Result<(), TaskError> { - if self.funding_address.is_none() { - self.generate_funding_address()? - } + fn render_qr_code(&mut self, ui: &mut egui::Ui) { + let Some(address) = self.funding_address.as_ref() else { + self.queue_funding_address_request(); + ui.label("Generating a deposit address…"); + return; + }; - let address = self.funding_address.as_ref().unwrap(); let amount = self .amount_input .as_ref() @@ -165,8 +153,6 @@ impl CreateAssetLockScreen { MessageType::Success, ); } - - Ok(()) } fn show_success(&mut self, ui: &mut Ui) -> AppAction { @@ -201,7 +187,7 @@ impl CreateAssetLockScreen { self.selected_identity_string.clear(); // Recalculate next unused identity index self.identity_index = { - let wallet_guard = self.wallet.read().unwrap(); + let wallet_guard = self.wallet.read_recover(); wallet_guard .identities .keys() @@ -218,11 +204,10 @@ impl CreateAssetLockScreen { .with_min_amount(Some(1000)), ); self.funding_address = None; - self.funding_utxo = None; - self.core_has_funding_address = None; + self.pending_funding_address_request = None; self.asset_lock_tx_id = None; self.show_advanced_options = false; - *self.step.write().unwrap() = WalletFundedScreenStep::WaitingOnFunds; + *self.step.write_recover() = WalletFundedScreenStep::WaitingOnFunds; } ui.add_space(100.0); @@ -232,22 +217,10 @@ impl CreateAssetLockScreen { } } -impl ScreenWithWalletUnlock for CreateAssetLockScreen { - fn selected_wallet_ref(&self) -> &Option<Arc<RwLock<Wallet>>> { - &self.selected_wallet - } - - fn password_input(&mut self) -> &mut PasswordInput { - &mut self.password_input - } - - fn app_context(&self) -> Arc<AppContext> { - self.app_context.clone() - } -} - impl ScreenLike for CreateAssetLockScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let wallet_name = self .wallet .read() @@ -256,7 +229,7 @@ impl ScreenLike for CreateAssetLockScreen { .unwrap_or_else(|| "Unknown Wallet".to_string()); let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ( @@ -271,14 +244,14 @@ impl ScreenLike for CreateAssetLockScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Header with Back button and Advanced Options checkbox (outside ScrollArea) ui.horizontal(|ui| { @@ -309,7 +282,7 @@ impl ScreenLike for CreateAssetLockScreen { .show(ui, |ui| { // Show success screen - if *self.step.read().unwrap() == WalletFundedScreenStep::Success { + if *self.step.read_recover() == WalletFundedScreenStep::Success { inner_action |= self.show_success(ui); return; } @@ -318,10 +291,37 @@ impl ScreenLike for CreateAssetLockScreen { ui.separator(); ui.add_space(10.0); - // Wallet unlock section - let (needs_unlock, unlocked) = self.render_wallet_unlock_if_needed(ui); + // Determine if the selected wallet is ready for operations. + let wallet_is_open = self + .selected_wallet + .as_ref() + .map(|w| { + let g = w.read_recover(); + !g.uses_password || g.is_open() + }) + .unwrap_or(false); + + if !wallet_is_open { + // Auto-open no-password wallets; open the popup for password wallets. + if let Some(wallet) = self.selected_wallet.clone() { + if !wallet.read_recover().uses_password { + if let Err(e) = + try_open_wallet_no_password(&self.app_context, &wallet) + { + MessageBanner::set_global( + ui.ctx(), + &e, + MessageType::Error, + ) + .disable_auto_dismiss(); + } + } else if !self.wallet_unlock_popup.is_open() { + self.wallet_unlock_popup.open(); + } + } + } - if !needs_unlock || unlocked { + if wallet_is_open { // First, select the purpose of the asset lock if self.asset_lock_purpose.is_none() { ui.heading(RichText::new("Select Asset Lock Purpose").color(DashColors::text_primary(dark_mode))); @@ -397,12 +397,15 @@ impl ScreenLike for CreateAssetLockScreen { return; } + // READ-only (R1): seed from app-scoped selection iff the global id + // is in this wallet's identity list; no syncing_global (K1 guard). let identity_selector_response = ui.add(IdentitySelector::new( "top_up_identity_selector", &mut self.selected_identity_string, &identities ) .selected_identity(&mut self.selected_identity).unwrap() + .with_app_default(&self.app_context) .label("Identity to top up:") .width(300.0)); @@ -473,7 +476,7 @@ impl ScreenLike for CreateAssetLockScreen { ui.add_space(10.0); // Get used indices from wallet - let wallet_guard = self.wallet.read().unwrap(); + let wallet_guard = self.wallet.read_recover(); let used_indices: HashSet<u32> = wallet_guard.identities.keys().cloned().collect(); drop(wallet_guard); @@ -515,16 +518,7 @@ impl ScreenLike for CreateAssetLockScreen { ui.separator(); ui.add_space(10.0); - // Check if funds have arrived at the funding address - if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( - &self.step, - self.selected_wallet.as_ref(), - self.funding_address.as_ref(), - ) { - self.funding_utxo = Some(utxo); - } - - let step = *self.step.read().unwrap(); + let step = *self.step.read_recover(); // Request periodic repaints while waiting for funds if step == WalletFundedScreenStep::WaitingOnFunds { @@ -555,14 +549,7 @@ impl ScreenLike for CreateAssetLockScreen { let layout_action = ui.with_layout( egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { - if let Err(e) = self.render_qr_code(ui) { - MessageBanner::set_global( - ui.ctx(), - "Failed to render QR code", - MessageType::Error, - ) - .with_details(e); - } + self.render_qr_code(ui); ui.add_space(20.0); @@ -582,7 +569,7 @@ impl ScreenLike for CreateAssetLockScreen { if let Some(credits) = credits { // Transition to WaitingForAssetLock BEFORE dispatching to prevent duplicate dispatches { - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::WaitingForAssetLock; } @@ -632,9 +619,8 @@ impl ScreenLike for CreateAssetLockScreen { inner_action |= layout_action.inner; } - } else { - // Wallet needs to be unlocked } + // (implicit else: popup is open; modal overlay handles the interaction) }); // Message display is handled by the global MessageBanner @@ -642,6 +628,23 @@ impl ScreenLike for CreateAssetLockScreen { inner_action }); + // Show the wallet unlock popup modal when needed. + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = self.selected_wallet.clone() + { + self.wallet_unlock_popup + .show(ctx, &wallet, &self.app_context); + } + + // Drain a queued "derive the deposit address" request into a backend + // task that derives it from the SPV-watched upstream pool. The address + // returns via `GeneratedReceiveAddress`. + if let Some(seed_hash) = self.pending_funding_address_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )); + } + action } @@ -656,7 +659,21 @@ impl ScreenLike for CreateAssetLockScreen { fn refresh(&mut self) {} fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - let current_step = *self.step.read().unwrap(); + // The backend derived the deposit address from the SPV-watched pool; + // store it so the QR renders. Only adopt it for this wallet. + if let BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } = &result { + let is_ours = self + .wallet + .read() + .map(|w| w.seed_hash() == *seed_hash) + .unwrap_or(false); + if is_ours && let Ok(addr) = address.parse::<Address<_>>() { + self.funding_address = Some(addr.assume_checked()); + } + return; + } + + let current_step = *self.step.read_recover(); match current_step { WalletFundedScreenStep::WaitingOnFunds => { @@ -664,15 +681,11 @@ impl ScreenLike for CreateAssetLockScreen { CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), ) = result { - for utxo in outpoints_with_addresses { - let (_, _, address) = &utxo; + for (_, _, address) in outpoints_with_addresses { if let Some(funding_address) = &self.funding_address - && funding_address == address + && *funding_address == address { - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some(utxo); - drop(step); // Release the lock before creating new action + *self.step.write_recover() = WalletFundedScreenStep::FundsReceived; // Refresh wallet to create the asset lock self.is_creating = true; @@ -684,31 +697,25 @@ impl ScreenLike for CreateAssetLockScreen { WalletFundedScreenStep::FundsReceived => { // Asset lock creation was triggered match &result { - BackendTaskSuccessResult::Message(msg) => { - if msg.contains("Asset lock transaction broadcast successfully") { - // Extract TX ID from message - if let Some(tx_id_start) = msg.find("TX ID: ") { - let tx_id = msg[tx_id_start + 7..].trim().to_string(); - self.asset_lock_tx_id = Some(tx_id); - } - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::Success; - drop(step); - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Asset lock created successfully!", - MessageType::Success, - ); - } + BackendTaskSuccessResult::AssetLockBroadcast { txid } => { + self.asset_lock_tx_id = Some(txid.clone()); + + let mut step = self.step.write_recover(); + *step = WalletFundedScreenStep::Success; + drop(step); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Asset lock created successfully!", + MessageType::Success, + ); } BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), ) => { - // This is the asset lock transaction from ZMQ + // The asset-lock transaction surfaced as a received UTXO. if tx.special_transaction_payload.is_some() { self.asset_lock_tx_id = Some(tx.txid().to_string()); - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::Success; drop(step); MessageBanner::set_global( @@ -723,31 +730,25 @@ impl ScreenLike for CreateAssetLockScreen { } WalletFundedScreenStep::WaitingForAssetLock => { match &result { - BackendTaskSuccessResult::Message(msg) => { - if msg.contains("Asset lock transaction broadcast successfully") { - // Extract TX ID from message - if let Some(tx_id_start) = msg.find("TX ID: ") { - let tx_id = msg[tx_id_start + 7..].trim().to_string(); - self.asset_lock_tx_id = Some(tx_id); - } - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::Success; - drop(step); - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Asset lock created successfully!", - MessageType::Success, - ); - } + BackendTaskSuccessResult::AssetLockBroadcast { txid } => { + self.asset_lock_tx_id = Some(txid.clone()); + + let mut step = self.step.write_recover(); + *step = WalletFundedScreenStep::Success; + drop(step); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Asset lock created successfully!", + MessageType::Success, + ); } BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), ) => { - // This is the asset lock transaction from ZMQ + // The asset-lock transaction surfaced as a received UTXO. if tx.special_transaction_payload.is_some() { self.asset_lock_tx_id = Some(tx.txid().to_string()); - let mut step = self.step.write().unwrap(); + let mut step = self.step.write_recover(); *step = WalletFundedScreenStep::Success; drop(step); MessageBanner::set_global( @@ -941,27 +942,6 @@ mod tests { assert_eq!(registration, registration_copy); } - /// Test TX ID extraction from success message - #[test] - fn test_tx_id_extraction() { - let msg = "Asset lock transaction broadcast successfully. TX ID: abc123def456"; - - // Extract TX ID from message - let tx_id = msg - .find("TX ID: ") - .map(|tx_id_start| msg[tx_id_start + 7..].trim().to_string()); - - assert_eq!(tx_id, Some("abc123def456".to_string())); - - // Test message without TX ID - let msg_without_id = "Some other message"; - let no_tx_id = msg_without_id - .find("TX ID: ") - .map(|tx_id_start| msg_without_id[tx_id_start + 7..].trim().to_string()); - - assert_eq!(no_tx_id, None); - } - /// Test MAX_IDENTITY_INDEX constant #[test] fn test_max_identity_index_constant() { diff --git a/src/ui/wallets/import_mnemonic_screen.rs b/src/ui/wallets/import_mnemonic_screen.rs index 6082e35be..bad96b5de 100644 --- a/src/ui/wallets/import_mnemonic_screen.rs +++ b/src/ui/wallets/import_mnemonic_screen.rs @@ -1,8 +1,4 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::core::CoreTask; -use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::components::left_panel::add_left_panel; @@ -11,18 +7,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::identities::add_existing_identity_screen::AddExistingIdentityScreen; use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; use crate::ui::{RootScreenType, Screen, ScreenLike}; -use eframe::egui::Context; -use crate::database::is_unique_constraint_violation; use crate::model::wallet::Wallet; -use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; use crate::ui::components::password_input::PasswordInput; use crate::ui::theme::{ComponentStyles, DashColors}; use bip39::Mnemonic; use egui::{ComboBox, Grid, RichText, Ui, Vec2}; use std::sync::Arc; -use std::sync::RwLock; -use std::sync::atomic::Ordering; use zeroize::Zeroize; use zxcvbn::zxcvbn; @@ -41,7 +32,6 @@ pub struct ImportMnemonicScreen { estimated_time_to_crack: String, error: Option<String>, pub app_context: Arc<AppContext>, - use_password_for_app: bool, wallet_imported: bool, show_advanced_options: bool, @@ -56,13 +46,6 @@ pub struct ImportMnemonicScreen { // Identity discovery options identity_scan_count: u32, - - /// Cached list of Core wallets (fetched asynchronously via backend task) - core_wallets: Option<Vec<String>>, - /// Whether the backend task to fetch Core wallets has been dispatched - core_wallets_loading: bool, - /// Index of selected Core wallet in the ComboBox - selected_core_wallet_index: usize, } impl ImportMnemonicScreen { @@ -76,7 +59,6 @@ impl ImportMnemonicScreen { estimated_time_to_crack: String::new(), error: None, app_context: app_context.clone(), - use_password_for_app: true, wallet_imported: false, show_advanced_options: false, @@ -93,18 +75,9 @@ impl ImportMnemonicScreen { // Identity discovery options identity_scan_count: 5, - - core_wallets: None, - core_wallets_loading: false, - selected_core_wallet_index: 0, } } - pub fn reset_core_wallets_cache(&mut self) { - self.core_wallets = None; - self.core_wallets_loading = false; - } - fn try_parse_private_key(&mut self) { let input = self.private_key_input.text().trim(); if input.is_empty() { @@ -130,19 +103,26 @@ impl ImportMnemonicScreen { } fn save_private_key_wallet(&mut self) -> Result<AppAction, String> { + use dash_sdk::dpp::dashcore::PrivateKey; + let input = self.private_key_input.text().trim(); if input.is_empty() { return Err("Please enter a private key".to_string()); } - // Parse the key with password and alias - let password = if self.password_input.is_empty() { - None - } else { - Some(self.password_input.text()) - }; + // T-W-01b: imported keys live in the upstream `SecretStore` vault, + // which is scoped at the vault level — there is no per-key + // password layer here. The per-wallet password UX is deferred + // (T-MIG-03); until then, reject password-protected single-key + // imports rather than silently storing them in the clear. + if !self.password_input.is_empty() { + return Err( + "Per-key passwords are not supported in this version. Leave the password \ + field blank to import the key; your wallet vault protects all imported keys." + .to_string(), + ); + } - // Generate default wallet name if none provided let alias = if self.alias_input.trim().is_empty() { let existing_wallet_count = self .app_context @@ -155,37 +135,40 @@ impl ImportMnemonicScreen { Some(self.alias_input.clone()) }; - // Try WIF first, then hex - let mut wallet = - SingleKeyWallet::from_wif(input, password, alias.clone()).or_else(|_| { - SingleKeyWallet::from_hex(input, self.app_context.network, password, alias) - })?; - - wallet.core_wallet_name = self - .core_wallets - .as_ref() - .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()); - - let key_hash = wallet.key_hash(); - - // Store in database - self.app_context - .db - .store_single_key_wallet(&wallet, self.app_context.network) - .map_err(|e| { - if is_unique_constraint_violation(&e) { - "This key has already been imported.".to_string() - } else { - e.to_string() + // The single import path takes WIF only — normalise hex input to + // WIF first so users can paste either shape while every import + // still funnels through `AppContext::import_single_key_wif`. + let wif = match PrivateKey::from_wif(input) { + Ok(_) => input.to_string(), + Err(_) => { + let bytes = hex::decode(input).map_err(|_| { + "This does not look like a valid WIF or hex private key. Check the input." + .to_string() + })?; + if bytes.len() != 32 { + return Err(format!( + "Hex private keys must be exactly 32 bytes; got {} bytes.", + bytes.len() + )); } - })?; + let mut buf = [0u8; 32]; + buf.copy_from_slice(&bytes); + PrivateKey::from_byte_array(&buf, self.app_context.network) + .map_err(|e| format!("Invalid private key: {e}"))? + .to_wif() + } + }; - // Add to app context - let wallet_arc = Arc::new(RwLock::new(wallet)); - if let Ok(mut single_key_wallets) = self.app_context.single_key_wallets.write() { - single_key_wallets.insert(key_hash, wallet_arc); - self.app_context.has_wallet.store(true, Ordering::Relaxed); - } + // Consolidated import: vault write + sidecar + in-memory mirror, + // shared with the advanced import dialog (#192). Per-key passwords + // are rejected above, so this screen always imports unprotected. + self.app_context + .import_single_key_wif( + &wif, + alias, + crate::wallet_backend::single_key::ImportPassphrase::default(), + ) + .map_err(|e| e.to_string())?; self.wallet_imported = true; Ok(AppAction::None) @@ -195,15 +178,6 @@ impl ImportMnemonicScreen { if let Some(mnemonic) = &self.seed_phrase { let seed = mnemonic.to_seed(""); - // Handle app-level password encryption (UI concern, separate from wallet) - if !self.password_input.is_empty() && self.use_password_for_app { - let (encrypted_message, salt, nonce) = - encrypt_message(DASH_SECRET_MESSAGE, self.password_input.text())?; - self.app_context - .update_main_password(&salt, &nonce, &encrypted_message) - .map_err(|e| e.to_string())?; - } - let password = if self.password_input.is_empty() { None } else { @@ -223,7 +197,7 @@ impl ImportMnemonicScreen { self.alias_input.clone() }; - let mut wallet = Wallet::new_from_seed( + let wallet = Wallet::new_from_seed( seed, self.app_context.network, Some(wallet_alias), @@ -231,14 +205,13 @@ impl ImportMnemonicScreen { ) .map_err(|e| e.to_string())?; - wallet.core_wallet_name = self - .core_wallets - .as_ref() - .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()); - let (new_wallet_seed_hash, wallet_arc) = self .app_context - .register_wallet(wallet) + .register_wallet( + wallet, + &seed, + crate::model::wallet::birth_height::WalletOrigin::Imported, + ) .map_err(|e| e.to_string())?; // Set pending wallet selection so the wallet screen auto-selects this wallet @@ -366,7 +339,7 @@ impl ImportMnemonicScreen { let mut word = self.seed_phrase_words[i].clone(); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let response = ui.add_sized( Vec2::new(input_width, 20.0), egui::TextEdit::singleline(&mut word) @@ -466,35 +439,11 @@ impl Drop for ImportMnemonicScreen { } impl ScreenLike for ImportMnemonicScreen { - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::CoreWalletsList(wallets) = backend_task_success_result { - self.selected_core_wallet_index = self - .selected_core_wallet_index - .min(wallets.len().saturating_sub(1)); - self.core_wallets = Some(wallets); - } - } - - fn display_task_error(&mut self, _error: &TaskError) -> bool { - self.core_wallets_loading = false; - self.core_wallets = Some(vec![]); - false - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut pending_action = AppAction::None; - if self.core_wallets.is_none() && !self.core_wallets_loading { - if self.app_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv { - self.core_wallets = Some(vec![]); - } else { - self.core_wallets_loading = true; - pending_action = - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::ListCoreWallets)); - } - } + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let pending_action = AppAction::None; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![ ("Wallets", AppAction::GoToMainScreen), @@ -504,12 +453,12 @@ impl ScreenLike for ImportMnemonicScreen { ); action |= add_left_panel( - ctx, + ui, &self.app_context, crate::ui::RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; // Show success screen if wallet was imported @@ -698,47 +647,8 @@ impl ScreenLike for ImportMnemonicScreen { self.estimated_time_to_crack )); - // if self.app_context.password_info.is_none() { - // ui.add_space(10.0); - // ui.checkbox(&mut self.use_password_for_app, "Use password for Dash Evo Tool loose keys (recommended)"); - // } - step += 1; - if self - .core_wallets - .as_ref() - .is_some_and(|w| w.len() > 1) - { - let core_wallets = self.core_wallets.as_ref().unwrap(); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.heading(format!( - "{}. Select the Dash Core wallet to use for RPC operations.", - step - )); - step += 1; - ui.add_space(8.0); - - ui.horizontal(|ui| { - ui.label("Dash Core Wallet:"); - let selected_name = &core_wallets[self.selected_core_wallet_index]; - ComboBox::from_id_salt("import_core_wallet_selector") - .selected_text(selected_name) - .show_ui(ui, |ui| { - for (i, name) in core_wallets.iter().enumerate() { - ui.selectable_value( - &mut self.selected_core_wallet_index, - i, - name, - ); - } - }); - }); - } - ui.add_space(10.0); ui.separator(); ui.add_space(10.0); diff --git a/src/ui/wallets/import_single_key.rs b/src/ui/wallets/import_single_key.rs new file mode 100644 index 000000000..d3a7de42d --- /dev/null +++ b/src/ui/wallets/import_single_key.rs @@ -0,0 +1,506 @@ +//! J-6 "Import private key (advanced)" dialog. +//! +//! Lets a power user paste a WIF-encoded single private key, preview the +//! derived P2PKH address, then route the import through +//! [`crate::wallet_backend::SingleKeyView::import_wif`]. The dialog is a +//! self-contained component: callers create one, set `open = true`, and +//! call [`ImportSingleKeyDialog::show`] each frame. Side effects on +//! confirm are returned via [`ImportSingleKeyResponse::confirmed`] so the +//! parent screen owns the `WalletBackend` lookup and the post-import +//! refresh. +//! +//! Backed by TC-SK-004 (valid WIF accepted), TC-SK-005 (invalid WIF +//! inline error), TC-SK-007 (mask + reveal toggle, ARIA), TC-A11Y-005. + +use dash_sdk::dpp::dashcore::Network; +use eframe::egui::{self, Context, RichText, Ui}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::single_key::validate_wif; +use crate::model::wallet::passphrase::validate_single_key_passphrase; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::theme::{DashColors, network_label}; + +/// Maximum alias length accepted by the dialog. Matches the legacy +/// single-key alias limit so list rows stay readable. +const ALIAS_MAX_CHARS: usize = 64; + +/// Outcome of a single frame of [`ImportSingleKeyDialog::show`]. +/// +/// `Debug` is hand-written so the embedded [`ImportSingleKeyRequest`]'s +/// secret material never leaks through a derived `{:?}`. +#[derive(Default, Clone)] +pub struct ImportSingleKeyResponse { + /// `Some` only on the frame the user clicks **Add to wallets** with a + /// valid WIF. Carries the WIF and the user-supplied alias (trimmed) + /// so the parent can call + /// [`crate::wallet_backend::SingleKeyView::import_wif`] without + /// re-reading the input. + pub confirmed: Option<ImportSingleKeyRequest>, + /// `true` if the user dismissed the dialog this frame (Cancel button + /// or window close). Parent should set `open = false` and clear any + /// stale state. + pub cancelled: bool, +} + +/// Request emitted when the user confirms a valid WIF. +/// +/// Holds raw secret material (`wif`, `passphrase`). The WIF is wrapped in +/// [`Zeroizing`] so the copy wipes on drop; `Debug` is hand-written to redact +/// both — deriving it would dump the private key and passphrase into logs or +/// panic backtraces. +#[derive(Clone)] +pub struct ImportSingleKeyRequest { + /// WIF-encoded private key. Wrapped in [`Zeroizing`]; derefs to `&str` + /// for callers that need the raw text. + pub wif: Zeroizing<String>, + pub alias: Option<String>, + /// Address preview shown to the user — handed back so the parent can + /// echo it in a success message without re-deriving. + pub address_preview: String, + /// Optional per-key passphrase. `None` means store the + /// key without an additional encryption layer (still inside the + /// vault); `Some` means encrypt under the user's passphrase. Wrapped + /// in [`Zeroizing`] so the copy wipes on drop and never lingers as a + /// plain `String` between the input field and the vault. + pub passphrase: Option<Zeroizing<String>>, + /// Optional hint shown next to the unlock prompt for protected + /// imports. + pub passphrase_hint: Option<String>, +} + +impl std::fmt::Debug for ImportSingleKeyRequest { + /// Redacts `wif` and `passphrase` (presence + length only) so neither + /// the private key nor the passphrase can leak through `{:?}`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportSingleKeyRequest") + .field("wif", &format_args!("[redacted; len {}]", self.wif.len())) + .field("alias", &self.alias) + .field("address_preview", &self.address_preview) + .field( + "passphrase", + &self + .passphrase + .as_ref() + .map(|p| format!("[redacted; len {}]", p.len())), + ) + .field("passphrase_hint", &self.passphrase_hint) + .finish() + } +} + +impl std::fmt::Debug for ImportSingleKeyResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportSingleKeyResponse") + .field("confirmed", &self.confirmed) + .field("cancelled", &self.cancelled) + .finish() + } +} + +/// Modal dialog state. Hold one per wallets screen; reset between +/// invocations by calling [`Self::reset`]. +pub struct ImportSingleKeyDialog { + /// Toggle this from outside to open/close the dialog. + pub open: bool, + network: Network, + wif_input: PasswordInput, + alias_input: String, + /// `Some` once the WIF parses cleanly — drives the preview rendering + /// and the Add button enablement. + derived_address: Option<String>, + /// Set when the latest WIF input fails to parse. `None` while the + /// field is empty so we don't yell at the user on first focus. + error_message: Option<String>, + /// Option C — "Protect with passphrase" toggle. + passphrase_enabled: bool, + passphrase_input: PasswordInput, + confirm_passphrase_input: PasswordInput, + hint_input: String, + /// Inline validation message for the passphrase fields. Reset on + /// every keystroke; populated on confirm click if validation fails. + passphrase_error: Option<String>, +} + +impl ImportSingleKeyDialog { + /// Build a fresh dialog scoped to `network`. The dialog only derives + /// the address preview locally; the canonical import path still uses + /// the network configured on the active `WalletBackend`. + pub fn new(network: Network) -> Self { + Self { + open: false, + network, + wif_input: PasswordInput::new() + .with_hint_text("Paste your private key (WIF)") + .with_monospace(), + alias_input: String::new(), + derived_address: None, + error_message: None, + passphrase_enabled: false, + passphrase_input: PasswordInput::new().with_hint_text("Passphrase"), + confirm_passphrase_input: PasswordInput::new().with_hint_text("Confirm passphrase"), + hint_input: String::new(), + passphrase_error: None, + } + } + + /// Switch the network the preview is derived against. Call this on + /// network change so the dialog never previews a mainnet address for + /// a testnet wallet (or vice versa). + pub fn set_network(&mut self, network: Network) { + if self.network != network { + self.network = network; + self.recompute_preview(); + } + } + + /// Drop all transient state. Called by the parent after a successful + /// import or an explicit dismiss so the next open starts clean. + pub fn reset(&mut self) { + self.wif_input.clear(); + self.alias_input.clear(); + self.derived_address = None; + self.error_message = None; + self.passphrase_enabled = false; + self.passphrase_input.clear(); + self.confirm_passphrase_input.clear(); + self.hint_input.clear(); + self.passphrase_error = None; + } + + /// Render the dialog as an egui modal window. Returns the per-frame + /// response describing whether the user confirmed or cancelled. + pub fn show(&mut self, ctx: &Context) -> ImportSingleKeyResponse { + let mut response = ImportSingleKeyResponse::default(); + if !self.open { + return response; + } + + let mut open_flag = self.open; + egui::Window::new("Import private key (advanced)") + .collapsible(false) + .resizable(false) + .open(&mut open_flag) + .show(ctx, |ui| { + self.body(ui, &mut response); + }); + + // Window close icon ('x') flips `open_flag` to false — treat that + // the same as the Cancel button so the parent learns to dismiss. + if !open_flag && self.open { + response.cancelled = true; + } + self.open = open_flag; + response + } + + /// Body of the dialog, factored out so the kittest harness can drive + /// the layout directly without a window wrapper. + pub fn show_in_ui(&mut self, ui: &mut Ui) -> ImportSingleKeyResponse { + let mut response = ImportSingleKeyResponse::default(); + self.body(ui, &mut response); + response + } + + fn body(&mut self, ui: &mut Ui, response: &mut ImportSingleKeyResponse) { + let dark_mode = ui.style().visuals.dark_mode; + let text_color = DashColors::text_primary(dark_mode); + + ui.label( + RichText::new( + "Paste a WIF-encoded private key. The key is stored encrypted on this device.", + ) + .color(text_color), + ); + ui.add_space(8.0); + + ui.label("Private key (WIF)"); + let wif_response = self.wif_input.show(ui); + if wif_response.changed { + self.recompute_preview(); + } + ui.add_space(8.0); + + if let Some(err) = &self.error_message { + ui.colored_label(DashColors::VALIDATION_WARNING, err); + ui.add_space(8.0); + } + + if let Some(addr) = &self.derived_address { + ui.horizontal_wrapped(|ui| { + ui.label("Derived address:"); + ui.label(RichText::new(addr).monospace().color(text_color)); + }); + ui.label( + RichText::new(format!( + "This is a {network} address.", + network = network_label(self.network) + )) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + } + + ui.label("Nickname (optional)"); + ui.add( + egui::TextEdit::singleline(&mut self.alias_input) + .hint_text("Imported key") + .char_limit(ALIAS_MAX_CHARS), + ); + ui.add_space(12.0); + + // Option C — passphrase opt-in. + ui.checkbox(&mut self.passphrase_enabled, "Protect with passphrase"); + if self.passphrase_enabled { + ui.add_space(4.0); + ui.label("Passphrase"); + let pp = self.passphrase_input.show(ui); + if pp.changed { + self.passphrase_error = None; + } + ui.label("Confirm passphrase"); + let cp = self.confirm_passphrase_input.show(ui); + if cp.changed { + self.passphrase_error = None; + } + ui.label("Hint (optional)"); + ui.add( + egui::TextEdit::singleline(&mut self.hint_input) + .hint_text("Shown next to the unlock prompt") + .char_limit(ALIAS_MAX_CHARS), + ); + if let Some(err) = &self.passphrase_error { + ui.colored_label(DashColors::VALIDATION_WARNING, err); + } + ui.add_space(8.0); + } + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + response.cancelled = true; + } + + let can_confirm = self.derived_address.is_some(); + let confirm_btn = egui::Button::new(RichText::new("Add to wallets").strong()); + if ui.add_enabled(can_confirm, confirm_btn).clicked() + && let Some(addr) = self.derived_address.clone() + { + let (passphrase, hint) = if self.passphrase_enabled { + if let Err(err) = validate_single_key_passphrase( + self.passphrase_input.text(), + self.confirm_passphrase_input.text(), + ) { + self.passphrase_error = Some(err.to_string()); + return; + } + let trimmed_hint = self.hint_input.trim().to_string(); + let hint = (!trimmed_hint.is_empty()).then_some(trimmed_hint); + ( + Some(Zeroizing::new(self.passphrase_input.text().to_string())), + hint, + ) + } else { + (None, None) + }; + let alias = self.alias_input.trim().to_string(); + response.confirmed = Some(ImportSingleKeyRequest { + wif: Zeroizing::new(self.wif_input.text().to_string()), + alias: (!alias.is_empty()).then_some(alias), + address_preview: addr, + passphrase, + passphrase_hint: hint, + }); + } + }); + } + + /// Test-only seam: write the given string into the WIF input and + /// recompute the preview so the kittest harness can drive the dialog + /// without simulating keystrokes (which `password()`-flagged TextEdits + /// are awkward to script). Not exposed for production callers. + #[doc(hidden)] + pub fn force_input_for_test(&mut self, wif: String) { + self.wif_input.set_text(wif); + self.recompute_preview(); + } + + fn recompute_preview(&mut self) { + let raw = self.wif_input.text().trim(); + if raw.is_empty() { + self.derived_address = None; + self.error_message = None; + return; + } + match validate_wif(raw, self.network) { + Ok(address) => { + self.derived_address = Some(address); + self.error_message = None; + } + Err(e) => { + self.derived_address = None; + self.error_message = Some( + TaskError::InvalidWif { + source: Box::new(e), + } + .to_string(), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Testnet WIF pinned to the same value the wallet-backend unit test + /// uses (`src/wallet_backend/single_key.rs::known_wif`). Keeping the + /// dialog test in sync with the backend avoids a third magic string. + fn known_testnet_wif() -> &'static str { + "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN8rFTv2sfUK" + } + + /// F92 — the hand-written `Debug` must redact `wif` and `passphrase`, + /// in every representation. We assert the secret text is absent and that + /// neither its hex nor its decimal-byte-array form leaks. + #[test] + fn debug_redacts_wif_and_passphrase_no_leak() { + let wif = known_testnet_wif(); + let passphrase = "correct-horse-battery-staple"; + let request = ImportSingleKeyRequest { + wif: Zeroizing::new(wif.to_string()), + alias: Some("primary".into()), + address_preview: "yPreviewAddr".into(), + passphrase: Some(Zeroizing::new(passphrase.to_string())), + passphrase_hint: Some("the usual".into()), + }; + + // Cover the request directly and nested in the response. + let response = ImportSingleKeyResponse { + confirmed: Some(request.clone()), + cancelled: false, + }; + let dbgs = [format!("{request:?}"), format!("{response:?}")]; + + let wif_hex = hex::encode(wif.as_bytes()); + let pp_hex = hex::encode(passphrase.as_bytes()); + let wif_decimal = format!("{:?}", wif.as_bytes()); // decimal byte-array form + let pp_decimal = format!("{:?}", passphrase.as_bytes()); + + for dbg in &dbgs { + assert!(!dbg.contains(wif), "debug leaked the WIF text: {dbg}"); + assert!( + !dbg.contains(passphrase), + "debug leaked the passphrase text: {dbg}" + ); + assert!(!dbg.contains(&wif_hex), "debug leaked the WIF hex: {dbg}"); + assert!( + !dbg.contains(&pp_hex), + "debug leaked the passphrase hex: {dbg}" + ); + assert!( + !dbg.contains(&wif_decimal), + "debug leaked the WIF byte array: {dbg}" + ); + assert!( + !dbg.contains(&pp_decimal), + "debug leaked the passphrase byte array: {dbg}" + ); + assert!( + dbg.contains("[redacted"), + "debug must mark redaction: {dbg}" + ); + } + // Non-secret fields are still visible for diagnostics. + assert!(dbgs[0].contains("yPreviewAddr")); + assert!(dbgs[0].contains("primary")); + } + + /// TC-SK-005 (typed-error path): an invalid WIF surfaces the inline + /// error string the dialog shows next to the input. No derived + /// address; the confirm button reads "disabled" by virtue of + /// `derived_address == None`. + #[test] + fn invalid_wif_sets_inline_error_no_address() { + let mut dialog = ImportSingleKeyDialog::new(Network::Testnet); + dialog.wif_input.set_text("not-a-valid-wif".to_string()); + dialog.recompute_preview(); + assert!(dialog.derived_address.is_none()); + assert_eq!( + dialog.error_message.as_deref(), + Some( + "This does not look like a valid private key. Check the characters and try again." + ) + ); + } + + /// TC-SK-004 (preview half): a valid testnet WIF yields a P2PKH + /// address and clears any prior error. Dash testnet P2PKH addresses + /// start with `y` (script-version 140) — defensive against future + /// network mis-routing. + #[test] + fn valid_wif_produces_testnet_p2pkh_preview() { + let mut dialog = ImportSingleKeyDialog::new(Network::Testnet); + dialog.wif_input.set_text(known_testnet_wif().to_string()); + dialog.recompute_preview(); + let addr = dialog.derived_address.as_deref().expect("address derived"); + assert!(dialog.error_message.is_none()); + assert!( + addr.starts_with('y'), + "Dash testnet P2PKH addresses start with `y`; got {addr}" + ); + } + + /// Switching networks recomputes the preview so a paste-and-switch + /// flow can't show a mainnet address while the active wallet is on + /// testnet. + #[test] + fn network_switch_recomputes_preview() { + let mut dialog = ImportSingleKeyDialog::new(Network::Testnet); + dialog.wif_input.set_text(known_testnet_wif().to_string()); + dialog.recompute_preview(); + let testnet_addr = dialog.derived_address.clone().expect("testnet addr"); + + dialog.set_network(Network::Mainnet); + let mainnet_addr = dialog.derived_address.clone().expect("mainnet addr"); + assert_ne!(testnet_addr, mainnet_addr); + assert!( + mainnet_addr.starts_with('X'), + "mainnet P2PKH addresses use `X` prefix; got {mainnet_addr}" + ); + } + + /// Clearing the input wipes the preview and the error so the next + /// paste starts from a clean slate. + #[test] + fn empty_input_clears_preview_and_error() { + let mut dialog = ImportSingleKeyDialog::new(Network::Testnet); + dialog.wif_input.set_text("not-a-valid-wif".to_string()); + dialog.recompute_preview(); + assert!(dialog.error_message.is_some()); + + dialog.wif_input.clear(); + dialog.recompute_preview(); + assert!(dialog.derived_address.is_none()); + assert!(dialog.error_message.is_none()); + } + + /// Reset blanks every transient field — used by the parent screen + /// after a successful import so the next open starts clean. + #[test] + fn reset_clears_every_field() { + let mut dialog = ImportSingleKeyDialog::new(Network::Testnet); + dialog.wif_input.set_text(known_testnet_wif().to_string()); + dialog.alias_input = "primary".to_string(); + dialog.recompute_preview(); + assert!(dialog.derived_address.is_some()); + + dialog.reset(); + assert_eq!(dialog.wif_input.text(), ""); + assert!(dialog.alias_input.is_empty()); + assert!(dialog.derived_address.is_none()); + assert!(dialog.error_message.is_none()); + } +} diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index c021241ea..8e184a619 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -1,12 +1,10 @@ -pub mod account_summary; pub mod add_new_wallet_screen; pub mod asset_lock_detail_screen; pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; +pub mod import_single_key; +pub mod restore_single_key; pub mod send_screen; -pub mod shield_screen; -pub mod shielded_send_screen; pub mod shielded_tab; pub mod single_key_send_screen; -pub mod unshield_credits_screen; pub mod wallets_screen; diff --git a/src/ui/wallets/restore_single_key.rs b/src/ui/wallets/restore_single_key.rs new file mode 100644 index 000000000..e0587a799 --- /dev/null +++ b/src/ui/wallets/restore_single_key.rs @@ -0,0 +1,309 @@ +//! T-SK-03 — "Restore a password-protected imported key" dialog. +//! +//! After the platform-wallet migration, single-key rows that were saved +//! with a per-key password are preserved but invisible: they are still +//! encrypted under the user's OLD password, which the migration could not +//! supply. This dialog takes that password, hands it to +//! [`restore_protected_single_key`](crate::backend_task::migration::single_key_restore::restore_protected_single_key), +//! and re-protects the recovered key in the modern vault. +//! +//! The dialog is a self-contained component: the parent sets the target +//! key, flips `open = true`, and reads +//! [`RestoreSingleKeyResponse::confirmed`] on the frame the user submits. +//! The parent owns the actual restore call and the post-restore refresh. + +use eframe::egui::{self, Context, RichText, Ui}; +use zeroize::Zeroizing; + +use crate::backend_task::migration::single_key_restore::PendingProtectedRestore; +use crate::model::wallet::passphrase::validate_single_key_passphrase; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::theme::DashColors; + +/// Maximum hint length, matching the import dialog. +const HINT_MAX_CHARS: usize = 64; + +/// Per-frame outcome of [`RestoreSingleKeyDialog::show`]. +/// +/// `Debug` is hand-written so the embedded secret material never leaks. +#[derive(Default, Clone)] +pub struct RestoreSingleKeyResponse { + /// `Some` only on the frame the user submits valid inputs. + pub confirmed: Option<RestoreSingleKeyRequest>, + /// `true` if the user dismissed the dialog this frame. + pub cancelled: bool, +} + +/// Request emitted when the user submits the restore form. Holds the old +/// password and the new passphrase, both wrapped in [`Zeroizing`] so the +/// copies wipe on drop. `Debug` is hand-written to redact both — deriving +/// it would dump the secrets into logs or panic backtraces. +#[derive(Clone)] +pub struct RestoreSingleKeyRequest { + /// Address of the protected key being restored. + pub address: String, + /// The OLD per-key password that decrypts the legacy blob. + pub legacy_password: Zeroizing<String>, + /// The NEW passphrase to re-protect the key in the modern vault. + /// `None` ⇒ store without an extra passphrase layer (vault-scoped). + pub new_passphrase: Option<Zeroizing<String>>, + /// Optional hint for the new passphrase. + pub new_hint: Option<String>, +} + +impl std::fmt::Debug for RestoreSingleKeyRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RestoreSingleKeyRequest") + .field("address", &self.address) + .field( + "legacy_password", + &format_args!("[redacted; len {}]", self.legacy_password.len()), + ) + .field( + "new_passphrase", + &self + .new_passphrase + .as_ref() + .map(|p| format!("[redacted; len {}]", p.len())), + ) + .field("new_hint", &self.new_hint) + .finish() + } +} + +impl std::fmt::Debug for RestoreSingleKeyResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RestoreSingleKeyResponse") + .field("confirmed", &self.confirmed) + .field("cancelled", &self.cancelled) + .finish() + } +} + +/// Modal dialog state. Hold one per wallets screen. +pub struct RestoreSingleKeyDialog { + /// Toggle this from outside to open/close the dialog. + pub open: bool, + /// The protected key the dialog is currently restoring. `None` until + /// the parent points it at a key via [`Self::set_target`]. + target: Option<PendingProtectedRestore>, + legacy_password_input: PasswordInput, + protect_again: bool, + new_passphrase_input: PasswordInput, + confirm_passphrase_input: PasswordInput, + hint_input: String, + /// Inline validation message for the new-passphrase fields. + passphrase_error: Option<String>, +} + +impl Default for RestoreSingleKeyDialog { + fn default() -> Self { + Self::new() + } +} + +impl RestoreSingleKeyDialog { + pub fn new() -> Self { + Self { + open: false, + target: None, + legacy_password_input: PasswordInput::new().with_hint_text("Your old password"), + protect_again: true, + new_passphrase_input: PasswordInput::new().with_hint_text("New passphrase"), + confirm_passphrase_input: PasswordInput::new().with_hint_text("Confirm new passphrase"), + hint_input: String::new(), + passphrase_error: None, + } + } + + /// Point the dialog at a specific protected key and open it. + pub fn set_target(&mut self, target: PendingProtectedRestore) { + self.target = Some(target); + self.open = true; + } + + /// Drop all transient state. Called by the parent after a successful + /// restore or an explicit dismiss. + pub fn reset(&mut self) { + self.target = None; + self.legacy_password_input.clear(); + self.protect_again = true; + self.new_passphrase_input.clear(); + self.confirm_passphrase_input.clear(); + self.hint_input.clear(); + self.passphrase_error = None; + } + + /// Render the dialog as an egui modal window. Returns the per-frame + /// response describing whether the user confirmed or cancelled. + pub fn show(&mut self, ctx: &Context) -> RestoreSingleKeyResponse { + let mut response = RestoreSingleKeyResponse::default(); + if !self.open || self.target.is_none() { + return response; + } + let mut open_flag = self.open; + egui::Window::new("Restore a protected imported key") + .collapsible(false) + .resizable(false) + .open(&mut open_flag) + .show(ctx, |ui| { + self.body(ui, &mut response); + }); + if !open_flag && self.open { + response.cancelled = true; + } + self.open = open_flag; + response + } + + /// Body of the dialog without the window wrapper, so the kittest + /// harness can drive the layout directly. Returns the per-frame + /// response. + pub fn show_in_ui(&mut self, ui: &mut Ui) -> RestoreSingleKeyResponse { + let mut response = RestoreSingleKeyResponse::default(); + self.body(ui, &mut response); + response + } + + fn body(&mut self, ui: &mut Ui, response: &mut RestoreSingleKeyResponse) { + let dark_mode = ui.style().visuals.dark_mode; + let text_color = DashColors::text_primary(dark_mode); + + let Some(target) = self.target.clone() else { + return; + }; + + ui.label( + RichText::new( + "This imported key was protected with a password before the last update. \ + Enter that password to restore it.", + ) + .color(text_color), + ); + ui.add_space(8.0); + + ui.horizontal_wrapped(|ui| { + ui.label("Address:"); + ui.label(RichText::new(&target.address).monospace().color(text_color)); + }); + if let Some(alias) = &target.alias { + ui.horizontal_wrapped(|ui| { + ui.label("Name:"); + ui.label(RichText::new(alias).color(text_color)); + }); + } + ui.add_space(8.0); + + ui.label("Old password"); + self.legacy_password_input.show(ui); + ui.add_space(8.0); + + ui.checkbox(&mut self.protect_again, "Protect with a new passphrase"); + if self.protect_again { + ui.add_space(4.0); + ui.label("New passphrase"); + let pp = self.new_passphrase_input.show(ui); + if pp.changed { + self.passphrase_error = None; + } + ui.label("Confirm new passphrase"); + let cp = self.confirm_passphrase_input.show(ui); + if cp.changed { + self.passphrase_error = None; + } + ui.label("Hint (optional)"); + ui.add( + egui::TextEdit::singleline(&mut self.hint_input) + .hint_text("Shown next to the unlock prompt") + .char_limit(HINT_MAX_CHARS), + ); + if let Some(err) = &self.passphrase_error { + ui.colored_label(DashColors::VALIDATION_WARNING, err); + } + ui.add_space(8.0); + } + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + response.cancelled = true; + } + let can_confirm = !self.legacy_password_input.is_empty(); + let confirm_btn = egui::Button::new(RichText::new("Restore key").strong()); + if ui.add_enabled(can_confirm, confirm_btn).clicked() { + let new_passphrase = if self.protect_again { + let pp = Zeroizing::new(self.new_passphrase_input.text().to_string()); + if let Err(err) = + validate_single_key_passphrase(&pp, self.confirm_passphrase_input.text()) + { + self.passphrase_error = Some(err.to_string()); + return; + } + Some(pp) + } else { + None + }; + let hint = { + let trimmed = self.hint_input.trim().to_string(); + (self.protect_again && !trimmed.is_empty()).then_some(trimmed) + }; + response.confirmed = Some(RestoreSingleKeyRequest { + address: target.address.clone(), + legacy_password: Zeroizing::new(self.legacy_password_input.text().to_string()), + new_passphrase, + new_hint: hint, + }); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::dashcore::Network; + + fn sample_target() -> PendingProtectedRestore { + PendingProtectedRestore { + address: "yProtectedAddr".into(), + alias: Some("savings".into()), + network: Network::Testnet, + } + } + + /// The hand-written `Debug` must redact both secrets and never leak + /// them in any representation. + #[test] + fn debug_redacts_old_password_and_new_passphrase() { + let legacy = "old-legacy-password"; + let new_pp = "brand-new-passphrase"; + let request = RestoreSingleKeyRequest { + address: "yProtectedAddr".into(), + legacy_password: Zeroizing::new(legacy.to_string()), + new_passphrase: Some(Zeroizing::new(new_pp.to_string())), + new_hint: Some("the usual".into()), + }; + let response = RestoreSingleKeyResponse { + confirmed: Some(request.clone()), + cancelled: false, + }; + for dbg in [format!("{request:?}"), format!("{response:?}")] { + assert!(!dbg.contains(legacy), "leaked old password: {dbg}"); + assert!(!dbg.contains(new_pp), "leaked new passphrase: {dbg}"); + assert!(dbg.contains("[redacted"), "must mark redaction: {dbg}"); + } + // Non-secret fields stay visible for diagnostics. + assert!(format!("{request:?}").contains("yProtectedAddr")); + } + + /// `set_target` opens the dialog at the given key; `reset` clears it. + #[test] + fn set_target_opens_and_reset_clears() { + let mut dialog = RestoreSingleKeyDialog::new(); + assert!(!dialog.open); + dialog.set_target(sample_target()); + assert!(dialog.open); + assert!(dialog.target.is_some()); + dialog.reset(); + assert!(dialog.target.is_none()); + } +} diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index a21f904dc..eea28a33b 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -6,7 +6,13 @@ use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; -use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::fee_estimation::{ + MAX_PLATFORM_INPUTS, PlatformFeeEstimator, allocate_platform_addresses, + allocate_platform_addresses_with_fee, core_max_send_amount_duffs, core_max_send_reserve_duffs, + estimate_address_funding_fee_from_transition, estimate_platform_fee, + estimate_withdrawal_fee_from_transition, format_credits_as_dash, format_duffs_as_dash, + shield_from_balance_fee_headroom, +}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::address_input::AddressInput; @@ -23,250 +29,15 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; -use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Credits}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::prelude::AddressNonce; -use dash_sdk::dpp::prelude::AssetLockProof; -use dash_sdk::dpp::state_transition::StateTransitionEstimatedFeeValidation; -use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; -use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0; -use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; -use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::v0::AddressFundingFromAssetLockTransitionV0; -use dash_sdk::dpp::withdrawal::Pooling; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; -/// Maximum number of platform address inputs allowed per state transition -const MAX_PLATFORM_INPUTS: usize = 16; - -use crate::model::fee_estimation::PlatformFeeEstimator; - -/// Estimated serialized bytes per input (address + signature/witness data) -const ESTIMATED_BYTES_PER_INPUT: usize = 225; - -/// Calculate the estimated fee for a platform address funds transfer. -/// -/// Uses PlatformFeeEstimator for base costs (input/output fees) plus storage fees. -fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) -> u64 { - let inputs = input_count.max(1); - - // Base fee from Platform's min fee structure - // - 500,000 credits per input (address_funds_transfer_input_cost) - // - 6,000,000 credits per output (address_funds_transfer_output_cost) - let base_fee = estimator.estimate_address_funds_transfer(inputs, 1); - - // Add storage fees for serialized input bytes only - // (outputs don't add significant serialization overhead) - let estimated_bytes = inputs * ESTIMATED_BYTES_PER_INPUT; - let storage_fee = estimator.estimate_storage_based_fee(estimated_bytes, inputs); - - // Total with 20% safety buffer - let total = base_fee.saturating_add(storage_fee); - total.saturating_add(total / 5) -} - -/// Calculate the estimated fee for a Platform address withdrawal using a constructed state transition. -fn estimate_withdrawal_fee_from_transition( - platform_version: &dash_sdk::dpp::version::PlatformVersion, - inputs: &BTreeMap<PlatformAddress, u64>, - output_script: &CoreScript, -) -> u64 { - let inputs_with_nonce: BTreeMap<PlatformAddress, (AddressNonce, Credits)> = inputs - .iter() - .map(|(addr, amount)| (*addr, (0, *amount))) - .collect(); - - let transition = AddressCreditWithdrawalTransition::V0(AddressCreditWithdrawalTransitionV0 { - inputs: inputs_with_nonce, - output: None, - fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], - core_fee_per_byte: 1, - pooling: Pooling::Never, - output_script: output_script.clone(), - user_fee_increase: 0, - input_witnesses: Vec::new(), - }); - - transition - .calculate_min_required_fee(platform_version) - .unwrap_or(0) -} - -/// Calculate the estimated fee for funding a Platform address from an asset lock. -fn estimate_address_funding_fee_from_transition( - platform_version: &dash_sdk::dpp::version::PlatformVersion, - destination: &PlatformAddress, -) -> u64 { - let mut outputs = BTreeMap::new(); - outputs.insert(*destination, None); - - let transition = - AddressFundingFromAssetLockTransition::V0(AddressFundingFromAssetLockTransitionV0 { - asset_lock_proof: AssetLockProof::default(), - inputs: BTreeMap::new(), - outputs, - fee_strategy: vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], - user_fee_increase: 0, - ..Default::default() - }); - - transition - .calculate_min_required_fee(platform_version) - .unwrap_or(0) -} - -/// Result of allocating platform addresses for a transfer. -#[derive(Debug, Clone)] -struct AddressAllocationResult { - /// Map of platform address to amount to transfer from each - inputs: BTreeMap<PlatformAddress, u64>, - /// Index of the fee payer in BTreeMap iteration order - fee_payer_index: u16, - /// Estimated fee for this transaction - estimated_fee: u64, - /// Amount that couldn't be covered (0 if fully covered) - shortfall: u64, - /// Addresses sorted by balance descending (for UI display) - sorted_addresses: Vec<(PlatformAddress, Address, u64)>, -} - -/// Allocates platform addresses for a transfer, using a custom fee calculator. -fn allocate_platform_addresses_with_fee<F>( - addresses: &[(PlatformAddress, Address, u64)], - amount_credits: u64, - destination: Option<&PlatformAddress>, - fee_for_inputs: F, -) -> AddressAllocationResult -where - F: Fn(&BTreeMap<PlatformAddress, u64>) -> u64, -{ - // Filter out the destination address if provided (protocol doesn't allow same address as input and output) - let filtered: Vec<_> = addresses - .iter() - .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) - .cloned() - .collect(); - - // Sort addresses by balance descending so the largest balance is used first - let mut sorted_addresses = filtered; - sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); - - // Early return if no addresses available after filtering - if sorted_addresses.is_empty() { - return AddressAllocationResult { - inputs: BTreeMap::new(), - fee_payer_index: 0, - estimated_fee: fee_for_inputs(&BTreeMap::new()), - shortfall: amount_credits, - sorted_addresses: vec![], - }; - } - - // The highest-balance address (first in sorted order) will pay the fee - let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); - - let mut estimated_fee = fee_for_inputs(&BTreeMap::new()); - let mut inputs: BTreeMap<PlatformAddress, u64> = BTreeMap::new(); - - // Iterate until fee estimate stabilizes (input count affects fee) - for _ in 0..=MAX_PLATFORM_INPUTS { - inputs.clear(); - let mut remaining = amount_credits; - - for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { - if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { - break; - } - let is_fee_payer = idx == 0; - let available = if is_fee_payer { - balance.saturating_sub(estimated_fee) - } else { - *balance - }; - let use_amount = remaining.min(available); - if use_amount > 0 || is_fee_payer { - inputs.insert(*platform_addr, use_amount); - remaining = remaining.saturating_sub(use_amount); - } - } - - let new_fee = fee_for_inputs(&inputs); - if new_fee == estimated_fee { - break; - } - estimated_fee = new_fee; - } - - // Calculate shortfall (amount we couldn't allocate) - let total_allocated: u64 = inputs.values().sum(); - let allocation_shortfall = amount_credits.saturating_sub(total_allocated); - - // Check if fee payer can actually afford the fee from their remaining balance. - let fee_deficit = if let Some(fee_payer) = fee_payer_addr { - let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); - let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); - let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); - estimated_fee.saturating_sub(fee_payer_remaining) - } else { - estimated_fee - }; - - let shortfall = allocation_shortfall.saturating_add(fee_deficit); - - // Find the index of the fee payer in BTreeMap order (required by backend) - let fee_payer_index = fee_payer_addr - .and_then(|payer| { - inputs - .keys() - .enumerate() - .find(|(_, addr)| **addr == payer) - .map(|(idx, _)| idx as u16) - }) - .unwrap_or(0); - - AddressAllocationResult { - inputs, - fee_payer_index, - estimated_fee, - shortfall, - sorted_addresses, - } -} - -/// Allocates platform addresses for a transfer, selecting which addresses to use -/// and how much from each. -/// -/// Algorithm: -/// 1. Filters out the destination address (can't be both input and output) -/// 2. Sorts addresses by balance descending (largest first) -/// 3. The highest-balance address pays the fee -/// 4. Iteratively allocates until fee estimate converges -/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted -/// -/// Returns the allocation result with inputs, fee payer index, and any shortfall. -fn allocate_platform_addresses( - estimator: &PlatformFeeEstimator, - addresses: &[(PlatformAddress, Address, u64)], - amount_credits: u64, - destination: Option<&PlatformAddress>, -) -> AddressAllocationResult { - let max_inputs = addresses - .iter() - .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) - .count() - .min(MAX_PLATFORM_INPUTS); - - allocate_platform_addresses_with_fee(addresses, amount_credits, destination, |_| { - // Keep the legacy behavior: use a worst-case fee based on max possible inputs. - estimate_platform_fee(estimator, max_inputs.max(1)) - }) -} - /// Source selection for sending #[derive(Debug, Clone, PartialEq)] pub enum SourceSelection { @@ -280,6 +51,71 @@ pub enum SourceSelection { Shielded(WalletSeedHash, u64), } +/// Optional preset that opens the send screen pre-configured for one of the +/// shielded flows launched from the Shielded tab. +/// +/// [`SendFlow::General`] is the full free-form send screen (any source, any +/// destination). The other variants lock the source — and, for +/// [`SendFlow::Shield`], the destination — so the screen presents only the +/// controls that flow needs while reusing the unified screen's validation and +/// dispatch. This is how the three former standalone shielded screens are +/// expressed as routes into the one canonical send screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SendFlow { + /// Free-form send: any source, any destination. + #[default] + General, + /// Shield into the wallet's own shielded pool (Core or Platform → Shielded). + Shield, + /// Private transfer within the shielded pool (Shielded → Shielded). + ShieldedSend, + /// Move credits out of the shielded pool (Shielded → Platform or Core). + Unshield, +} + +impl SendFlow { + /// Whether this is a locked shielded preset (anything but `General`). + fn is_preset(self) -> bool { + !matches!(self, SendFlow::General) + } + + /// Heading shown at the top of the send screen for this flow. + fn heading(self) -> &'static str { + match self { + SendFlow::General => "Send Dash", + SendFlow::Shield => "Shield", + SendFlow::ShieldedSend => "Send (Private)", + SendFlow::Unshield => "Unshield Credits", + } + } + + /// One-line description shown under the heading, if any. + fn description(self) -> Option<&'static str> { + match self { + SendFlow::General => None, + SendFlow::Shield => { + Some("Move funds from your wallet or platform balance into the shielded pool.") + } + SendFlow::ShieldedSend => Some("Transfer credits privately within the shielded pool."), + SendFlow::Unshield => Some( + "Move credits from the shielded pool to a platform address or a core DASH address.", + ), + } + } + + /// Destination address kinds accepted by a preset flow that takes a + /// recipient address. Returns `None` for `General` (the caller derives the + /// kinds from the selected source) and for `Shield` (the destination is the + /// wallet's own pool, so no address input is rendered). + fn preset_destination_kinds(self) -> Option<Vec<AddressKind>> { + match self { + SendFlow::General | SendFlow::Shield => None, + SendFlow::ShieldedSend => Some(vec![AddressKind::Shielded]), + SendFlow::Unshield => Some(vec![AddressKind::Platform, AddressKind::Core]), + } + } +} + /// Status of the send operation #[derive(Debug, Clone, PartialEq)] pub enum SendStatus { @@ -347,9 +183,6 @@ pub struct CoreAddressInput { pub struct PlatformAddressInput { /// The platform address pub platform_address: PlatformAddress, - /// The corresponding core address (for lookup/display) - #[allow(dead_code)] - pub core_address: Address, /// Amount to send from this address (as string for input field) pub amount: String, } @@ -366,7 +199,6 @@ pub struct AdvancedOutput { pub struct WalletSendScreen { pub app_context: Arc<AppContext>, pub selected_wallet: Option<Arc<RwLock<Wallet>>>, - #[allow(dead_code)] selected_wallet_seed_hash: Option<WalletSeedHash>, // Unified send fields (simple mode) @@ -389,19 +221,17 @@ pub struct WalletSendScreen { // Identity source fields selected_identity: Option<QualifiedIdentity>, - // Common options - subtract_fee: bool, - // State send_status: SendStatus, send_banner: Option<BannerHandle>, + /// Preset flow this screen was opened for. `General` is the full send + /// screen; the shielded presets lock source/destination for that flow. + flow: SendFlow, + // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, - - /// Queued task to dispatch on next frame (e.g., sync shielded notes after send). - pending_refresh_task: Option<BackendTask>, } impl WalletSendScreen { @@ -426,15 +256,32 @@ impl WalletSendScreen { }], fee_strategy: PlatformFeeStrategy::default(), selected_identity: None, - subtract_fee: false, send_status: SendStatus::NotStarted, send_banner: None, + flow: SendFlow::General, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, - pending_refresh_task: None, } } + /// The preset flow this screen was opened for. + pub fn flow(&self) -> SendFlow { + self.flow + } + + /// Open the screen pre-configured for a shielded [`SendFlow`]. The flow + /// locks the source (and, for [`SendFlow::Shield`], the destination) so the + /// screen shows only the controls that flow needs. + pub fn with_flow(mut self, flow: SendFlow) -> Self { + self.flow = flow; + // For shielded-source presets, seed the source immediately so the first + // frame's amount limits are correct; `sync_flow_state` keeps it fresh. + if flow.is_preset() { + self.selected_source = None; + } + self + } + fn estimate_max_fee_for_platform_send( &self, fee_estimator: &PlatformFeeEstimator, @@ -483,6 +330,23 @@ impl WalletSendScreen { self.validated_destination = None; } + /// Reset all wallet-bound state on a network switch. The old wallet, its + /// seed hash, and any source/destination/amount selection belong to the + /// previous network — leaving the seed hash behind would let a preset flow + /// resurrect a stale source and show the previous network's balance. Source + /// resets to the Core-wallet default so the free-form screen behaves as it + /// does on first open; a preset re-derives its source once a wallet for the + /// new network is selected. + pub(crate) fn reset_for_network_switch(&mut self) { + self.selected_wallet = None; + self.selected_wallet_seed_hash = None; + self.selected_source = Some(SourceSelection::CoreWallet); + self.selected_identity = None; + self.amount = None; + self.amount_input = None; + self.invalidate_address_input(); + } + fn reset_form(&mut self) { self.address_input = None; self.validated_destination = None; @@ -505,13 +369,21 @@ impl WalletSendScreen { self.send_status = SendStatus::WaitingForResult; } - fn format_dash(amount_duffs: u64) -> String { - Amount::dash_from_duffs(amount_duffs).to_string() - } - - fn format_credits(credits: Credits) -> String { - let dash = credits as f64 / 1000.0 / 100_000_000.0; - format!("{:.8} DASH", dash) + /// Renders the fee summary appended to a completed-send message. Shows the + /// settled fee only when the platform reported one; otherwise labels the + /// value as an estimate. + fn format_fee_info(fee_result: &crate::backend_task::FeeResult) -> String { + match fee_result.actual_fee { + Some(actual) => format!( + "\n\nFee: Estimated {} • Actual {}", + format_credits_as_dash(fee_result.estimated_fee), + format_credits_as_dash(actual) + ), + None => format!( + "\n\nFee: Estimated {}", + format_credits_as_dash(fee_result.estimated_fee) + ), + } } fn parse_amount_to_duffs(input: &str) -> Result<u64, String> { @@ -624,30 +496,12 @@ impl WalletSendScreen { .collect() } - /// Get shielded pool balance for the selected wallet (if initialized). + /// Get shielded pool balance for the selected wallet from the frame-safe + /// push snapshot (no lock-in-frame-loop, no `block_in_place`). Returns + /// `None` when there is no positive shielded balance to spend. fn get_shielded_balance(&self) -> Option<(WalletSeedHash, u64)> { let seed_hash = self.selected_wallet_seed_hash?; - // Try in-memory state first (most accurate, reflects optimistic spend marks) - let Ok(states) = self.app_context.shielded_states.lock() else { - return None; - }; - if let Some(state) = states.get(&seed_hash) { - let balance = state.shielded_balance; - return if balance > 0 { - Some((seed_hash, balance)) - } else { - None - }; - } - drop(states); - // Fall back to database balance (works even if shielded state is temporarily - // removed during an async operation, or if the Shielded tab was never visited) - let network_str = self.app_context.network.to_string(); - let balance = self - .app_context - .db - .get_shielded_balance(&seed_hash, &network_str) - .ok()?; + let balance = self.app_context.shielded_balance_credits(&seed_hash); if balance > 0 { Some((seed_hash, balance)) } else { @@ -655,12 +509,23 @@ impl WalletSendScreen { } } - /// Get Core wallet balance + /// Get the Core wallet's **spendable** balance from the display-only + /// `WalletBackend` snapshot (P4a). DISPLAY-ONLY — this number never feeds + /// coin selection itself, but it must mirror what coin selection can spend + /// so the amount checks here agree with the actual send. `spendable()` is + /// the upstream `CoinSelector`'s set (confirmed + unconfirmed); reading + /// `confirmed` alone would understate IS-locked funds that have not yet been + /// flagged locally (they sit in `unconfirmed`), making "Max" exceed this + /// check and the validations reject sends coin selection would accept. fn get_core_balance(&self) -> u64 { self.selected_wallet .as_ref() .and_then(|w| w.read().ok()) - .map(|w| w.confirmed_balance_duffs()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) .unwrap_or(0) } @@ -692,8 +557,15 @@ impl WalletSendScreen { let Ok(wallet) = wallet_arc.read() else { return vec![]; }; + let seed_hash = wallet.seed_hash(); + drop(wallet); - let mut addresses = wallet.utxos_by_address(); + let mut addresses: Vec<(Address, u64)> = self + .app_context + .snapshot_address_balances(&seed_hash) + .into_iter() + .filter(|(_, balance)| *balance > 0) + .collect(); // Sort by balance descending for better UX addresses.sort_by(|a, b| b.1.cmp(&a.1)); addresses @@ -877,8 +749,8 @@ impl WalletSendScreen { if amount_duffs > balance { return Err(format!( "Insufficient balance. Need {} but have {}", - Self::format_dash(amount_duffs), - Self::format_dash(balance) + format_duffs_as_dash(amount_duffs), + format_duffs_as_dash(balance) )); } @@ -900,8 +772,6 @@ impl WalletSendScreen { wallet, request: WalletPaymentRequest { recipients: vec![recipient], - subtract_fee_from_amount: self.subtract_fee, - memo: None, override_fee: None, }, }, @@ -931,8 +801,8 @@ impl WalletSendScreen { if required > balance { return Err(format!( "Insufficient balance. Need {} (including fee) but have {}", - Self::format_dash(required), - Self::format_dash(balance) + format_duffs_as_dash(required), + format_duffs_as_dash(balance) )); } @@ -972,16 +842,16 @@ impl WalletSendScreen { tracing::debug!( "Platform transfer: {} requested, {} total balance across {} addresses", - Self::format_credits(amount_credits), - Self::format_credits(total_balance), + format_credits_as_dash(amount_credits), + format_credits_as_dash(total_balance), addresses.len() ); if amount_credits > total_balance { return Err(format!( "Insufficient balance. Need {} but have {}", - Self::format_credits(amount_credits), - Self::format_credits(total_balance) + format_credits_as_dash(amount_credits), + format_credits_as_dash(total_balance) )); } @@ -1012,8 +882,8 @@ impl WalletSendScreen { if amount_credits > available_balance { return Err(format!( "Insufficient balance from other addresses. Need {} but have {} (excluding destination address)", - Self::format_credits(amount_credits), - Self::format_credits(available_balance) + format_credits_as_dash(amount_credits), + format_credits_as_dash(available_balance) )); } @@ -1041,14 +911,14 @@ impl WalletSendScreen { • Estimated fee: {} (for {} inputs)\n\ • Shortfall: {}\n\n\ Try reducing the amount slightly to account for fees.", - Self::format_credits(amount_credits), - Self::format_credits(max_sendable), + format_credits_as_dash(amount_credits), + format_credits_as_dash(max_sendable), addresses_available, - Self::format_credits(max_balance), + format_credits_as_dash(max_balance), MAX_PLATFORM_INPUTS, - Self::format_credits(allocation.estimated_fee), + format_credits_as_dash(allocation.estimated_fee), allocation.inputs.len(), - Self::format_credits(allocation.shortfall) + format_credits_as_dash(allocation.shortfall) )); } @@ -1060,9 +930,9 @@ impl WalletSendScreen { tracing::debug!( "Platform transfer: {} inputs totaling {}, output {}, fee {} (payer idx {})", allocation.inputs.len(), - Self::format_credits(total_input), - Self::format_credits(amount_credits), - Self::format_credits(allocation.estimated_fee), + format_credits_as_dash(total_input), + format_credits_as_dash(amount_credits), + format_credits_as_dash(allocation.estimated_fee), allocation.fee_payer_index ); @@ -1098,16 +968,16 @@ impl WalletSendScreen { tracing::debug!( "Platform withdrawal: {} requested, {} total balance across {} addresses", - Self::format_credits(amount_credits), - Self::format_credits(total_balance), + format_credits_as_dash(amount_credits), + format_credits_as_dash(total_balance), addresses.len() ); if amount_credits > total_balance { return Err(format!( "Insufficient balance. Need {} but have {}", - Self::format_credits(amount_credits), - Self::format_credits(total_balance) + format_credits_as_dash(amount_credits), + format_credits_as_dash(total_balance) )); } @@ -1158,14 +1028,14 @@ impl WalletSendScreen { • Estimated fee: {} (for {} inputs)\n\ • Shortfall: {}\n\n\ Try reducing the amount slightly to account for fees.", - Self::format_credits(amount_credits), - Self::format_credits(max_sendable), + format_credits_as_dash(amount_credits), + format_credits_as_dash(max_sendable), addresses_available, - Self::format_credits(max_balance), + format_credits_as_dash(max_balance), MAX_PLATFORM_INPUTS, - Self::format_credits(allocation.estimated_fee), + format_credits_as_dash(allocation.estimated_fee), allocation.inputs.len(), - Self::format_credits(allocation.shortfall) + format_credits_as_dash(allocation.shortfall) )); } @@ -1174,9 +1044,9 @@ impl WalletSendScreen { tracing::debug!( "Platform withdrawal: {} inputs totaling {}, withdraw {}, fee {} (payer idx {})", allocation.inputs.len(), - Self::format_credits(total_input), - Self::format_credits(amount_credits), - Self::format_credits(allocation.estimated_fee), + format_credits_as_dash(total_input), + format_credits_as_dash(amount_credits), + format_credits_as_dash(allocation.estimated_fee), allocation.fee_payer_index ); @@ -1208,13 +1078,13 @@ impl WalletSendScreen { }; if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error).disable_auto_dismiss(); } self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(10.0); ui.colored_label( DashColors::warning_color(dark_mode), @@ -1318,8 +1188,165 @@ impl WalletSendScreen { action } + /// Keep a preset flow's source (and, for `Shield`, the sentinel own-pool + /// destination) in sync with current balances. Idempotent — safe to call + /// every frame. Reads only the frame-safe push snapshot. + fn sync_flow_state(&mut self) { + let Some(seed_hash) = self.selected_wallet_seed_hash else { + return; + }; + match self.flow { + SendFlow::ShieldedSend | SendFlow::Unshield => { + // Source is always the wallet's shielded pool; refresh the + // captured balance so the amount cap tracks spends. + let balance = self.app_context.shielded_balance_credits(&seed_hash); + self.selected_source = Some(SourceSelection::Shielded(seed_hash, balance)); + } + SendFlow::Shield => { + // Default to shielding the whole Core wallet; the user may switch + // to the platform balance via the toggle. + if !matches!( + self.selected_source, + Some(SourceSelection::CoreWallet | SourceSelection::PlatformAddresses(_)) + ) { + self.selected_source = Some(SourceSelection::CoreWallet); + } + // Shielding always targets the wallet's own pool and the dispatch + // ignores the destination address, so satisfy the router with a + // sentinel shielded destination instead of rendering an input. + if !matches!( + self.validated_destination, + Some(ValidatedAddress::Shielded(_)) + ) { + self.validated_destination = Some(ValidatedAddress::Shielded(String::new())); + } + } + SendFlow::General => {} + } + } + + /// Render the source controls for a preset flow: a Core/Platform toggle for + /// `Shield`, or a read-only shielded-balance line for the spend presets. + fn render_flow_source(&mut self, ui: &mut Ui) { + let dark_mode = ui.style().visuals.dark_mode; + match self.flow { + SendFlow::Shield => { + let has_platform = !self.get_platform_addresses().is_empty(); + ui.label( + RichText::new("Shield from") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + let mut is_core = + matches!(self.selected_source, Some(SourceSelection::CoreWallet)); + if ui + .radio_value(&mut is_core, true, "Core wallet (whole balance)") + .changed() + && is_core + { + self.selected_source = Some(SourceSelection::CoreWallet); + self.amount = None; + self.amount_input = None; + } + ui.add_enabled_ui(has_platform, |ui| { + let mut is_platform = matches!( + self.selected_source, + Some(SourceSelection::PlatformAddresses(_)) + ); + if ui + .radio_value(&mut is_platform, true, "Platform balance") + .changed() + && is_platform + { + let addresses: Vec<_> = self + .get_platform_addresses() + .into_iter() + .map(|(core_addr, platform_addr, balance)| { + (platform_addr, core_addr, balance) + }) + .collect(); + self.selected_source = + Some(SourceSelection::PlatformAddresses(addresses)); + self.amount = None; + self.amount_input = None; + } + }); + }); + ui.add_space(4.0); + let balance_label = match &self.selected_source { + Some(SourceSelection::PlatformAddresses(addresses)) => { + let total: u64 = addresses.iter().map(|(_, _, b)| *b).sum(); + format!( + "Available platform balance: {}", + format_credits_as_dash(total) + ) + } + _ => format!( + "Available core wallet balance: {}", + format_duffs_as_dash(self.get_core_balance()) + ), + }; + ui.label(RichText::new(balance_label).color(DashColors::success_color(dark_mode))); + } + SendFlow::ShieldedSend | SendFlow::Unshield => { + let balance = match &self.selected_source { + Some(SourceSelection::Shielded(_, balance)) => *balance, + _ => 0, + }; + ui.label( + RichText::new(format!( + "Available shielded balance: {}", + format_credits_as_dash(balance) + )) + .color(DashColors::success_color(dark_mode)), + ); + } + SendFlow::General => {} + } + } + + /// Render a preset shielded flow (Shield / Send Private / Unshield): a + /// locked source, a flow-scoped destination (or none for Shield), the shared + /// amount input, and the shared send button. + fn render_flow_send(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + self.render_wallet_info(ui); + if !self.render_unlock_gate(ui) { + return AppAction::None; + } + ui.add_space(10.0); + + self.sync_flow_state(); + self.render_flow_source(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Destination (Shield targets the own pool and renders no input). + if let Some(kinds) = self.flow.preset_destination_kinds() { + self.render_destination_input_with_kinds(ui, &kinds); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + self.render_amount_input(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + action |= self.render_send_button(ui); + action + } + fn render_wallet_info(&self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(wallet_arc) = &self.selected_wallet && let Ok(wallet) = wallet_arc.read() @@ -1364,13 +1391,8 @@ impl WalletSendScreen { .value(); let recipient = self.destination_address_string(); - let recipient_bytes = if let Ok((addr, _)) = - dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&recipient) - { - addr.to_raw_bytes().to_vec() - } else { - return Err("Invalid shielded address".to_string()); - }; + let recipient_bytes = crate::model::address::parse_shielded_recipient(&recipient) + .ok_or_else(|| "Invalid shielded address".to_string())?; self.send_status = SendStatus::WaitingForResult; Ok(AppAction::BackendTask( @@ -1439,8 +1461,8 @@ impl WalletSendScreen { if amount_duffs > balance { return Err(format!( "Insufficient balance. Need {} but have {}", - Self::format_dash(amount_duffs), - Self::format_dash(balance) + format_duffs_as_dash(amount_duffs), + format_duffs_as_dash(balance) )); } @@ -1449,7 +1471,6 @@ impl WalletSendScreen { crate::backend_task::shielded::ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs, - source_address: None, }, ))) } @@ -1469,8 +1490,8 @@ impl WalletSendScreen { if amount_duffs > balance { return Err(format!( "Insufficient balance. Need {} but have {}", - Self::format_dash(amount_duffs), - Self::format_dash(balance) + format_duffs_as_dash(amount_duffs), + format_duffs_as_dash(balance) )); } @@ -1512,11 +1533,12 @@ impl WalletSendScreen { ))) } - /// Shield credits from Platform address(es) to shielded pool (Platform -> Shielded). + /// Shield credits from the wallet's Platform balance into the shielded pool + /// (Platform -> Shielded). /// - /// When the requested amount exceeds a single address balance, multiple - /// addresses are used — one `ShieldCredits` task per address, dispatched - /// sequentially. + /// The upstream coordinator selects the funding platform addresses, so DET + /// dispatches a single `ShieldFromBalance` task with the total amount rather + /// than one task per address. fn send_platform_to_shielded( &mut self, seed_hash: WalletSeedHash, @@ -1538,11 +1560,9 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Sort addresses by balance descending (greedy allocation) - let mut sorted_addrs = addresses; - sorted_addrs.sort_by(|a, b| b.2.cmp(&a.2)); - - let total_available: u64 = sorted_addrs.iter().map(|(_, _, b)| b).sum(); + // Pre-flight: the total platform balance must cover the amount. The + // upstream coordinator handles per-address selection and fees. + let total_available: u64 = addresses.iter().map(|(_, _, b)| b).sum(); if amount_credits > total_available { return Err(format!( "Insufficient platform balance. Need {} but total available is {}.", @@ -1551,62 +1571,13 @@ impl WalletSendScreen { )); } - // Allocate amount across addresses (highest balance first), reserving - // per-operation fee headroom so each address can cover its own shield fee. - // Apply the network fee multiplier for consistency with ShieldScreen. - let base_fee = crate::model::fee_estimation::shielded_fee_for_actions( - 2, - dash_sdk::dpp::version::PlatformVersion::latest(), - ); - let multiplier = self.app_context.fee_multiplier_permille().max(1000); - let per_op_fee = base_fee.saturating_mul(multiplier) / 1000; - let mut remaining = amount_credits; - let mut tasks: Vec<BackendTask> = Vec::new(); - for (platform_addr, _, balance) in &sorted_addrs { - if remaining == 0 { - break; - } - let available = balance.saturating_sub(per_op_fee); - if available == 0 { - continue; - } - let spend = remaining.min(available); - tasks.push(BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::ShieldCredits { - seed_hash, - amount: spend, - from_address: *platform_addr, - nonce_override: None, - }, - )); - remaining -= spend; - } - - // Reject if allocation could not cover the full amount after fee deductions - if tasks.is_empty() { - return Err( - "Insufficient platform balance after fees. No address has enough to cover the shield operation fee." - .to_string(), - ); - } - if remaining > 0 { - let max_sendable = amount_credits.saturating_sub(remaining); - return Err(format!( - "Insufficient platform balance after fees. Need {} but only {} is available after estimated shield fees.", - format_credits_as_dash(amount_credits), - format_credits_as_dash(max_sendable), - )); - } - self.mark_sending(); - if tasks.len() == 1 { - Ok(AppAction::BackendTask(tasks.into_iter().next().unwrap())) - } else { - Ok(AppAction::BackendTasks( - tasks, - crate::app::BackendTasksExecutionMode::Sequential, - )) - } + Ok(AppAction::BackendTask(BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldFromBalance { + seed_hash, + amount: amount_credits, + }, + ))) } /// Top up an identity from Platform addresses (Platform -> Identity). @@ -1813,7 +1784,7 @@ impl WalletSendScreen { } fn render_source_selection(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.label( RichText::new("Send from") @@ -1856,7 +1827,7 @@ impl WalletSendScreen { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_dash(core_balance)) + RichText::new(format_duffs_as_dash(core_balance)) .color(DashColors::success_color(dark_mode)) .strong(), ); @@ -1914,7 +1885,7 @@ impl WalletSendScreen { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_credits(total_platform_balance)) + RichText::new(format_credits_as_dash(total_platform_balance)) .color(DashColors::success_color(dark_mode)) .strong(), ); @@ -1925,6 +1896,18 @@ impl WalletSendScreen { // Identity source option — visible when identities exist or developer mode let identities = self.get_loaded_identities(); + // READ-only seed (R1, W5): if no identity is pre-selected yet, try the app-scoped + // identity — but only if it belongs to this wallet's identity list. + // No syncing_global: the send screen is wallet-primary; K1 reconcile would fight it. + if self.selected_identity.is_none() + && let Some(preferred_id) = self.app_context.selected_identity_id() + && let Some(qi) = identities.iter().find(|qi| { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + qi.identity.id() == preferred_id + }) + { + self.selected_identity = Some(qi.clone()); + } if !identities.is_empty() || self.app_context.is_developer_mode() { ui.add_space(5.0); @@ -1972,7 +1955,7 @@ impl WalletSendScreen { egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_credits( + RichText::new(format_credits_as_dash( qi.identity.balance(), )) .color(DashColors::success_color(dark_mode)) @@ -1985,7 +1968,7 @@ impl WalletSendScreen { egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_credits( + RichText::new(format_credits_as_dash( first.identity.balance(), )) .color(DashColors::success_color(dark_mode)) @@ -2017,7 +2000,7 @@ impl WalletSendScreen { format!( "{} ({})", name, - Self::format_credits(qi.identity.balance()) + format_credits_as_dash(qi.identity.balance()) ) }) .unwrap_or_else(|| "Select identity".to_string()); @@ -2045,7 +2028,7 @@ impl WalletSendScreen { format!( "{} ({})", name, - Self::format_credits(identity.identity.balance()) + format_credits_as_dash(identity.identity.balance()) ) }; let is_selected = self @@ -2107,7 +2090,7 @@ impl WalletSendScreen { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_credits(balance)) + RichText::new(format_credits_as_dash(balance)) .color(DashColors::success_color(dark_mode)) .strong(), ); @@ -2117,9 +2100,49 @@ impl WalletSendScreen { } } - fn render_destination_input(&mut self, ui: &mut Ui) { + /// Destination address kinds for the free-form (General) send, derived from + /// the selected source. Shielded destinations are developer-mode only here. + fn general_destination_kinds(&self) -> Vec<AddressKind> { let developer_mode = self.app_context.is_developer_mode(); - // Pre-load data outside the closure to avoid double-borrow of self. + match &self.selected_source { + Some(SourceSelection::CoreWallet) => { + let mut kinds = vec![AddressKind::Core, AddressKind::Platform]; + if developer_mode { + kinds.push(AddressKind::Shielded); + } + kinds.push(AddressKind::Identity); + kinds + } + Some(SourceSelection::PlatformAddresses(_)) => { + let mut kinds = vec![AddressKind::Platform, AddressKind::Core]; + if developer_mode { + kinds.push(AddressKind::Shielded); + } + kinds.push(AddressKind::Identity); + kinds + } + Some(SourceSelection::Identity(_)) => { + vec![ + AddressKind::Core, + AddressKind::Platform, + AddressKind::Identity, + ] + } + Some(SourceSelection::Shielded(..)) => { + vec![ + AddressKind::Shielded, + AddressKind::Platform, + AddressKind::Core, + ] + } + None => AddressKind::ALL.to_vec(), + } + } + + /// Build a fresh destination [`AddressInput`] for `allowed_kinds`, wired + /// with wallet and identity autocomplete. Returns the widget so callers can + /// assign it without holding a mutable borrow of `self` across the build. + fn build_address_input(&self, allowed_kinds: &[AddressKind]) -> AddressInput { // Filter out the source identity (if any) to prevent self-sends. let source_identity_id = if let Some(SourceSelection::Identity(qi)) = &self.selected_source { @@ -2127,107 +2150,79 @@ impl WalletSendScreen { } else { None }; - // Only load identities and shielded state when building a new AddressInput - // (get_or_insert_with fires once). Avoids per-frame DB queries. - let addr_input = if self.address_input.is_some() { - self.address_input.as_mut().unwrap() - } else { - let loaded_identities: Vec<_> = self - .get_loaded_identities() - .into_iter() - .filter(|qi| Some(qi.identity.id()) != source_identity_id) - .collect(); - let shielded_info: Option<(String, u64)> = - self.selected_wallet_seed_hash.and_then(|sh| { - let states = self.app_context.shielded_states.lock().ok()?; - let state = states.get(&sh)?; - use dash_sdk::dpp::address_funds::OrchardAddress; - let raw = state.keys.default_address.to_raw_address_bytes(); - let addr = OrchardAddress::from_raw_bytes(&raw).ok()?; - Some(( - addr.to_bech32m_string(self.app_context.network), - state.shielded_balance, - )) - }); - self.address_input.get_or_insert_with(|| { - let allowed_kinds = match &self.selected_source { - Some(SourceSelection::CoreWallet) => { - let mut kinds = vec![AddressKind::Core, AddressKind::Platform]; - if developer_mode { - kinds.push(AddressKind::Shielded); - } - kinds.push(AddressKind::Identity); - kinds - } - Some(SourceSelection::PlatformAddresses(_)) => { - let mut kinds = vec![AddressKind::Platform, AddressKind::Core]; - if developer_mode { - kinds.push(AddressKind::Shielded); - } - kinds.push(AddressKind::Identity); - kinds - } - Some(SourceSelection::Identity(_)) => { - vec![ - AddressKind::Core, - AddressKind::Platform, - AddressKind::Identity, - ] - } - Some(SourceSelection::Shielded(..)) => { - vec![ - AddressKind::Shielded, - AddressKind::Platform, - AddressKind::Core, - ] - } - None => AddressKind::ALL.to_vec(), - }; - - let mut builder = AddressInput::new(self.app_context.network) - .with_label("Send to") - .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .with_address_kinds(&allowed_kinds) - .with_exclude_change(true); - - // Provide all wallet addresses for autocomplete - if let Ok(wallets_guard) = self.app_context.wallets.read() { - let all_wallets: Vec<Arc<RwLock<Wallet>>> = - wallets_guard.values().cloned().collect(); - if !all_wallets.is_empty() { - builder = builder.with_wallets(&all_wallets); - } - } + let loaded_identities: Vec<_> = self + .get_loaded_identities() + .into_iter() + .filter(|qi| Some(qi.identity.id()) != source_identity_id) + .collect(); - // Add identities for autocomplete (searchable by alias/DPNS name) - if !loaded_identities.is_empty() { - builder = builder.with_identities(&loaded_identities); - } + let mut builder = AddressInput::new(self.app_context.network) + .with_label("Send to") + .with_address_kinds(allowed_kinds) + .with_exclude_change(true); + + // Provide all wallet addresses for autocomplete. + if let Ok(wallets_guard) = self.app_context.wallets.read() { + let all_wallets: Vec<_> = wallets_guard + .values() + .map(|w| { + let seed_hash = w.read().map(|g| g.seed_hash()).unwrap_or_default(); + ( + w.clone(), + self.app_context.snapshot_address_balances(&seed_hash), + ) + }) + .collect(); + if !all_wallets.is_empty() { + builder = builder.with_wallets(&all_wallets); + } + } - // Add shielded address for autocomplete (if wallet has shielded state) - if let Some((addr_str, balance)) = &shielded_info { - builder = builder.with_shielded_balance(addr_str.clone(), *balance); - } + // Add identities for autocomplete (searchable by alias/DPNS name). + if !loaded_identities.is_empty() { + builder = builder.with_identities(&loaded_identities); + } - builder - }) - }; + builder + } - let resp = addr_input.show(ui); + /// Render the destination address input for `allowed_kinds`. Lazily builds + /// the widget once (avoiding per-frame DB queries) then shows it. + fn render_destination_input_with_kinds(&mut self, ui: &mut Ui, allowed_kinds: &[AddressKind]) { + if self.address_input.is_none() { + self.address_input = Some(self.build_address_input(allowed_kinds)); + } + let resp = self + .address_input + .as_mut() + .expect("invariant: address_input set to Some immediately above") + .show(ui); resp.inner.update(&mut self.validated_destination); } + fn render_destination_input(&mut self, ui: &mut Ui) { + let kinds = self.general_destination_kinds(); + self.render_destination_input_with_kinds(ui, &kinds); + } + fn render_amount_input(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let fee_estimator = self.app_context.fee_estimator(); // Get max amount and hint based on source selection let (max_amount_credits, max_hint) = match &self.selected_source { Some(SourceSelection::CoreWallet) => { let mut max = self.selected_wallet.as_ref().and_then(|w| { - w.read() - .ok() - .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits + w.read().ok().map(|wallet| { + // Reserve against the spendable set (confirmed + + // unconfirmed), not `total` — `total` counts immature + // and locked funds coin selection can't spend, so a + // total-based Max over-shoots and the send fails. + self.app_context + .snapshot_balance(&wallet.seed_hash()) + .spendable() + * CREDITS_PER_DUFF // duffs to credits + }) }); let dest_kind = self.destination_kind(); let hint = match dest_kind { @@ -2244,7 +2239,7 @@ impl WalletSendScreen { max = max.map(|amount| amount.saturating_sub(estimated_fee)); Some(format!( "Estimated platform fee ~{} (deducted from amount)", - Self::format_credits(estimated_fee) + format_credits_as_dash(estimated_fee) )) } else { None @@ -2258,20 +2253,76 @@ impl WalletSendScreen { max = max.map(|amount| amount.saturating_sub(total_fee_credits)); Some(format!( "~{} reserved for shield fees", - Self::format_credits(total_fee_credits) + format_credits_as_dash(total_fee_credits) )) } Some(AddressKind::Core) => { - let (_, l1_tx_fee_duffs) = - fee_estimator.estimate_shield_from_core_fees_duffs(); - let l1_fee_credits = l1_tx_fee_duffs * CREDITS_PER_DUFF; - max = max.map(|amount| amount.saturating_sub(l1_fee_credits)); - None + // Core-to-Core "Max": reserve the L1 network fee so the + // send leaves enough to cover it. The fee scales with + // the wallet's UTXO count (a Max send spends them all) + // into a single recipient output with no change. + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|wallet| wallet.seed_hash())); + if let Some(seed_hash) = seed_hash { + let spendable_balance_duffs = + self.app_context.snapshot_balance(&seed_hash).spendable(); + let utxo_count = self.app_context.snapshot_utxo_count(&seed_hash); + match core_max_send_amount_duffs(spendable_balance_duffs, utxo_count, 1) + { + Some(send_amount_duffs) => { + max = Some(send_amount_duffs * CREDITS_PER_DUFF); + let fee_duffs = core_max_send_reserve_duffs( + spendable_balance_duffs, + utxo_count, + 1, + ) + .unwrap_or(0); + Some(format!( + "~{} reserved for the network fee", + format_credits_as_dash(fee_duffs * CREDITS_PER_DUFF) + )) + } + None => { + // Balance does not cover the network fee. + // Leave no Max value so the button cannot + // produce an amount that would fail. + max = None; + Some( + "Your balance is too low to cover the network fee." + .to_string(), + ) + } + } + } else { + None + } } _ => None, }; (max, hint) } + Some(SourceSelection::PlatformAddresses(addresses)) + if self.destination_kind() == Some(AddressKind::Shielded) => + { + // Shield-from-Platform: the coordinator selects the inputs and + // the shield fee is paid from the same balance as the amount, so + // reserve the two-action shielded-fee headroom (far larger than + // the plain platform-transfer estimate) against the full balance. + let total: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); + let headroom = shield_from_balance_fee_headroom( + self.app_context.platform_version(), + self.app_context.fee_multiplier_permille(), + ); + ( + Some(total.saturating_sub(headroom)), + Some(format!( + "~{} reserved for the shield fee", + format_credits_as_dash(headroom) + )), + ) + } Some(SourceSelection::PlatformAddresses(addresses)) => { // Extract destination to exclude it from max calculation (can't send to yourself) let destination = self @@ -2304,10 +2355,10 @@ impl WalletSendScreen { format!( "Limited to {} input addresses per transaction, ~{} reserved for fees", MAX_PLATFORM_INPUTS, - Self::format_credits(max_fee) + format_credits_as_dash(max_fee) ) } else { - format!("~{} reserved for fees", Self::format_credits(max_fee)) + format!("~{} reserved for fees", format_credits_as_dash(max_fee)) }; (Some(total.saturating_sub(max_fee)), Some(hint)) } @@ -2322,7 +2373,7 @@ impl WalletSendScreen { Some(available), Some(format!( "~{} reserved for fees", - Self::format_credits(estimated_fee) + format_credits_as_dash(estimated_fee) )), ) } @@ -2359,14 +2410,6 @@ impl WalletSendScreen { let response = amount_input.show(ui); response.inner.update(&mut self.amount); - - // When Max is clicked for Core wallet, automatically enable subtract_fee - // so the transaction fee is deducted from the amount instead of failing - if response.inner.max_clicked - && matches!(self.selected_source, Some(SourceSelection::CoreWallet)) - { - self.subtract_fee = true; - } }); // Show transaction type hint @@ -2380,31 +2423,12 @@ impl WalletSendScreen { .size(12.0), ); } - - // Show subtract fee checkbox for Core wallet to Core address transactions - let dest_kind = self.destination_kind(); - if matches!(self.selected_source, Some(SourceSelection::CoreWallet)) - && dest_kind == Some(AddressKind::Core) - { - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.checkbox(&mut self.subtract_fee, "Subtract fee from amount"); - if self.subtract_fee { - ui.label( - RichText::new("(recipient receives amount minus fee)") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0) - .italics(), - ); - } - }); - } } /// Renders a breakdown of which platform addresses will be used and how much from each. /// Uses the same allocation algorithm as the actual send logic. fn render_platform_source_breakdown(&self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let network = self.app_context.network; let fee_estimator = self.app_context.fee_estimator(); @@ -2467,7 +2491,7 @@ impl WalletSendScreen { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( - RichText::new(Self::format_credits(*use_amount)) + RichText::new(format_credits_as_dash(*use_amount)) .color(DashColors::success_color(dark_mode)) .size(11.0), ); @@ -2566,7 +2590,7 @@ impl WalletSendScreen { /// Render the advanced send UI with multiple inputs/outputs fn render_advanced_send(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Wallet info self.render_wallet_info(ui); @@ -2735,7 +2759,7 @@ impl WalletSendScreen { /// Render Core address inputs for advanced mode fn render_core_inputs(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let mut inputs_to_remove = Vec::new(); ui.label( @@ -2784,7 +2808,7 @@ impl WalletSendScreen { .monospace(), ); ui.label( - RichText::new(format!("({})", Self::format_dash(balance))) + RichText::new(format!("({})", format_duffs_as_dash(balance))) .color(DashColors::success_color(dark_mode)) .size(12.0), ); @@ -2838,7 +2862,7 @@ impl WalletSendScreen { let display = format!( "{}... ({})", &addr_str[..12.min(addr_str.len())], - Self::format_dash(*balance) + format_duffs_as_dash(*balance) ); if ui.selectable_label(false, display).clicked() { self.core_inputs.push(CoreAddressInput { @@ -2859,7 +2883,7 @@ impl WalletSendScreen { /// Render Platform address inputs for advanced mode fn render_platform_inputs(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let mut inputs_to_remove = Vec::new(); ui.label( @@ -2912,7 +2936,7 @@ impl WalletSendScreen { .monospace(), ); ui.label( - RichText::new(format!("({})", Self::format_credits(balance))) + RichText::new(format!("({})", format_credits_as_dash(balance))) .color(DashColors::success_color(dark_mode)) .size(12.0), ); @@ -2961,17 +2985,16 @@ impl WalletSendScreen { egui::ComboBox::from_id_salt("add_platform_input") .selected_text("+ Add Platform Address") .show_ui(ui, |ui| { - for (core_addr, platform_addr, balance) in available_addresses { + for (_core_addr, platform_addr, balance) in available_addresses { let addr_str = platform_addr.to_bech32m_string(network); let display = format!( "{}... ({})", &addr_str[..20.min(addr_str.len())], - Self::format_credits(*balance) + format_credits_as_dash(*balance) ); if ui.selectable_label(false, display).clicked() { self.platform_inputs.push(PlatformAddressInput { platform_address: *platform_addr, - core_address: core_addr.clone(), amount: String::new(), }); } @@ -2988,7 +3011,7 @@ impl WalletSendScreen { /// Render the outputs section for advanced mode fn render_advanced_outputs(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let mut outputs_to_remove = Vec::new(); let num_outputs = self.advanced_outputs.len(); @@ -3256,8 +3279,8 @@ impl WalletSendScreen { if total_output > total_input { return Err(format!( "Insufficient input amount. Outputs total {} but inputs only {}", - Self::format_dash(total_output), - Self::format_dash(total_input) + format_duffs_as_dash(total_output), + format_duffs_as_dash(total_input) )); } @@ -3268,8 +3291,6 @@ impl WalletSendScreen { wallet, request: WalletPaymentRequest { recipients, - subtract_fee_from_amount: self.subtract_fee, - memo: None, override_fee: None, }, }, @@ -3305,15 +3326,14 @@ impl WalletSendScreen { if amount_duffs > total_input { return Err(format!( "Insufficient input amount. Output is {} but inputs only {}", - Self::format_dash(amount_duffs), - Self::format_dash(total_input) + format_duffs_as_dash(amount_duffs), + format_duffs_as_dash(total_input) )); } // Parse platform address let address_str = output.address.trim(); let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) .map_err(|e| format!("Invalid platform address: {}", e))?; // Determine fee strategy based on user selection @@ -3358,7 +3378,6 @@ impl WalletSendScreen { let mut outputs: BTreeMap<PlatformAddress, Credits> = BTreeMap::new(); for output in &self.advanced_outputs { let destination = PlatformAddress::from_bech32m_string(output.address.trim()) - .map(|(addr, _)| addr) .map_err(|e| format!("Invalid platform address: {}", e))?; let credits = Self::parse_amount_to_credits(&output.amount)?; if credits > 0 { @@ -3453,29 +3472,30 @@ impl WalletSendScreen { } impl ScreenLike for WalletSendScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = self - .pending_refresh_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; + let mut action = AppAction::None; action |= add_top_panel( - ctx, + ui, &self.app_context, - vec![("Wallets", AppAction::PopScreen), ("Send", AppAction::None)], + vec![ + ("Wallets", AppAction::PopScreen), + (self.flow.heading(), AppAction::None), + ], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(status_action) = self.render_send_status(ui) { return status_action; @@ -3484,21 +3504,39 @@ impl ScreenLike for WalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - // Heading with advanced options checkbox + // Heading. Advanced options apply to the free-form send only; + // shielded presets hide the toggle. ui.horizontal(|ui| { ui.heading( - RichText::new("Send Dash") + RichText::new(self.flow.heading()) .color(DashColors::text_primary(dark_mode)) .size(24.0), ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); + if !self.flow.is_preset() { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.checkbox( + &mut self.show_advanced_options, + "Advanced Options", + ); + }, + ); + } }); + if let Some(description) = self.flow.description() { + ui.add_space(4.0); + ui.label( + RichText::new(description).color(DashColors::text_secondary(dark_mode)), + ); + } + ui.add_space(15.0); - if self.show_advanced_options { + if self.flow.is_preset() { + inner_action |= self.render_flow_send(ui); + } else if self.show_advanced_options { inner_action |= self.render_advanced_send(ui); } else { inner_action |= self.render_unified_send(ui); @@ -3553,22 +3591,18 @@ impl ScreenLike for WalletSendScreen { } => { let msg = if recipients.len() == 1 { let (address, amount) = &recipients[0]; - format!("Sent {} to {}", Self::format_dash(*amount), address,) + format!("Sent {} to {}", format_duffs_as_dash(*amount), address,) } else { format!( "Sent {} to {} recipients", - Self::format_dash(total_amount), + format_duffs_as_dash(total_amount), recipients.len(), ) }; self.send_status = SendStatus::Complete(msg); } crate::backend_task::BackendTaskSuccessResult::TransferredCredits(fee_result) => { - let fee_info = format!( - "\n\nFee: Estimated {} • Actual {}", - format_credits_as_dash(fee_result.estimated_fee), - format_credits_as_dash(fee_result.actual_fee) - ); + let fee_info = Self::format_fee_info(&fee_result); self.send_status = SendStatus::Complete(format!("Credits transferred successfully!{}", fee_info)); } @@ -3587,8 +3621,8 @@ impl ScreenLike for WalletSendScreen { SendStatus::Complete("Platform credits transferred successfully!".to_string()); } crate::backend_task::BackendTaskSuccessResult::ShieldedTransferComplete { - seed_hash, amount, + .. } => { self.send_status = SendStatus::Complete(format!( "Shielded transfer of {} complete!\n\n\ @@ -3596,43 +3630,29 @@ impl ScreenLike for WalletSendScreen { The recipient's balance will also update after the next block and a wallet sync.", format_credits_as_dash(amount) )); - self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, - )); } crate::backend_task::BackendTaskSuccessResult::ShieldedCreditsUnshielded { - seed_hash, amount, + .. } => { self.send_status = SendStatus::Complete(format!( "Unshielded {} to platform address!\n\n\ Your remaining balance will update after the next block is confirmed.", format_credits_as_dash(amount) )); - self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, - )); } // Core->Identity or Platform->Identity top-up result crate::backend_task::BackendTaskSuccessResult::ToppedUpIdentity( _identity, fee_result, ) => { - let fee_info = format!( - "\n\nFee: Estimated {} • Actual {}", - format_credits_as_dash(fee_result.estimated_fee), - format_credits_as_dash(fee_result.actual_fee) - ); + let fee_info = Self::format_fee_info(&fee_result); self.send_status = SendStatus::Complete(format!("Identity topped up successfully!{}", fee_info)); } // Identity->Core withdrawal result crate::backend_task::BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) => { - let fee_info = format!( - "\n\nFee: Estimated {} • Actual {}", - format_credits_as_dash(fee_result.estimated_fee), - format_credits_as_dash(fee_result.actual_fee) - ); + let fee_info = Self::format_fee_info(&fee_result); self.send_status = SendStatus::Complete(format!( "Identity withdrawal initiated. Funds will appear on the Core chain after confirmation.{}", fee_info @@ -3640,45 +3660,35 @@ impl ScreenLike for WalletSendScreen { } // Core->Shielded or Platform->Shielded shield result crate::backend_task::BackendTaskSuccessResult::ShieldedCreditsShielded { - seed_hash, amount, + .. } => { self.send_status = SendStatus::Complete(format!( "{} shielded successfully!\n\n\ Balance will update after the next block.", format_credits_as_dash(amount) )); - self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, - )); } // Core->Shielded via asset lock result crate::backend_task::BackendTaskSuccessResult::ShieldedFromAssetLock { - seed_hash, - amount, + amount, .. } => { self.send_status = SendStatus::Complete(format!( "{} shielded from asset lock successfully!\n\n\ Balance will update after the next block.", format_credits_as_dash(amount) )); - self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, - )); } // Shielded->Core withdrawal result crate::backend_task::BackendTaskSuccessResult::ShieldedWithdrawalComplete { - seed_hash, amount, + .. } => { self.send_status = SendStatus::Complete(format!( "Withdrawal of {} from shielded pool initiated.\n\n\ Funds will appear after confirmation.", format_credits_as_dash(amount) )); - self.pending_refresh_task = Some(crate::backend_task::BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::SyncNotes { seed_hash }, - )); } _ => { // Ignore other results @@ -3690,3 +3700,53 @@ impl ScreenLike for WalletSendScreen { fn refresh(&mut self) {} } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn general_flow_is_not_a_preset() { + assert!(!SendFlow::General.is_preset()); + assert!(SendFlow::Shield.is_preset()); + assert!(SendFlow::ShieldedSend.is_preset()); + assert!(SendFlow::Unshield.is_preset()); + } + + #[test] + fn preset_headings_match_former_standalone_screens() { + assert_eq!(SendFlow::Shield.heading(), "Shield"); + assert_eq!(SendFlow::ShieldedSend.heading(), "Send (Private)"); + assert_eq!(SendFlow::Unshield.heading(), "Unshield Credits"); + } + + #[test] + fn preset_destination_kinds_scope_each_flow() { + // Shield targets the own pool — no destination input. + assert_eq!(SendFlow::Shield.preset_destination_kinds(), None); + // General derives kinds from the source elsewhere. + assert_eq!(SendFlow::General.preset_destination_kinds(), None); + // Private send accepts only shielded recipients. + assert_eq!( + SendFlow::ShieldedSend.preset_destination_kinds(), + Some(vec![AddressKind::Shielded]) + ); + // Unshield exits to platform or core, never back to shielded. + assert_eq!( + SendFlow::Unshield.preset_destination_kinds(), + Some(vec![AddressKind::Platform, AddressKind::Core]) + ); + } + + #[test] + fn every_preset_has_a_description() { + assert!(SendFlow::General.description().is_none()); + for flow in [SendFlow::Shield, SendFlow::ShieldedSend, SendFlow::Unshield] { + let description = flow.description().expect("preset has a description"); + assert!( + description.ends_with('.'), + "description is a complete sentence: {description}" + ); + } + } +} diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs deleted file mode 100644 index 13dac145e..000000000 --- a/src/ui/wallets/shield_screen.rs +++ /dev/null @@ -1,1075 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::shielded::ShieldedTask; -use crate::backend_task::shielded::bundle::ShieldStage; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::address::{AddressKind, ValidatedAddress}; -use crate::model::amount::Amount; -use crate::model::feature_gate::{FeatureGate, FeatureGateUiExt}; -use crate::model::fee_estimation::shielded_fee_for_actions; -use crate::model::wallet::WalletSeedHash; -use crate::ui::components::ComponentResponse; -use crate::ui::components::MessageBanner; -use crate::ui::components::address_input::AddressInput; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::Component; -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; -use crate::ui::theme::{ComponentStyles, DashColors}; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::serialization::PlatformSerializable; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; -use dash_sdk::dpp::version::PlatformVersion; -use eframe::egui::{self, Context}; -use egui::RichText; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// Extract the expected nonce from an `AddressInvalidNonceError` buried in an SDK error. -/// -/// The error can arrive via two paths: -/// 1. `Error::StateTransitionBroadcastError` → `cause: ConsensusError` → `AddressInvalidNonceError` -/// 2. `Error::Protocol(ProtocolError::ConsensusError(...))` → `AddressInvalidNonceError` -fn extract_expected_nonce(error: &dash_sdk::Error) -> Option<u32> { - use dash_sdk::dpp::ProtocolError; - use dash_sdk::dpp::consensus::ConsensusError; - use dash_sdk::dpp::consensus::state::state_error::StateError; - - // Helper: extract from a ConsensusError - let from_consensus = |c: &ConsensusError| -> Option<u32> { - match c { - ConsensusError::StateError(StateError::AddressInvalidNonceError(e)) => { - Some(e.expected_nonce()) - } - _ => None, - } - }; - - match error { - // Path 1: broadcast error with consensus cause - dash_sdk::Error::StateTransitionBroadcastError(e) => from_consensus(e.cause.as_ref()?), - // Path 2: protocol error wrapping consensus error (boxed) - dash_sdk::Error::Protocol(ProtocolError::ConsensusError(c)) => from_consensus(c.as_ref()), - _ => None, - } -} - -#[derive(PartialEq)] -enum Status { - NotStarted, - WaitingForResult, - BatchInProgress, - Complete, -} - -pub struct ShieldScreen { - pub app_context: Arc<AppContext>, - pub seed_hash: WalletSeedHash, - address_input: Option<AddressInput>, - validated_source: Option<ValidatedAddress>, - amount_input: Option<AmountInput>, - amount: Option<Amount>, - status: Status, - // Batch mode (dev only, Platform flow only) - repeat_count_str: String, - parallel: bool, - batch_total: u32, - batch_succeeded: u32, - batch_failed: u32, - batch_remaining: u32, - /// Queued task to dispatch on next frame (for sequential batch mode). - pending_next_task: Option<BackendTask>, - /// Queued sync task to dispatch on next frame after successful operation. - pending_refresh_task: Option<BackendTask>, - /// Per-operation progress for parallel batch mode. - batch_stages: Option<Vec<Arc<Mutex<ShieldStage>>>>, - /// JSON of a failed state transition to show in the popup. - json_preview: Option<String>, - /// Frozen amount for the current batch (set at batch start, cleared on completion). - batch_amount: Option<u64>, - /// Frozen platform address for the current batch. - batch_address: Option<PlatformAddress>, - // Cached wallet data to avoid per-frame RwLock reads (CODE-007) - cached_base_nonce: Option<u32>, - cached_platform_balance: Option<u64>, - cached_core_balance: Option<u64>, -} - -impl ShieldScreen { - pub fn new(seed_hash: WalletSeedHash, app_context: &Arc<AppContext>) -> Self { - let mut screen = Self { - app_context: app_context.clone(), - seed_hash, - address_input: None, - validated_source: None, - amount_input: None, - amount: None, - status: Status::NotStarted, - repeat_count_str: "1".to_string(), - parallel: false, - batch_total: 0, - batch_succeeded: 0, - batch_failed: 0, - batch_remaining: 0, - pending_next_task: None, - pending_refresh_task: None, - batch_stages: None, - json_preview: None, - batch_amount: None, - batch_address: None, - cached_base_nonce: None, - cached_platform_balance: None, - cached_core_balance: None, - }; - screen.refresh_cached_balances(); - screen - } - - /// Reset the address and amount inputs — called when AppContext switches network. - pub(crate) fn invalidate_address_input(&mut self) { - self.address_input = None; - self.validated_source = None; - self.amount_input = None; - self.amount = None; - self.cached_base_nonce = None; - self.cached_platform_balance = None; - self.cached_core_balance = None; - } - - fn parse_repeat_count(&self) -> u32 { - self.repeat_count_str - .trim() - .parse::<u32>() - .unwrap_or(1) - .clamp(1, 1000) - } - - /// Returns the selected platform address, if a platform source is selected. - fn selected_platform_address(&self) -> Option<PlatformAddress> { - self.validated_source - .as_ref() - .and_then(|v| v.as_platform().copied()) - } - - /// Refresh cached wallet data (balance, nonce) from the RwLock-protected wallet. - fn refresh_cached_balances(&mut self) { - // Clone the wallet Arc while holding the wallets map read lock, then - // drop the map lock before acquiring the per-wallet lock to avoid - // lock-order deadlocks with code that holds a wallet lock and needs - // wallets write access. - let wallet_arc = self - .app_context - .wallets - .read() - .ok() - .and_then(|w| w.get(&self.seed_hash).cloned()); - let Some(wallet_arc) = wallet_arc else { - return; - }; - let wallet_guard = wallet_arc.read().ok(); - - if let Some(wallet) = &wallet_guard { - // Platform nonce and balance for selected address - if let Some(from_address) = self.selected_platform_address() { - let info = wallet - .platform_address_info - .iter() - .find_map(|(addr, info)| { - let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; - (platform_addr == from_address).then_some(info) - }); - self.cached_base_nonce = info.map(|i| i.nonce); - self.cached_platform_balance = info.map(|i| i.balance); - } else { - self.cached_base_nonce = None; - self.cached_platform_balance = None; - } - - // Core balance - if let Some(addr) = self.validated_source.as_ref().and_then(|v| v.as_core()) { - self.cached_core_balance = - Some(wallet.address_balances.get(addr).copied().unwrap_or(0)); - } else { - self.cached_core_balance = Some(wallet.total_balance_duffs()); - } - } else { - self.cached_base_nonce = None; - self.cached_platform_balance = None; - self.cached_core_balance = Some(0); - } - } - - /// Return the cached nonce for the selected platform address. - fn read_base_nonce(&self) -> Option<u32> { - self.cached_base_nonce - } - - /// Return the cached balance (credits) for the selected platform address. - fn read_platform_balance(&self) -> Option<u64> { - self.cached_platform_balance - } - - /// Return the cached core wallet balance in duffs. - fn read_core_balance_duffs(&self) -> u64 { - self.cached_core_balance.unwrap_or(0) - } - - /// Build a single ShieldCredits task with optional nonce override. - fn make_shield_credits_task( - &self, - amount: u64, - addr: PlatformAddress, - nonce_override: Option<u32>, - ) -> BackendTask { - BackendTask::ShieldedTask(ShieldedTask::ShieldCredits { - seed_hash: self.seed_hash, - amount, - from_address: addr, - nonce_override, - }) - } - - /// Queue the next sequential batch task if any remain, using frozen batch parameters. - fn queue_next_sequential(&mut self) { - if self.batch_remaining > 0 - && let (Some(amount), Some(addr)) = (self.batch_amount, self.batch_address) - { - self.batch_remaining -= 1; - self.pending_next_task = Some(self.make_shield_credits_task(amount, addr, None)); - } - } - - /// Check if the sequential batch is complete and update status accordingly. - fn check_batch_complete(&mut self, ctx: &Context) { - if self.batch_stages.is_none() - && self.batch_succeeded + self.batch_failed >= self.batch_total - { - self.status = Status::Complete; - self.batch_amount = None; - self.batch_address = None; - MessageBanner::set_global( - ctx, - format!( - "Batch complete: {} succeeded, {} failed out of {}", - self.batch_succeeded, self.batch_failed, self.batch_total, - ), - if self.batch_failed > 0 { - MessageType::Warning - } else { - MessageType::Success - }, - ); - } - } - - /// Spawn parallel batch: build proofs in parallel, broadcast in nonce order. - fn spawn_parallel_batch( - &mut self, - ctx: &Context, - amount: u64, - addr: PlatformAddress, - repeat: u32, - ) { - let base_nonce = match self.read_base_nonce() { - Some(n) => n, - None => { - MessageBanner::set_global( - ctx, - "Could not read wallet data. Please try again.", - MessageType::Error, - ); - return; - } - }; - let default_address = match self.app_context.shielded_default_address(&self.seed_hash) { - Some(a) => a, - None => { - MessageBanner::set_global( - ctx, - "Shielded wallet not initialized. Please set up the shielded wallet first.", - MessageType::Error, - ); - return; - } - }; - - self.batch_total = repeat; - self.batch_succeeded = 0; - self.batch_failed = 0; - self.batch_remaining = 0; - self.status = Status::BatchInProgress; - - let stages: Vec<Arc<Mutex<ShieldStage>>> = (0..repeat) - .map(|_| Arc::new(Mutex::new(ShieldStage::Queued))) - .collect(); - self.batch_stages = Some(stages.clone()); - - let app_ctx = self.app_context.clone(); - let seed_hash = self.seed_hash; - - tokio::spawn(async move { - use crate::backend_task::shielded::bundle; - use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; - - let build_futures: Vec<_> = (0..repeat) - .map(|i| { - let app_ctx = app_ctx.clone(); - let stage = stages[i as usize].clone(); - let nonce = base_nonce + 1 + i; - - async move { - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::BuildingProof { nonce }; - } - - let result = tokio::task::spawn_blocking(move || { - bundle::build_shield_credit( - &app_ctx, - &seed_hash, - &default_address, - amount, - addr, - nonce, - ) - }) - .await - .map_err(|e| e.to_string()) - .and_then(|r| r.map_err(|e| e.to_string())); - - match &result { - Ok(_) => { - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::WaitingToBroadcast; - } - } - Err(e) => { - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Failed { - error: e.clone(), - st_json: None, - }; - } - } - } - - result - } - }) - .collect(); - - let build_results = futures::future::join_all(build_futures).await; - - let sdk = { app_ctx.sdk.load().as_ref().clone() }; - - for (i, result) in build_results.into_iter().enumerate() { - let stage = &stages[i]; - match result { - Ok(state_transition) => { - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Broadcasting; - } - - let st_repr: Option<String> = - serde_json::to_string_pretty(&state_transition) - .ok() - .or_else(|| { - state_transition.serialize_to_bytes().map(hex::encode).ok() - }); - - let our_nonce = base_nonce + 1 + i as u32; - match state_transition.broadcast(&sdk, None).await { - Ok(_) => { - // Address nonces are strictly sequential — Platform - // requires block confirmation before accepting the next - // nonce. Retry wait_for_response to ensure the state - // transition is included in a block before proceeding. - let mut confirmed = false; - for attempt in 0..3 { - match state_transition - .wait_for_response::<StateTransitionProofResult>(&sdk, None) - .await - { - Ok(_) => { - confirmed = true; - break; - } - Err(e) => { - tracing::warn!( - "Batch item {} wait_for_response attempt {}: {e}", - i + 1, - attempt + 1 - ); - if attempt < 2 { - tokio::time::sleep(Duration::from_secs(5)).await; - } - } - } - } - - if confirmed { - app_ctx.bump_platform_address_nonce(&seed_hash, &addr); - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Complete; - } - } else { - // Cannot confirm — nonce chain is broken, cascade-fail - tracing::error!( - "Batch item {} broadcast succeeded but confirmation failed after 3 attempts", - i + 1 - ); - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Failed { - error: "Broadcast succeeded but could not confirm. \ - Remaining items skipped to avoid nonce errors." - .to_string(), - st_json: st_repr, - }; - } - for remaining in stages.iter().skip(i + 1) { - if let Ok(mut s) = remaining.lock() - && !s.is_terminal() - { - *s = ShieldStage::Failed { - error: "Skipped: previous item not confirmed" - .to_string(), - st_json: None, - }; - } - } - break; - } - } - Err(e) => { - // Check for AddressInvalidNonceError via typed error chain. - // On any nonce mismatch, fail this item but continue to the - // next — Platform may catch up (nonce-ahead) or the next item - // may have a valid nonce (stale). Only cascade on non-nonce errors. - if let Some(expected) = extract_expected_nonce(&e) { - tracing::warn!( - "Batch item {} nonce mismatch: ours={}, Platform expects {}", - i + 1, - our_nonce, - expected - ); - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Failed { - error: format!( - "Nonce mismatch: sent {}, Platform expects {}", - our_nonce, expected - ), - st_json: st_repr, - }; - } - continue; - } - - // Non-nonce error — fail and cascade - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Failed { - error: format!("Broadcast failed: {e}"), - st_json: st_repr, - }; - } - for remaining in stages.iter().skip(i + 1) { - if let Ok(mut s) = remaining.lock() - && !s.is_terminal() - { - *s = ShieldStage::Failed { - error: "Skipped: earlier nonce failed".to_string(), - st_json: None, - }; - } - } - break; - } - } - } - Err(_) => { - for remaining in stages.iter().skip(i + 1) { - if let Ok(mut s) = remaining.lock() - && !s.is_terminal() - { - *s = ShieldStage::Failed { - error: "Skipped: earlier nonce failed".to_string(), - st_json: None, - }; - } - } - break; - } - } - } - }); - } - - /// Render the batch progress UI (used for Platform batch mode). - fn render_batch_progress(&mut self, ui: &mut egui::Ui, ctx: &Context, action: &mut AppAction) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let stages_snapshot = self.batch_stages.clone(); - if let Some(stages) = stages_snapshot { - let lock_stage = |s: &Arc<Mutex<ShieldStage>>| -> ShieldStage { - s.lock() - .ok() - .map(|guard| guard.clone()) - .unwrap_or(ShieldStage::Failed { - error: "Internal error: lock poisoned".to_string(), - st_json: None, - }) - }; - - let all_done = stages.iter().all(|s| lock_stage(s).is_terminal()); - - if !all_done { - ctx.request_repaint_after(Duration::from_millis(100)); - } - - let succeeded = stages - .iter() - .filter(|s| matches!(lock_stage(s), ShieldStage::Complete)) - .count(); - let failed = stages - .iter() - .filter(|s| matches!(lock_stage(s), ShieldStage::Failed { .. })) - .count(); - - if all_done { - if failed > 0 { - ui.colored_label( - DashColors::error_color(dark_mode), - format!( - "Batch complete: {} succeeded, {} failed out of {}", - succeeded, - failed, - stages.len(), - ), - ); - } else { - ui.colored_label( - DashColors::success_color(dark_mode), - format!("Batch complete: all {} succeeded", stages.len()), - ); - } - } else { - ui.label(format!( - "Succeeded {}/{} Failed {}/{}", - succeeded, - stages.len(), - failed, - stages.len(), - )); - } - ui.add_space(5.0); - - let rows: Vec<(ShieldStage, Option<String>)> = stages - .iter() - .map(|s| { - let s = lock_stage(s); - let json = if let ShieldStage::Failed { ref st_json, .. } = s { - st_json.clone() - } else { - None - }; - (s, json) - }) - .collect(); - - let total = rows.len(); - let mut pending_json: Option<String> = None; - - egui::ScrollArea::vertical() - .max_height(400.0) - .show(ui, |ui| { - for (i, (stage, st_json)) in rows.iter().enumerate() { - let fraction = stage.progress_fraction(); - let text = format!("[{}/{}] {}", i + 1, total, stage.label()); - - // Progress bar fills need vibrant, saturated colors for contrast - // against bar background — use static constants, not theme-aware - // text colors (which are muted/dark for readability on backgrounds). - let color = match stage { - ShieldStage::Queued => DashColors::GRAY, - ShieldStage::BuildingProof { .. } => DashColors::DASH_BLUE, - ShieldStage::WaitingToBroadcast => DashColors::INFO, - ShieldStage::Broadcasting => DashColors::WARNING, - ShieldStage::Complete => DashColors::SUCCESS, - ShieldStage::Failed { .. } => DashColors::ERROR, - }; - - if let Some(json_str) = st_json { - ui.horizontal(|ui| { - let btn_width = 100.0_f32; - let bar_width = (ui.available_width() - btn_width - 6.0).max(100.0); - ui.add_sized( - [bar_width, 20.0], - egui::ProgressBar::new(fraction).text(text).fill(color), - ); - let btn = egui::Button::new( - RichText::new("View JSON") - .color(DashColors::WHITE) - .size(12.0), - ) - .fill(ComponentStyles::button_disabled_fill(dark_mode)); - if ui - .add_sized([btn_width, 20.0], btn) - .on_hover_text("View state transition JSON") - .clicked() - { - pending_json = Some(json_str.clone()); - } - }); - } else { - let bar = egui::ProgressBar::new(fraction).text(text).fill(color); - if matches!(stage, ShieldStage::BuildingProof { .. }) { - ui.add(bar.animate(true)); - } else { - ui.add(bar); - } - } - } - }); - - if let Some(json) = pending_json { - self.json_preview = Some(json); - } - - if all_done { - ui.add_space(10.0); - if ui.button("Done").clicked() { - *action = AppAction::PopScreen; - } - } - } else { - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label(format!( - "Succeeded {}/{} Failed {}/{}", - self.batch_succeeded, self.batch_total, self.batch_failed, self.batch_total, - )); - }); - } - } -} - -impl ScreenLike for ShieldScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Wallets", AppAction::PopScreen), - ("Shield", AppAction::None), - ], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenWalletsBalances, - ); - - // Dispatch pending sequential task from previous frame - if let Some(task) = self.pending_next_task.take() { - action |= AppAction::BackendTask(task); - } - - // Dispatch pending refresh task (sync notes after successful shield) - if let Some(task) = self.pending_refresh_task.take() { - action |= AppAction::BackendTask(task); - } - - island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.heading("Shield"); - ui.add_space(10.0); - ui.label("Move funds from a platform or core address into the shielded pool."); - ui.add_space(15.0); - - // When complete, show a Done button below the banner - if self.status == Status::Complete { - ui.add_space(10.0); - if ui.button("Done").clicked() { - action = AppAction::PopScreen; - } - return; - } - - let is_busy = - self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; - - // Source address and amount inputs (disabled during batch) - let source_kind = ui - .add_enabled_ui(!is_busy, |ui| { - let addr_input = self.address_input.get_or_insert_with(|| { - let mut builder = AddressInput::new(self.app_context.network) - .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) - .with_label("From address") - .with_hint_text("Select a platform or core wallet address") - .with_selection_only(true) - .with_balance_range(1..) - .with_exclude_change(true); - - if let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.get(&self.seed_hash) - { - builder = builder.with_wallets(std::slice::from_ref(wallet)); - } - - builder - }); - let resp = addr_input.show(ui); - if resp.inner.has_changed() { - resp.inner.update(&mut self.validated_source); - // Reset amount input when source changes (different balance constraints) - self.amount_input = None; - self.amount = None; - self.refresh_cached_balances(); - } - ui.add_space(5.0); - - // Show source-specific info based on selected address type - let source_kind = self.validated_source.as_ref().map(|v| v.kind()); - - match source_kind { - Some(AddressKind::Platform) => { - // Platform flow: show balance and nonce - if let Some(balance_credits) = self.read_platform_balance() { - let balance_dash = - balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.horizontal(|ui| { - ui.label( - RichText::new(format!( - "Available: {:.8} DASH", - balance_dash - )) - .color(DashColors::success_color(dark_mode)), - ); - if self.app_context.is_developer_mode() - && let Some(nonce) = self.read_base_nonce() - { - ui.label( - RichText::new(format!("(nonce: {})", nonce)) - .color(DashColors::muted_color(dark_mode)) - .small(), - ); - } - }); - ui.add_space(5.0); - } - } - Some(AddressKind::Core) => { - // Core flow: show balance (per-address or whole wallet) - let balance_duffs = self.read_core_balance_duffs(); - let dash_balance = balance_duffs as f64 / 1e8; - let label = if self - .validated_source - .as_ref() - .and_then(|v| v.as_core()) - .is_some() - { - format!("Available address balance: {:.8} DASH", dash_balance) - } else { - format!("Available core wallet balance: {:.8} DASH", dash_balance) - }; - ui.label( - RichText::new(label).color(DashColors::success_color(dark_mode)), - ); - ui.add_space(5.0); - } - _ => {} - } - - // Amount input (only when a source address is selected) - if self.validated_source.is_some() { - let max_credits = match source_kind { - Some(AddressKind::Platform) => { - let base_fee = - shielded_fee_for_actions(2, PlatformVersion::latest()); - let multiplier = - self.app_context.fee_multiplier_permille().max(1000); - let fee_headroom = base_fee.saturating_mul(multiplier) / 1000; - self.read_platform_balance() - .map(|b| b.saturating_sub(fee_headroom)) - } - Some(AddressKind::Core) => { - let balance_duffs = self.read_core_balance_duffs(); - let (platform_fee_duffs, l1_tx_fee_duffs) = self - .app_context - .fee_estimator() - .estimate_shield_from_core_fees_duffs(); - let shieldable_duffs = balance_duffs - .saturating_sub(platform_fee_duffs) - .saturating_sub(l1_tx_fee_duffs); - Some(shieldable_duffs * CREDITS_PER_DUFF) - } - _ => None, - }; - - let amount_input = self.amount_input.get_or_insert_with(|| { - let mut builder = AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_desired_width(150.0); - if source_kind == Some(AddressKind::Core) { - builder = builder.with_max_button(true); - } - builder - }); - if let Some(max) = max_credits { - amount_input.set_max_amount(Some(max)); - } - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); - ui.add_space(5.0); - - // Dev-mode batch controls (Platform flow only) - if source_kind == Some(AddressKind::Platform) - && self.status == Status::NotStarted - { - ui.feature_gated(&self.app_context, FeatureGate::DeveloperMode, |ui| { - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Repeat"); - let te = egui::TextEdit::singleline(&mut self.repeat_count_str) - .desired_width(50.0); - ui.add(te); - ui.label("times"); - }); - ui.checkbox(&mut self.parallel, "Parallel"); - }); - } - } - - source_kind - }) - .inner; - - ui.add_space(15.0); - - // Progress display - if self.status == Status::BatchInProgress { - self.render_batch_progress(ui, ctx, &mut action); - } else if self.status == Status::WaitingForResult { - let spinner_msg = match source_kind { - Some(AddressKind::Core) => { - "Creating asset lock and shielding... (this may take a few minutes)" - } - _ => "Shielding credits...", - }; - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label(spinner_msg); - }); - } - - // Buttons (only when not busy and source is selected) - if !is_busy && self.status == Status::NotStarted && self.validated_source.is_some() { - let can_confirm = self - .amount - .as_ref() - .map(|a| a.value()) - .is_some_and(|v| v > 0); - - ui.horizontal(|ui| { - let button_label = match source_kind { - Some(AddressKind::Core) => "Shield from Core", - _ => "Shield", - }; - - if ui - .add_enabled( - can_confirm, - egui::Button::new( - RichText::new(button_label) - .color(DashColors::WHITE) - .size(16.0), - ) - .fill(DashColors::DASH_BLUE), - ) - .clicked() - && let Some(amount) = self.amount.as_ref().map(|a| a.value()) - { - match source_kind { - Some(AddressKind::Platform) => { - let addr = self.selected_platform_address().unwrap(); - let repeat = if self.app_context.is_developer_mode() { - self.parse_repeat_count() - } else { - 1 - }; - - // Balance check - if let Some(balance) = self.read_platform_balance() { - let total = amount.saturating_mul(repeat as u64); - if total > balance { - let total_dash = - total as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let balance_dash = - balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - MessageBanner::set_global( - ctx, - format!( - "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available. Try a smaller amount.", - amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, - total_dash, - balance_dash, - ), - MessageType::Error, - ); - return; - } - } - - if repeat <= 1 { - self.status = Status::WaitingForResult; - action = AppAction::BackendTask( - self.make_shield_credits_task(amount, addr, None), - ); - } else if self.parallel { - self.batch_amount = Some(amount); - self.batch_address = Some(addr); - self.spawn_parallel_batch(ctx, amount, addr, repeat); - } else { - self.batch_total = repeat; - self.batch_succeeded = 0; - self.batch_failed = 0; - self.batch_remaining = repeat - 1; - self.batch_amount = Some(amount); - self.batch_address = Some(addr); - self.status = Status::BatchInProgress; - action = AppAction::BackendTask( - self.make_shield_credits_task(amount, addr, None), - ); - } - } - Some(AddressKind::Core) => { - let amount_duffs = amount / CREDITS_PER_DUFF; - let source_address = self - .validated_source - .as_ref() - .and_then(|v| v.as_core()) - .cloned(); - self.status = Status::WaitingForResult; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::ShieldFromAssetLock { - seed_hash: self.seed_hash, - amount_duffs, - source_address, - }, - )); - } - _ => {} - } - } - - ui.add_space(10.0); - if ui.button("Cancel").clicked() { - action = AppAction::PopScreen; - } - }); - } - }); - - // JSON preview popup - if let Some(json) = self.json_preview.clone() { - let mut is_open = true; - egui::Window::new("State Transition JSON") - .collapsible(false) - .resizable(true) - .max_height(500.0) - .max_width(800.0) - .scroll(true) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut is_open) - .show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.monospace(&json); - }); - ui.add_space(10.0); - if ui.button("Copy").clicked() { - let _ = crate::ui::helpers::copy_text_to_clipboard(&json); - } - }); - if !is_open { - self.json_preview = None; - } - } - - action - } - - fn refresh_on_arrival(&mut self) { - self.refresh_cached_balances(); - } - - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - self.refresh_cached_balances(); - let ctx = self.app_context.egui_ctx().clone(); - match result { - BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } - if seed_hash == self.seed_hash => - { - if self.status == Status::BatchInProgress { - self.batch_succeeded += 1; - self.check_batch_complete(&ctx); - if self.status == Status::BatchInProgress { - self.queue_next_sequential(); - } else { - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } - } else { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - MessageBanner::set_global( - &ctx, - format!("Successfully shielded {:.8} DASH", dash), - MessageType::Success, - ); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } - } - BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } - if seed_hash == self.seed_hash => - { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - MessageBanner::set_global( - &ctx, - format!("Successfully shielded {:.8} DASH from core wallet", dash), - MessageType::Success, - ); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } - _ => {} - } - } - - fn display_message(&mut self, _message: &str, message_type: MessageType) { - let ctx = self.app_context.egui_ctx().clone(); - if message_type == MessageType::Error { - if self.status == Status::BatchInProgress { - self.batch_failed += 1; - self.check_batch_complete(&ctx); - if self.status == Status::BatchInProgress { - self.queue_next_sequential(); - } - } else if self.status == Status::WaitingForResult { - self.status = Status::NotStarted; - } - // If status is Complete, leave it — the shield succeeded, a post-success - // refresh failure (e.g. SyncNotes) is non-critical. - } - } -} diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs deleted file mode 100644 index 43bc7b82e..000000000 --- a/src/ui/wallets/shielded_send_screen.rs +++ /dev/null @@ -1,279 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::shielded::ShieldedTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::amount::Amount; -use crate::model::wallet::WalletSeedHash; -use crate::ui::components::ComponentResponse; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::Component; -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; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use eframe::egui::{self, Context}; -use egui::{Color32, RichText}; -use std::sync::Arc; - -#[derive(PartialEq)] -enum Status { - NotStarted, - WaitingForResult, - Complete, -} - -pub struct ShieldedSendScreen { - pub app_context: Arc<AppContext>, - pub seed_hash: WalletSeedHash, - amount_input: Option<AmountInput>, - amount: Option<Amount>, - recipient_address_input: String, - max_balance: u64, - status: Status, - error_message: Option<String>, - success_message: Option<String>, - /// Queued task to dispatch on next frame (e.g., sync notes after successful send). - pending_refresh_task: Option<BackendTask>, - /// Whether to show the balance-update-pending info banner on the success screen. - balance_update_pending: bool, -} - -impl ShieldedSendScreen { - pub fn new(seed_hash: WalletSeedHash, app_context: &Arc<AppContext>) -> Self { - let max_balance = app_context - .shielded_states - .lock() - .ok() - .and_then(|states| states.get(&seed_hash).map(|s| s.shielded_balance)) - .unwrap_or(0); - - Self { - app_context: app_context.clone(), - seed_hash, - amount_input: None, - amount: None, - recipient_address_input: String::new(), - max_balance, - status: Status::NotStarted, - error_message: None, - success_message: None, - pending_refresh_task: None, - balance_update_pending: false, - } - } - - pub(crate) fn invalidate_address_input(&mut self) { - self.recipient_address_input.clear(); - } - - fn validate_recipient(&self) -> Option<Vec<u8>> { - let trimmed = self.recipient_address_input.trim(); - if trimmed.is_empty() { - return None; - } - // Try bech32m first (dash1z... or tdash1z...) - if let Ok((addr, _network)) = - dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(trimmed) - { - return Some(addr.to_raw_bytes().to_vec()); - } - // Fall back to raw hex (43 bytes = 86 hex chars) - let bytes = hex::decode(trimmed).ok()?; - if bytes.len() != 43 { - return None; - } - Some(bytes) - } -} - -impl ScreenLike for ShieldedSendScreen { - fn refresh_on_arrival(&mut self) { - if let Ok(states) = self.app_context.shielded_states.lock() - && let Some(state) = states.get(&self.seed_hash) - { - self.max_balance = state.shielded_balance; - } - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = self - .pending_refresh_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); - - action |= add_top_panel( - ctx, - &self.app_context, - vec![ - ("Wallets", AppAction::PopScreen), - ("Send (Private)", AppAction::None), - ], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenWalletsBalances, - ); - - island_central_panel(ctx, |ui| { - ui.heading("Send (Private)"); - ui.add_space(10.0); - ui.label("Transfer credits privately within the shielded pool."); - ui.add_space(5.0); - - let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!( - "Available shielded balance: {:.8} DASH", - dash_balance - )); - ui.add_space(15.0); - - // Error/success messages - if let Some(err) = &self.error_message { - ui.colored_label(Color32::from_rgb(255, 100, 100), err); - ui.add_space(5.0); - } - if let Some(msg) = &self.success_message { - ui.colored_label(Color32::DARK_GREEN, msg); - if self.balance_update_pending { - ui.add_space(8.0); - ui.label( - "Your remaining balance will update after the next block is confirmed. \ - The recipient's balance will also update after the next block and a wallet sync.", - ); - } - ui.add_space(10.0); - if ui.button("Done").clicked() { - action = AppAction::PopScreen; - } - return; - } - - // Recipient address input - ui.label("Recipient shielded address (dash1z.../tdash1z... or hex):"); - ui.add_space(2.0); - ui.text_edit_singleline(&mut self.recipient_address_input); - if !self.recipient_address_input.trim().is_empty() - && self.validate_recipient().is_none() - { - ui.colored_label(Color32::from_rgb(255, 100, 100), "Invalid shielded address"); - } - ui.add_space(10.0); - - // Amount input - let amount_input = self.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_max_button(true) - .with_desired_width(150.0) - }); - amount_input.set_max_amount(Some(self.max_balance)); - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); - ui.add_space(15.0); - - // Confirm - let amount_ok = self.amount.is_some(); - let recipient_ok = self.validate_recipient().is_some(); - let can_confirm = self.status == Status::NotStarted && amount_ok && recipient_ok; - - if self.status == Status::WaitingForResult { - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label("Sending privately..."); - }); - } else { - ui.horizontal(|ui| { - if ui - .add_enabled( - can_confirm, - egui::Button::new( - RichText::new("Send").color(Color32::WHITE).size(16.0), - ) - .fill(crate::ui::theme::DashColors::DASH_BLUE), - ) - .clicked() - && let (Some(amount), Some(recipient_bytes)) = ( - self.amount.as_ref().map(|a| a.value()), - self.validate_recipient(), - ) - { - self.status = Status::WaitingForResult; - self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::ShieldedTransfer { - seed_hash: self.seed_hash, - amount, - recipient_address_bytes: recipient_bytes, - }, - )); - } - - ui.add_space(10.0); - if ui.button("Cancel").clicked() { - action = AppAction::PopScreen; - } - }); - } - }); - - action - } - - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - match result { - BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } - if seed_hash == self.seed_hash => - { - tracing::info!( - "ShieldedSendScreen: transfer complete, amount={} credits, queueing post-transfer note sync", - amount, - ); - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = - Some(format!("Successfully sent {:.8} DASH privately", dash)); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - self.balance_update_pending = true; - } - BackendTaskSuccessResult::ShieldedNotesSynced { - seed_hash, - new_notes, - balance, - } if seed_hash == self.seed_hash => { - tracing::debug!( - "ShieldedSendScreen: post-transfer sync complete, new_notes={}, balance={} credits", - new_notes, - balance, - ); - self.max_balance = balance; - self.balance_update_pending = false; - let dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - if let Some(msg) = self.success_message.as_mut() { - *msg = format!("{}\nBalance updated: {:.8} DASH remaining.", msg, dash,); - } - } - _ => {} - } - } - - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { - self.status = Status::NotStarted; - self.error_message = Some(message.to_string()); - } - _ => { - self.success_message = Some(message.to_string()); - } - } - } -} diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 5b1f06d8c..26925d0d4 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -1,17 +1,73 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; -use crate::backend_task::shielded::ShieldedTask; +use crate::backend_task::migration::MigrationTask; use crate::context::AppContext; +use crate::context::migration_status::{MigrationState, MigrationStep}; +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 dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use crate::ui::wallets::send_screen::SendFlow; use eframe::egui::{self, Ui}; use egui::{Color32, Frame, Margin, RichText}; use std::sync::Arc; +/// J-3 indicator strings — single complete sentences so the i18n +/// extraction pass picks each one up as a discrete translation unit. +/// Exposed `pub` so kittest coverage (TC-A11Y-006) can assert against +/// the exact label the UI renders. +pub const SHIELDED_VERIFYING_LABEL: &str = "Verifying shielded balance."; +pub const SHIELDED_VERIFIED_LABEL: &str = "Verified."; +pub const SHIELDED_SPEND_LOCKED_LABEL: &str = "Spending paused."; +pub const SHIELDED_SPEND_LOCKED_TOOLTIP: &str = + "Spending paused until shielded balance is verified."; +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"; +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 = + "Shielded features are paused until the next launch. Restart the app to retry the migration."; + +/// J-3 indicator state. Derived purely from [`MigrationState`] and the +/// session-local "skip" flag, so the same inputs always yield the same +/// indicator — testable without a UI harness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShieldedIndicator { + /// Migration completed (or never required) — balance is authoritative. + Verified, + /// Migration is mid-flight on the shielded step. Spends paused. + Verifying, + /// Sidecar mirror failed. Spend locked with a retry / skip prompt. + Failed, + /// Migration not yet started or in a non-shielded step — no badge. + Hidden, +} + +/// Derive the indicator state from the migration status. Pure function +/// so tests can drive it without a UI harness (TC-A11Y-006 backstop). +/// +/// `skipped` is the user-driven "skip for now" toggle held in +/// [`ShieldedTabView::sidecar_skipped`]; when set, the indicator is +/// hidden so the tab content can render the disabled-skip notice +/// instead of the retry banner. +pub fn derive_shielded_indicator(state: &MigrationState, skipped: bool) -> ShieldedIndicator { + if skipped { + return ShieldedIndicator::Hidden; + } + match state { + MigrationState::Running { + step: MigrationStep::Shielded, + } => ShieldedIndicator::Verifying, + MigrationState::Failed { .. } => ShieldedIndicator::Failed, + MigrationState::Success => ShieldedIndicator::Verified, + // Idle / non-shielded running step → no badge. + MigrationState::Idle | MigrationState::Running { .. } => ShieldedIndicator::Hidden, + } +} + /// View component for the Shielded tab within the Wallets screen. pub struct ShieldedTabView { app_context: Arc<AppContext>, @@ -28,6 +84,10 @@ pub struct ShieldedTabView { pending_task: Option<BackendTask>, /// Number of diversified addresses generated (always >= 1). address_count: u32, + /// 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. + sidecar_skipped: bool, } impl ShieldedTabView { @@ -44,11 +104,34 @@ impl ShieldedTabView { tree_synced: false, pending_task: None, address_count: 1, + sidecar_skipped: false, } } - pub fn is_syncing(&self) -> bool { - self.syncing + /// Open the unified send screen pre-configured for `flow`, resolving the + /// wallet handle for this tab's seed hash. The three shielded flows (Shield, + /// Send Private, Unshield) are routes into the one canonical send screen — + /// there are no bespoke shielded send screens. + fn open_send_flow(&self, flow: SendFlow) -> AppAction { + let Some(wallet) = self + .app_context + .wallets + .read() + .ok() + .and_then(|wallets| wallets.get(&self.seed_hash).cloned()) + else { + return AppAction::None; + }; + AppAction::AddScreen( + ScreenType::WalletSendScreen(wallet, flow).create_screen(&self.app_context), + ) + } + + /// Compute the J-3 indicator for the current frame. Reads the + /// migration status atomic; cheap. + fn current_indicator(&self) -> ShieldedIndicator { + let state = self.app_context.migration_status().state(); + derive_shielded_indicator(&state, self.sidecar_skipped) } pub fn update_seed_hash(&mut self, seed_hash: WalletSeedHash) { @@ -63,6 +146,9 @@ impl ShieldedTabView { self.syncing = false; self.pending_task = None; self.address_count = 1; + // Skip-for-now is session-scoped to the wallet; a new + // wallet starts with the retry banner re-enabled. + self.sidecar_skipped = false; } } @@ -82,22 +168,25 @@ impl ShieldedTabView { .unwrap_or(AppAction::None) } - /// Sync local display state from `AppContext::shielded_states`. + /// Sync local display state from the push balance snapshot 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`]. fn refresh_from_backend_state(&mut self) { - if let Ok(states) = self.app_context.shielded_states.lock() - && let Some(state) = states.get(&self.seed_hash) - { + // Balance: use the frame-safe push snapshot (no lock in frame loop). + self.shielded_balance = self.app_context.shielded_balance_credits(&self.seed_hash); + + // Treat the wallet as initialized and the tree as synced whenever the + // backend is available — the coordinator resyncs Orchard state from + // chain on its own schedule. + if self.app_context.wallet_backend().is_ok() { self.is_initialized = true; - self.shielded_balance = state.shielded_balance; - // The background sync chain (SyncNotes -> CheckNullifiers) runs - // outside the UI task system. Derive tree_synced from state so - // spend buttons become enabled after the backend finishes. - if state.last_notes_synced_at.is_some() { - self.tree_synced = true; - } - if state.last_nullifiers_synced_at.is_some() { - self.syncing = false; - } + self.tree_synced = true; + self.syncing = false; } } @@ -115,162 +204,49 @@ impl ShieldedTabView { .default_open(dev_mode); header.show(ui, |ui| { - ui.horizontal(|ui| { - if ui - .small_button("+") - .on_hover_text("Generate new diversified address") - .clicked() - { - self.address_count += 1; - } - }); - - ui.add_space(4.0); - - // Collect all addresses for the table - let addresses: Vec<(u32, String)> = { - let Ok(states) = self.app_context.shielded_states.lock() else { - ui.label( - RichText::new("Unable to read shielded state.") - .color(DashColors::text_secondary(dark_mode)), - ); - return; - }; - if let Some(state) = states.get(&self.seed_hash) { - (0..self.address_count) - .filter_map(|idx| { - use dash_sdk::dpp::address_funds::OrchardAddress; - use dash_sdk::grovedb_commitment_tree::Scope; - let addr = state.keys.fvk.address_at(idx, Scope::External); - let raw = addr.to_raw_address_bytes(); - let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; - Some(( - idx, - orchard_addr.to_bech32m_string(self.app_context.network), - )) - }) - .collect() - } else { - vec![] - } - }; - - if addresses.is_empty() { - ui.label( - RichText::new("No addresses generated yet.") - .color(DashColors::text_secondary(dark_mode)), - ); - return; - } - - egui::Grid::new("shielded_addresses_grid") - .num_columns(4) - .striped(true) - .spacing([20.0, 4.0]) - .show(ui, |ui| { - ui.label(RichText::new("Index").strong()); - ui.label(RichText::new("Address").strong()); - ui.label(RichText::new("Status").strong()); - ui.label(""); // Copy column header - ui.end_row(); - - for (idx, full_addr) in &addresses { - // Index column - if *idx == 0 { - ui.label("0 (Default)"); - } else { - ui.label(idx.to_string()); - } - - // Address column: truncated, clickable to copy - let truncated = truncate_address(full_addr); - let addr_response = ui.add( - egui::Label::new(RichText::new(&truncated).monospace()) - .sense(egui::Sense::click()), - ); - if addr_response.clicked() { - let _ = copy_text_to_clipboard(full_addr); - } - addr_response.on_hover_text(full_addr.as_str()); - - // Status column - if *idx == 0 { - ui.label("Default"); - } else { - ui.label(""); - } - - // Copy button column - if ui.small_button("Copy").clicked() { - let _ = copy_text_to_clipboard(full_addr); - } - - ui.end_row(); - } - }); + // 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. + ui.label( + RichText::new("Shielded address available after wallet unlock and sync.") + .color(DashColors::text_secondary(dark_mode)), + ); }); } /// Handle backend task results for shielded operations. + /// Fund-moving results only. pub fn handle_result( &mut self, result: &crate::backend_task::BackendTaskSuccessResult, ) -> bool { use crate::backend_task::BackendTaskSuccessResult; match result { - BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance } - if *seed_hash == self.seed_hash => - { - self.initializing = false; - self.is_initialized = true; - self.shielded_balance = *balance; - // Chain SyncNotes after user-initiated Resync (the only UI - // path that dispatches InitializeShieldedWallet). - if self.syncing || self.pending_task.is_some() { - // Already in a sync flow — skip duplicate chain. - } else { - self.syncing = true; - self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } - true - } - BackendTaskSuccessResult::ShieldedNotesSynced { - seed_hash, - new_notes, - balance, - } if *seed_hash == self.seed_hash => { - self.tree_synced = true; - self.shielded_balance = *balance; - if *new_notes > 0 { - self.success_message = Some(format!("Synced {} new note(s)", new_notes)); - } - // Auto-check nullifiers after sync to detect spent notes - self.pending_task = - Some(BackendTask::ShieldedTask(ShieldedTask::CheckNullifiers { - seed_hash: self.seed_hash, - })); - true - } BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } if *seed_hash == self.seed_hash => { - self.success_message = - Some(format!("Shielded {} successfully", format_credits(*amount))); + self.success_message = Some(format!( + "Shielded {} successfully", + format_credits_as_dash(*amount) + )); true } BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash, amount } if *seed_hash == self.seed_hash => { - self.success_message = - Some(format!("Transferred {} privately", format_credits(*amount))); + self.success_message = Some(format!( + "Transferred {} privately", + format_credits_as_dash(*amount) + )); true } BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } if *seed_hash == self.seed_hash => { - self.success_message = Some(format!("Unshielded {}", format_credits(*amount))); + self.success_message = + Some(format!("Unshielded {}", format_credits_as_dash(*amount))); true } BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } @@ -278,24 +254,17 @@ impl ShieldedTabView { { self.success_message = Some(format!( "Shielded {} from core wallet", - format_credits(*amount) + format_credits_as_dash(*amount) )); true } - BackendTaskSuccessResult::ShieldedNullifiersChecked { - seed_hash, - spent_count, - } if *seed_hash == self.seed_hash => { - self.syncing = false; - // Update balance from state after nullifier check - if let Ok(states) = self.app_context.shielded_states.lock() - && let Some(state) = states.get(&self.seed_hash) - { - self.shielded_balance = state.shielded_balance; - } - if *spent_count > 0 { - self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); - } + BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } + if *seed_hash == self.seed_hash => + { + self.success_message = Some(format!( + "Withdrew {} to core address", + format_credits_as_dash(*amount) + )); true } _ => false, @@ -315,10 +284,122 @@ impl ShieldedTabView { // Currently the layout is: balance card -> address card -> buttons -> notes list. // The redesign should move buttons to the top and use collapsible sections. + /// Render in-flight shielded sync progress, read from the push-based + /// [`ConnectionStatus`]. Shows the downloaded-notes counter and + /// the committed-to-tree ("checked") progress — a determinate bar when the + /// on-chain leaf total is known, a spinner otherwise. Renders nothing + /// between passes (both progress fields `None`). + fn render_sync_progress(&self, ui: &mut Ui, dark_mode: bool) { + let cs = self.app_context.connection_status(); + let sync = cs.shielded_sync_progress(); + let tree = cs.shielded_tree_progress(); + if sync.is_none() && tree.is_none() { + return; + } + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 8)) + .corner_radius(6.0) + .show(ui, |ui| { + if let Some(s) = sync { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0).color(DashColors::DASH_BLUE)); + ui.label( + RichText::new(format!( + "Scanning shielded notes: {} scanned (block {}).", + s.cumulative_scanned, s.block_height + )) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + if let Some(t) = tree { + if t.total_target > 0 { + let fraction = + (t.leaves_committed as f32 / t.total_target as f32).clamp(0.0, 1.0); + ui.add( + egui::ProgressBar::new(fraction) + .text(format!( + "Checked {} / {} notes", + t.leaves_committed, t.total_target + )) + .fill(DashColors::DASH_BLUE), + ); + } else { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0).color(DashColors::DASH_BLUE)); + ui.label( + RichText::new(format!( + "Checking shielded notes: {} committed.", + t.leaves_committed + )) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + } + }); + ui.add_space(10.0); + } + /// Render the shielded tab content. pub fn ui(&mut self, ui: &mut Ui) -> AppAction { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let mut action = self.tick(); + let indicator = self.current_indicator(); + + // J-3 sidecar-failure banner — surfaces above everything else + // because spends are locked in this state. Both buttons emit + // either a retry task or set the session-skip flag. + if matches!(indicator, ShieldedIndicator::Failed) { + Frame::new() + .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(SHIELDED_LOCK_ICON) + .color(Color32::from_rgb(255, 100, 100)), + ); + ui.label( + RichText::new(SHIELDED_MIGRATION_ERROR_LABEL) + .color(Color32::from_rgb(255, 100, 100)), + ); + if ui.small_button(SHIELDED_RETRY_MIGRATION_LABEL).clicked() { + action |= AppAction::BackendTask(BackendTask::MigrationTask( + MigrationTask::FinishUnwire, + )); + } + if ui.small_button(SHIELDED_SKIP_MIGRATION_LABEL).clicked() { + self.sidecar_skipped = true; + } + }); + }); + ui.add_space(5.0); + } + + // Tab-locked notice — the user has dismissed the retry banner. + // Spends stay disabled until the next launch. + if self.sidecar_skipped { + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(SHIELDED_LOCK_ICON)); + ui.label( + RichText::new(SHIELDED_TAB_SKIPPED_LABEL) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + }); + ui.add_space(5.0); + return action; + } // Messages if let Some(err) = &self.error_message.clone() { @@ -391,7 +472,7 @@ impl ShieldedTabView { // --- Initialized: show balance, address, actions --- - // Balance display + // Balance display + J-3 indicator badge. ui.add_space(10.0); Frame::new() .fill(DashColors::surface(dark_mode)) @@ -406,39 +487,82 @@ impl ShieldedTabView { ui.add_space(4.0); ui.horizontal(|ui| { ui.label( - RichText::new(format_credits(self.shielded_balance)) + RichText::new(format_credits_as_dash(self.shielded_balance)) .size(28.0) .strong() .color(DashColors::text_primary(dark_mode)), ); }); + // J-3: indicator badge. Verifying / Verified — both use + // icon + text per TC-A11Y-006 so screen readers and + // greyscale viewers get the same signal as sighted + // colour users. + match indicator { + ShieldedIndicator::Verifying => { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new(SHIELDED_VERIFYING_LABEL) + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + }); + } + ShieldedIndicator::Verified => { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + RichText::new(SHIELDED_VERIFIED_ICON).color(Color32::DARK_GREEN), + ); + ui.label( + RichText::new(SHIELDED_VERIFIED_LABEL) + .size(12.0) + .color(Color32::DARK_GREEN), + ); + }); + } + ShieldedIndicator::Failed | ShieldedIndicator::Hidden => {} + } }); ui.add_space(10.0); + // In-flight shielded sync progress (push-based). + self.render_sync_progress(ui, dark_mode); + // Shielded Addresses (collapsible table) self.render_address_section(ui, dark_mode); ui.add_space(10.0); + // J-3 spend lock: any verifying / failed indicator pauses spends + // regardless of the local sync state. Computed once so the + // hover-text and the "Spending paused" notice agree. + let spend_locked = matches!( + indicator, + ShieldedIndicator::Verifying | ShieldedIndicator::Failed + ); + // Action buttons ui.horizontal(|ui| { let shield_btn = egui::Button::new(RichText::new("Shield").color(Color32::WHITE).size(14.0)) .fill(DashColors::DASH_BLUE); if ui - .add_enabled(!self.syncing, shield_btn) - .on_hover_text( - "Shield funds from a platform or core address into the shielded pool", - ) + .add_enabled(!self.syncing && !spend_locked, shield_btn) + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else { + "Shield funds from a platform or core address into the shielded pool" + }) .clicked() { - action |= AppAction::AddScreen( - ScreenType::ShieldScreen(self.seed_hash).create_screen(&self.app_context), - ); + action |= self.open_send_flow(SendFlow::Shield); } - let can_spend = !self.syncing && self.tree_synced && self.shielded_balance > 0; + let can_spend = + !self.syncing && self.tree_synced && self.shielded_balance > 0 && !spend_locked; let send_btn = egui::Button::new( RichText::new("Send (Private)") @@ -448,16 +572,16 @@ impl ShieldedTabView { .fill(DashColors::DASH_BLUE); if ui .add_enabled(can_spend, send_btn) - .on_hover_text(if self.tree_synced { + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else if self.tree_synced { "Transfer privately within the shielded pool" } else { "Sync notes first to enable spending" }) .clicked() { - action |= AppAction::AddScreen( - ScreenType::ShieldedSendScreen(self.seed_hash).create_screen(&self.app_context), - ); + action |= self.open_send_flow(SendFlow::ShieldedSend); } let unshield_btn = @@ -465,170 +589,178 @@ impl ShieldedTabView { .fill(DashColors::DASH_BLUE); if ui .add_enabled(can_spend, unshield_btn) - .on_hover_text(if self.tree_synced { + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else if self.tree_synced { "Unshield credits to a platform address" } else { "Sync notes first to enable spending" }) .clicked() { - action |= AppAction::AddScreen( - ScreenType::UnshieldCreditsScreen(self.seed_hash) - .create_screen(&self.app_context), - ); + action |= self.open_send_flow(SendFlow::Unshield); } }); - ui.add_space(15.0); + // J-3 "Spending paused" row — icon + text per TC-A11Y-006 so + // colour-blind / greyscale users get the same signal as the + // disabled-button affordance. + if spend_locked { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(RichText::new(SHIELDED_LOCK_ICON)); + ui.label( + RichText::new(SHIELDED_SPEND_LOCKED_LABEL) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } - // Notes section header with sync status and buttons - let (notes_info, synced_index): (Vec<(u64, u64, bool)>, u64) = { - self.app_context - .shielded_states - .lock() - .ok() - .and_then(|states| { - states.get(&self.seed_hash).map(|state| { - let notes = state - .notes - .iter() - .map(|n| (n.value, n.block_height, n.is_spent)) - .collect(); - (notes, state.last_synced_index) - }) - }) - .unwrap_or_default() - }; + ui.add_space(15.0); - // Shielded Notes (collapsible) - let notes_label = if notes_info.is_empty() { - "Shielded Notes".to_string() - } else { - format!( - "Shielded Notes (synced to index {}, {} notes)", - synced_index, - notes_info.len() - ) - }; + // Shielded Notes (owned by the upstream coordinator). let notes_header = egui::CollapsingHeader::new( - RichText::new(notes_label) + RichText::new("Shielded Notes") .size(16.0) .color(DashColors::text_primary(dark_mode)), ) .id_salt("shielded_notes") - .default_open(true); + .default_open(false); notes_header.show(ui, |ui| { - ui.horizontal(|ui| { - // Sync status indicator - if self.syncing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); - ui.label( - RichText::new("Syncing...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } else if self.tree_synced { - ui.label( - RichText::new("Synced") - .size(12.0) - .color(Color32::DARK_GREEN), - ); - } - - // Sync buttons - if !self.syncing { - if ui.small_button("Sync Notes").clicked() { - self.syncing = true; - self.success_message = None; - self.error_message = None; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - }, - )); - } - - if self.app_context.is_developer_mode() - && ui.small_button("Resync Notes").clicked() - { - if let Ok(mut states) = self.app_context.shielded_states.lock() { - states.remove(&self.seed_hash); - } - let network_str = self.app_context.network.to_string(); - let _ = self - .app_context - .db - .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); - - self.shielded_balance = 0; - self.tree_synced = false; - self.is_initialized = false; - self.initializing = true; - self.syncing = false; - self.success_message = None; - self.error_message = None; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } - }); - ui.add_space(5.0); - - if !notes_info.is_empty() { - egui::Grid::new("shielded_notes_grid") - .num_columns(3) - .striped(true) - .spacing([20.0, 4.0]) - .show(ui, |ui| { - ui.label(RichText::new("Value").strong()); - ui.label(RichText::new("Block").strong()); - ui.label(RichText::new("Status").strong()); - ui.end_row(); - - for (value, height, is_spent) in &notes_info { - ui.label(format_credits(*value)); - ui.label(if *height > 0 { - height.to_string() - } else { - "-".to_string() - }); - if *is_spent { - ui.label( - RichText::new("Spent") - .color(DashColors::text_secondary(dark_mode)), - ); - } else { - ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); - } - ui.end_row(); - } - }); - } else if !self.syncing { - ui.label( - RichText::new("No shielded notes yet. Shield some credits to get started.") - .color(DashColors::text_secondary(dark_mode)), - ); - } + ui.label( + RichText::new( + "Note history is managed by the upstream platform-wallet coordinator \ + and will be surfaced here in a future update.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); }); action } } -/// Truncate a bech32m address for display (12 prefix + 8 suffix). -fn truncate_address(addr: &str) -> String { - crate::model::address::truncate_address(addr, 12, 8) -} +#[cfg(test)] +mod tests { + use super::*; + + /// TC-A11Y-006 — the locked-spend state surfaces both an icon and a + /// "Spending paused." sentence, never colour alone. We assert on the + /// constants the UI binds to so a future refactor that drops either + /// half of the signal fails this guard before it reaches users. + #[test] + fn tc_a11y_006_locked_spend_state_uses_icon_and_text() { + // The lock icon and the text are distinct, non-empty constants. + assert!(!SHIELDED_LOCK_ICON.is_empty(), "lock icon present"); + assert!(!SHIELDED_SPEND_LOCKED_LABEL.is_empty(), "lock text present",); + // i18n hygiene: complete sentence terminated with a period. + assert!( + SHIELDED_SPEND_LOCKED_LABEL.ends_with('.'), + "locked label is a complete sentence", + ); + assert!( + SHIELDED_SPEND_LOCKED_TOOLTIP.ends_with('.'), + "locked tooltip is a complete sentence", + ); + // Icon and text are distinct strings — the indicator never + // collapses into a single signal. + assert_ne!(SHIELDED_LOCK_ICON, SHIELDED_SPEND_LOCKED_LABEL); + } + + /// TC-SH-007 — when shielded balance is mid-verification (Verifying + /// state) the Send button copy must read "Spending paused until + /// shielded balance is verified." verbatim. We assert against the + /// public constant the UI binds the tooltip to so a wording drift + /// fails this test before reaching users. + #[test] + fn tc_sh_007_spending_paused_tooltip_matches_spec() { + // Spec text (TC-SH-007): "Spending paused until shielded balance is verified." + assert_eq!( + SHIELDED_SPEND_LOCKED_TOOLTIP, "Spending paused until shielded balance is verified.", + "tooltip must match the Diziet §2.3 wording verbatim", + ); + // The Verifying indicator (mid-sync) is the gate for the lock. + // If the indicator ever stops mapping the Shielded migration step + // to Verifying, the lock would silently disappear. + assert_eq!( + derive_shielded_indicator( + &MigrationState::Running { + step: MigrationStep::Shielded, + }, + false, + ), + ShieldedIndicator::Verifying, + "Verifying gates the spending-paused lock — must stay wired", + ); + } + + /// The Verified badge follows the same icon + text rule so + /// greyscale viewers see the same affirmation as colour users. + #[test] + fn verified_indicator_uses_icon_and_text() { + assert!(!SHIELDED_VERIFIED_ICON.is_empty()); + assert!(!SHIELDED_VERIFIED_LABEL.is_empty()); + assert!(SHIELDED_VERIFIED_LABEL.ends_with('.')); + assert_ne!(SHIELDED_VERIFIED_ICON, SHIELDED_VERIFIED_LABEL); + } -fn format_credits(credits: u64) -> String { - let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - if dash >= 0.01 { - format!("{:.4} DASH", dash) - } else { - format!("{} credits", credits) + /// `derive_shielded_indicator` maps every migration state onto the + /// expected J-3 badge. Pure inputs / pure output — testable without + /// a UI harness. + #[test] + fn indicator_mapping_covers_every_migration_state() { + assert_eq!( + derive_shielded_indicator(&MigrationState::Idle, false), + ShieldedIndicator::Hidden, + ); + assert_eq!( + derive_shielded_indicator( + &MigrationState::Running { + step: MigrationStep::Detecting, + }, + false, + ), + ShieldedIndicator::Hidden, + "non-shielded steps don't hijack the shielded badge", + ); + assert_eq!( + derive_shielded_indicator( + &MigrationState::Running { + step: MigrationStep::Shielded, + }, + false, + ), + ShieldedIndicator::Verifying, + ); + assert_eq!( + derive_shielded_indicator( + &MigrationState::Failed { + error: std::sync::Arc::new( + crate::backend_task::migration::MigrationError::WalletBackendUnavailable, + ), + }, + false, + ), + ShieldedIndicator::Failed, + ); + assert_eq!( + derive_shielded_indicator(&MigrationState::Success, false), + ShieldedIndicator::Verified, + ); + // Skip-for-now hides the indicator regardless of state — the + // session-local override the UI uses to dismiss the retry + // banner. + assert_eq!( + derive_shielded_indicator( + &MigrationState::Failed { + error: std::sync::Arc::new( + crate::backend_task::migration::MigrationError::WalletBackendUnavailable, + ), + }, + true, + ), + ShieldedIndicator::Hidden, + ); } } diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 394db3ad4..0aad9fb5c 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -3,10 +3,11 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::fee_estimation::format_duffs_as_dash; use crate::model::wallet::single_key::SingleKeyWallet; -use crate::spv::CoreBackendMode; use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; @@ -14,7 +15,7 @@ use crate::ui::components::password_input::PasswordInput; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{ComponentStyles, DashColors}; -use crate::ui::wallets::wallets_screen::SINGLE_KEY_REQUIRES_CORE; +use crate::ui::wallets::wallets_screen::SINGLE_KEY_SEND_UNAVAILABLE; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use eframe::egui::{self, Context, RichText, Ui}; @@ -58,10 +59,6 @@ pub struct SingleKeyWalletSendScreen { recipients: Vec<SendRecipient>, next_recipient_id: usize, - // Common options - subtract_fee: bool, - memo: String, - // State sending: bool, @@ -88,8 +85,6 @@ impl SingleKeyWalletSendScreen { selected_wallet: Some(wallet), recipients: vec![SendRecipient::new(0)], next_recipient_id: 1, - subtract_fee: false, - memo: String::new(), sending: false, password_input: PasswordInput::new().with_hint_text("Enter password"), fee_dialog: FeeConfirmationDialog::default(), @@ -110,10 +105,6 @@ impl SingleKeyWalletSendScreen { } } - fn format_dash(amount_duffs: u64) -> String { - Amount::dash_from_duffs(amount_duffs).to_string() - } - fn parse_amount_to_duffs(input: &str) -> Result<u64, String> { let amount = Amount::parse(input, DASH_DECIMAL_PLACES)?.with_unit_name("DASH"); amount.dash_to_duffs() @@ -258,21 +249,14 @@ impl SingleKeyWalletSendScreen { if total_amount > wallet_guard.total_balance { return Err(format!( "Insufficient balance. Need {} but only have {}", - Self::format_dash(total_amount), - Self::format_dash(wallet_guard.total_balance) + format_duffs_as_dash(total_amount), + format_duffs_as_dash(wallet_guard.total_balance) )); } } - let memo = self.memo.trim(); let request = WalletPaymentRequest { recipients: payment_recipients, - subtract_fee_from_amount: self.subtract_fee, - memo: if memo.is_empty() { - None - } else { - Some(memo.to_string()) - }, override_fee: None, }; @@ -293,7 +277,7 @@ impl SingleKeyWalletSendScreen { } fn render_recipients(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(15.0); @@ -395,7 +379,7 @@ impl SingleKeyWalletSendScreen { } fn render_options(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(15.0); @@ -413,30 +397,6 @@ impl SingleKeyWalletSendScreen { .inner_margin(Margin::symmetric(12, 10)) .corner_radius(5.0) .show(ui, |ui| { - // Memo field - ui.horizontal(|ui| { - ui.label( - RichText::new("Memo (optional):") - .color(DashColors::text_secondary(dark_mode)) - .size(14.0), - ); - ui.add_space(5.0); - ui.add( - egui::TextEdit::singleline(&mut self.memo) - .hint_text("Add a note...") - .desired_width(300.0), - ); - }); - - ui.add_space(10.0); - - // Subtract fee checkbox - ui.checkbox( - &mut self.subtract_fee, - RichText::new("Subtract fee from amount") - .color(DashColors::text_primary(dark_mode)), - ); - // Fee estimation display if let Some((estimated_fee, utxo_count, tx_size)) = self.estimate_fee() { ui.add_space(10.0); @@ -489,7 +449,7 @@ impl SingleKeyWalletSendScreen { /// Render the simple (beginner) send UI - single recipient, minimal options fn render_simple_send(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(15.0); @@ -561,7 +521,7 @@ impl SingleKeyWalletSendScreen { return action; } - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; egui::Window::new("Fee Confirmation Required") .collapsible(false) @@ -677,7 +637,7 @@ impl SingleKeyWalletSendScreen { } fn render_wallet_info(&self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(wallet_arc) = &self.selected_wallet && let Ok(wallet) = wallet_arc.read() @@ -728,7 +688,7 @@ impl SingleKeyWalletSendScreen { .size(14.0), ); ui.label( - RichText::new(Self::format_dash(balance)) + RichText::new(format_duffs_as_dash(balance)) .color(DashColors::SUCCESS) .strong() .size(14.0), @@ -739,7 +699,7 @@ impl SingleKeyWalletSendScreen { } fn render_wallet_unlock(&mut self, ui: &mut Ui) -> AppAction { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -770,27 +730,32 @@ impl SingleKeyWalletSendScreen { if ui.button("Unlock").clicked() && let Some(wallet) = &self.selected_wallet { - match wallet.write() { - Ok(mut wallet_guard) => { - match wallet_guard.open(self.password_input.text()) { - Ok(_) => { - self.password_input.clear(); - } - Err(e) => { - MessageBanner::set_global( - ui.ctx(), - format!("Failed to unlock: {}", e), - MessageType::Error, - ); - } - } + // Verify the passphrase against the encrypted vault + // without opening the shared map entry: signing + // decrypts just-in-time, so no plaintext is re-parked. + let address = wallet.read().ok().map(|w| w.address.to_string()); + let verify_result = match address { + Some(addr) => self + .app_context + .verify_single_key_passphrase(&addr, self.password_input.text()), + None => Err(TaskError::ImportedKeyNotFound), + }; + match verify_result { + Ok(()) => { + self.password_input.clear(); + MessageBanner::set_global( + ui.ctx(), + "Password confirmed. This key is ready to use.", + MessageType::Success, + ); } - Err(_) => { + Err(e) => { MessageBanner::set_global( ui.ctx(), - "Wallet lock error, please try again", + e.to_string(), MessageType::Error, - ); + ) + .with_details(&e); } } } @@ -818,7 +783,8 @@ impl SingleKeyWalletSendScreen { .selected_wallet .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; + // 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; @@ -841,7 +807,7 @@ impl SingleKeyWalletSendScreen { let mut response = ui.add_enabled(button_enabled, send_button); if !is_rpc_mode { - response = response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE); + response = response.on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE); } if response.clicked() { match self.validate_and_send() { @@ -860,25 +826,28 @@ impl SingleKeyWalletSendScreen { } impl ScreenLike for SingleKeyWalletSendScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = add_top_panel( - ctx, + ui, &self.app_context, vec![("Wallets", AppAction::PopScreen), ("Send", AppAction::None)], vec![], ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenWalletsBalances, ); - let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; + // Single-key wallets are unsupported in this version. + let is_rpc_mode = false; - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Message display is handled by the global MessageBanner. @@ -892,7 +861,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { if !is_rpc_mode { if !self.spv_warning_banner.has_message() { self.spv_warning_banner - .set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning) + .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) .disable_auto_dismiss(); } self.spv_warning_banner.show(ui); @@ -932,7 +901,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { } if self.show_advanced_options { - // Advanced mode: multiple recipients, memo, subtract fee, detailed info + // Advanced mode: multiple recipients, subtract fee, detailed info self.render_recipients(ui); self.render_options(ui); } else { @@ -990,19 +959,19 @@ impl ScreenLike for SingleKeyWalletSendScreen { let (address, amount) = &recipients[0]; format!( "Sent {} to {}\nTxID: {}", - Self::format_dash(*amount), + format_duffs_as_dash(*amount), address, txid ) } else { let recipient_list: String = recipients .iter() - .map(|(addr, amt)| format!(" {} to {}", Self::format_dash(*amt), addr)) + .map(|(addr, amt)| format!(" {} to {}", format_duffs_as_dash(*amt), addr)) .collect::<Vec<_>>() .join("\n"); format!( "Sent {} total to {} recipients:\n{}\nTxID: {}", - Self::format_dash(total_amount), + format_duffs_as_dash(total_amount), recipients.len(), recipient_list, txid @@ -1014,8 +983,6 @@ impl ScreenLike for SingleKeyWalletSendScreen { // Clear the form after successful send self.recipients = vec![SendRecipient::new(0)]; self.next_recipient_id = 1; - self.memo.clear(); - self.subtract_fee = false; } _ => { // Ignore other results diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs deleted file mode 100644 index baf766eda..000000000 --- a/src/ui/wallets/unshield_credits_screen.rs +++ /dev/null @@ -1,309 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::shielded::ShieldedTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::address::{AddressKind, ValidatedAddress}; -use crate::model::amount::Amount; -use crate::model::wallet::WalletSeedHash; -use crate::ui::components::ComponentResponse; -use crate::ui::components::address_input::AddressInput; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::Component; -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; -use crate::ui::theme::DashColors; -use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use eframe::egui::{self, Context}; -use egui::{Color32, RichText}; -use std::sync::Arc; - -#[derive(PartialEq)] -enum Status { - NotStarted, - WaitingForResult, - Complete, -} - -pub struct UnshieldCreditsScreen { - pub app_context: Arc<AppContext>, - pub seed_hash: WalletSeedHash, - amount_input: Option<AmountInput>, - amount: Option<Amount>, - address_input: Option<AddressInput>, - validated_destination: Option<ValidatedAddress>, - max_balance: u64, - status: Status, - error_message: Option<String>, - success_message: Option<String>, - /// Queued task to dispatch on next frame (e.g., sync notes after successful unshield). - pending_refresh_task: Option<BackendTask>, - /// Whether to show the balance-update-pending info on the success screen. - balance_update_pending: bool, -} - -impl UnshieldCreditsScreen { - /// Clear the AddressInput widget so it picks up the new network on next frame. - pub(crate) fn invalidate_address_input(&mut self) { - self.address_input = None; - self.validated_destination = None; - } - - pub fn new(seed_hash: WalletSeedHash, app_context: &Arc<AppContext>) -> Self { - let max_balance = app_context - .shielded_states - .lock() - .ok() - .and_then(|states| states.get(&seed_hash).map(|s| s.shielded_balance)) - .unwrap_or(0); - - Self { - app_context: app_context.clone(), - seed_hash, - amount_input: None, - amount: None, - address_input: None, - validated_destination: None, - max_balance, - status: Status::NotStarted, - error_message: None, - success_message: None, - pending_refresh_task: None, - balance_update_pending: false, - } - } -} - -impl ScreenLike for UnshieldCreditsScreen { - fn refresh_on_arrival(&mut self) { - if let Ok(states) = self.app_context.shielded_states.lock() - && let Some(state) = states.get(&self.seed_hash) - { - self.max_balance = state.shielded_balance; - } - } - - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = self - .pending_refresh_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); - - action |= add_top_panel( - ctx, - &self.app_context, - vec![ - ("Wallets", AppAction::PopScreen), - ("Unshield Credits", AppAction::None), - ], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenWalletsBalances, - ); - - island_central_panel(ctx, |ui| { - ui.heading("Unshield Credits"); - ui.add_space(10.0); - ui.label( - "Move credits from the shielded pool to a platform address or a core DASH address.", - ); - ui.add_space(5.0); - - let dash_balance = self.max_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!( - "Available shielded balance: {:.8} DASH", - dash_balance - )); - ui.add_space(15.0); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - - // Error/success messages - if let Some(err) = &self.error_message { - ui.colored_label(DashColors::error_color(dark_mode), err); - ui.add_space(5.0); - } - if let Some(msg) = &self.success_message { - ui.colored_label(DashColors::success_color(dark_mode), msg); - if self.balance_update_pending { - ui.add_space(8.0); - ui.label( - "Your remaining balance will update after the next block is confirmed.", - ); - } - ui.add_space(10.0); - if ui.button("Done").clicked() { - action = AppAction::PopScreen; - } - return; - } - - // Destination address input via AddressInput component - let addr_input = self.address_input.get_or_insert_with(|| { - let mut builder = AddressInput::new(self.app_context.network) - .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) - .with_label("To address") - .with_hint_text( - "Enter a platform address (tdash1.../dash1...) or core DASH address", - ); - - if let Ok(wallets) = self.app_context.wallets.read() { - let all_wallets: Vec<_> = wallets.values().cloned().collect(); - builder = builder.with_wallets(&all_wallets); - } - - builder - }); - let resp = addr_input.show(ui); - resp.inner.update(&mut self.validated_destination); - - // Show what was parsed - match self.validated_destination.as_ref().map(|v| v.kind()) { - Some(AddressKind::Platform) => { - ui.colored_label( - DashColors::success_color(dark_mode), - "Platform address — credits will be moved to this platform address", - ); - } - Some(AddressKind::Core) => { - ui.colored_label( - DashColors::success_color(dark_mode), - "Core address — credits will be withdrawn as DASH to this address", - ); - } - _ => {} - } - ui.add_space(10.0); - - // Amount input - let amount_input = self.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_max_button(true) - .with_desired_width(150.0) - }); - amount_input.set_max_amount(Some(self.max_balance)); - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); - ui.add_space(15.0); - - let amount_ok = self.amount.is_some(); - let has_destination = self.validated_destination.is_some(); - let can_confirm = self.status == Status::NotStarted && amount_ok && has_destination; - - if self.status == Status::WaitingForResult { - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label("Processing..."); - }); - } else { - ui.horizontal(|ui| { - let btn_label = match self.validated_destination.as_ref().map(|v| v.kind()) { - Some(AddressKind::Core) => "Withdraw to Core", - _ => "Unshield", - }; - - if ui - .add_enabled( - can_confirm, - egui::Button::new( - RichText::new(btn_label).color(Color32::WHITE).size(16.0), - ) - .fill(crate::ui::theme::DashColors::DASH_BLUE), - ) - .clicked() - && let Some(amount) = self.amount.as_ref().map(|a| a.value()) - { - match &self.validated_destination { - Some(ValidatedAddress::Platform { address: addr, .. }) => { - self.status = Status::WaitingForResult; - self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::UnshieldCredits { - seed_hash: self.seed_hash, - amount, - to_platform_address: *addr, - }, - )); - } - Some(ValidatedAddress::Core(addr)) => { - self.status = Status::WaitingForResult; - self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::ShieldedWithdrawal { - seed_hash: self.seed_hash, - amount, - to_core_address: addr.clone(), - }, - )); - } - _ => {} - } - } - - ui.add_space(10.0); - if ui.button("Cancel").clicked() { - action = AppAction::PopScreen; - } - }); - } - }); - - action - } - - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - match result { - BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash, amount } - if seed_hash == self.seed_hash => - { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!( - "Successfully unshielded {:.8} DASH to platform address", - dash - )); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - self.balance_update_pending = true; - } - BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash, amount } - if seed_hash == self.seed_hash => - { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!( - "Successfully withdrew {:.8} DASH to core address", - dash - )); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - self.balance_update_pending = true; - } - _ => {} - } - } - - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { - self.status = Status::NotStarted; - self.error_message = Some(message.to_string()); - } - _ => { - self.success_message = Some(message.to_string()); - } - } - } -} diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index af4348bbf..b37aa3b3f 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -1,8 +1,7 @@ use crate::app::AppAction; use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; -use crate::ui::MessageType; -use crate::ui::components::message_banner::MessageBanner; -use crate::ui::wallets::account_summary::{AccountCategory, categorize_account_path}; +use crate::ui::state::account_summary::{AccountCategory, categorize_account_path}; +use crate::wallet_backend::poison::RwLockRecover; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; @@ -16,7 +15,6 @@ pub(super) enum SortColumn { Address, Balance, UTXOs, - TotalReceived, Type, Index, DerivationPath, @@ -34,7 +32,6 @@ pub(super) struct AddressData { /// Platform credits balance for Platform Payment addresses platform_credits: u64, utxo_count: usize, - total_received: u64, /// Platform address nonce (for Platform Payment addresses) nonce: u32, address_type: String, @@ -79,7 +76,6 @@ impl WalletsBalancesScreen { SortColumn::Address => a.address.cmp(&b.address), SortColumn::Balance => a.balance.cmp(&b.balance), SortColumn::UTXOs => a.utxo_count.cmp(&b.utxo_count), - SortColumn::TotalReceived => a.total_received.cmp(&b.total_received), SortColumn::Type => a.address_type.cmp(&b.address_type), SortColumn::Index => a.index.cmp(&b.index), SortColumn::DerivationPath => a.derivation_path.cmp(&b.derivation_path), @@ -108,25 +104,45 @@ impl WalletsBalancesScreen { ) -> AppAction { let action = AppAction::None; + // Per-address UTXO counts + balances come from the display-only + // `WalletBackend` snapshot (P4a). Cumulative historical receipts have no + // upstream source post-migration, so no "Total Received" column is shown. + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|g| g.seed_hash()); + let snap_address_balances = seed_hash + .map(|sh| self.snapshot_address_balances(&sh)) + .unwrap_or_default(); + let snap_utxo_counts: std::collections::HashMap<Address, usize> = seed_hash + .map(|sh| { + let mut counts: std::collections::HashMap<Address, usize> = + std::collections::HashMap::new(); + if let Ok(wb) = self.app_context.wallet_backend() { + for u in wb.utxos(&sh) { + *counts.entry(u.address).or_insert(0) += 1; + } + } + counts + }) + .unwrap_or_default(); + + // Without a selected wallet there is no address data to render. + let Some(selected_wallet) = self.selected_wallet.as_ref() else { + return action; + }; + // Move the data preparation into its own scope let mut address_data = { - let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); + let wallet = selected_wallet.read_recover(); // Prepare data for the table wallet .known_addresses .iter() .map(|(address, derivation_path)| { - let utxo_info = wallet.utxos.get(address); - - let utxo_count = utxo_info.map(|outpoints| outpoints.len()).unwrap_or(0); - - // Get total received from the wallet (fetched from Core RPC) - let total_received = wallet - .address_total_received - .get(address) - .cloned() - .unwrap_or(0u64); + let utxo_count = snap_utxo_counts.get(address).copied().unwrap_or(0); let index = derivation_path .into_iter() @@ -178,14 +194,12 @@ impl WalletsBalancesScreen { AddressData { address: address.clone(), - balance: wallet - .address_balances + balance: snap_address_balances .get(address) - .cloned() + .copied() .unwrap_or_default(), platform_credits, utxo_count, - total_received, nonce, address_type, index, @@ -221,9 +235,8 @@ impl WalletsBalancesScreen { }); let auto_show = account_address_count < 5 && all_zero_balance; - // INTENTIONAL(CMT-002): Zero-balance filter treats key-only addresses the same as all - // others. The old exception (always showing key-only addresses) was removed intentionally - // to reduce UI clutter — key-only accounts with no balance carry no actionable information. + // Zero-balance filter treats key-only addresses like any other: a + // key-only account with no balance carries no actionable information. if !self.show_zero_balance_addresses && !auto_show { address_data.retain(|data| { if data.account_category == AccountCategory::PlatformPayment { @@ -244,12 +257,7 @@ impl WalletsBalancesScreen { let is_platform_account = account_filter.0 == AccountCategory::PlatformPayment; // Reset sort column if it refers to a column not visible for the current account type - if is_platform_account - && matches!( - self.sort_column, - SortColumn::UTXOs | SortColumn::TotalReceived - ) - { + if is_platform_account && matches!(self.sort_column, SortColumn::UTXOs) { self.sort_column = SortColumn::Balance; self.sort_order = SortOrder::Descending; } @@ -266,11 +274,8 @@ impl WalletsBalancesScreen { builder = if is_platform_account { builder.column(Column::initial(80.0)) // Nonce (replaces UTXOs) - // Total Received column omitted } else { - builder - .column(Column::initial(70.0)) // UTXOs - .column(Column::initial(150.0)) // Total Received + builder.column(Column::initial(70.0)) // UTXOs }; builder @@ -323,19 +328,6 @@ impl WalletsBalancesScreen { self.toggle_sort(SortColumn::UTXOs); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::TotalReceived { - match self.sort_order { - SortOrder::Ascending => "Total Received (DASH) ^", - SortOrder::Descending => "Total Received (DASH) v", - } - } else { - "Total Received (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::TotalReceived); - } - }); }; header.col(|ui| { let label = if self.sort_column == SortColumn::Type { @@ -408,10 +400,6 @@ impl WalletsBalancesScreen { row.col(|ui| { ui.label(format!("{}", data.utxo_count)); }); - row.col(|ui| { - let dash_received = data.total_received as f64 * 1e-8; - ui.label(format!("{:.8}", dash_received)); - }); }; row.col(|ui| { ui.label(&data.address_type); @@ -444,21 +432,10 @@ impl WalletsBalancesScreen { self.private_key_dialog.pending_address = Some(display_address); self.wallet_unlock_popup.open(); } else { - match self.derive_private_key_wif(&data.derivation_path) { - Ok(key) => { - self.private_key_dialog.is_open = true; - self.private_key_dialog.address = display_address; - self.private_key_dialog.private_key_wif = key; - self.private_key_dialog.show_key = false; - } - Err(err) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &err, - MessageType::Error, - ); - } - } + self.queue_view_key_request( + &data.derivation_path, + display_address, + ); } } }); diff --git a/src/ui/wallets/wallets_screen/asset_locks.rs b/src/ui/wallets/wallets_screen/asset_locks.rs index da3b37ddc..b36957fb6 100644 --- a/src/ui/wallets/wallets_screen/asset_locks.rs +++ b/src/ui/wallets/wallets_screen/asset_locks.rs @@ -2,153 +2,197 @@ use crate::app::AppAction; use crate::model::wallet::DerivationPathHelpers; use crate::ui::ScreenType; use crate::ui::theme::{DashColors, ResponseExt}; +use crate::wallet_backend::poison::RwLockRecover; use eframe::egui::{self, Ui}; use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; +use platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock; use super::WalletsBalancesScreen; impl WalletsBalancesScreen { pub(super) fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; - let mut open_fund_dialog_for_idx: Option<(usize, Vec<(String, u64)>)> = None; - let mut recover_asset_locks_clicked = false; + let mut open_fund_dialog_for_op: Option<( + dash_sdk::dpp::dashcore::OutPoint, + Vec<(String, u64)>, + )> = None; - if let Some(arc_wallet) = &self.selected_wallet { - let wallet = arc_wallet.read().unwrap(); + let Some(arc_wallet) = &self.selected_wallet else { + ui.label("No wallet selected."); + return app_action; + }; - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .corner_radius(5.0) - .inner_margin(Margin::same(15)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .show(ui, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.horizontal(|ui| { - ui.heading(RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Create Asset Lock").clicked() { - app_action = AppAction::AddScreen( - ScreenType::CreateAssetLock(arc_wallet.clone()).create_screen(&self.app_context) - ); - } - if ui.button("Search for Unused").clickable_tooltip("Scan Core wallet for untracked asset locks").clicked() { - recover_asset_locks_clicked = true; - } - }); + let (seed_hash, platform_addresses) = { + let wallet = arc_wallet.read_recover(); + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet + .known_addresses + .iter() + .filter(|(_, path)| path.is_platform_payment(network)) + .filter_map(|(addr, _)| { + use dash_sdk::dpp::address_funds::PlatformAddress; + let balance = wallet + .get_platform_address_info(addr) + .map(|info| info.balance) + .unwrap_or(0); + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa.to_bech32m_string(network), balance)) + }) + .collect(); + (wallet.seed_hash(), platform_addresses) + }; + + // Fetch the locks off the UI thread via the App Task System; render + // from the cache. `None` ⇒ the fetch is loading or failed. + if let Some(task) = self.asset_lock_cache.ensure_requested(seed_hash) { + app_action = AppAction::BackendTask(task); + } + let tracked: Option<Vec<TrackedAssetLock>> = + self.asset_lock_cache.get(&seed_hash).map(<[_]>::to_vec); + let load_failed = self.asset_lock_cache.is_failed(&seed_hash); + let mut retry_clicked = false; + + let dark_mode = ui.style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(5.0) + .inner_margin(Margin::same(15)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .show(ui, |ui| { + let dark_mode = ui.style().visuals.dark_mode; + ui.horizontal(|ui| { + ui.heading( + RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Create Asset Lock").clicked() { + app_action = AppAction::AddScreen( + ScreenType::CreateAssetLock(arc_wallet.clone()) + .create_screen(&self.app_context), + ); + } }); - ui.add_space(10.0); + }); + ui.add_space(10.0); - if wallet.unused_asset_locks.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label(RichText::new("No asset locks found").color(Color32::GRAY).size(14.0)); - ui.add_space(10.0); - ui.label(RichText::new("Asset locks are special transactions that can be used to create identities or fund Platform addresses").color(Color32::GRAY).size(12.0)); - ui.add_space(20.0); - }); - } else { - // Collect Platform addresses for the fund dialog (using DIP-18 Bech32m format) - // Get from known_addresses where path is platform payment - let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet - .known_addresses - .iter() - .filter(|(_, path)| path.is_platform_payment(network)) - .filter_map(|(addr, _)| { - use dash_sdk::dpp::address_funds::PlatformAddress; - let balance = wallet - .get_platform_address_info(addr) - .map(|info| info.balance) - .unwrap_or(0); - PlatformAddress::try_from(addr.clone()) - .ok() - .map(|pa| (pa.to_bech32m_string(network), balance)) - }) - .collect(); + let Some(tracked) = tracked.as_deref() else { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + if load_failed { + ui.label( + RichText::new("Couldn't load asset locks.") + .color(Color32::GRAY) + .size(14.0), + ); + ui.add_space(8.0); + if ui.button("Retry").clicked() { + retry_clicked = true; + } + } else { + ui.label( + RichText::new("Loading asset locks…") + .color(Color32::GRAY) + .size(14.0), + ); + } + ui.add_space(20.0); + }); + return; + }; - egui::ScrollArea::both() - .id_salt("asset_locks_table") - .min_scrolled_height(200.0) - .show(ui, |ui| { - TableBuilder::new(ui) - .striped(false) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(200.0)) // Transaction ID - .column(Column::initial(100.0)) // Address - .column(Column::initial(100.0)) // Amount (Duffs) - .column(Column::initial(100.0)) // InstantLock status - .column(Column::initial(100.0)) // Usable status - .column(Column::initial(200.0)) // Actions - .header(30.0, |mut header| { - header.col(|ui| { - ui.label("Transaction ID"); - }); - header.col(|ui| { - ui.label("Address"); - }); - header.col(|ui| { - ui.label("Amount (Duffs)"); - }); - header.col(|ui| { - ui.label("InstantLock"); - }); - header.col(|ui| { - ui.label("Usable"); - }); - header.col(|ui| { - ui.label("Actions"); - }); - }) - .body(|mut body| { - for (index, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { - body.row(25.0, |mut row| { - row.col(|ui| { - ui.label(tx.txid().to_string()); - }); - row.col(|ui| { - ui.label(address.to_string()); - }); - row.col(|ui| { - ui.label(format!("{}", amount)); - }); - row.col(|ui| { - let status = if islock.is_some() { "Yes" } else { "No" }; - ui.label(status); - }); - row.col(|ui| { - let status = if proof.is_some() { "Yes" } else { "No" }; - ui.label(status); - }); - row.col(|ui| { - if ui.small_button("View").clickable_tooltip("View full asset lock details").clicked() { - app_action = AppAction::AddScreen( - ScreenType::AssetLockDetail( - wallet.seed_hash(), - index - ).create_screen(&self.app_context) - ); - } - if proof.is_some() - && ui.small_button("Fund").clickable_tooltip("Fund a Platform address with this asset lock").clicked() { - open_fund_dialog_for_idx = Some((index, platform_addresses.clone())); - } - }); + if tracked.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label( + RichText::new("No asset locks found") + .color(Color32::GRAY) + .size(14.0), + ); + ui.add_space(10.0); + ui.label(RichText::new("Asset locks are special transactions that can be used to create identities or fund Platform addresses").color(Color32::GRAY).size(12.0)); + ui.add_space(20.0); + }); + } else { + egui::ScrollArea::both() + .id_salt("asset_locks_table") + .min_scrolled_height(200.0) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(false) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::initial(200.0)) // Transaction ID + .column(Column::initial(60.0)) // Vout + .column(Column::initial(120.0)) // Amount (Duffs) + .column(Column::initial(140.0)) // Status + .column(Column::initial(100.0)) // Usable + .column(Column::initial(200.0)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { ui.label("Transaction ID"); }); + header.col(|ui| { ui.label("Vout"); }); + header.col(|ui| { ui.label("Amount (Duffs)"); }); + header.col(|ui| { ui.label("Status"); }); + header.col(|ui| { ui.label("Usable"); }); + header.col(|ui| { ui.label("Actions"); }); + }) + .body(|mut body| { + for lock in tracked { + body.row(25.0, |mut row| { + row.col(|ui| { + ui.label(lock.out_point.txid.to_string()); + }); + row.col(|ui| { + ui.label(lock.out_point.vout.to_string()); + }); + row.col(|ui| { + ui.label(format!("{}", lock.amount)); + }); + row.col(|ui| { + ui.label(format!("{:?}", lock.status)); + }); + row.col(|ui| { + let usable = if lock.proof.is_some() { "Yes" } else { "No" }; + ui.label(usable); + }); + row.col(|ui| { + if ui.small_button("View") + .clickable_tooltip("View full asset lock details") + .clicked() + { + app_action = AppAction::AddScreen( + ScreenType::AssetLockDetail( + seed_hash, + lock.out_point, + ) + .create_screen(&self.app_context), + ); + } + if lock.proof.is_some() + && ui.small_button("Fund") + .clickable_tooltip("Fund a Platform address with this asset lock") + .clicked() + { + open_fund_dialog_for_op = Some(( + lock.out_point, + platform_addresses.clone(), + )); + } + }); + }); + } }); - } }); - }); - } - }); - } else { - ui.label("No wallet selected."); + } + }); + + if retry_clicked { + self.asset_lock_cache.invalidate_one(&seed_hash); } - // Handle dialog opening outside the borrow - if let Some((idx, platform_addresses)) = open_fund_dialog_for_idx { - self.fund_platform_dialog.selected_asset_lock_index = Some(idx); + if let Some((out_point, platform_addresses)) = open_fund_dialog_for_op { + self.fund_platform_dialog.selected_asset_lock_out_point = Some(out_point); self.fund_platform_dialog.is_open = true; self.fund_platform_dialog.platform_addresses = platform_addresses; self.fund_platform_dialog.selected_platform_address = None; @@ -156,11 +200,6 @@ impl WalletsBalancesScreen { self.fund_platform_dialog.is_processing = false; } - // Handle recover asset locks button click - use custom action to check lock status - if recover_asset_locks_clicked { - app_action = AppAction::Custom("SearchAssetLocks".to_string()); - } - app_action } } diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 6e10fe72e..8bacaa337 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -1,15 +1,13 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; -use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::core::CoreTask; use crate::backend_task::wallet::WalletTask; use crate::model::address::{AddressKind, ValidatedAddress}; -use crate::model::amount::Amount; use crate::model::secret::Secret; -use crate::model::wallet::{DerivationPathHelpers, Wallet}; +use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::components::address_input::AddressInput; -use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::helpers::clicked_outside_window; use crate::ui::helpers::copy_text_to_clipboard; @@ -27,18 +25,6 @@ use std::sync::{Arc, RwLock}; use super::WalletsBalancesScreen; -#[derive(Default)] -pub(super) struct SendDialogState { - pub is_open: bool, - pub address: String, - pub address_error: Option<String>, - pub amount: Option<Amount>, - pub amount_input: Option<AmountInput>, - pub subtract_fee: bool, - pub memo: String, - pub error: Option<String>, -} - /// Type of address to receive to #[derive(Default, Clone, Copy, PartialEq, Eq)] pub(super) enum ReceiveAddressType { @@ -66,14 +52,27 @@ pub(super) struct ReceiveDialogState { pub qr_texture: Option<TextureHandle>, pub qr_address: Option<String>, pub status: Option<String>, + /// A queued "generate Platform receive address" request the `ui()` loop + /// drains into a `WalletTask::GeneratePlatformReceiveAddress` backend task. + /// The seed is fetched just-in-time in the backend; only the new address + /// returns here. Carries the wallet's seed hash. + pub pending_platform_address_request: Option<WalletSeedHash>, + /// A queued "generate Core receive address" request the `ui()` loop drains + /// into a `WalletTask::GenerateReceiveAddress` backend task. The address is + /// derived from the upstream SPV-watched pool so it is always monitored — + /// never a DET-side index past the gap window. Carries the wallet's seed + /// hash; the new address returns via `GeneratedReceiveAddress`. + pub pending_core_address_request: Option<WalletSeedHash>, } /// State for the Fund Platform Address from Asset Lock dialog #[derive(Default)] pub(super) struct FundPlatformAddressDialogState { pub is_open: bool, - /// Selected asset lock index - pub selected_asset_lock_index: Option<usize>, + /// Outpoint of the upstream-tracked asset lock chosen to fund a Platform + /// address. `None` until the user clicks "Fund" on a row in the asset- + /// locks table. + pub selected_asset_lock_out_point: Option<dash_sdk::dpp::dashcore::OutPoint>, /// Selected Platform address to fund pub selected_platform_address: Option<String>, /// List of Platform addresses available @@ -110,6 +109,11 @@ pub(super) struct PrivateKeyDialogState { pub pending_derivation_path: Option<DerivationPath>, /// Pending address string (when wallet needs unlock first) pub pending_address: Option<String>, + /// A queued key-display request the `ui()` loop drains into a + /// `WalletTask::DeriveKeyForDisplay` backend task. The seed is fetched + /// just-in-time in the backend; only the WIF returns here. Tuple is + /// `(seed_hash, derivation_path, display_address)`. + pub pending_view_key_request: Option<(WalletSeedHash, DerivationPath, String)>, } impl WalletsBalancesScreen { @@ -133,154 +137,35 @@ impl WalletsBalancesScreen { spread: 0, color: DashColors::popup_shadow(), }, - fill: ctx.style().visuals.window_fill, + fill: ctx.global_style().visuals.window_fill, stroke: egui::Stroke::new(1.0, DashColors::popup_border_glow()), } } - pub(super) fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.send_dialog.is_open { - return AppAction::None; - } - - let mut action = AppAction::None; - let mut open = self.send_dialog.is_open; - - // Draw dark overlay behind the dialog - Self::draw_modal_overlay(ctx, "send_dialog_overlay"); - - egui::Window::new("Send Dash") - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - ui.label("Recipient Address"); - let hint = if self.app_context.network == Network::Mainnet { - "Enter Core address (X.../7...)" - } else { - "Enter Core address (y.../8...)" - }; - let response = ui - .add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text(hint)); - - // Validate address when it changes - if response.changed() { - if self.send_dialog.address.trim().is_empty() { - self.send_dialog.address_error = None; - } else { - let trimmed = self.send_dialog.address.trim(); - if crate::ui::helpers::is_platform_address_string(trimmed) { - self.send_dialog.address_error = Some( - "Platform addresses not supported. Use a Core address.".to_string(), - ); - } else { - match trimmed.parse::<Address<NetworkUnchecked>>() { - Ok(_) => { - self.send_dialog.address_error = None; - } - Err(_) => { - self.send_dialog.address_error = - Some("Invalid Core address".to_string()); - } - } - } - } - } - - if let Some(error) = &self.send_dialog.address_error { - ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error); - } - - ui.add_space(8.0); - - // Amount input using AmountInput component - let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount (e.g., 0.01)") - .with_desired_width(150.0) - }); - - let response = amount_input.show(ui); - response.inner.update(&mut self.send_dialog.amount); - - ui.checkbox( - &mut self.send_dialog.subtract_fee, - "Subtract fee from amount", - ); - - ui.label("Memo (optional)"); - ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); - - if let Some(error) = self.send_dialog.error.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.send_dialog.error = None; - } - }); - }); - } - - ui.add_space(8.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.horizontal(|ui| { - let has_address_error = self.send_dialog.address_error.is_some(); - if ComponentStyles::add_primary_button_enabled(ui, !has_address_error, "Send") - .clicked() - { - match self.prepare_send_action() { - Ok(app_action) => { - action = app_action; - self.send_dialog = SendDialogState::default(); - } - Err(err) => self.send_dialog.error = Some(err), - } - } - if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { - self.send_dialog = SendDialogState::default(); - } - }); - }); - - self.send_dialog.is_open = open; - action - } - pub(super) fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { if !self.receive_dialog.is_open { return AppAction::None; } - // Refresh cached balances from the wallet so SPV updates are reflected + // Refresh cached balances from the display-only WalletBackend + // snapshot so chain updates are reflected. if let Some(wallet) = &self.selected_wallet && let Ok(wallet_guard) = wallet.read() { use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; + let address_balances = self + .app_context + .snapshot_address_balances(&wallet_guard.seed_hash()); for (addr_str, balance) in &mut self.receive_dialog.core_addresses { if let Ok(addr) = addr_str.parse::<Address<NetworkUnchecked>>() && let Ok(addr) = addr.require_network(self.app_context.network) { - *balance = wallet_guard - .address_balances - .get(&addr) - .copied() - .unwrap_or(0); + *balance = address_balances.get(&addr).copied().unwrap_or(0); } } } - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; // Determine current address based on selected type let current_address = match self.receive_dialog.address_type { @@ -473,20 +358,8 @@ impl WalletsBalancesScreen { } if generate_new - && let Some(wallet) = &self.selected_wallet { - match self.generate_new_core_receive_address(wallet) { - Ok((new_addr, new_balance)) => { - self.receive_dialog.core_addresses.push((new_addr, new_balance)); - self.receive_dialog.selected_core_index = - self.receive_dialog.core_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } + && let Some(wallet) = self.selected_wallet.clone() { + self.queue_core_address_request(&wallet); } } @@ -571,7 +444,7 @@ impl WalletsBalancesScreen { ui.add_space(8.0); let mut copy_status: Option<String> = None; - let mut new_addr_result: Option<Result<String, String>> = None; + let mut request_new_addr = false; ui.horizontal(|ui| { if ComponentStyles::add_primary_button(ui, "Copy Address") @@ -585,7 +458,7 @@ impl WalletsBalancesScreen { } // Button to add new Platform address - if let Some(wallet) = &self.selected_wallet + if self.selected_wallet.is_some() && ComponentStyles::add_secondary_button( ui, "New Address", @@ -593,7 +466,7 @@ impl WalletsBalancesScreen { ) .clicked() { - new_addr_result = Some(self.generate_platform_address(wallet)); + request_new_addr = true; } }); @@ -602,22 +475,12 @@ impl WalletsBalancesScreen { self.receive_dialog.status = Some(status); } - // Handle new address generation after the closure - if let Some(result) = new_addr_result { - match result { - Ok(new_addr) => { - self.receive_dialog.platform_addresses.push((new_addr, 0)); - self.receive_dialog.selected_platform_index = - self.receive_dialog.platform_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = - Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } + // Queue the backend address-generation request + // after the closure (it borrows `&mut self`). + if request_new_addr + && let Some(wallet) = self.selected_wallet.clone() + { + self.queue_platform_address_request(&wallet); } } @@ -655,40 +518,45 @@ impl WalletsBalancesScreen { AppAction::None } - /// Generate a new Platform address for the wallet. - /// Returns the address in Bech32m format (e.g., tdash1k... for testnet per DIP-18) - pub(super) fn generate_platform_address( - &self, - wallet: &Arc<RwLock<Wallet>>, - ) -> Result<String, String> { - use dash_sdk::dpp::address_funds::PlatformAddress; - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - // Pass true to skip known addresses and generate a new one - let address = wallet_guard - .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - // Convert to PlatformAddress and encode as Bech32m per DIP-18 - let platform_addr = - PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; - Ok(platform_addr.to_bech32m_string(self.app_context.network)) + /// Queue a "generate a new Platform receive address" request for the wallet. + /// + /// The actual derivation runs in the backend via + /// `WalletTask::GeneratePlatformReceiveAddress` — the `ui()` loop drains + /// `pending_platform_address_request` into a backend task, the seed is + /// fetched just-in-time, and only the new Bech32m address returns. The seed + /// never crosses into the UI layer. + pub(super) fn queue_platform_address_request(&mut self, wallet: &Arc<RwLock<Wallet>>) { + let seed_hash = match wallet.read() { + Ok(w) => w.seed_hash(), + Err(_) => { + self.receive_dialog.status = + Some("Could not read the selected wallet. Please retry.".to_string()); + return; + } + }; + self.receive_dialog.pending_platform_address_request = Some(seed_hash); + self.receive_dialog.status = Some("Generating a new address…".to_string()); } - /// Generate a new Core receive address for the wallet - /// Returns (address_string, balance_duffs) - pub(super) fn generate_new_core_receive_address( - &self, - wallet: &Arc<RwLock<Wallet>>, - ) -> Result<(String, u64), String> { - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - let address = wallet_guard - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - let balance = wallet_guard - .address_balances - .get(&address) - .copied() - .unwrap_or(0); - Ok((address.to_string(), balance)) + /// Queue a "generate a new Core receive address" request for the wallet. + /// + /// The derivation runs in the backend via `WalletTask::GenerateReceiveAddress` + /// (→ upstream `next_unused`), so the returned address is always inside the + /// SPV-watched gap-limit window. The `ui()` loop drains + /// `pending_core_address_request` into the backend task; the new address + /// returns via `GeneratedReceiveAddress`. Deriving DET-side here would hand + /// out an address past the watched window and lose deposits sent to it. + pub(super) fn queue_core_address_request(&mut self, wallet: &Arc<RwLock<Wallet>>) { + let seed_hash = match wallet.read() { + Ok(w) => w.seed_hash(), + Err(_) => { + self.receive_dialog.status = + Some("Could not read the selected wallet. Please retry.".to_string()); + return; + } + }; + self.receive_dialog.pending_core_address_request = Some(seed_hash); + self.receive_dialog.status = Some("Generating a new address…".to_string()); } /// Render the Fund Platform Address from Asset Lock dialog @@ -699,7 +567,7 @@ impl WalletsBalancesScreen { let mut action = AppAction::None; let mut open = self.fund_platform_dialog.is_open; - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; // Draw dark overlay behind the popup Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); @@ -790,7 +658,7 @@ impl WalletsBalancesScreen { // Buttons ui.horizontal(|ui| { let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() - && self.fund_platform_dialog.selected_asset_lock_index.is_some() + && self.fund_platform_dialog.selected_asset_lock_out_point.is_some() && !self.fund_platform_dialog.is_processing; // Cancel button @@ -872,7 +740,7 @@ impl WalletsBalancesScreen { return; } - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; let mut open = self.private_key_dialog.is_open; // Draw dark overlay behind the dialog @@ -999,85 +867,81 @@ impl WalletsBalancesScreen { return AppAction::None; }; - let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { + let Some(out_point) = self.fund_platform_dialog.selected_asset_lock_out_point else { self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); self.fund_platform_dialog.status_is_error = true; return AppAction::None; }; - // Get the asset lock proof and address from the wallet - let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { - let wallet = match wallet_arc.read() { - Ok(guard) => guard, - Err(e) => { - self.fund_platform_dialog.status = Some(e.to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - }; - - let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); - let Some((_, addr, _, _, Some(proof))) = asset_lock else { - self.fund_platform_dialog.status = - Some("Asset lock not found or not ready".to_string()); + let seed_hash = match wallet_arc.read() { + Ok(guard) => guard.seed_hash(), + Err(e) => { + self.fund_platform_dialog.status = Some(e.to_string()); self.fund_platform_dialog.status_is_error = true; return AppAction::None; - }; - - // Parse the Platform address (Bech32m format: dash1.../tdash1... per DIP-18) - let platform_addr = if crate::ui::helpers::is_platform_address_string(selected_addr) { - match PlatformAddress::from_bech32m_string(selected_addr) { - Ok((addr, network)) => { - // Validate that address network matches app network - if !crate::model::wallet::networks_address_compatible( - &network, - &self.app_context.network, - ) { - self.fund_platform_dialog.status = Some(format!( - "Address network mismatch: address is for {:?} but app is on {:?}", - network, self.app_context.network - )); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - addr - } - Err(e) => { - self.fund_platform_dialog.status = - Some(format!("Invalid Bech32m address: {}", e)); + } + }; + + // Parse the Platform address (Bech32m format: dash1.../tdash1... per DIP-18) + let platform_addr = if crate::ui::helpers::is_platform_address_string(selected_addr) { + match PlatformAddress::from_bech32m_string(selected_addr) { + Ok(addr) => { + // `from_bech32m_string` no longer returns the network. Derive + // the mainnet/non-mainnet class from the HRP and synthesise a + // representative `Network` for `networks_address_compatible`: + // mainnet HRP ("dash1…") → `Mainnet`, anything else → `Testnet` + // (testnet and all non-mainnet networks share the "tdash1…" HRP). + let addr_is_mainnet = + PlatformAddress::is_mainnet_bech32m(selected_addr).unwrap_or(false); + let addr_network = if addr_is_mainnet { + Network::Mainnet + } else { + Network::Testnet + }; + if !crate::model::wallet::networks_address_compatible( + &addr_network, + &self.app_context.network, + ) { + let addr_net_label = if addr_is_mainnet { + "mainnet" + } else { + "testnet" + }; + self.fund_platform_dialog.status = Some(format!( + "Address network mismatch: address is for {} but app is on {:?}", + addr_net_label, self.app_context.network + )); self.fund_platform_dialog.status_is_error = true; return AppAction::None; } + addr } - } else { - // Fall back to base58 parsing for backwards compatibility - match selected_addr - .parse::<Address<NetworkUnchecked>>() - .map_err(|e| e.to_string()) - .and_then(|a: Address<NetworkUnchecked>| { - PlatformAddress::try_from(a.assume_checked()) - .map_err(|e| format!("Invalid Platform address: {}", e)) - }) { - Ok(addr) => addr, - Err(e) => { - self.fund_platform_dialog.status = Some(e); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } + Err(e) => { + self.fund_platform_dialog.status = + Some(format!("Invalid Bech32m address: {}", e)); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + } else { + match selected_addr + .parse::<Address<NetworkUnchecked>>() + .map_err(|e| e.to_string()) + .and_then(|a: Address<NetworkUnchecked>| { + PlatformAddress::try_from(a.assume_checked()) + .map_err(|e| format!("Invalid Platform address: {}", e)) + }) { + Ok(addr) => addr, + Err(e) => { + self.fund_platform_dialog.status = Some(e); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; } - }; - - ( - wallet.seed_hash(), - Box::new(proof.clone()), - addr.clone(), - platform_addr, - ) + } }; - // Build outputs - fund the entire asset lock to the selected Platform address let mut outputs: BTreeMap<PlatformAddress, Option<u64>> = BTreeMap::new(); - outputs.insert(platform_addr, None); // None = take the full amount + outputs.insert(platform_addr, None); self.fund_platform_dialog.is_processing = true; self.fund_platform_dialog.status = Some("Processing...".to_string()); @@ -1086,64 +950,12 @@ impl WalletsBalancesScreen { AppAction::BackendTask(BackendTask::WalletTask( WalletTask::FundPlatformAddressFromAssetLock { seed_hash, - asset_lock_proof, - asset_lock_address, + out_point, outputs, }, )) } - pub(super) fn prepare_send_action(&mut self) -> Result<AppAction, String> { - let wallet = self - .selected_wallet - .as_ref() - .ok_or_else(|| "Select a wallet first".to_string())?; - - let amount_duffs = self - .send_dialog - .amount - .as_ref() - .ok_or_else(|| "Enter an amount".to_string())? - .dash_to_duffs()?; - - if amount_duffs == 0 { - return Err("Amount must be greater than 0".to_string()); - } - - { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if amount_duffs > wallet_guard.confirmed_balance_duffs() { - return Err("Insufficient confirmed balance".to_string()); - } - } - - if self.send_dialog.address.trim().is_empty() { - return Err("Enter a recipient address".to_string()); - } - - let memo = self.send_dialog.memo.trim(); - let request = WalletPaymentRequest { - recipients: vec![PaymentRecipient { - address: self.send_dialog.address.trim().to_string(), - amount_duffs, - }], - subtract_fee_from_amount: self.send_dialog.subtract_fee, - memo: if memo.is_empty() { - None - } else { - Some(memo.to_string()) - }, - override_fee: None, - }; - - Ok(AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::SendWalletPayment { - wallet: wallet.clone(), - request, - }, - ))) - } - pub(super) fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { let Some(wallet) = self.selected_wallet.clone() else { self.receive_dialog.status = Some("Select a wallet first".to_string()); @@ -1168,24 +980,32 @@ impl WalletsBalancesScreen { AppAction::None } - /// Load BIP44 external addresses with balances from a wallet. + /// Load the SPV-watched BIP44 external (receive) addresses with balances. + /// + /// Sourced from the lock-free `WalletSnapshot` monitored set, so the Receive + /// list shows exactly the addresses SPV watches — never a DET-side index + /// past the gap window. Empty before the first sync publishes a snapshot; + /// the caller then asks the backend to derive a watched address. fn load_bip44_external_addresses( &self, wallet: &Arc<RwLock<Wallet>>, ) -> Result<Vec<(String, u64)>, String> { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - let network = self.app_context.network; - let addresses: Vec<(String, u64)> = wallet_guard - .watched_addresses - .iter() - .filter(|(path, _)| path.is_bip44_external(network)) - .map(|(_, info)| { - let balance = wallet_guard - .address_balances - .get(&info.address) - .copied() + let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + let backend = self + .app_context + .wallet_backend() + .map_err(|e| e.to_string())?; + let address_balances = self.app_context.snapshot_address_balances(&seed_hash); + let addresses: Vec<(String, u64)> = backend + .snapshot_monitored_receive_addresses(&seed_hash) + .into_iter() + .map(|addr_str| { + let balance = addr_str + .parse::<Address<_>>() + .ok() + .and_then(|addr| address_balances.get(&addr.assume_checked()).copied()) .unwrap_or(0); - (info.address.to_string(), balance) + (addr_str, balance) }) .collect(); Ok(addresses) @@ -1194,24 +1014,18 @@ impl WalletsBalancesScreen { /// Load Core addresses into the receive dialog fn load_core_addresses_for_receive(&mut self, wallet: &Arc<RwLock<Wallet>>) { match self.load_bip44_external_addresses(wallet) { - Ok(addresses) if addresses.is_empty() => { - match self.generate_new_core_receive_address(wallet) { - Ok((address, balance)) => { - self.receive_dialog.core_addresses = vec![(address, balance)]; - self.receive_dialog.selected_core_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.core_addresses.clear(); - } - } - } - Ok(addresses) => { + Ok(addresses) if !addresses.is_empty() => { self.receive_dialog.core_addresses = addresses; self.receive_dialog.selected_core_index = 0; } - Err(err) => { - self.receive_dialog.status = Some(err); + // Empty list or the wallet isn't watched yet: ask the backend to + // derive an address from the SPV-watched pool. The result arrives + // via `GeneratedReceiveAddress`; this is self-healing once the + // wallet finishes registering with the backend. + _ => { + self.receive_dialog.core_addresses.clear(); + self.receive_dialog.selected_core_index = 0; + self.queue_core_address_request(wallet); } } } @@ -1245,34 +1059,50 @@ impl WalletsBalancesScreen { drop(wallet_guard); if platform_addresses.is_empty() { - // Generate a new Platform address if none exists - match self.generate_platform_address(wallet) { - Ok(address) => { - self.receive_dialog.platform_addresses = vec![(address, 0)]; - self.receive_dialog.selected_platform_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.platform_addresses.clear(); - } - } + // No address yet: queue a backend generation request. The seed is + // fetched just-in-time and the new address arrives via + // `display_task_result`. + self.receive_dialog.platform_addresses.clear(); + self.queue_platform_address_request(wallet); } else { self.receive_dialog.platform_addresses = platform_addresses; self.receive_dialog.selected_platform_index = 0; } } - pub(super) fn derive_private_key_wif(&self, path: &DerivationPath) -> Result<Secret, String> { - let wallet_arc = self - .selected_wallet - .clone() - .ok_or_else(|| "Select a wallet first".to_string())?; - let wallet = wallet_arc.read().map_err(|e| e.to_string())?; - if wallet.uses_password && !wallet.is_open() { - return Err("Unlock this wallet to view private keys.".to_string()); - } - let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; - Ok(Secret::new(private_key.to_wif())) + /// Queue a private-key-display request for the given path and address. + /// + /// The actual derivation runs in the backend via + /// `WalletTask::DeriveKeyForDisplay` — the `ui()` loop drains + /// `pending_view_key_request` into a backend task, the seed is fetched + /// just-in-time, and only the WIF (wrapped in `Secret`) returns. The seed + /// never crosses into the UI layer. + pub(super) fn queue_view_key_request( + &mut self, + path: &DerivationPath, + display_address: String, + ) { + let Some(wallet_arc) = self.selected_wallet.clone() else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select a wallet first", + MessageType::Error, + ); + return; + }; + let seed_hash = match wallet_arc.read() { + Ok(w) => w.seed_hash(), + Err(_) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not read the selected wallet. Please retry.", + MessageType::Error, + ); + return; + } + }; + self.private_key_dialog.pending_view_key_request = + Some((seed_hash, path.clone(), display_address)); } pub(super) fn open_mine_dialog(&mut self) { @@ -1285,10 +1115,12 @@ impl WalletsBalancesScreen { return; }; + let seed_hash = wallet.read().map(|g| g.seed_hash()).unwrap_or_default(); + let balances = self.app_context.snapshot_address_balances(&seed_hash); let address_input = AddressInput::new(self.app_context.network) .with_label("Mine to address:") .with_address_kinds(&[AddressKind::Core]) - .with_wallets(&[wallet]) + .with_wallets(&[(wallet, balances)]) .with_selection_only(true) .with_full_addresses(true); @@ -1308,7 +1140,7 @@ impl WalletsBalancesScreen { let mut action = AppAction::None; let mut open = self.mine_dialog.is_open; - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; Self::draw_modal_overlay(ctx, "mine_dialog_overlay"); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 144799af3..2540cc712 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -3,49 +3,50 @@ mod asset_locks; mod dialogs; mod single_key_view; -pub(crate) use single_key_view::SINGLE_KEY_REQUIRES_CORE; +pub(crate) use single_key_view::SINGLE_KEY_SEND_UNAVAILABLE; -use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; +use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::backend_task::error::TaskError; -use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; -use crate::model::amount::Amount; -use crate::model::feature_gate::FeatureGate; +use crate::context::feature_gate::FeatureGate; +use crate::model::fee_estimation::format_duffs_as_dash; +use crate::model::spv_status::SpvStatus; use crate::model::wallet::{TransactionStatus, Wallet, WalletSeedHash, WalletTransaction}; -use crate::spv::{CoreBackendMode, SpvStatus}; +use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; -use crate::ui::components::selection_dialog::{SelectionDialog, SelectionStatus}; use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_wallet_only_spec}; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::clicked_outside_window; use crate::ui::helpers::copy_text_to_clipboard; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; -use crate::ui::wallets::account_summary::{ +use crate::ui::state::TrackedAssetLockCache; +use crate::ui::state::account_summary::{ AccountCategory, AccountSummary, collect_account_summaries, }; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; +use crate::wallet_backend::poison::RwLockRecover; use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::dashcore::Address; -use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use eframe::egui::{self, ComboBox, Context, Ui}; use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; use std::sync::{Arc, RwLock}; +use crate::backend_task::migration::single_key_restore::PendingProtectedRestore; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::wallets::import_single_key::ImportSingleKeyDialog; +use crate::ui::wallets::restore_single_key::RestoreSingleKeyDialog; use crate::ui::wallets::shielded_tab::ShieldedTabView; use address_table::{SortColumn, SortOrder}; use dialogs::{ FundPlatformAddressDialogState, MineDialogState, PrivateKeyDialogState, ReceiveDialogState, - SendDialogState, }; /// Tab selector for the Accounts & Addresses section. @@ -58,7 +59,7 @@ use dialogs::{ enum AccountTab { /// Regular account category (BIP44, PlatformPayment) Category(AccountCategory, Option<u32>), - /// Shielded wallet view (replaces the old top-level Shielded tab) + /// Shielded wallet view. Shielded, /// Consolidated system tab (developer mode only) — shows all non-primary /// account categories as collapsible sections. @@ -71,14 +72,96 @@ impl Default for AccountTab { } } -/// Refresh mode for dev mode dropdown - controls what gets refreshed +/// Pure tab-visibility planner. Given the per-account summaries and display +/// context, decide which account tabs to show. Extracted from +/// [`WalletScreen::build_account_tabs`] so the visibility rules are unit-testable +/// without a live screen: +/// +/// - **Platform is shown immediately** on wallet load (`show_platform_tab`), +/// empty until sync populates it — every HD wallet unconditionally bootstraps +/// a platform-payment receive address, so gating the tab on a completed sync +/// would hide the user's only in-app way to find that receive address until +/// the network responds. +/// - **The header total is always reconciled by a visible tab.** In developer +/// mode the System tab always shows the full breakdown; in default mode a +/// consolidated tab appears whenever a non-visible category actually holds +/// funds (`hidden_balance > 0`), so the visible tabs' balances always sum to +/// the wallet header total instead of silently dropping funds the user cannot +/// see. +/// - **No duplicate Platform tab.** Today upstream's `all_accounts()` excludes +/// the platform-payment pool, so the primary loop never emits a Platform tab +/// and the dedicated push is the sole source. The de-dup guard keeps that true +/// if a future upstream bump ever folds that pool into `all_accounts()`. +fn plan_account_tabs( + summaries: &[AccountSummary], + developer_mode: bool, + shielded_available: bool, + show_platform_tab: bool, +) -> Vec<AccountTab> { + let mut tabs: Vec<AccountTab> = Vec::new(); + + // Default-visible primary tabs: BIP-44 accounts (the per-category Core + // breakdown). Platform is normally added by the dedicated block below. + for summary in summaries { + if !summary.category.is_visible_in_default_mode() { + continue; + } + tabs.push(AccountTab::Category( + summary.category.clone(), + summary.index, + )); + } + + // Ensure the Dash Core tab exists even without summaries. + if !tabs + .iter() + .any(|t| matches!(t, AccountTab::Category(AccountCategory::Bip44, Some(0)))) + { + tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); + } + + // Platform tab, right after the BIP-44 accounts. De-dup-guarded against a + // Platform tab the primary loop may already have pushed. + if show_platform_tab + && !tabs + .iter() + .any(|t| matches!(t, AccountTab::Category(AccountCategory::PlatformPayment, _))) + { + tabs.push(AccountTab::Category(AccountCategory::PlatformPayment, None)); + } + + // Shielded tab only when the connected network supports it. + if shielded_available { + tabs.push(AccountTab::Shielded); + } + + // Consolidated System/Other tab: always in developer mode; in default mode + // only when a non-visible category actually holds funds, so the visible tabs + // always reconcile the wallet header total. + let hidden_balance: u64 = summaries + .iter() + .filter(|s| s.category.is_system_category()) + .map(|s| s.confirmed_balance) + .sum(); + if developer_mode || hidden_balance > 0 { + tabs.push(AccountTab::System); + } + + tabs +} + +/// Refresh mode for dev mode dropdown - controls what gets refreshed. +/// +/// There is no "Core only" mode: Core wallet state (balances/UTXOs) is kept +/// current continuously by the upstream runtime and pushed via the EventBridge, +/// so there is nothing to reconcile on demand. Refresh only re-fetches the +/// DAPI-sourced Platform-address balances, optionally alongside the always-live +/// Core view. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { /// Core wallet + Platform address sync #[default] All, - /// Only refresh Core wallet balances - CoreOnly, /// Only Platform address sync PlatformOnly, } @@ -87,15 +170,13 @@ impl RefreshMode { fn label(&self) -> &'static str { match self { RefreshMode::All => "Core + Platform", - RefreshMode::CoreOnly => "Core Only", RefreshMode::PlatformOnly => "Platform Only", } } fn next(self) -> Self { match self { - RefreshMode::All => RefreshMode::CoreOnly, - RefreshMode::CoreOnly => RefreshMode::PlatformOnly, + RefreshMode::All => RefreshMode::PlatformOnly, RefreshMode::PlatformOnly => RefreshMode::All, } } @@ -116,7 +197,6 @@ pub struct WalletsBalancesScreen { remove_wallet_dialog: Option<ConfirmationDialog>, pending_wallet_removal: Option<WalletSeedHash>, pending_wallet_removal_alias: Option<String>, - send_dialog: SendDialogState, receive_dialog: ReceiveDialogState, fund_platform_dialog: FundPlatformAddressDialogState, private_key_dialog: PrivateKeyDialogState, @@ -129,10 +209,6 @@ pub struct WalletsBalancesScreen { pending_refresh_after_unlock: bool, /// The refresh mode to use after unlock (if pending_refresh_after_unlock is true) pending_refresh_mode: RefreshMode, - /// Whether we should search for asset locks after wallet is unlocked - pending_asset_lock_search_after_unlock: bool, - /// Banner handle for asset lock search progress - asset_lock_search_banner: Option<BannerHandle>, /// Current page for single key wallet UTXO pagination (0-indexed) utxo_page: usize, /// Selected refresh mode (only shown in dev mode) @@ -141,24 +217,8 @@ pub struct WalletsBalancesScreen { selected_account_tab: AccountTab, /// Shielded tab view component (lazily initialized per wallet) shielded_tab_view: Option<ShieldedTabView>, - /// Cached platform sync info: (last_sync_timestamp, last_sync_height) - platform_sync_info: Option<(u64, u64)>, - /// Core wallet selection dialog (shown when auto-detection fails) - core_wallet_dialog: Option<SelectionDialog>, - /// Seed/key hash of the wallet pending Core wallet selection - pending_core_wallet_seed_hash: Option<[u8; 32]>, - /// Core wallet options for the pending selection - pending_core_wallet_options: Option<Vec<String>>, - /// Whether the pending Core wallet selection is for a single-key wallet - pending_core_wallet_is_single_key: bool, /// Whether a wallet switch should trigger a Core refresh on the next frame pending_wallet_refresh_on_switch: bool, - /// Whether we need to fire a ListCoreWallets backend task (set on CoreWalletNotConfigured error) - pending_list_core_wallets: bool, - /// Wallet hash pending the ListCoreWallets response - pending_list_wallet_hash: Option<[u8; 32]>, - /// Whether the wallet pending list is a single-key wallet - pending_list_is_single_key: bool, /// Cached filtered transaction indices for the currently selected wallet. /// Invalidated (set to None) on wallet switch or transaction updates. cached_tx_indices: Option<Vec<usize>>, @@ -170,6 +230,25 @@ pub struct WalletsBalancesScreen { /// (rather than constructed fresh each frame) so the underlying tracing /// log fires once on mode entry instead of every repaint. pub(crate) sk_spv_warning_banner: crate::ui::components::MessageBanner, + /// "Import private key (advanced)" modal dialog. Routes single-key imports + /// through [`crate::wallet_backend::SingleKeyView::import_wif`]. + import_single_key_dialog: ImportSingleKeyDialog, + /// "Restore a protected imported key" modal dialog. Opened from the + /// post-migration restore banner; routes the password through + /// [`crate::backend_task::migration::single_key_restore::restore_protected_single_key`]. + restore_single_key_dialog: RestoreSingleKeyDialog, + /// Protected single-key rows preserved by the migration that still need + /// the user's old password to restore. Recomputed lazily (see + /// [`Self::refresh_pending_protected_restores`]); drives the restore + /// banner and is emptied as keys are restored. + pending_protected_restores: Vec<PendingProtectedRestore>, + /// Whether [`Self::pending_protected_restores`] has been computed at + /// least once this session, so the (DB-touching) scan runs lazily on + /// first paint rather than in the constructor. + pending_restores_scanned: bool, + /// Tracked asset locks for the selected wallet, fetched off the UI thread + /// via the App Task System and rendered by the Asset Locks tab. + asset_lock_cache: TrackedAssetLockCache, } impl WalletsBalancesScreen { @@ -204,12 +283,11 @@ impl WalletsBalancesScreen { } // Default: try HD wallet first, then single key wallet - let hd_wallet = app_context.wallets.read().unwrap().values().next().cloned(); + let hd_wallet = app_context.wallets.read_recover().values().next().cloned(); let sk_wallet = if hd_wallet.is_none() { app_context .single_key_wallets - .read() - .unwrap() + .read_recover() .values() .next() .cloned() @@ -227,12 +305,6 @@ impl WalletsBalancesScreen { selected_wallet: Option<Arc<RwLock<Wallet>>>, selected_single_key_wallet: Option<Arc<RwLock<SingleKeyWallet>>>, ) -> Self { - let platform_sync_info = selected_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())) - .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) - .filter(|(ts, _)| *ts > 0); - let shielded_tab_view = selected_wallet .as_ref() .and_then(|w| w.read().ok().map(|g| g.seed_hash())) @@ -253,7 +325,6 @@ impl WalletsBalancesScreen { remove_wallet_dialog: None, pending_wallet_removal: None, pending_wallet_removal_alias: None, - send_dialog: SendDialogState::default(), receive_dialog: ReceiveDialogState::default(), fund_platform_dialog: FundPlatformAddressDialogState::default(), private_key_dialog: PrivateKeyDialogState::default(), @@ -263,115 +334,33 @@ impl WalletsBalancesScreen { pending_platform_balance_refresh: None, pending_refresh_after_unlock: false, pending_refresh_mode: RefreshMode::default(), - pending_asset_lock_search_after_unlock: false, - asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), selected_account_tab: AccountTab::default(), shielded_tab_view, - platform_sync_info, - core_wallet_dialog: None, - pending_core_wallet_seed_hash: None, - pending_core_wallet_options: None, - pending_core_wallet_is_single_key: false, pending_wallet_refresh_on_switch: false, - pending_list_core_wallets: false, - pending_list_wallet_hash: None, - pending_list_is_single_key: false, cached_tx_indices: None, cached_tx_source_len: None, sk_spv_warning_banner: crate::ui::components::MessageBanner::new(), + import_single_key_dialog: ImportSingleKeyDialog::new(app_context.network), + restore_single_key_dialog: RestoreSingleKeyDialog::new(), + pending_protected_restores: Vec::new(), + pending_restores_scanned: false, + asset_lock_cache: TrackedAssetLockCache::default(), } } fn persist_selected_wallet_hash(&self, hash: Option<WalletSeedHash>) { - if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { - *guard = hash; - } - let _ = self - .app_context - .db - .update_selected_wallet_hash(hash.as_ref()); + self.app_context.set_selected_hd_wallet(hash); } fn persist_selected_single_key_hash(&self, hash: Option<[u8; 32]>) { - if let Ok(mut guard) = self.app_context.selected_single_key_hash.lock() { - *guard = hash; - } - let _ = self - .app_context - .db - .update_selected_single_key_hash(hash.as_ref()); - } - - /// Persist the selected Core wallet name to the DB and in-memory wallet. - /// - /// Returns `Ok(())` on success or `Err` with a user-facing message on failure. - fn apply_core_wallet_selection( - &mut self, - wallet_hash: &[u8; 32], - wallet_name: &str, - is_single_key: bool, - ) -> Result<(), String> { - if !is_single_key { - match self - .app_context - .db - .set_wallet_core_wallet_name(wallet_hash, Some(wallet_name)) - { - Ok(false) => { - return Err("Wallet not found in database".to_string()); - } - Err(e) => { - return Err(format!("Failed to save Dash Core wallet: {e}")); - } - Ok(true) => {} - } - if let Ok(wallets) = self.app_context.wallets.read() - && let Some(w) = wallets.get(wallet_hash) - && let Ok(mut guard) = w.write() - { - guard.core_wallet_name = Some(wallet_name.to_string()); - } - } else { - match self - .app_context - .db - .set_single_key_wallet_core_wallet_name(wallet_hash, Some(wallet_name)) - { - Ok(false) => { - return Err("Wallet not found in database".to_string()); - } - Err(e) => { - return Err(format!("Failed to save Dash Core wallet: {e}")); - } - Ok(true) => {} - } - if let Ok(skw) = self.app_context.single_key_wallets.read() - && let Some(w) = skw.get(wallet_hash) - && let Ok(mut guard) = w.write() - { - guard.core_wallet_name = Some(wallet_name.to_string()); - } - } - - Ok(()) - } - - /// Refresh the cached platform sync info from the database. - fn refresh_platform_sync_info_cache(&mut self, seed_hash: &WalletSeedHash) { - self.platform_sync_info = self - .app_context - .db - .get_platform_sync_info(seed_hash) - .ok() - .filter(|(ts, _)| *ts > 0); + self.app_context.set_selected_single_key_wallet(hash); } /// Set the selected HD wallet and update all associated state (persisted - /// hash, platform sync info cache). All code paths that change - /// `selected_wallet` should go through this helper to keep the sync - /// status panel consistent. + /// hash). All code paths that change `selected_wallet` should go through + /// this helper to keep the panel consistent. fn set_selected_hd_wallet(&mut self, wallet: Option<Arc<RwLock<Wallet>>>) { let seed_hash = wallet .as_ref() @@ -388,14 +377,9 @@ impl WalletsBalancesScreen { if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); - self.refresh_platform_sync_info_cache(&hash); - // Trigger a refresh on the next frame for the newly selected wallet - if self.app_context.core_backend_mode() == CoreBackendMode::Rpc { - self.pending_wallet_refresh_on_switch = true; - } + // Chain sync is SPV-only and continuous; no RPC refresh-on-switch. } else { self.persist_selected_wallet_hash(None); - self.platform_sync_info = None; } } @@ -408,7 +392,6 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; self.selected_account = None; - self.platform_sync_info = None; self.utxo_page = 0; if let Ok(hash) = wallet.read().map(|g| g.key_hash) { @@ -464,18 +447,10 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = Some(wallet); self.selected_wallet = None; self.selected_account = None; - self.platform_sync_info = None; return; } self.selected_account = None; - self.platform_sync_info = None; - } - - pub(crate) fn reset_pending_list_state(&mut self) { - self.pending_list_core_wallets = false; - self.pending_list_wallet_hash = None; - self.pending_list_is_single_key = false; } /// Clear all transient request/pending state that could fire against the @@ -483,13 +458,8 @@ impl WalletsBalancesScreen { pub(crate) fn reset_transient_state(&mut self) { self.pending_platform_balance_refresh = None; self.pending_refresh_after_unlock = false; - self.pending_asset_lock_search_after_unlock = false; self.pending_wallet_refresh_on_switch = false; - self.pending_core_wallet_seed_hash = None; - self.pending_core_wallet_options = None; - self.core_wallet_dialog = None; self.refreshing = false; - self.asset_lock_search_banner.take_and_clear(); } /// Reset all cached AddressInput widgets so they pick up the new network. @@ -500,33 +470,31 @@ impl WalletsBalancesScreen { self.cached_tx_source_len = None; } - fn add_receiving_address(&mut self) { - if let Some(wallet) = &self.selected_wallet { - let result = { - let mut wallet = wallet.write().unwrap(); - wallet.receive_address(self.app_context.network, true, Some(&self.app_context)) - }; - - match result { - Ok(address) => { - let message = format!("Added new receiving address: {}", address); - MessageBanner::set_global( - self.app_context.egui_ctx(), - &message, - MessageType::Success, - ); - } - Err(e) => { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } - } - } else { + /// Request a fresh receive address through the SPV-watched upstream pool. + /// + /// Routes through the [`GenerateReceiveAddress`](crate::backend_task::wallet::WalletTask::GenerateReceiveAddress) + /// backend task (→ `next_receive_address` → upstream `next_unused`) so the + /// returned address is always inside the gap-limit window SPV monitors. + /// Deriving DET-side here would hand out addresses past the watched window + /// and lose deposits sent to them. + fn add_receiving_address(&mut self) -> AppAction { + let Some(seed_hash) = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.seed_hash()) + else { MessageBanner::set_global( self.app_context.egui_ctx(), - "No wallet selected", + "Select a wallet first, then try again.", MessageType::Error, ); - } + return AppAction::None; + }; + + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::GenerateReceiveAddress { seed_hash }, + )) } fn render_wallet_selection(&mut self, ui: &mut Ui) -> AppAction { @@ -544,10 +512,11 @@ impl WalletsBalancesScreen { // Add HD wallets if let Ok(wallets_guard) = self.app_context.wallets.read() { for wallet in wallets_guard.values() { - let guard = wallet.read().unwrap(); - let core_balance = guard.total_balance_duffs(); - let platform_balance = Self::platform_balance_duffs(&guard); - let shielded_balance = self.shielded_balance_duffs(&guard.seed_hash()); + let guard = wallet.read_recover(); + let seed_hash = guard.seed_hash(); + let core_balance = self.core_balance_duffs(&seed_hash); + let platform_balance = self.platform_balance_duffs(&seed_hash); + let shielded_balance = self.shielded_balance_duffs(&seed_hash); let balance_dash = (core_balance + platform_balance + shielded_balance) as f64 * 1e-8; let label = format!( @@ -562,7 +531,7 @@ impl WalletsBalancesScreen { // Add single key wallets if let Ok(wallets_guard) = self.app_context.single_key_wallets.read() { for wallet in wallets_guard.values() { - let guard = wallet.read().unwrap(); + let guard = wallet.read_recover(); let balance_dash = guard.total_balance_duffs() as f64 * 1e-8; let label = format!( "SK: {} ({:.4} DASH)", @@ -611,9 +580,10 @@ impl WalletsBalancesScreen { .read() .ok() .map(|g| { - let core = g.total_balance_duffs(); - let platform = Self::platform_balance_duffs(&g); - let shielded = self.shielded_balance_duffs(&g.seed_hash()); + let seed_hash = g.seed_hash(); + let core = self.core_balance_duffs(&seed_hash); + let platform = self.platform_balance_duffs(&seed_hash); + let shielded = self.shielded_balance_duffs(&seed_hash); core + platform + shielded }) .unwrap_or(0) @@ -659,8 +629,8 @@ impl WalletsBalancesScreen { }); ui.colored_label( - DashColors::text_primary(ui.ctx().style().visuals.dark_mode), - format!(" Balance: {}", Self::format_dash(current_balance)), + DashColors::text_primary(ui.style().visuals.dark_mode), + format!(" Balance: {}", format_duffs_as_dash(current_balance)), ); }); @@ -705,7 +675,7 @@ impl WalletsBalancesScreen { // Buttons for single key wallet if let Some(wallet_arc) = single_key_wallet_opt { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let (key_hash, alias) = wallet_arc .read() .ok() @@ -722,23 +692,31 @@ impl WalletsBalancesScreen { .corner_radius(4.0); if ui.add(remove_button).clicked() { - if let Err(e) = self - .app_context - .db - .remove_single_key_wallet(&key_hash, self.app_context.network) - { + // T-W-01b: imported keys live in the upstream + // `SecretStore` vault and the DET k/v sidecar. + // Route through `SingleKeyView::forget` so + // both stay consistent. + let address = wallet_arc.read().ok().map(|w| w.address.to_string()); + let outcome = match self.app_context.wallet_backend() { + Ok(backend) => match address { + Some(addr) => backend.single_key().forget(&addr).err(), + None => None, + }, + Err(e) => Some(e), + }; + if let Some(e) = outcome { MessageBanner::set_global( ui.ctx(), - format!("Failed to remove: {}", e), + "Failed to remove the imported key.", MessageType::Error, - ); + ) + .with_details(e); } else { if let Ok(mut wallets) = self.app_context.single_key_wallets.write() { wallets.remove(&key_hash); } self.selected_single_key_wallet = None; - // Clear persisted selection in AppContext and database self.persist_selected_single_key_hash(None); MessageBanner::set_global( ui.ctx(), @@ -750,25 +728,18 @@ impl WalletsBalancesScreen { ui.add_space(8.0); - // Lock/Unlock buttons for SK wallet - let (uses_password, is_open) = wallet_arc + // A password-protected single key is never opened into + // the shared map (signing decrypts just-in-time), so the + // only gesture is "Unlock", which confirms the passphrase + // against the vault without retaining any plaintext. + let uses_password = wallet_arc .read() .ok() - .map(|w| (w.uses_password, w.is_open())) - .unwrap_or((false, false)); + .map(|w| w.uses_password) + .unwrap_or(false); - let mut should_lock_sk_wallet = false; - if uses_password { - if is_open { - if ui.button("Lock").clicked() { - should_lock_sk_wallet = true; - } - } else if ui.button("Unlock").clicked() { - self.show_sk_unlock_dialog = true; - } - } - if should_lock_sk_wallet && let Ok(mut wallet) = wallet_arc.write() { - wallet.private_key_data.close(); + if uses_password && ui.button("Unlock").clicked() { + self.show_sk_unlock_dialog = true; } ui.add_space(8.0); @@ -786,11 +757,12 @@ impl WalletsBalancesScreen { action } - fn render_bottom_options(&mut self, ui: &mut Ui) { + fn render_bottom_options(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; let wallet_is_open = self .selected_wallet .as_ref() - .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); + .is_some_and(|wallet_guard| wallet_guard.read_recover().is_open()); // Only show "Add Receiving Address" button for Dash Core account (BIP44 account 0) let is_main_account = self @@ -807,14 +779,15 @@ impl WalletsBalancesScreen { .button(RichText::new("➕ Add Receiving Address").size(14.0)) .clicked() { - self.add_receiving_address(); + action |= self.add_receiving_address(); } }); } + action } fn render_remove_wallet_button(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if let Some(selected_wallet) = &self.selected_wallet { let remove_button = @@ -825,7 +798,7 @@ impl WalletsBalancesScreen { .corner_radius(4.0); if ui.add(remove_button).clicked() { - let wallet = selected_wallet.read().unwrap(); + let wallet = selected_wallet.read_recover(); let alias = wallet .alias .clone() @@ -910,6 +883,17 @@ impl WalletsBalancesScreen { } fn render_no_wallets_view(&self, ui: &mut Ui) { + // While the cold-start data migration is mid-flight we hold a + // neutral placeholder — the global migration banner already + // tells the user what's happening, so the empty-state must not + // race ahead with Create/Import CTAs that the rehydrated wallet + // list might invalidate seconds later. + let migration_state = (*self.app_context.migration_status().state()).clone(); + let migration_running = matches!( + migration_state, + crate::context::migration_status::MigrationState::Running { .. } + ); + // Optionally put everything in a framed "card"-like container Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) // background color @@ -918,54 +902,63 @@ impl WalletsBalancesScreen { .shadow(ui.visuals().window_shadow) // drop shadow .show(ui, |ui| { ui.vertical_centered(|ui| { - // Heading + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + + if migration_running { + // Diziet J-4 + D-1: defer the empty-state CTAs + // while the migration is still working — the + // banner above is the source of truth. + ui.label( + RichText::new("Preparing your wallet…") + .strong() + .size(22.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + "We are finishing the storage update. Your wallet will appear here in a moment.", + ); + ui.add_space(5.0); + return; + } + + // Fresh-install empty state (Diziet J-4). Single + // complete sentences per i18n-extraction rules; the + // primary Create CTA is the first focusable element + // so it lands as Tab-stop #1 from the wallet root + // (TC-A11Y-007). ui.label( - RichText::new("No Wallets Loaded") + RichText::new("No wallets yet") .strong() .size(25.0) .color(DashColors::text_primary(dark_mode)), ); - // A separator line for visual clarity ui.add_space(5.0); ui.separator(); ui.add_space(10.0); - // Description - ui.label("It looks like you are not tracking any wallets yet."); - - ui.add_space(10.0); - - // Subheading or emphasis - ui.heading( - RichText::new("Here’s what you can do:") - .strong() - .size(18.0) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(5.0); - - // Bullet points ui.label( - "• IMPORT a Dash wallet by clicking \ - on \"Import Wallet\" at the top right, or", + "Create a wallet or import an existing one to get started.", ); - ui.add_space(1.0); + + ui.add_space(12.0); ui.label( - "• CREATE a new Dash wallet by clicking \ - on \"Create Wallet\".", + RichText::new( + "Use the Create Wallet or Import Wallet buttons in the top-right corner.", + ) + .color(DashColors::text_secondary(dark_mode)), ); ui.add_space(10.0); ui.separator(); ui.add_space(10.0); - // Footnote or extra info + // Footnote — keeps Dash Core wallet wiring guidance + // discoverable for the Power-User persona. ui.label( - "(Make sure Dash Core is running. You can check in the \ - network tab on the left.)", + "Looking for an older wallet? Make sure Dash Core is running and visit the Network tab on the left.", ); ui.add_space(5.0); @@ -973,15 +966,6 @@ impl WalletsBalancesScreen { }); } - fn format_dash(amount_duffs: u64) -> String { - Amount::dash_from_duffs(amount_duffs).to_string() - } - - /// Format a `std::time::Instant` as a relative "time ago" string. - fn format_instant_ago(instant: std::time::Instant) -> String { - Self::format_duration_ago(instant.elapsed()) - } - /// Format a Unix timestamp (seconds since epoch) as a relative "time ago" string. fn format_unix_time_ago(unix_ts: u64) -> String { let now = std::time::SystemTime::now() @@ -1016,7 +1000,7 @@ impl WalletsBalancesScreen { } fn transaction_amount_display(tx: &WalletTransaction, dark_mode: bool) -> (String, Color32) { - let amount = Self::format_dash(tx.amount_abs()); + let amount = format_duffs_as_dash(tx.amount_abs()); if tx.is_incoming() { (format!("+{}", amount), DashColors::SUCCESS) } else if tx.is_outgoing() { @@ -1042,35 +1026,84 @@ impl WalletsBalancesScreen { } fn format_transaction_timestamp(ts: u64) -> String { + // `0` means "no block yet" (mempool or InstantSend-locked-but- + // unconfirmed) — rendering it through `DateTime` would show the Unix + // epoch (1970-01-01), which reads as a data bug rather than "still + // pending". + if ts == 0 { + return "Pending…".to_string(); + } DateTime::<Utc>::from_timestamp(ts as i64, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "Unknown".to_string()) } - fn platform_balance_duffs(wallet: &Wallet) -> u64 { - // Only sum Platform address balances - // Identity balances are shown separately on the Identities screen - wallet - .platform_address_info - .values() - .map(|info| info.balance / CREDITS_PER_DUFF) - .sum() + /// Platform-address balance in duffs, read from the coordinator-push snapshot. + /// + /// The snapshot is written by `on_platform_address_sync_completed` in `EventBridge` + /// and contains only OWNED addresses (no orphan inflation). Safe to call from the + /// egui frame loop — synchronous, no blocking I/O (Nagatha ruling). + fn platform_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + self.app_context.platform_balance_duffs(seed_hash) } fn shielded_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + self.app_context.shielded_balance_duffs(seed_hash) + } + + /// Core (chain) balance in duffs, read from the display-only + /// `WalletBackend` snapshot (P4a). Pre-first-sync ⇒ `0`, which the + /// surrounding UI renders as the "syncing" state. + fn core_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { self.app_context - .shielded_states - .lock() - .ok() - .and_then(|states| states.get(seed_hash).map(|s| s.shielded_balance)) + .wallet_backend() + .map(|wb| wb.wallet_balance(seed_hash).total) .unwrap_or(0) - / CREDITS_PER_DUFF + } + + /// Seed hash of the currently selected wallet, if any. Frame-safe read. + fn selected_wallet_seed_hash(&self) -> Option<WalletSeedHash> { + self.selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + } + + /// UTXO-derived per-address balances from the snapshot. + fn snapshot_address_balances( + &self, + seed_hash: &WalletSeedHash, + ) -> std::collections::BTreeMap<Address, u64> { + self.app_context + .wallet_backend() + .map(|wb| wb.address_balances(seed_hash)) + .unwrap_or_default() + } + + /// Authoritative per-address derivation paths from the snapshot. Lets the + /// account-summary view categorize funded addresses `watched_addresses` + /// has not indexed yet, so none are dropped from the per-category totals. + fn snapshot_address_paths( + &self, + seed_hash: &WalletSeedHash, + ) -> std::collections::BTreeMap<Address, dash_sdk::dpp::key_wallet::bip32::DerivationPath> { + self.app_context + .wallet_backend() + .map(|wb| wb.address_paths(seed_hash)) + .unwrap_or_default() + } + + /// Full transaction history from the snapshot. + fn snapshot_transactions(&self, seed_hash: &WalletSeedHash) -> Vec<WalletTransaction> { + self.app_context + .wallet_backend() + .map(|wb| wb.transaction_history(seed_hash)) + .unwrap_or_default() } fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let mut action = AppAction::None; ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { if ui .button( @@ -1082,8 +1115,11 @@ impl WalletsBalancesScreen { { if let Some(wallet) = &self.selected_wallet { action = AppAction::AddScreen( - crate::ui::ScreenType::WalletSendScreen(wallet.clone()) - .create_screen(&self.app_context), + crate::ui::ScreenType::WalletSendScreen( + wallet.clone(), + crate::ui::wallets::send_screen::SendFlow::General, + ) + .create_screen(&self.app_context), ); } else if let Some(sk_wallet) = &self.selected_single_key_wallet { action = AppAction::AddScreen( @@ -1137,14 +1173,13 @@ impl WalletsBalancesScreen { self.app_context.network, dash_sdk::dpp::dashcore::Network::Regtest | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() + ) && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() { self.open_mine_dialog(); } @@ -1172,39 +1207,27 @@ impl WalletsBalancesScreen { /// Build the list of visible account tabs based on current summaries and dev mode. fn build_account_tabs(&self, summaries: &[AccountSummary]) -> Vec<AccountTab> { - let developer_mode = self.app_context.is_developer_mode(); - let mut tabs: Vec<AccountTab> = Vec::new(); - - // Always-visible primary tabs: all BIP44 accounts and Platform - for summary in summaries { - if !summary.category.is_visible_in_default_mode() { - continue; - } - tabs.push(AccountTab::Category( - summary.category.clone(), - summary.index, - )); - } + plan_account_tabs( + summaries, + self.app_context.is_developer_mode(), + FeatureGate::Shielded.is_available(&self.app_context), + // Every HD wallet unconditionally bootstraps a platform-payment + // receive address at load (`Wallet::bootstrap_known_addresses`), so + // an HD wallet always gets a Platform tab. `render_account_tabs` + // only runs for the selected HD wallet, so a seed hash is present. + self.selected_wallet_seed_hash().is_some(), + ) + } - // Ensure Dash Core tab exists even without summaries - if !tabs + /// Sum of the per-account balances that are hidden from the default-mode + /// primary tabs (everything not BIP-44 or Platform). Drives the + /// header-reconciling System/Other tab and its balance label. + fn hidden_category_balance(summaries: &[AccountSummary]) -> u64 { + summaries .iter() - .any(|t| matches!(t, AccountTab::Category(AccountCategory::Bip44, Some(0)))) - { - tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); - } - - // Add the Shielded tab only when the connected network supports it. - if FeatureGate::Shielded.is_available(&self.app_context) { - tabs.push(AccountTab::Shielded); - } - - // In developer mode, add the consolidated System tab last - if developer_mode { - tabs.push(AccountTab::System); - } - - tabs + .filter(|s| s.category.is_system_category()) + .map(|s| s.confirmed_balance) + .sum() } /// Collect the system account categories to display inside the System tab. @@ -1272,7 +1295,7 @@ impl WalletsBalancesScreen { }; let network = self.app_context.network; for (path, info) in &wallet.watched_addresses { - let (cat, _) = crate::ui::wallets::account_summary::categorize_account_path( + let (cat, _) = crate::ui::state::account_summary::categorize_account_path( path, network, info.path_reference, @@ -1294,7 +1317,7 @@ impl WalletsBalancesScreen { /// Render the Accounts & Addresses tab bar and content. fn render_account_tabs(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(14.0); @@ -1313,11 +1336,12 @@ impl WalletsBalancesScreen { let (base_label, balance_duffs) = match tab { AccountTab::Category(cat, idx) => { let balance = if matches!(cat, AccountCategory::PlatformPayment) { - summaries - .iter() - .filter(|s| s.category == *cat && s.index == *idx) - .map(|s| s.platform_credits / CREDITS_PER_DUFF) - .sum::<u64>() + // Single authoritative Platform total — the same + // figure the wallet header shows, so the two cannot + // diverge. + self.selected_wallet_seed_hash() + .map(|seed_hash| self.platform_balance_duffs(&seed_hash)) + .unwrap_or(0) } else { summaries .iter() @@ -1337,12 +1361,17 @@ impl WalletsBalancesScreen { ("Shielded".to_string(), balance) } AccountTab::System => { - let balance: u64 = summaries - .iter() - .filter(|s| s.category.is_system_category()) - .map(|s| s.confirmed_balance) - .sum(); - ("System".to_string(), balance) + let balance = Self::hidden_category_balance(summaries); + // "System" in developer mode (full breakdown); "Other" + // in default mode, where the tab only appears to + // reconcile funds outside the primary Core/Platform + // tabs. + let label = if self.app_context.is_developer_mode() { + "System" + } else { + "Other" + }; + (label.to_string(), balance) } }; let label = if balance_duffs == 0 { @@ -1399,11 +1428,16 @@ impl WalletsBalancesScreen { action |= self.render_system_tab_content(ui, summaries); } (AccountTab::Category(..), Some((cat, idx))) => { - // Show empty state if no summaries match this category + // Show empty state if no summaries match this category. Dash + // Core and Platform always render their address tables — Platform + // is driven by the coordinator snapshot, not the Core summaries. if !summaries .iter() .any(|s| s.category == cat && s.index == idx) - && !matches!(cat, AccountCategory::Bip44) + && !matches!( + cat, + AccountCategory::Bip44 | AccountCategory::PlatformPayment + ) { ui.label( RichText::new("No account activity yet.") @@ -1445,7 +1479,7 @@ impl WalletsBalancesScreen { }); ui.add_space(4.0); action |= self.render_address_table(ui, (cat.clone(), idx)); - self.render_bottom_options(ui); + action |= self.render_bottom_options(ui); }); // Dash Core tab: transaction history + asset locks @@ -1485,15 +1519,34 @@ impl WalletsBalancesScreen { /// Render the System tab content: each system account category as a /// collapsible section, collapsed by default. + /// + /// In developer mode every system category is listed (diagnostic view). In + /// default mode this tab only appears to reconcile funds held outside the + /// primary Core/Platform tabs, so it shows a short explanation and lists + /// only the categories that actually hold a balance. fn render_system_tab_content( &mut self, ui: &mut Ui, summaries: &[AccountSummary], ) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; + let developer_mode = self.app_context.is_developer_mode(); let sections = self.system_tab_sections(summaries); + if !developer_mode { + ui.label( + RichText::new( + "These funds are held on addresses outside your main and Platform accounts. \ + They are included in your total balance.", + ) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + ui.add_space(6.0); + } + ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.checkbox( @@ -1505,6 +1558,11 @@ impl WalletsBalancesScreen { ui.add_space(4.0); for (cat, idx, addr_count, balance) in &sections { + // Default mode only reconciles funded categories — empty system + // sections are diagnostic noise for a non-developer user. + if !developer_mode && *balance == 0 { + continue; + } let balance_text = if *balance == 0 { "empty".to_string() } else { @@ -1553,38 +1611,36 @@ impl WalletsBalancesScreen { return; }; - // Defensive check: verify the selected wallet Arc matches the one in - // app_context.wallets. If they diverge (stale reference), skip rendering - // to avoid showing another wallet's data. - let wallet_guard = wallet_arc.read().unwrap(); - let selected_seed_hash = wallet_guard.seed_hash(); - let arc_matches = self + let selected_seed_hash = { + let wallet_guard = wallet_arc.read_recover(); + wallet_guard.seed_hash() + }; + + // Transaction history comes from the display-only `WalletBackend` + // snapshot. Pre-first-sync there is no snapshot yet → render the + // "syncing" state rather than a misleading "no transactions" message. + let backend_ready = self .app_context - .wallets - .read() - .ok() - .and_then(|wallets| wallets.get(&selected_seed_hash).cloned()) - .is_some_and(|canonical| Arc::ptr_eq(wallet_arc, &canonical)); - if !arc_matches { - tracing::warn!( - "selected_wallet Arc does not match app_context.wallets — skipping transaction render" - ); - ui.label("Wallet data is being updated. Please re-select the wallet."); + .wallet_backend() + .map(|wb| wb.has_snapshot(&selected_seed_hash)) + .unwrap_or(false); + let transactions = self.snapshot_transactions(&selected_seed_hash); + + if !backend_ready { + ui.label("Syncing transactions from the network…"); return; } - if wallet_guard.transactions.is_empty() { - ui.label( - "No transactions found. Try refreshing your wallet to load transaction history.", - ); + if transactions.is_empty() { + ui.label("No transactions found for this wallet yet."); return; } - // Filter to transactions involving this wallet's addresses. - // The `is_ours` flag is set by both RPC and SPV paths for all - // transactions that belong to this wallet (sends and receives). - // Invalidate cache when source tx count changes or indices go stale. - let tx_len = wallet_guard.transactions.len(); + // Filter to transactions involving this wallet's addresses (`is_ours` + // is always true on the per-wallet snapshot, but the filter is kept + // for parity and future cross-wallet views). Invalidate the index + // cache when the source length changes or cached indices go stale. + let tx_len = transactions.len(); if self.cached_tx_source_len != Some(tx_len) || self .cached_tx_indices @@ -1594,31 +1650,23 @@ impl WalletsBalancesScreen { self.cached_tx_indices = None; self.cached_tx_source_len = Some(tx_len); } - let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { - (0..tx_len) - .filter(|&i| wallet_guard.transactions[i].is_ours) - .collect() - }); + let relevant_indices = self + .cached_tx_indices + .get_or_insert_with(|| (0..tx_len).filter(|&i| transactions[i].is_ours).collect()); if relevant_indices.is_empty() { - ui.label( - "No transactions found. Try refreshing your wallet to load transaction history.", - ); + ui.label("No transactions found for this wallet yet."); return; } - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let show_fee = self.app_context.is_developer_mode(); let mut order: Vec<usize> = relevant_indices.clone(); order.sort_by(|&a, &b| { - wallet_guard.transactions[b] + transactions[b] .timestamp - .cmp(&wallet_guard.transactions[a].timestamp) - .then_with(|| { - wallet_guard.transactions[b] - .txid - .cmp(&wallet_guard.transactions[a].txid) - }) + .cmp(&transactions[a].timestamp) + .then_with(|| transactions[b].txid.cmp(&transactions[a].txid)) }); let row_height = 26.0; @@ -1684,7 +1732,7 @@ impl WalletsBalancesScreen { }) .body(|mut body| { for idx in order { - let tx = &wallet_guard.transactions[idx]; + let tx = &transactions[idx]; body.row(row_height, |mut row| { row.col(|ui| { ui.label(Self::format_transaction_timestamp(tx.timestamp)); @@ -1701,7 +1749,7 @@ impl WalletsBalancesScreen { row.col(|ui| { let fee_text = tx .fee - .map(Self::format_dash) + .map(format_duffs_as_dash) .unwrap_or_else(|| "-".to_string()); ui.label(fee_text); }); @@ -1751,7 +1799,7 @@ impl WalletsBalancesScreen { /// Render a compact sync status panel showing Core, Platform, and Shielded sync progress. fn render_sync_status(&self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let secondary = DashColors::text_secondary(dark_mode); let syncing_color = DashColors::DASH_BLUE; let sz = 12.0; @@ -1768,85 +1816,77 @@ impl WalletsBalancesScreen { .strong() .color(DashColors::text_primary(dark_mode)), ); - match self.app_context.core_backend_mode() { - CoreBackendMode::Rpc => { - if self.app_context.connection_status().rpc_online() { + { + // Chain sync is owned by upstream platform-wallet; the + // EventBridge pushes live status + per-phase progress + // into ConnectionStatus, the single source of truth. + let snapshot = self.app_context.connection_status().spv_status_snapshot(); + match snapshot.status { + SpvStatus::Idle | SpvStatus::Stopped => { + ui.label(RichText::new("Disconnected").size(sz).color(secondary)); + } + SpvStatus::Starting => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + ui.label( + RichText::new("Connecting...").size(sz).color(syncing_color), + ); + } + SpvStatus::Syncing => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + let phase_text = snapshot + .sync_progress + .as_ref() + .map(spv_phase_summary) + .unwrap_or_else(|| "starting...".to_string()); + ui.label( + RichText::new(format!("Syncing — {phase_text}")) + .size(sz) + .color(syncing_color), + ); + } + SpvStatus::Running => { ui.colored_label( Color32::DARK_GREEN, - RichText::new("Connected").size(sz), + RichText::new(format!( + "Synced — {} peers", + snapshot.connected_peers + )) + .size(sz), ); - } else { + } + SpvStatus::Stopping => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + ui.label( + RichText::new("Disconnecting...") + .size(sz) + .color(syncing_color), + ); + } + SpvStatus::Error => { ui.colored_label( DashColors::ERROR, - RichText::new("Disconnected").size(sz), + RichText::new("Error").size(sz), ); } } - CoreBackendMode::Spv => { - let snapshot = self.app_context.spv_manager().status(); - match snapshot.status { - SpvStatus::Idle | SpvStatus::Stopped => { - ui.label( - RichText::new("Disconnected").size(sz).color(secondary), - ); - } - SpvStatus::Starting => { - ui.add(egui::Spinner::new().size(sz).color(syncing_color)); - ui.label( - RichText::new("Connecting...") - .size(sz) - .color(syncing_color), - ); - } - SpvStatus::Syncing => { - ui.add(egui::Spinner::new().size(sz).color(syncing_color)); - let phase_text = snapshot - .sync_progress - .as_ref() - .map(spv_phase_summary) - .unwrap_or_else(|| "starting...".to_string()); - ui.label( - RichText::new(format!("Syncing — {phase_text}")) - .size(sz) - .color(syncing_color), - ); - } - SpvStatus::Running => { - ui.colored_label( - Color32::DARK_GREEN, - RichText::new(format!( - "Synced — {} peers", - snapshot.connected_peers - )) - .size(sz), - ); - } - SpvStatus::Stopping => { - ui.add(egui::Spinner::new().size(sz).color(syncing_color)); - ui.label( - RichText::new("Disconnecting...") - .size(sz) - .color(syncing_color), - ); - } - SpvStatus::Error => { - ui.colored_label( - DashColors::ERROR, - RichText::new("Error").size(sz), - ); - } - } - } } }); // -- Platform: Addresses -- - let addr_count = self + // Count and sync cursor both come from the coordinator-push + // snapshot, read live each frame so the label stays truthful + // without a manual refresh — and even while the wallet is locked. + let (addr_count, platform_sync_info) = self .selected_wallet .as_ref() .and_then(|w| w.read().ok()) - .map(|w| w.platform_address_info.len()) - .unwrap_or(0); + .map(|w| { + ( + w.platform_address_info.len(), + self.app_context.platform_sync_info(&w.seed_hash()), + ) + }) + .unwrap_or((0, None)); let addr_color = if self.refreshing { syncing_color } else { @@ -1857,113 +1897,50 @@ impl WalletsBalancesScreen { if self.refreshing { ui.add(egui::Spinner::new().size(sz).color(syncing_color)); } - let addr_text = - if let Some((last_sync_ts, sync_height)) = self.platform_sync_info { - let ago = Self::format_unix_time_ago(last_sync_ts); - format!( - "Addresses: {} synced (blk {}, {})", - addr_count, sync_height, ago - ) - } else { - "Addresses: never synced".to_string() - }; + let addr_text = if let Some((last_sync_ts, sync_height)) = platform_sync_info { + let ago = Self::format_unix_time_ago(last_sync_ts); + format!("Addresses: {addr_count} synced (blk {sync_height}, {ago})") + } else { + "Addresses: never synced".to_string() + }; ui.label(RichText::new(addr_text).size(sz).color(addr_color)); }); - // -- Shielded: Notes + Nullifiers -- - let seed_hash = self + // -- Shielded balance -- + // The upstream coordinator's 60-second sync loop keeps the push + // snapshot current. + // TODO: restore the detailed per-note / nullifier sync display + // once the coordinator exposes a read path for it. + let shielded_seed_hash = self .selected_wallet .as_ref() .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - let shielded_info = seed_hash.and_then(|hash| { - let states = self.app_context.shielded_states.lock().ok()?; - let state = states.get(&hash)?; - Some(( - state.last_synced_index, - state.notes.iter().filter(|n| !n.is_spent).count(), - state.last_nullifier_sync_height, - state.last_notes_synced_at, - state.last_nullifiers_synced_at, - )) + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + let shielded_text = match shielded_seed_hash { + Some(hash) => format!( + "Shielded: {}", + format_duffs_as_dash(self.app_context.shielded_balance_duffs(&hash)) + ), + None => "Shielded: unavailable".to_string(), + }; + ui.label(RichText::new(shielded_text).size(sz).color(secondary)); }); - let shielded_syncing = self - .shielded_tab_view - .as_ref() - .is_some_and(|v| v.is_syncing()); - let shielded_color = if shielded_syncing { - syncing_color - } else { - secondary - }; - - match shielded_info { - Some((synced_index, note_count, nf_height, notes_synced_at, nf_synced_at)) => { - // Notes bullet - ui.horizontal(|ui| { - ui.label(RichText::new("•").size(sz).color(secondary)); - if shielded_syncing { - ui.add(egui::Spinner::new().size(sz).color(syncing_color)); - } - let notes_text = if let Some(t) = notes_synced_at { - let ago = Self::format_instant_ago(t); - format!( - "Notes: {} synced ({} notes, {})", - synced_index, note_count, ago - ) - } else if synced_index > 0 { - format!("Notes: {} synced ({} notes)", synced_index, note_count) - } else { - "Notes: never synced".to_string() - }; - ui.label(RichText::new(notes_text).size(sz).color(shielded_color)); - }); - // Nullifiers bullet - ui.horizontal(|ui| { - ui.label(RichText::new("•").size(sz).color(secondary)); - let nf_text = if let Some(t) = nf_synced_at { - let ago = Self::format_instant_ago(t); - format!("Nullifiers: height {} ({})", nf_height, ago) - } else if nf_height > 0 { - format!("Nullifiers: height {}", nf_height) - } else { - "Nullifiers: never synced".to_string() - }; - ui.label(RichText::new(nf_text).size(sz).color(shielded_color)); - }); - } - None => { - ui.horizontal(|ui| { - ui.label(RichText::new("•").size(sz).color(secondary)); - ui.label( - RichText::new("Notes: never synced") - .size(sz) - .color(secondary), - ); - }); - ui.horizontal(|ui| { - ui.label(RichText::new("•").size(sz).color(secondary)); - ui.label( - RichText::new("Nullifiers: never synced") - .size(sz) - .color(secondary), - ); - }); - } - } }, ); } /// Render the total balance label only (used in the left column of the header). fn render_balance_total(&self, ui: &mut Ui, wallet: &Wallet) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let core_balance = wallet.total_balance_duffs(); - let platform_balance = Self::platform_balance_duffs(wallet); - let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); + let dark_mode = ui.style().visuals.dark_mode; + let seed_hash = wallet.seed_hash(); + let core_balance = self.core_balance_duffs(&seed_hash); + let platform_balance = self.platform_balance_duffs(&seed_hash); + let shielded_balance = self.shielded_balance_duffs(&seed_hash); let total = core_balance + platform_balance + shielded_balance; ui.label( - RichText::new(format!("Balance: {}", Self::format_dash(total))) + RichText::new(format!("Balance: {}", format_duffs_as_dash(total))) .color(DashColors::text_primary(dark_mode)) .size(20.0) .strong(), @@ -1972,10 +1949,11 @@ impl WalletsBalancesScreen { /// Render the collapsible breakdown detail (used in the right column of the header). fn render_balance_breakdown_detail(&mut self, ui: &mut Ui, wallet: &Wallet) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let core_balance = wallet.total_balance_duffs(); - let platform_balance = Self::platform_balance_duffs(wallet); - let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); + let dark_mode = ui.style().visuals.dark_mode; + let seed_hash = wallet.seed_hash(); + let core_balance = self.core_balance_duffs(&seed_hash); + let platform_balance = self.platform_balance_duffs(&seed_hash); + let shielded_balance = self.shielded_balance_duffs(&seed_hash); let header = egui::CollapsingHeader::new( RichText::new("Balance breakdown") @@ -1987,11 +1965,17 @@ impl WalletsBalancesScreen { header.show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("Core: {}", Self::format_dash(core_balance))); + ui.label(format!("Core: {}", format_duffs_as_dash(core_balance))); ui.label(" | "); - ui.label(format!("Platform: {}", Self::format_dash(platform_balance))); + ui.label(format!( + "Platform: {}", + format_duffs_as_dash(platform_balance) + )); ui.label(" | "); - ui.label(format!("Shielded: {}", Self::format_dash(shielded_balance))); + ui.label(format!( + "Shielded: {}", + format_duffs_as_dash(shielded_balance) + )); }); }); } @@ -2003,7 +1987,7 @@ impl WalletsBalancesScreen { }; let (alias, _seed_hash, _wallet_is_main) = { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read_recover(); ( wallet .alias @@ -2014,7 +1998,7 @@ impl WalletsBalancesScreen { ) }; let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; let detail_width = ui.available_width(); ui.horizontal(|row| { @@ -2052,7 +2036,7 @@ impl WalletsBalancesScreen { // Total balance line { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read_recover(); self.render_balance_total(ui, &wallet); } }); @@ -2063,7 +2047,7 @@ impl WalletsBalancesScreen { // Collapsible balance breakdown { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read_recover(); self.render_balance_breakdown_detail(ui, &wallet); } @@ -2080,8 +2064,14 @@ impl WalletsBalancesScreen { ui.separator(); let summaries = { - let wallet = wallet_arc.read().unwrap(); - collect_account_summaries(&wallet, self.app_context.network) + let seed_hash = wallet_arc.read_recover().seed_hash(); + let address_balances = self.snapshot_address_balances(&seed_hash); + let address_paths = self.snapshot_address_paths(&seed_hash); + collect_account_summaries( + self.app_context.network, + &address_balances, + &address_paths, + ) }; self.ensure_account_selection(&summaries); action |= self.render_account_tabs(ui, &summaries); @@ -2147,19 +2137,6 @@ impl WalletsBalancesScreen { } } - /// Returns a SyncNotes backend task if the shielded wallet has been initialized - /// for the given seed hash. - fn shielded_sync_task(&self, seed_hash: &WalletSeedHash) -> Option<BackendTask> { - let states = self.app_context.shielded_states.lock().ok()?; - if states.contains_key(seed_hash) { - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: *seed_hash, - })) - } else { - None - } - } - /// Creates the appropriate refresh action based on the current refresh mode fn create_refresh_action(&self, wallet_arc: &Arc<RwLock<Wallet>>) -> AppAction { self.create_refresh_action_for_mode(wallet_arc, self.refresh_mode) @@ -2186,10 +2163,6 @@ impl WalletsBalancesScreen { // Core + Platform BackendTask::CoreTask(CoreTask::RefreshWalletInfo(wallet_arc.clone(), true)) } - RefreshMode::CoreOnly => { - // Core only, no Platform sync - BackendTask::CoreTask(CoreTask::RefreshWalletInfo(wallet_arc.clone(), false)) - } RefreshMode::PlatformOnly => { // Platform only BackendTask::WalletTask( @@ -2200,20 +2173,183 @@ impl WalletsBalancesScreen { } }; - // Also trigger shielded note sync if initialized - if let Some(shielded_task) = self.shielded_sync_task(&seed_hash) { - AppAction::BackendTasks( - vec![core_task, shielded_task], - BackendTasksExecutionMode::Concurrent, - ) - } else { - AppAction::BackendTask(core_task) + // Shielded balances are kept current by the upstream coordinator's + // 60-second sync loop and the post-op snapshot refresh — DET no longer + // dispatches a manual shielded sync from the refresh chain. + AppAction::BackendTask(core_task) + } + + /// Run the single import path + /// ([`AppContext::import_single_key_wif`]) for `wif`, then select the + /// resulting wallet so it is immediately visible in the picker. + /// Returns the inserted in-memory wallet. + fn register_imported_single_key( + &mut self, + wif: &str, + passphrase: crate::wallet_backend::single_key::ImportPassphrase, + alias: Option<String>, + ) -> Result<Arc<RwLock<SingleKeyWallet>>, TaskError> { + let (_imported, wallet_arc) = self + .app_context + .import_single_key_wif(wif, alias, passphrase)?; + self.select_single_key_wallet(wallet_arc.clone()); + Ok(wallet_arc) + } + + /// Test-only seam: run the single-key import end to end (vault write + + /// in-memory sync) the way the confirmed dialog does, then return the + /// in-memory wallet that became selectable, so an integration test can + /// assert the imported key becomes visible in the same session. Not + /// exposed for production callers. + #[doc(hidden)] + pub fn import_single_key_for_test( + &mut self, + wif: &str, + alias: Option<String>, + ) -> Result<Arc<RwLock<SingleKeyWallet>>, String> { + self.register_imported_single_key( + wif, + crate::wallet_backend::single_key::ImportPassphrase::default(), + alias, + ) + .map_err(|e| e.to_string()) + } + + /// Render the J-6 "Import private key (advanced)" modal and route a + /// confirmed WIF through [`crate::wallet_backend::SingleKeyView::import_wif`]. + /// Errors surface as a global banner with the typed `TaskError` details + /// attached; success emits a confirmation toast naming the derived + /// address so the user can match it against their records. + fn render_import_single_key_dialog(&mut self, ctx: &Context) { + let response = self.import_single_key_dialog.show(ctx); + if response.cancelled { + self.import_single_key_dialog.open = false; + self.import_single_key_dialog.reset(); + } + if let Some(request) = response.confirmed { + let passphrase = crate::wallet_backend::single_key::ImportPassphrase { + passphrase: request.passphrase.clone(), + hint: request.passphrase_hint.clone(), + }; + match self.register_imported_single_key(&request.wif, passphrase, request.alias.clone()) + { + Ok(_) => { + MessageBanner::set_global( + ctx, + format!("Imported key added for {}.", request.address_preview), + MessageType::Success, + ); + self.import_single_key_dialog.open = false; + self.import_single_key_dialog.reset(); + } + Err(e) => { + MessageBanner::set_global(ctx, e.to_string(), MessageType::Error) + .with_details(&e); + } + } + } + } + + /// Recompute the set of protected single-key rows still awaiting + /// restore. Cheap DB scan; runs lazily on first paint and after each + /// successful restore. A scan failure leaves the list empty (the + /// banner simply does not appear) and is logged — it must never block + /// the wallets screen. + fn refresh_pending_protected_restores(&mut self) { + self.pending_restores_scanned = true; + match crate::backend_task::migration::single_key_restore::list_pending_protected_restores( + &self.app_context, + ) { + Ok(list) => self.pending_protected_restores = list, + Err(e) => { + tracing::warn!( + error = ?e, + "Failed to scan for protected single-key restores; banner suppressed", + ); + self.pending_protected_restores.clear(); + } + } + } + + /// Render the post-migration "protected imported keys need your + /// password" banner. Offers to open the per-key restore dialog for the + /// first outstanding key. No-op when nothing is pending. + fn render_protected_restore_banner(&mut self, ui: &mut Ui) { + if !self.pending_restores_scanned { + self.refresh_pending_protected_restores(); + } + let Some(next) = self.pending_protected_restores.first().cloned() else { + return; + }; + let count = self.pending_protected_restores.len(); + let dark_mode = ui.style().visuals.dark_mode; + + egui::Frame::group(ui.style()) + .fill(DashColors::input_background(dark_mode)) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + let message = if count == 1 { + "One imported key needs your old password to restore it.".to_string() + } else { + format!("{count} imported keys need your old password to restore them.") + }; + ui.label(RichText::new(message).color(DashColors::text_primary(dark_mode))); + if ui.button("Restore now").clicked() { + self.restore_single_key_dialog.set_target(next); + } + }); + }); + ui.add_space(8.0); + } + + /// Render the per-key restore dialog and route a confirmed request + /// through the restore bridge. On success the key becomes visible and + /// the banner shrinks; on failure a generic, actionable banner appears + /// (no oracle distinguishing a wrong password from a corrupt blob). + fn render_restore_single_key_dialog(&mut self, ctx: &Context) { + let response = self.restore_single_key_dialog.show(ctx); + if response.cancelled { + self.restore_single_key_dialog.open = false; + self.restore_single_key_dialog.reset(); + } + if let Some(request) = response.confirmed { + let new_passphrase = crate::wallet_backend::single_key::ImportPassphrase { + passphrase: request.new_passphrase.clone(), + hint: request.new_hint.clone(), + }; + match crate::backend_task::migration::single_key_restore::restore_protected_single_key( + &self.app_context, + &request.address, + &request.legacy_password, + new_passphrase, + ) { + Ok(address) => { + MessageBanner::set_global( + ctx, + format!( + "Restored your imported key for {address}. \ + Balance and sending for single-key wallets are coming in a future update." + ), + MessageType::Success, + ); + self.restore_single_key_dialog.open = false; + self.restore_single_key_dialog.reset(); + // Re-scan so the restored key drops off the banner. + self.refresh_pending_protected_restores(); + } + Err(e) => { + MessageBanner::set_global(ctx, e.to_string(), MessageType::Error) + .with_details(&e); + } + } } } } impl ScreenLike for WalletsBalancesScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; // Check for pending platform balance refresh (triggered after transfers) let pending_refresh_action = if let Some(seed_hash) = self.pending_platform_balance_refresh.take() @@ -2262,6 +2398,10 @@ impl ScreenLike for WalletsBalancesScreen { "Import Wallet", DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportMnemonic)), ), + ( + "Import key (advanced)", + DesiredAppAction::Custom("OpenImportSingleKey".to_string()), + ), ( "Create Wallet", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddNewWallet)), @@ -2269,10 +2409,7 @@ impl ScreenLike for WalletsBalancesScreen { ]; // Add Refresh button for HD wallet - if !self.refreshing - && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && self.selected_wallet.is_some() - { + if !self.refreshing && self.selected_wallet.is_some() { right_buttons.push(( "Refresh", DesiredAppAction::Custom("RefreshHDWallet".to_string()), @@ -2280,43 +2417,45 @@ impl ScreenLike for WalletsBalancesScreen { } // Add Refresh button for single key wallet - if !self.refreshing - && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && self.selected_single_key_wallet.is_some() - { + if !self.refreshing && self.selected_single_key_wallet.is_some() { right_buttons.push(( "Refresh", DesiredAppAction::Custom("RefreshSKWallet".to_string()), )); } - let mut action = add_top_panel( - ctx, + // TODO: wire wallet selection consumption for the Wallets page. + let mut action = add_top_panel_with_global_nav( + ui, &self.app_context, - vec![("Wallets", AppAction::None)], + subdued_wallet_only_spec("Wallets", RootScreenType::RootScreenWalletsBalances), right_buttons, ); action |= add_left_panel( - ctx, + ui, &self.app_context, RootScreenType::RootScreenWalletsBalances, ); - action |= island_central_panel(ctx, |ui| { + action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; // Message display is handled by the global MessageBanner egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - let has_hd_wallets = !self.app_context.wallets.read().unwrap().is_empty(); + // Post-migration restore banner — shown even on an + // otherwise-empty wallets screen, since protected + // single keys may be all the user has left to restore. + self.render_protected_restore_banner(ui); + + let has_hd_wallets = !self.app_context.wallets.read_recover().is_empty(); let has_single_key_wallets = !self .app_context .single_key_wallets - .read() - .unwrap() + .read_recover() .is_empty(); if !has_hd_wallets && !has_single_key_wallets { @@ -2345,11 +2484,46 @@ impl ScreenLike for WalletsBalancesScreen { inner_action }); - action |= self.render_send_dialog(ctx); action |= self.render_receive_dialog(ctx); action |= self.render_fund_platform_dialog(ctx); action |= self.render_mine_dialog(ctx); self.render_private_key_dialog(ctx); + self.render_import_single_key_dialog(ctx); + self.render_restore_single_key_dialog(ctx); + + // Drain a queued "view private key" request into a backend task that + // fetches the seed just-in-time and derives the key off the UI thread. + if let Some((seed_hash, derivation_path, address)) = + self.private_key_dialog.pending_view_key_request.take() + { + self.private_key_dialog.address = address; + action |= AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::DeriveKeyForDisplay { + seed_hash, + derivation_path, + }, + )); + } + + // Drain a queued "generate Platform receive address" request into a + // backend task that fetches the seed just-in-time and derives + registers + // the new address off the UI thread. + if let Some(seed_hash) = self.receive_dialog.pending_platform_address_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::GeneratePlatformReceiveAddress { + seed_hash, + }, + )); + } + + // Drain a queued "generate Core receive address" request into a backend + // task that derives the next address from the SPV-watched upstream pool. + // The new address returns via `GeneratedReceiveAddress`. + if let Some(seed_hash) = self.receive_dialog.pending_core_address_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::GenerateReceiveAddress { seed_hash }, + )); + } // Rename dialog if self.show_rename_dialog { @@ -2358,7 +2532,7 @@ impl ScreenLike for WalletsBalancesScreen { .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .show(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; ui.vertical(|ui| { ui.label("Enter new wallet name:"); ui.add_space(5.0); @@ -2388,39 +2562,91 @@ impl ScreenLike for WalletsBalancesScreen { // Handle HD wallet rename if let Some(selected_wallet) = &self.selected_wallet { - let mut wallet = selected_wallet.write().unwrap(); + let mut wallet = selected_wallet.write_recover(); wallet.alias = Some(self.rename_input.clone()); - // Update the alias in the database + // T-W-01: alias persistence goes + // through the wallet-meta sidecar. + // The cold-boot picker reads from + // the same key shape, so the new + // name surfaces on the next launch + // without touching the legacy + // `wallet` table. let seed_hash = wallet.seed_hash(); - self.app_context - .db - .set_wallet_alias( + if let Ok(backend) = self.app_context.wallet_backend() { + let meta_view = backend.wallet_meta(); + let mut meta = meta_view + .get(self.app_context.network, &seed_hash) + .unwrap_or_default(); + meta.alias = self.rename_input.clone(); + // Backfill the xpub on first + // rename after migration so old + // entries written before T-W-00.5 + // get a non-empty picker hint. + if meta.xpub_encoded.is_empty() { + meta.xpub_encoded = wallet + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(); + } + if let Err(e) = meta_view.set( + self.app_context.network, &seed_hash, - Some(self.rename_input.clone()), - ) - .ok(); + &meta, + ) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to persist wallet alias to sidecar", + ); + } + } + self.show_rename_dialog = false; + self.rename_input.clear(); } // Handle single key wallet rename else if let Some(selected_sk_wallet) = &self.selected_single_key_wallet { - let mut wallet = selected_sk_wallet.write().unwrap(); - wallet.alias = Some(self.rename_input.clone()); - - // Update the alias in the database - let key_hash = wallet.key_hash; - self.app_context - .db - .update_single_key_wallet_alias( - &key_hash, - Some(&self.rename_input), - ) - .ok(); + // Persist FIRST so the in-memory display + // alias and the "renamed" outcome only + // reflect a durable change. Alias + // persistence goes through the modern + // single-key sidecar (matching the + // HD-wallet rename path above), so the + // new name survives a restart without + // touching the legacy `single_key_wallet` + // table. + let address = + selected_sk_wallet.read_recover().address.to_string(); + let new_alias = self.rename_input.clone(); + let persisted = match self.app_context.wallet_backend() { + Ok(backend) => backend + .single_key() + .set_alias(&address, Some(new_alias.clone())), + Err(e) => Err(e), + }; + match persisted { + Ok(()) => { + selected_sk_wallet.write_recover().alias = + Some(new_alias); + self.show_rename_dialog = false; + self.rename_input.clear(); + } + Err(e) => { + MessageBanner::set_global( + ctx, + "Could not rename the imported key. Check available disk space and try again." + .to_string(), + MessageType::Error, + ) + .with_details(&e); + } + } + } else { + self.show_rename_dialog = false; + self.rename_input.clear(); } - - self.show_rename_dialog = false; - self.rename_input.clear(); } }); }); @@ -2445,17 +2671,7 @@ impl ScreenLike for WalletsBalancesScreen { if let Some(path) = self.private_key_dialog.pending_derivation_path.take() && let Some(address) = self.private_key_dialog.pending_address.take() { - match self.derive_private_key_wif(&path) { - Ok(key) => { - self.private_key_dialog.is_open = true; - self.private_key_dialog.address = address; - self.private_key_dialog.private_key_wif = key; - self.private_key_dialog.show_key = false; - } - Err(err) => { - MessageBanner::set_global(ctx, &err, MessageType::Error); - } - } + self.queue_view_key_request(&path, address); } // Check if we were trying to fund a Platform address @@ -2474,24 +2690,6 @@ impl ScreenLike for WalletsBalancesScreen { action |= self.create_pending_refresh_action(wallet_arc); } } - - // Check if we were trying to search for asset locks - if self.pending_asset_lock_search_after_unlock { - self.pending_asset_lock_search_after_unlock = false; - if let Some(wallet_arc) = self.selected_wallet.clone() { - self.asset_lock_search_banner.take_and_clear(); - let handle = MessageBanner::set_global( - ctx, - "Searching for unused asset locks...", - MessageType::Info, - ); - handle.with_elapsed(); - self.asset_lock_search_banner = Some(handle); - action |= AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RecoverAssetLocks(wallet_arc), - )); - } - } } WalletUnlockResult::Cancelled => { // Clear any pending private key view request on cancel @@ -2503,9 +2701,6 @@ impl ScreenLike for WalletsBalancesScreen { // Clear pending refresh request on cancel self.pending_refresh_after_unlock = false; - - // Clear pending asset lock search on cancel - self.pending_asset_lock_search_after_unlock = false; } WalletUnlockResult::Pending => {} } @@ -2547,7 +2742,7 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); ui.horizontal(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; + let dark_mode = ui.style().visuals.dark_mode; if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode) .clicked() { @@ -2563,15 +2758,40 @@ impl ScreenLike for WalletsBalancesScreen { if attempt_unlock { if let Some(wallet_arc) = &self.selected_single_key_wallet { - let mut wallet = wallet_arc.write().unwrap(); - let unlock_result = wallet.open(self.sk_password_input.text()); + // Verify the passphrase against the encrypted + // vault without opening the map entry: signing + // decrypts just-in-time, so the key stays closed + // (no plaintext re-parked in the session map). + let address = wallet_arc + .read() + .ok() + .map(|w| w.address.to_string()); + let verify_result = match address { + Some(addr) => self + .app_context + .verify_single_key_passphrase( + &addr, + self.sk_password_input.text(), + ), + None => Err(TaskError::ImportedKeyNotFound), + }; - match unlock_result { - Ok(_) => { + match verify_result { + Ok(()) => { + MessageBanner::set_global( + ui.ctx(), + "Password confirmed. This key is ready to use.", + MessageType::Success, + ); close_dialog = true; } - Err(_) => { - MessageBanner::set_global(ui.ctx(), "Incorrect Password", MessageType::Error); + Err(e) => { + MessageBanner::set_global( + ui.ctx(), + e.to_string(), + MessageType::Error, + ) + .with_details(&e); } } } @@ -2606,7 +2826,15 @@ impl ScreenLike for WalletsBalancesScreen { // Handle custom refresh actions - check wallet lock status if let AppAction::Custom(ref cmd) = action { - if cmd == "RefreshHDWallet" { + if cmd == "OpenImportSingleKey" { + // Sync the dialog's network with the active context every + // open so a quick network switch can't show a stale preview. + self.import_single_key_dialog + .set_network(self.app_context.network); + self.import_single_key_dialog.reset(); + self.import_single_key_dialog.open = true; + action = AppAction::None; + } else if cmd == "RefreshHDWallet" { if let Some(wallet_arc) = &self.selected_wallet { let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); if is_locked { @@ -2637,87 +2865,6 @@ impl ScreenLike for WalletsBalancesScreen { CoreTask::RefreshSingleKeyWalletInfo(wallet_arc.clone()), )); } - } else if cmd == "SearchAssetLocks" - && let Some(wallet_arc) = self.selected_wallet.clone() - { - let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); - if is_locked { - // Wallet is locked - open unlock popup - self.pending_asset_lock_search_after_unlock = true; - self.wallet_unlock_popup.open(); - action = AppAction::None; - } else { - // Wallet is unlocked - proceed with search - self.asset_lock_search_banner.take_and_clear(); - let handle = MessageBanner::set_global( - ctx, - "Searching for unused asset locks...", - MessageType::Info, - ); - handle.with_elapsed(); - self.asset_lock_search_banner = Some(handle); - action = AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RecoverAssetLocks(wallet_arc), - )); - } - } - } - - // Dispatch the async ListCoreWallets task if pending - if self.pending_list_core_wallets { - self.pending_list_core_wallets = false; - action |= AppAction::BackendTask(BackendTask::CoreTask(CoreTask::ListCoreWallets)); - } - - // Show Core wallet selection dialog if active - if let Some(dialog) = self.core_wallet_dialog.as_mut() - && let Some(status) = dialog.show_modal(ctx) - { - self.core_wallet_dialog = None; - match status { - SelectionStatus::Selected(idx) => { - if let Some(wallet_hash) = self.pending_core_wallet_seed_hash.take() - && let Some(wallets) = self.pending_core_wallet_options.take() - && let Some(wallet_name) = wallets.get(idx).cloned() - { - let is_single_key = self.pending_core_wallet_is_single_key; - match self.apply_core_wallet_selection( - &wallet_hash, - &wallet_name, - is_single_key, - ) { - Ok(()) => { - MessageBanner::set_global( - ctx, - format!( - "Dash Core wallet '{}' assigned — refreshing wallet. If you were performing another operation, please retry it.", - wallet_name - ), - MessageType::Success, - ); - self.refresh(); - } - Err(e) => { - MessageBanner::set_global( - ctx, - "Failed to save Dash Core wallet", - MessageType::Error, - ) - .with_details(e); - } - } - } - } - SelectionStatus::Canceled => { - self.pending_core_wallet_seed_hash = None; - self.pending_core_wallet_options = None; - self.pending_core_wallet_is_single_key = false; - MessageBanner::set_global( - ctx, - "Dash Core wallet not selected. Some operations may fail until a wallet is assigned.", - MessageType::Info, - ); - } } } @@ -2734,8 +2881,6 @@ impl ScreenLike for WalletsBalancesScreen { self.refreshing = false; if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.asset_lock_search_banner.take_and_clear(); - // If the fund platform dialog is processing, show error in the dialog instead if self.fund_platform_dialog.is_processing { self.fund_platform_dialog.is_processing = false; @@ -2750,39 +2895,6 @@ impl ScreenLike for WalletsBalancesScreen { } } - /// Intercept Core-wallet-not-configured errors and schedule an async - /// `ListCoreWallets` backend task (instead of blocking the UI thread). - fn display_task_error(&mut self, error: &TaskError) -> bool { - if matches!(error, TaskError::CoreWalletNotConfigured) { - self.refreshing = false; - self.asset_lock_search_banner.take_and_clear(); - - // Determine the wallet hash and whether it is a single-key wallet - let (wallet_hash, is_single_key) = if let Some(hash) = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())) - { - (Some(hash), false) - } else if let Some(hash) = self - .selected_single_key_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.key_hash)) - { - (Some(hash), true) - } else { - (None, false) - }; - - self.pending_list_core_wallets = true; - self.pending_list_wallet_hash = wallet_hash; - self.pending_list_is_single_key = is_single_key; - true // Suppress generic error banner - } else { - false - } - } - fn display_task_result( &mut self, backend_task_success_result: crate::ui::BackendTaskSuccessResult, @@ -2792,21 +2904,13 @@ impl ScreenLike for WalletsBalancesScreen { self.refreshing = false; self.cached_tx_indices = None; self.cached_tx_source_len = None; - // Refresh the cached platform sync info so the panel shows - // updated timestamps and block heights after a wallet sync. - let seed_hash = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - if let Some(hash) = seed_hash { - self.refresh_platform_sync_info_cache(&hash); - } - if let Some(warn_msg) = warning { + if let Some(err) = warning { MessageBanner::set_global( self.app_context.egui_ctx(), - format!("Wallet refreshed with warning: {}", warn_msg), + "Wallet refreshed, but platform balances could not be updated. Retry in a moment.", MessageType::Info, - ); + ) + .with_details(err.as_ref()); } else { MessageBanner::set_global( self.app_context.egui_ctx(), @@ -2815,22 +2919,6 @@ impl ScreenLike for WalletsBalancesScreen { ); } } - crate::ui::BackendTaskSuccessResult::RecoveredAssetLocks { - recovered_count, - total_amount, - } => { - self.asset_lock_search_banner.take_and_clear(); - let msg = if recovered_count == 0 { - "No additional unused asset locks found".to_string() - } else { - format!( - "Found {} unused asset lock(s) worth {} Dash", - recovered_count, - Self::format_dash(total_amount) - ) - }; - MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); - } crate::ui::BackendTaskSuccessResult::WalletPayment { txid, recipients, @@ -2840,32 +2928,38 @@ impl ScreenLike for WalletsBalancesScreen { let (address, amount) = &recipients[0]; format!( "Sent {} to {}\nTxID: {}", - Self::format_dash(*amount), + format_duffs_as_dash(*amount), address, txid ) } else { format!( "Sent {} total to {} recipients\nTxID: {}", - Self::format_dash(total_amount), + format_duffs_as_dash(total_amount), recipients.len(), txid ) }; MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } + crate::ui::BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks } => { + self.asset_lock_cache.store(seed_hash, locks); + } crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { - if let Some(selected) = &self.selected_wallet - && let Ok(wallet) = selected.read() - && wallet.seed_hash() == seed_hash - { - // Parse address and get balance + let is_selected = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .is_some_and(|g| g.seed_hash() == seed_hash); + if is_selected { + // Look up the address balance in the display snapshot + // (P4a). A freshly-derived receive address is virtually + // always 0 until it is funded; absent ⇒ 0. + let balances = self.snapshot_address_balances(&seed_hash); let balance = address .parse::<Address<_>>() .ok() - .and_then(|addr| { - wallet.address_balances.get(&addr.assume_checked()).copied() - }) + .and_then(|addr| balances.get(&addr.assume_checked()).copied()) .unwrap_or(0); self.receive_dialog .core_addresses @@ -2877,6 +2971,26 @@ impl ScreenLike for WalletsBalancesScreen { self.receive_dialog.status = None; } } + crate::ui::BackendTaskSuccessResult::WalletKeyForDisplay { wif, .. } => { + // The backend derived the key just-in-time; show it in the + // private-key dialog (hidden until the user reveals it). + self.private_key_dialog.private_key_wif = wif; + self.private_key_dialog.show_key = false; + self.private_key_dialog.is_open = true; + } + crate::ui::BackendTaskSuccessResult::GeneratedPlatformReceiveAddress { + address, + .. + } => { + // The backend derived + registered the new Platform address + // just-in-time; surface it in the receive dialog. + self.receive_dialog.platform_addresses.push((address, 0)); + self.receive_dialog.selected_platform_index = + self.receive_dialog.platform_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = Some("New address generated!".to_string()); + } crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { MessageBanner::set_global( self.app_context.egui_ctx(), @@ -2929,7 +3043,6 @@ impl ScreenLike for WalletsBalancesScreen { wallet.set_platform_address_info(core_addr, balance, nonce); } } - self.refresh_platform_sync_info_cache(&seed_hash); MessageBanner::set_global( self.app_context.egui_ctx(), "Successfully synced Platform balances", @@ -2949,67 +3062,29 @@ impl ScreenLike for WalletsBalancesScreen { ); } // Shielded pool results - result @ (crate::ui::BackendTaskSuccessResult::ShieldedInitialized { .. } - | crate::ui::BackendTaskSuccessResult::ShieldedNotesSynced { .. } - | crate::ui::BackendTaskSuccessResult::ShieldedCreditsShielded { .. } + result @ (crate::ui::BackendTaskSuccessResult::ShieldedCreditsShielded { .. } | crate::ui::BackendTaskSuccessResult::ShieldedTransferComplete { .. } | crate::ui::BackendTaskSuccessResult::ShieldedCreditsUnshielded { .. } - | crate::ui::BackendTaskSuccessResult::ShieldedNullifiersChecked { .. }) => { + | crate::ui::BackendTaskSuccessResult::ShieldedFromAssetLock { .. } + | crate::ui::BackendTaskSuccessResult::ShieldedWithdrawalComplete { + .. + }) => { if let Some(shielded_view) = &mut self.shielded_tab_view { shielded_view.handle_result(&result); } } - crate::ui::BackendTaskSuccessResult::CoreWalletsList(wallets) => { - let wallet_hash = self.pending_list_wallet_hash.take(); - let is_single_key = self.pending_list_is_single_key; - self.pending_list_is_single_key = false; - - if wallets.len() == 1 { - if let Some(hash) = wallet_hash { - match self.apply_core_wallet_selection(&hash, &wallets[0], is_single_key) { - Ok(()) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - format!( - "Auto-selected Core wallet '{}' — refreshing wallet. If you were performing another operation, please retry it.", - wallets[0] - ), - MessageType::Success, - ); - self.refresh(); - } - Err(e) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Failed to save Core wallet selection", - MessageType::Error, - ) - .with_details(e); - } - } - } - } else if wallets.len() > 1 { - let dialog = SelectionDialog::new( - "Select Dash Core Wallet", - "Multiple wallets loaded in Dash Core. Select the one to use:", - wallets.clone(), - ); - self.core_wallet_dialog = Some(dialog); - self.pending_core_wallet_seed_hash = wallet_hash; - self.pending_core_wallet_options = Some(wallets); - self.pending_core_wallet_is_single_key = is_single_key; - } else { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No wallets loaded in Dash Core", - MessageType::Error, - ); - } - } _ => {} } } + fn display_task_error(&mut self, _error: &TaskError) -> bool { + // A failed asset-lock fetch would otherwise strand the tab on a spinner; + // flip the in-flight fetch to a retryable state. The error carries no + // seed_hash, so this routes any in-flight lock fetch to Failed. + self.asset_lock_cache.mark_loading_failed(); + false + } + fn refresh_on_arrival(&mut self) { // Clear the spinner in case a refresh completed while this screen was not // visible (task results are dispatched to the visible screen, so ours would @@ -3060,6 +3135,145 @@ impl ScreenLike for WalletsBalancesScreen { fn refresh(&mut self) { self.refreshing = false; + // Re-fetch tracked asset locks on an explicit refresh (e.g. after + // creating an asset lock) so the Asset Locks tab reflects new state. + self.asset_lock_cache.invalidate(); + // Re-scan for protected single-key rows still awaiting restore so a + // post-migration refresh surfaces (or clears) the restore banner. + self.pending_restores_scanned = false; self.refresh_on_arrival(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::wallet::DerivationPathReference; + + /// A tx with no block yet (mempool or InstantSend-locked-but- + /// unconfirmed) carries `timestamp == 0`. Must render a "still pending" + /// placeholder, never the Unix epoch. + #[test] + fn format_transaction_timestamp_zero_renders_pending_placeholder() { + let rendered = WalletsBalancesScreen::format_transaction_timestamp(0); + assert_eq!(rendered, "Pending…"); + assert!(!rendered.contains("1970")); + } + + #[test] + fn format_transaction_timestamp_nonzero_renders_the_block_date() { + assert_eq!( + WalletsBalancesScreen::format_transaction_timestamp(1_700_000_000), + "2023-11-14 22:13:20" + ); + } + + fn summary( + category: AccountCategory, + index: Option<u32>, + confirmed_balance: u64, + ) -> AccountSummary { + AccountSummary { + category, + index, + confirmed_balance, + } + } + + fn platform_tab_count(tabs: &[AccountTab]) -> usize { + tabs.iter() + .filter(|t| matches!(t, AccountTab::Category(AccountCategory::PlatformPayment, _))) + .count() + } + + /// QA-004: an HD wallet shows the Platform tab immediately on load — before + /// any platform-address sync completes — so the user's only in-app route to + /// their Platform receive address is never hidden waiting on the network. + /// The tab is present even with empty summaries and no platform balance. + #[test] + fn platform_tab_present_immediately_pre_sync() { + let tabs = plan_account_tabs(&[], false, false, true); + assert_eq!( + platform_tab_count(&tabs), + 1, + "Platform tab must appear on wallet load, before first sync" + ); + // The Dash Core tab is always present too. + assert!( + tabs.iter() + .any(|t| matches!(t, AccountTab::Category(AccountCategory::Bip44, Some(0)))), + "Dash Core tab is always present" + ); + } + + /// A wallet with no platform-payment bootstrap (e.g. single-key) gets no + /// Platform tab. + #[test] + fn no_platform_tab_when_not_applicable() { + let tabs = plan_account_tabs(&[], false, false, false); + assert_eq!(platform_tab_count(&tabs), 0); + } + + /// QA-006: if a future upstream bump ever makes the primary loop emit a + /// `PlatformPayment` tab, the dedicated push must not add a second one. + #[test] + fn platform_tab_is_never_duplicated() { + let summaries = vec![summary(AccountCategory::PlatformPayment, None, 1_000)]; + let tabs = plan_account_tabs(&summaries, false, false, true); + assert_eq!( + platform_tab_count(&tabs), + 1, + "exactly one Platform tab, never a duplicate" + ); + } + + /// QA-002: in default mode, funds parked in a non-visible category surface a + /// consolidated tab so the visible tabs reconcile the header total. With no + /// such funds, no extra tab appears. + #[test] + fn default_mode_surfaces_other_tab_only_when_hidden_funds_exist() { + // No hidden funds → no System/Other tab in default mode. + let visible_only = vec![summary(AccountCategory::Bip44, Some(0), 500)]; + let tabs = plan_account_tabs(&visible_only, false, false, true); + assert!( + !tabs.contains(&AccountTab::System), + "no Other tab without hidden funds" + ); + + // Funded CoinJoin (a hidden/system category) → the reconciling tab + // appears so visible tabs still sum to the header total. + let with_hidden = vec![ + summary(AccountCategory::Bip44, Some(0), 500), + summary(AccountCategory::CoinJoin, None, 700), + ]; + let tabs = plan_account_tabs(&with_hidden, false, false, true); + assert!( + tabs.contains(&AccountTab::System), + "hidden funds must surface a reconciling tab in default mode" + ); + } + + /// A funded `Other(Unknown)` address surfaces the reconciling tab in + /// default mode. + #[test] + fn default_mode_surfaces_other_tab_for_unknown_funded_address() { + let summaries = vec![ + summary(AccountCategory::Bip44, Some(0), 500), + summary( + AccountCategory::Other(DerivationPathReference::Unknown), + None, + 9, + ), + ]; + let tabs = plan_account_tabs(&summaries, false, false, true); + assert!(tabs.contains(&AccountTab::System)); + } + + /// Developer mode always shows the System tab, even with no hidden funds — + /// it is the full diagnostic breakdown there. + #[test] + fn developer_mode_always_shows_system_tab() { + let tabs = plan_account_tabs(&[], true, false, true); + assert!(tabs.contains(&AccountTab::System)); + } +} diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index b13c30142..670b133de 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,17 +1,18 @@ use crate::app::AppAction; -use crate::spv::CoreBackendMode; 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}; use super::WalletsBalancesScreen; /// Shown as a disabled-button tooltip and in the in-screen warning banner for -/// any single-key-wallet action that depends on Dash Core RPC. Exported so the -/// dedicated send screen can reuse the same copy. -pub(crate) const SINGLE_KEY_REQUIRES_CORE: &str = "Sending from a single-key wallet requires a local Dash Core node. You can still receive funds at this address. To send, open Settings, switch to Expert mode, and select Local Dash Core node."; +/// 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. +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 { /// Render the detail view for a selected single key wallet @@ -27,7 +28,7 @@ impl WalletsBalancesScreen { None => return action, }; - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read_recover(); let address = wallet.address.to_string(); let alias = wallet .alias @@ -40,7 +41,8 @@ impl WalletsBalancesScreen { drop(wallet); let text_color = DashColors::text_primary(dark_mode); - let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; + // Single-key wallets are unsupported in this version. + let is_rpc_mode = false; Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -54,22 +56,20 @@ impl WalletsBalancesScreen { ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); ui.add_space(10.0); - // When the app is on its built-in SPV backend, surface a - // warning banner explaining that single-key wallet actions - // are unavailable. The actions themselves are greyed out - // below; this banner is the "why" users otherwise wouldn't - // see from a silent disable. + // 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. // // The banner lives on the screen struct so its state is // constructed once and then re-rendered each frame. Setting // the message via the struct field (instead of a fresh // local) means `BannerState::logged` is preserved, so the - // underlying tracing log fires once on mode entry — not 60 - // times a second while the screen is visible. + // 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_REQUIRES_CORE, MessageType::Warning) + .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) .disable_auto_dismiss(); } self.sk_spv_warning_banner.show(ui); @@ -94,7 +94,7 @@ impl WalletsBalancesScreen { let send_response = if is_rpc_mode { send_response } else { - send_response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE) + send_response.on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE) }; if send_response.clicked() { action = AppAction::AddScreen( @@ -125,7 +125,7 @@ impl WalletsBalancesScreen { ui.add_space(10.0); if utxos.is_empty() { - ui.label("No UTXOs available. Click 'Refresh' to load UTXOs from Core."); + ui.label("No funds at this address yet."); } else { const UTXOS_PER_PAGE: usize = 50; let total_pages = utxo_count.div_ceil(UTXOS_PER_PAGE); diff --git a/src/ui/welcome_screen.rs b/src/ui/welcome_screen.rs index 1a5a6e5e2..72233957c 100644 --- a/src/ui/welcome_screen.rs +++ b/src/ui/welcome_screen.rs @@ -1,10 +1,10 @@ use crate::app::AppAction; use crate::context::AppContext; -use crate::ui::components::left_panel::load_svg_icon; +use crate::ui::components::icons::load_svg_icon; use crate::ui::components::styled::island_central_panel; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing}; use crate::ui::{RootScreenType, ScreenType}; -use egui::{Context, RichText, ScrollArea, Vec2}; +use egui::{RichText, ScrollArea, Vec2}; use std::sync::Arc; /// The action the user wants to take after onboarding @@ -25,12 +25,14 @@ impl WelcomeScreen { Self { app_context } } - pub fn ui(&mut self, ctx: &Context) -> AppAction { + pub fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let mut action = AppAction::None; - let dark_mode = ctx.style().visuals.dark_mode; + let dark_mode = ctx.global_style().visuals.dark_mode; // Central panel with welcome content (using island style like other screens) - island_central_panel(ctx, |ui| { + island_central_panel(ui, |ui| { ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { @@ -177,7 +179,7 @@ impl WelcomeScreen { if response.response.interact(egui::Sense::click()).clicked() { // Save settings to database - let _ = self.app_context.db.update_onboarding_completed(true); + let _ = self.app_context.update_onboarding_completed(true); // Return OnboardingComplete with navigation based on selection let (main_screen, add_screen) = match onboarding_action { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 815b66e6d..d81e1a8bf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,3 @@ pub mod egui_mpsc; pub mod parsers; -pub mod path; pub mod tasks; diff --git a/src/utils/path.rs b/src/utils/path.rs deleted file mode 100644 index dbea509d0..000000000 --- a/src/utils/path.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::path::Path; - -/// Formats a file path for user-friendly display. -/// -/// On macOS, this function handles .app bundles specially: -/// - If the path ends with `.app/Contents/MacOS/Dash-Qt`, it displays as `Dash-Qt.app` -/// - Otherwise, it displays the full path -/// -/// # Examples: -/// -/// * `"/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt" -> "Dash-Qt.app"` -/// * `"/usr/local/bin/dash-qt" -> "/usr/local/bin/dash-qt"` -pub fn format_path_for_display(path: &Path) -> String { - let path_str = path.to_string_lossy(); - - // Check if this is a macOS app bundle executable path - // Check if the path matches the pattern for an app bundle executable - if let Some(app_start) = path_str.rfind(".app/Contents/MacOS/") { - // Find the start of the app name by looking backwards for a path separator - let before_app = &path_str[..app_start]; - let app_name_start = before_app.rfind('/').map(|i| i + 1).unwrap_or(0); - let app_name = &path_str[app_name_start..app_start + 4]; // Include ".app" - return app_name.to_string(); - } - - // For all other cases, return the full path - path_str.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_format_macos_app_bundle() { - let path = PathBuf::from("/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt"); - assert_eq!(format_path_for_display(&path), "Dash-Qt.app"); - } - - #[test] - fn test_format_macos_app_bundle_with_spaces() { - let path = PathBuf::from("/Applications/My Apps/Dash Qt.app/Contents/MacOS/Dash-Qt"); - assert_eq!(format_path_for_display(&path), "Dash Qt.app"); - } - - #[test] - fn test_format_regular_path() { - let path = PathBuf::from("/usr/local/bin/dash-qt"); - assert_eq!(format_path_for_display(&path), "/usr/local/bin/dash-qt"); - } - - #[test] - fn test_format_windows_path() { - let path = PathBuf::from("C:\\Program Files\\Dash\\dash-qt.exe"); - assert_eq!( - format_path_for_display(&path), - "C:\\Program Files\\Dash\\dash-qt.exe" - ); - } -} diff --git a/src/wallet_backend/auth_pubkey_cache.rs b/src/wallet_backend/auth_pubkey_cache.rs new file mode 100644 index 000000000..3797345d5 --- /dev/null +++ b/src/wallet_backend/auth_pubkey_cache.rs @@ -0,0 +1,234 @@ +//! DET-side identity-authentication public-key cache view (D4b). +//! +//! [`AuthPubkeyCacheView`] is the only doorway DET code uses to read or +//! write the memoised identity-authentication ECDSA public keys for an HD +//! wallet (see [`AuthPubkeyCache`]). The view borrows a shared [`DetKv`] +//! handle pointing at `det-app.sqlite` and stores one whole-blob entry +//! per wallet under a colon-prefixed, network-scoped key: +//! +//! ```text +//! <network>:auth_pubkeys:<seed_hash_base58> +//! ``` +//! +//! Unlike [`WalletMetaView`](crate::wallet_backend::WalletMetaView) — which +//! must use the global scope because it renders the picker before any +//! wallet is registered — this cache is only ever read or written from +//! paths that already resolved the wallet's seed (identity load/discover, +//! bootstrap). The wallet therefore exists, so the entry is scoped to +//! [`DetScope::Wallet`] and cascades when the wallet is removed. +//! +//! The cache is an optimisation: a missing or unreadable blob degrades to +//! a cold [`AuthPubkeyCache::default`] (empty map), and the read path +//! self-heals via one just-in-time seed derivation that repopulates it. +//! Correctness never depends on the cache being present. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::auth_pubkey_cache::AuthPubkeyCache; +use crate::wallet_backend::DetKv; +use crate::wallet_backend::kv::{KvAdapterError, map_kv_storage_error}; +#[cfg(test)] +use crate::wallet_backend::sidecar::sidecar_key; +use crate::wallet_backend::sidecar::{SidecarScope, SidecarValue, SidecarView}; + +/// Colon-separated namespace for the per-wallet auth-pubkey blob. The +/// full key is `<network>:auth_pubkeys:<seed_hash_base58>`. +pub(crate) const KEY_INFIX: &str = ":auth_pubkeys:"; + +/// Build the canonical k/v key for a wallet's auth-pubkey cache blob. The +/// generic view builds keys itself; this mirror exists for key-shape tests. +#[cfg(test)] +pub(crate) fn key_for(network: Network, seed_hash: &WalletSeedHash) -> String { + sidecar_key(network, KEY_INFIX, seed_hash) +} + +impl SidecarValue for AuthPubkeyCache {} + +/// Typed auth-pubkey-cache sidecar (D4b). A thin wrapper over the generic +/// [`SidecarView`]. Unlike the metadata sidecars it is +/// [`SidecarScope::WalletById`]-scoped: it is only ever touched from paths that +/// already resolved the wallet's seed, so the entry cascades on wallet removal. +/// The cache is an optimisation — a missing or unreadable blob degrades to a +/// cold [`AuthPubkeyCache::default`] that the read path self-heals. +pub struct AuthPubkeyCacheView<'a>(SidecarView<'a, AuthPubkeyCache>); + +impl<'a> AuthPubkeyCacheView<'a> { + /// Borrow a [`DetKv`] handle as a typed auth-pubkey-cache view. + pub fn new(kv: &'a Arc<DetKv>) -> Self { + Self(SidecarView::new( + kv, + KEY_INFIX, + SidecarScope::WalletById, + map_kv_error_to_task_error, + )) + } + + /// Load the cache for one wallet. + /// + /// Returns an empty (cold) [`AuthPubkeyCache`] when the key is absent + /// or the blob fails to decode (logged) — the read path self-heals, + /// so a corrupt blob must never block identity load/discovery. + pub fn get(&self, network: Network, seed_hash: &WalletSeedHash) -> AuthPubkeyCache { + self.0.get(network, seed_hash).unwrap_or_default() + } + + /// Whole-blob upsert of the cache for one wallet. The map is tiny + /// (a handful of identities x a few keys) and writes only happen on + /// cold-cache first-touch, so a whole-blob write matches the + /// `WalletMeta` discipline — no need for row-granular storage. + pub fn put( + &self, + network: Network, + seed_hash: &WalletSeedHash, + cache: &AuthPubkeyCache, + ) -> Result<(), TaskError> { + self.0.set(network, seed_hash, cache) + } + + /// Delete the cache for one wallet. Idempotent — a missing key + /// returns `Ok(())`. The wallet-scope cascade is the real deletion + /// mechanism in production; this direct delete exists for tests. + #[cfg(test)] + pub fn delete(&self, network: Network, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + self.0.delete(network, seed_hash) + } +} + +/// Auth-pubkey-cache adapter errors funnel into the dedicated +/// [`TaskError::KvSidecarStorage`] envelope. +fn map_kv_error_to_task_error(e: KvAdapterError) -> TaskError { + map_kv_storage_error(e, |source| TaskError::KvSidecarStorage { + sidecar: "auth_pubkey_cache", + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::base58; + use dash_sdk::dpp::dashcore::secp256k1::{ + PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, + }; + + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + fn pubkey(seed: u8) -> PublicKey { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[seed.max(1); 32]).expect("valid secret key"); + PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &sk)) + } + + /// AUTH-CACHE-VIEW-001 — a written cache round-trips through `get` + /// for the same wallet. + #[test] + fn put_then_get_round_trips() { + let kv = kv(); + let view = AuthPubkeyCacheView::new(&kv); + let seed: WalletSeedHash = [0x11; 32]; + let mut cache = AuthPubkeyCache::default(); + cache.insert(Network::Testnet, 0, 0, &pubkey(5)); + cache.insert(Network::Testnet, 1, 3, &pubkey(6)); + view.put(Network::Testnet, &seed, &cache).expect("put"); + let got = view.get(Network::Testnet, &seed); + assert_eq!(got, cache); + } + + /// AUTH-CACHE-VIEW-002 — `get` on an absent key returns a cold + /// (empty) cache rather than erroring; this is the read-path + /// graceful-degradation contract. + #[test] + fn get_missing_returns_cold_default() { + let kv = kv(); + let view = AuthPubkeyCacheView::new(&kv); + let seed: WalletSeedHash = [0x66; 32]; + let got = view.get(Network::Devnet, &seed); + assert!(got.is_empty()); + assert_eq!(got, AuthPubkeyCache::default()); + } + + /// AUTH-CACHE-VIEW-003 — entries do not leak across networks: the + /// same seed hash on two networks yields two independent blobs. + #[test] + fn get_partitions_by_network() { + let kv = kv(); + let view = AuthPubkeyCacheView::new(&kv); + let seed: WalletSeedHash = [0x33; 32]; + let mut mainnet = AuthPubkeyCache::default(); + mainnet.insert(Network::Mainnet, 0, 0, &pubkey(11)); + let mut testnet = AuthPubkeyCache::default(); + testnet.insert(Network::Testnet, 0, 0, &pubkey(22)); + view.put(Network::Mainnet, &seed, &mainnet).unwrap(); + view.put(Network::Testnet, &seed, &testnet).unwrap(); + assert_eq!(view.get(Network::Mainnet, &seed), mainnet); + assert_eq!(view.get(Network::Testnet, &seed), testnet); + } + + /// AUTH-CACHE-VIEW-004 — `put` is an upsert; a second write replaces + /// the prior blob. + #[test] + fn put_upserts() { + let kv = kv(); + let view = AuthPubkeyCacheView::new(&kv); + let seed: WalletSeedHash = [0x22; 32]; + let mut first = AuthPubkeyCache::default(); + first.insert(Network::Mainnet, 0, 0, &pubkey(1)); + view.put(Network::Mainnet, &seed, &first).unwrap(); + let mut second = AuthPubkeyCache::default(); + second.insert(Network::Mainnet, 0, 0, &pubkey(2)); + view.put(Network::Mainnet, &seed, &second).unwrap(); + assert_eq!(view.get(Network::Mainnet, &seed), second); + } + + /// AUTH-CACHE-VIEW-005 — `delete` is idempotent and removes the blob. + #[test] + fn delete_is_idempotent() { + let kv = kv(); + let view = AuthPubkeyCacheView::new(&kv); + let seed: WalletSeedHash = [0x55; 32]; + view.delete(Network::Testnet, &seed).expect("delete absent"); + let mut cache = AuthPubkeyCache::default(); + cache.insert(Network::Testnet, 0, 0, &pubkey(9)); + view.put(Network::Testnet, &seed, &cache).unwrap(); + view.delete(Network::Testnet, &seed).expect("first delete"); + view.delete(Network::Testnet, &seed).expect("second delete"); + assert!(view.get(Network::Testnet, &seed).is_empty()); + } + + /// AUTH-CACHE-VIEW-006 — the canonical key shape uses the + /// `<network>:auth_pubkeys:<base58>` layout. Locks the shape so a + /// future change needs an explicit migration. + #[test] + fn key_for_uses_base58_seed_hash() { + let seed: WalletSeedHash = [0xAB; 32]; + let key = key_for(Network::Mainnet, &seed); + assert!(key.starts_with("mainnet:auth_pubkeys:")); + let suffix = key.trim_start_matches("mainnet:auth_pubkeys:"); + let decoded = base58::decode(suffix).expect("base58 decodes"); + assert_eq!(decoded.as_slice(), seed.as_slice()); + } + + /// AUTH-CACHE-VIEW-007 — the cache is a *new* KV key, not a schema + /// change: adding it must not bump the DET KV `SCHEMA_VERSION` byte + /// (an additive new key is forward/backward tolerant). This is the + /// load-bearing "no DivergentVersion / no schema bump" guard from the + /// D4b design — a future maintainer who bumps the version trips here. + #[test] + fn does_not_bump_kv_schema_version() { + assert_eq!( + crate::wallet_backend::KV_SCHEMA_VERSION, + 1, + "adding the auth-pubkey cache key must not bump the KV schema version" + ); + } +} diff --git a/src/wallet_backend/avatar_cache.rs b/src/wallet_backend/avatar_cache.rs new file mode 100644 index 000000000..b1718a780 --- /dev/null +++ b/src/wallet_backend/avatar_cache.rs @@ -0,0 +1,491 @@ +//! DET-side avatar image cache. +//! +//! Avatars are binary images referenced by a profile's `avatarUrl`. Upstream +//! `platform-wallet` persists only the avatar hash and perceptual fingerprint +//! — never the image bytes — so without a DET-side cache every contact view +//! re-fetches every avatar from the network. [`AvatarCacheView`] stores the +//! validated image bytes keyed by URL in the cross-network app-level k/v +//! store, so a cached avatar survives offline and is fetched at most once per +//! content change. +//! +//! Keys live under [`DetScope::Global`] (avatars are not network-specific and +//! should outlive wallet deletion) with two shapes per URL: +//! +//! ```text +//! det:avatar:<sha256(url)_hex> the validated image bytes +//! det:avatar_ts:<sha256(url)_hex> sibling index: the fetch time only +//! ``` +//! +//! Hashing the URL keeps the key bounded and free of the colon / pattern +//! metacharacters a raw URL would carry into the k/v `list` matcher. The read +//! path is infallible-by-degradation: a missing or corrupt entry returns +//! `None` (logged) so the UI falls back to a network fetch rather than +//! blocking. +//! +//! ## Invalidation and bounds +//! +//! A cached avatar carries the wall-clock fetch time (`fetched_at_ms`). The +//! read path treats an entry older than [`AVATAR_TTL_MS`] as stale: [`get`] +//! drops it and returns `None`, so a changed avatar served at the same URL is +//! re-fetched rather than pinned forever. The cache is also size-bounded — +//! [`put`] evicts the oldest entries once the entry count exceeds +//! [`MAX_AVATAR_ENTRIES`], so the cross-network Global scope cannot grow +//! without limit, and the whole cache is cleared when a wallet is forgotten. +//! Eviction orders entries by the sibling `det:avatar_ts:` index, so it reads +//! only the small fetch timestamps rather than deserializing every cached +//! image's full bytes on each [`put`]. +//! +//! [`get`]: AvatarCacheView::get +//! [`put`]: AvatarCacheView::put + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::hashes::{Hash, sha256}; + +use crate::backend_task::error::TaskError; +use crate::model::dashpay::CachedAvatar; +use crate::wallet_backend::kv::{KvAdapterError, kv_get_logged, map_kv_storage_error}; +use crate::wallet_backend::{DetKv, DetScope}; + +/// Key prefix for every cached avatar entry (the image bytes). +const KEY_PREFIX: &str = "det:avatar:"; + +/// Key prefix for the sibling timestamp index. Each cached avatar has a +/// matching `det:avatar_ts:<hash>` entry holding only its `fetched_at_ms`, so +/// [`AvatarCacheView::put`] can order entries for eviction by reading these +/// small i64 values instead of deserializing every image's bytes. +const TS_KEY_PREFIX: &str = "det:avatar_ts:"; + +/// Maximum age of a cached avatar before [`AvatarCacheView::get`] treats it as +/// stale and re-fetches. Seven days balances offline survival against picking +/// up a changed avatar at a stable URL within a reasonable window. +const AVATAR_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000; + +/// Maximum number of cached avatar entries kept in the Global scope. Once a +/// [`AvatarCacheView::put`] would exceed this, the oldest entries are evicted +/// so the cache stays bounded regardless of how many distinct contacts are +/// viewed over a wallet's lifetime. +const MAX_AVATAR_ENTRIES: usize = 256; + +/// SHA-256 of `url`, hex-encoded — the fixed-length, metacharacter-free suffix +/// shared by an avatar's byte key and its timestamp-index key. +fn digest_hex(url: &str) -> String { + let digest = sha256::Hash::hash(url.as_bytes()); + hex::encode(digest.to_byte_array()) +} + +/// Build the canonical k/v key for an avatar URL (the image bytes). +fn key_for(url: &str) -> String { + format!("{KEY_PREFIX}{}", digest_hex(url)) +} + +/// Build the sibling timestamp-index key for an avatar URL. +fn ts_key_for(url: &str) -> String { + format!("{TS_KEY_PREFIX}{}", digest_hex(url)) +} + +/// View borrowing a shared [`DetKv`] handle. Cheap to construct, so callers +/// build one per operation rather than threading it. +pub struct AvatarCacheView<'a> { + kv: &'a Arc<DetKv>, +} + +impl<'a> AvatarCacheView<'a> { + /// Borrow a [`DetKv`] handle as a typed avatar-cache view. + pub fn new(kv: &'a Arc<DetKv>) -> Self { + Self { kv } + } + + /// Fetch the cached avatar for `url`, honouring the [`AVATAR_TTL_MS`] + /// freshness window. Returns `None` when the URL is not cached, its blob + /// fails to decode (logged and treated as absent), or the entry is older + /// than the TTL — in which case the stale entry is dropped so the next read + /// re-fetches. The caller falls back to a network fetch on any `None`. + pub fn get(&self, url: &str) -> Option<CachedAvatar> { + self.get_at(url, chrono::Utc::now().timestamp_millis()) + } + + /// TTL-aware read with an injected `now_ms` clock — the testable core of + /// [`Self::get`]. An entry whose `fetched_at_ms` precedes `now_ms` by more + /// than [`AVATAR_TTL_MS`] is invalidated and reported absent. + fn get_at(&self, url: &str, now_ms: i64) -> Option<CachedAvatar> { + let key = key_for(url); + let cached = + kv_get_logged::<CachedAvatar>(self.kv, DetScope::Global, &key, "avatar_cache")?; + + if now_ms.saturating_sub(cached.fetched_at_ms) > AVATAR_TTL_MS { + // Stale: drop so a changed avatar at the same URL is re-fetched. + if let Err(e) = self.invalidate(url) { + tracing::debug!( + target = "wallet_backend::avatar_cache", + error = ?e, + "Failed to evict stale cached avatar", + ); + } + return None; + } + + Some(cached) + } + + /// Cache `bytes` for `url`, computing and storing the content hash and + /// fetch timestamp. Overwrites any prior entry for the same URL (so a + /// changed avatar at a stable URL is refreshed in place) and evicts the + /// oldest entries when the cache exceeds [`MAX_AVATAR_ENTRIES`]. + pub fn put(&self, url: &str, bytes: Vec<u8>) -> Result<(), TaskError> { + self.put_at(url, bytes, chrono::Utc::now().timestamp_millis()) + } + + /// [`Self::put`] with an injected `now_ms` clock — the testable core. + fn put_at(&self, url: &str, bytes: Vec<u8>, now_ms: i64) -> Result<(), TaskError> { + let sha256 = sha256::Hash::hash(&bytes).to_byte_array().to_vec(); + let entry = CachedAvatar { + bytes, + sha256, + fetched_at_ms: now_ms, + }; + self.kv + .put(DetScope::Global, &key_for(url), &entry) + .map_err(map_kv_error_to_task_error)?; + // Sibling index: lets eviction order entries without loading image bytes. + self.kv + .put(DetScope::Global, &ts_key_for(url), &now_ms) + .map_err(map_kv_error_to_task_error)?; + self.evict_to_bound()?; + Ok(()) + } + + /// Drop the cached avatar for `url`. Idempotent — a missing entry returns + /// `Ok(())`. Used to invalidate a stale image (e.g. the profile's + /// `avatarUrl` changed, leaving the old URL's bytes orphaned). + pub fn invalidate(&self, url: &str) -> Result<(), TaskError> { + self.kv + .delete(DetScope::Global, &key_for(url)) + .map_err(map_kv_error_to_task_error)?; + self.kv + .delete(DetScope::Global, &ts_key_for(url)) + .map_err(map_kv_error_to_task_error) + } + + /// Drop every cached avatar. Called on wallet deletion so the cache cannot + /// outlive the wallets whose contacts populated it. Best-effort per entry — + /// a single delete failure is logged and the sweep continues. + pub fn clear(&self) -> Result<(), TaskError> { + for prefix in [KEY_PREFIX, TS_KEY_PREFIX] { + let keys = self + .kv + .list(DetScope::Global, Some(prefix)) + .map_err(map_kv_error_to_task_error)?; + for key in keys { + if let Err(e) = self.kv.delete(DetScope::Global, &key) { + tracing::debug!( + target = "wallet_backend::avatar_cache", + error = ?e, + "Failed to delete cached avatar during clear", + ); + } + } + } + Ok(()) + } + + /// Evict the oldest entries until the cache holds at most + /// [`MAX_AVATAR_ENTRIES`]. A best-effort housekeeping pass run after each + /// `put`; a read or delete failure on any single entry is non-fatal. + /// + /// Ordering reads only the sibling `det:avatar_ts:` index (small `i64` + /// timestamps), never the cached image bytes. + fn evict_to_bound(&self) -> Result<(), TaskError> { + let ts_keys = self + .kv + .list(DetScope::Global, Some(TS_KEY_PREFIX)) + .map_err(map_kv_error_to_task_error)?; + if ts_keys.len() <= MAX_AVATAR_ENTRIES { + return Ok(()); + } + + // Order by fetch time so the oldest are dropped first. Entries with a + // missing/unreadable timestamp sort to the front (age 0) and go first. + let mut aged: Vec<(i64, String)> = ts_keys + .into_iter() + .map(|ts_key| { + let age = self + .kv + .get::<i64>(DetScope::Global, &ts_key) + .ok() + .flatten() + .unwrap_or(0); + (age, ts_key) + }) + .collect(); + aged.sort_by_key(|(age, _)| *age); + + let evict = aged.len().saturating_sub(MAX_AVATAR_ENTRIES); + for (_, ts_key) in aged.into_iter().take(evict) { + // Drop both the image and its sibling timestamp so the index stays + // in lock-step with the byte entries. + let avatar_key = format!( + "{KEY_PREFIX}{}", + ts_key.strip_prefix(TS_KEY_PREFIX).unwrap_or(&ts_key) + ); + for key in [avatar_key, ts_key] { + if let Err(e) = self.kv.delete(DetScope::Global, &key) { + tracing::debug!( + target = "wallet_backend::avatar_cache", + error = ?e, + "Failed to evict cached avatar over bound", + ); + } + } + } + Ok(()) + } +} + +/// Avatar-cache adapter errors funnel into the dedicated +/// [`TaskError::AvatarCacheStorage`] envelope. +fn map_kv_error_to_task_error(e: KvAdapterError) -> TaskError { + map_kv_storage_error(e, |source| TaskError::AvatarCacheStorage { source }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + const URL_A: &str = "https://example.com/avatar-a.png"; + const URL_B: &str = "https://example.com/avatar-b.png"; + + #[test] + fn put_then_get_round_trips_and_hashes_bytes() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + let bytes = b"image-bytes-a".to_vec(); + view.put(URL_A, bytes.clone()).expect("put"); + + let cached = view.get(URL_A).expect("cache hit"); + assert_eq!(cached.bytes, bytes); + assert_eq!( + cached.sha256, + sha256::Hash::hash(&bytes).to_byte_array().to_vec(), + "the stored hash must match the bytes" + ); + } + + #[test] + fn get_missing_returns_none() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + assert_eq!(view.get(URL_A), None); + } + + #[test] + fn put_overwrites_in_place_for_changed_avatar() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put(URL_A, b"old".to_vec()).expect("first put"); + view.put(URL_A, b"new".to_vec()).expect("second put"); + let cached = view.get(URL_A).expect("cache hit"); + assert_eq!(cached.bytes, b"new".to_vec()); + assert_eq!( + cached.sha256, + sha256::Hash::hash(b"new").to_byte_array().to_vec() + ); + } + + #[test] + fn invalidate_drops_entry_and_is_idempotent() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + // Idempotent on an absent key. + view.invalidate(URL_A).expect("invalidate absent"); + view.put(URL_A, b"bytes".to_vec()).expect("put"); + view.invalidate(URL_A).expect("first invalidate"); + view.invalidate(URL_A).expect("second invalidate"); + assert_eq!(view.get(URL_A), None); + } + + #[test] + fn distinct_urls_do_not_collide() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put(URL_A, b"a".to_vec()).expect("put a"); + view.put(URL_B, b"b".to_vec()).expect("put b"); + assert_eq!(view.get(URL_A).unwrap().bytes, b"a".to_vec()); + assert_eq!(view.get(URL_B).unwrap().bytes, b"b".to_vec()); + // Invalidating one leaves the other intact. + view.invalidate(URL_A).expect("invalidate a"); + assert_eq!(view.get(URL_A), None); + assert_eq!(view.get(URL_B).unwrap().bytes, b"b".to_vec()); + } + + #[test] + fn stale_entry_is_not_served_and_is_invalidated() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + // Seed at t=0. + view.put_at(URL_A, b"old".to_vec(), 0).expect("put"); + + // A read within the TTL still serves the cached bytes. + let fresh = view + .get_at(URL_A, AVATAR_TTL_MS) + .expect("entry within TTL is served"); + assert_eq!(fresh.bytes, b"old".to_vec()); + + // One millisecond past the TTL: the entry is stale, must not be served, + // and must be evicted so the next read re-fetches. + assert_eq!( + view.get_at(URL_A, AVATAR_TTL_MS + 1), + None, + "an entry older than the TTL must not be served" + ); + // A fresh read (even at t=0) sees nothing — the stale read dropped it. + assert_eq!( + view.get_at(URL_A, 0), + None, + "the stale entry must have been invalidated, not just hidden" + ); + } + + #[test] + fn changed_avatar_at_same_url_is_refetched_after_ttl() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put_at(URL_A, b"v1".to_vec(), 0).expect("seed v1"); + // After the TTL the old bytes are not served; a re-fetch caches v2. + assert_eq!(view.get_at(URL_A, AVATAR_TTL_MS + 1), None); + view.put_at(URL_A, b"v2".to_vec(), AVATAR_TTL_MS + 1) + .expect("re-cache v2"); + let now = view.get_at(URL_A, AVATAR_TTL_MS + 1).expect("v2 served"); + assert_eq!(now.bytes, b"v2".to_vec()); + } + + #[test] + fn put_evicts_oldest_when_over_bound() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + // Fill exactly to the bound, oldest first (monotonic timestamps). + for i in 0..MAX_AVATAR_ENTRIES { + let url = format!("https://example.com/a{i}.png"); + view.put_at(&url, vec![i as u8], i as i64).expect("put"); + } + // The oldest entry (i=0) is still present at the bound. + assert!(view.get_at("https://example.com/a0.png", 0).is_some()); + + // One more entry pushes past the bound; the oldest must be evicted. + view.put_at( + "https://example.com/overflow.png", + vec![0xff], + MAX_AVATAR_ENTRIES as i64, + ) + .expect("put overflow"); + assert_eq!( + view.get_at("https://example.com/a0.png", 0), + None, + "the oldest entry must be evicted once the cache exceeds the bound" + ); + // The newest entry survives. + assert!( + view.get_at( + "https://example.com/overflow.png", + MAX_AVATAR_ENTRIES as i64 + ) + .is_some() + ); + } + + #[test] + fn clear_drops_every_entry() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put(URL_A, b"a".to_vec()).expect("put a"); + view.put(URL_B, b"b".to_vec()).expect("put b"); + view.clear().expect("clear"); + assert_eq!(view.get(URL_A), None); + assert_eq!(view.get(URL_B), None); + } + + /// Every `put` maintains a sibling timestamp entry, and `invalidate` drops + /// both — so the index never drifts from the byte entries. + #[test] + fn put_and_invalidate_keep_timestamp_index_in_lockstep() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put_at(URL_A, b"a".to_vec(), 42).expect("put"); + + let ts = kv + .get::<i64>(DetScope::Global, &ts_key_for(URL_A)) + .expect("read ts") + .expect("ts present"); + assert_eq!(ts, 42, "the sibling index stores the fetch time"); + + view.invalidate(URL_A).expect("invalidate"); + assert_eq!( + kv.get::<i64>(DetScope::Global, &ts_key_for(URL_A)) + .expect("read ts"), + None, + "invalidate must drop the sibling timestamp too" + ); + } + + /// Eviction removes the sibling timestamp alongside the byte entry, leaving + /// no orphaned index keys behind. + #[test] + fn eviction_drops_sibling_timestamp_entries() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + for i in 0..MAX_AVATAR_ENTRIES { + let url = format!("https://example.com/a{i}.png"); + view.put_at(&url, vec![i as u8], i as i64).expect("put"); + } + view.put_at("https://example.com/overflow.png", vec![0xff], i64::MAX) + .expect("put overflow"); + + let byte_keys = kv + .list(DetScope::Global, Some(KEY_PREFIX)) + .expect("list bytes"); + let ts_keys = kv + .list(DetScope::Global, Some(TS_KEY_PREFIX)) + .expect("list ts"); + assert_eq!( + byte_keys.len(), + MAX_AVATAR_ENTRIES, + "byte entries stay at the bound" + ); + assert_eq!( + ts_keys.len(), + MAX_AVATAR_ENTRIES, + "the sibling index tracks the byte entries exactly, no orphans" + ); + } + + #[test] + fn clear_drops_timestamp_index() { + let kv = kv(); + let view = AvatarCacheView::new(&kv); + view.put(URL_A, b"a".to_vec()).expect("put a"); + view.put(URL_B, b"b".to_vec()).expect("put b"); + view.clear().expect("clear"); + assert!( + kv.list(DetScope::Global, Some(TS_KEY_PREFIX)) + .expect("list ts") + .is_empty(), + "clear must sweep the sibling timestamp index too" + ); + } + + #[test] + fn key_is_url_hash_prefixed() { + let key = key_for(URL_A); + assert!(key.starts_with(KEY_PREFIX)); + let suffix = key.trim_start_matches(KEY_PREFIX); + // 32-byte sha256 hex-encoded. + assert_eq!(suffix.len(), 64); + assert!(suffix.bytes().all(|b| b.is_ascii_hexdigit())); + } +} diff --git a/src/wallet_backend/contact_profile_cache.rs b/src/wallet_backend/contact_profile_cache.rs new file mode 100644 index 000000000..ec1a47df5 --- /dev/null +++ b/src/wallet_backend/contact_profile_cache.rs @@ -0,0 +1,200 @@ +//! DET-side contact-profile cache. +//! +//! Upstream rehydrates a *local* identity's own DashPay profile into its +//! `ManagedIdentity`, but a *contact's* profile (display name, avatar URL, +//! bio, DPNS username) belongs to an out-of-wallet identity and is therefore +//! never rehydrated — [`WalletBackend::dashpay_set_profile`] is a no-op for a +//! non-managed identity. Without a DET-side cache, the contact list must +//! re-fetch every contact's profile and DPNS name from the network on every +//! view. +//! +//! [`ContactProfileCacheView`] stores the last fetched contact profile keyed +//! by the contact's identity in the cross-network app-level k/v store, so the +//! offline contact read can paint names and avatars without a round-trip. The +//! cache is refreshed whenever the network read fetches fresh profile data. +//! +//! Key shape (under [`DetScope::Global`]): +//! +//! ```text +//! det:contact_profile:<contact_id_base58> +//! ``` + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::base58; +use dash_sdk::platform::Identifier; + +use crate::backend_task::error::TaskError; +use crate::wallet_backend::kv::{KvAdapterError, kv_get_logged, map_kv_storage_error}; +use crate::wallet_backend::{DetKv, DetScope}; + +/// Key prefix for every cached contact profile. +const KEY_PREFIX: &str = "det:contact_profile:"; + +/// A cached contact profile — the network-fetched display fields DET serves +/// offline. Pure data; serialised whole into the k/v store. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CachedContactProfile { + pub username: Option<String>, + pub display_name: Option<String>, + pub avatar_url: Option<String>, + pub bio: Option<String>, + /// The contact's account reference (the "Account #N" badge), decoded from + /// the network contact's private data. Cached so the offline read can show + /// the badge and sort consistently with the post-refresh view. `None` when + /// the network read had no account reference to cache. + #[serde(default)] + pub account_reference: Option<u32>, +} + +impl CachedContactProfile { + /// Whether the cached profile carries any displayable field. An all-`None` + /// profile is not worth writing or serving. The account reference is + /// metadata, not a displayable field, so it alone does not make a profile + /// worth caching. + pub fn is_empty(&self) -> bool { + self.username.is_none() + && self.display_name.is_none() + && self.avatar_url.is_none() + && self.bio.is_none() + } +} + +/// Build the canonical k/v key for a contact's cached profile. +fn key_for(contact: &Identifier) -> String { + format!("{KEY_PREFIX}{}", base58::encode_slice(&contact.to_buffer())) +} + +/// View borrowing a shared [`DetKv`] handle. Cheap to construct. +pub struct ContactProfileCacheView<'a> { + kv: &'a Arc<DetKv>, +} + +impl<'a> ContactProfileCacheView<'a> { + /// Borrow a [`DetKv`] handle as a typed contact-profile cache view. + pub fn new(kv: &'a Arc<DetKv>) -> Self { + Self { kv } + } + + /// Fetch the cached profile for `contact`. Returns `None` when the contact + /// has no cached profile or its blob fails to decode (logged, treated as + /// absent so the caller falls back to a network fetch). + pub fn get(&self, contact: &Identifier) -> Option<CachedContactProfile> { + let key = key_for(contact); + kv_get_logged::<CachedContactProfile>( + self.kv, + DetScope::Global, + &key, + "contact_profile_cache", + ) + } + + /// Upsert the cached profile for `contact`. An all-`None` profile is + /// dropped instead of stored, so an empty fetch does not overwrite a + /// previously cached profile with nothing. + pub fn put( + &self, + contact: &Identifier, + profile: &CachedContactProfile, + ) -> Result<(), TaskError> { + if profile.is_empty() { + return self.invalidate(contact); + } + let key = key_for(contact); + self.kv + .put(DetScope::Global, &key, profile) + .map_err(map_kv_error_to_task_error) + } + + /// Drop the cached profile for `contact`. Idempotent. + pub fn invalidate(&self, contact: &Identifier) -> Result<(), TaskError> { + let key = key_for(contact); + self.kv + .delete(DetScope::Global, &key) + .map_err(map_kv_error_to_task_error) + } +} + +/// Contact-profile-cache adapter errors funnel into the dedicated +/// [`TaskError::ContactProfileCacheStorage`] envelope — the offline contact-card +/// surface the contact list shows. +fn map_kv_error_to_task_error(e: KvAdapterError) -> TaskError { + map_kv_storage_error(e, |source| TaskError::ContactProfileCacheStorage { source }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + fn contact(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn profile(name: &str) -> CachedContactProfile { + CachedContactProfile { + username: Some(format!("{name}.dash")), + display_name: Some(name.to_string()), + avatar_url: Some(format!("https://example.com/{name}.png")), + bio: None, + account_reference: None, + } + } + + #[test] + fn put_then_get_round_trips() { + let kv = kv(); + let view = ContactProfileCacheView::new(&kv); + let c = contact(1); + let p = profile("alice"); + view.put(&c, &p).expect("put"); + assert_eq!(view.get(&c), Some(p)); + } + + #[test] + fn get_missing_returns_none() { + let kv = kv(); + let view = ContactProfileCacheView::new(&kv); + assert_eq!(view.get(&contact(9)), None); + } + + #[test] + fn empty_profile_is_not_stored_and_clears_existing() { + let kv = kv(); + let view = ContactProfileCacheView::new(&kv); + let c = contact(2); + view.put(&c, &profile("bob")).expect("seed"); + // An all-None fetch must not overwrite the cached profile with nothing + // — it clears the entry rather than persisting an empty record. + view.put(&c, &CachedContactProfile::default()) + .expect("empty put"); + assert_eq!(view.get(&c), None); + } + + #[test] + fn invalidate_is_idempotent() { + let kv = kv(); + let view = ContactProfileCacheView::new(&kv); + let c = contact(3); + view.invalidate(&c).expect("invalidate absent"); + view.put(&c, &profile("carol")).expect("put"); + view.invalidate(&c).expect("first invalidate"); + view.invalidate(&c).expect("second invalidate"); + assert_eq!(view.get(&c), None); + } + + #[test] + fn distinct_contacts_do_not_collide() { + let kv = kv(); + let view = ContactProfileCacheView::new(&kv); + view.put(&contact(4), &profile("dave")).expect("put dave"); + view.put(&contact(5), &profile("erin")).expect("put erin"); + assert_eq!(view.get(&contact(4)), Some(profile("dave"))); + assert_eq!(view.get(&contact(5)), Some(profile("erin"))); + } +} diff --git a/src/wallet_backend/coordinator_gate.rs b/src/wallet_backend/coordinator_gate.rs new file mode 100644 index 000000000..d5030817e --- /dev/null +++ b/src/wallet_backend/coordinator_gate.rs @@ -0,0 +1,361 @@ +//! Quorum-readiness gate for the Platform/identity sync coordinators. +//! +//! At fresh boot the upstream platform-address and identity coordinators must +//! not run until the SPV masternode list has synced — they issue proof-verifying +//! DAPI calls that fail locally ("no masternode list yet") and get every queried +//! node banned by the SDK (`ban_failed_address: true`), bricking Platform. +//! +//! [`WalletBackend::start`](super::WalletBackend::start) spawns SPV immediately +//! but defers the coordinator starts: it *arms* this gate with a start action, +//! then fires it. The gate runs the action exactly once, and only when both +//! armed and masternodes-ready. The two readiness paths converge here: +//! +//! * already ready at `start()` time → `arm` fires immediately, or +//! * not ready yet → the `EventBridge` calls [`CoordinatorGate::on_masternodes_ready`] +//! when the masternode list reaches `Synced`, which fires the armed action. +//! +//! The drop+rebuild reconnect path builds a fresh backend (and a fresh gate) +//! each time, so the latch re-arms naturally. The restart-in-place reconnect +//! path reuses the same backend and gate instead; it calls +//! [`CoordinatorGate::reset`] to re-arm the gate for the next `start()`. + +use super::poison::MutexRecover; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// The one-shot start action: starts the platform-address and identity sync +/// coordinators. Cheap to construct (clones two `Arc` handles), invoked at most +/// once per gate. +type StartAction = Box<dyn Fn() + Send + Sync>; + +/// Idempotent latch that starts the Platform sync coordinators exactly once, +/// the first time both an arming action is installed and masternodes are ready. +#[derive(Default)] +pub(super) struct CoordinatorGate { + /// Whether the SPV masternode list has finished syncing. Set by the + /// `EventBridge`; mirrors `ConnectionStatus::masternodes_ready`. + masternodes_ready: AtomicBool, + /// The start action, installed by `WalletBackend::start`. Held in a + /// `Mutex<Option<…>>` (not a `OnceLock`) so [`Self::reset`] can clear it + /// for a restart-in-place reconnect, which re-arms this same gate rather + /// than building a fresh one. + action: Mutex<Option<StartAction>>, + /// Single-winner guard so the action runs exactly once across the two + /// concurrent fire paths (`arm` and `on_masternodes_ready`). + fired: AtomicBool, +} + +impl std::fmt::Debug for CoordinatorGate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoordinatorGate") + .field("masternodes_ready", &self.masternodes_ready()) + .field("armed", &self.action.lock().is_ok_and(|a| a.is_some())) + .field("fired", &self.fired.load(Ordering::SeqCst)) + .finish() + } +} + +impl CoordinatorGate { + /// Whether masternodes have been reported ready to this gate. + pub(super) fn masternodes_ready(&self) -> bool { + self.masternodes_ready.load(Ordering::SeqCst) + } + + /// Whether the start action has fired. + pub(super) fn has_fired(&self) -> bool { + self.fired.load(Ordering::SeqCst) + } + + /// Install the coordinator start action and try to fire immediately. + /// + /// Called once by `WalletBackend::start`. If masternodes are already ready + /// the action runs now (case (a)); otherwise it waits for + /// [`Self::on_masternodes_ready`] (case (b)). A second arm is ignored — the + /// action slot is write-once. + pub(super) fn arm(&self, action: StartAction) { + { + let mut slot = self.action.lock_recover(); + if slot.is_some() { + tracing::debug!("CoordinatorGate already armed; ignoring second arm"); + return; + } + *slot = Some(action); + } + self.try_fire(); + } + + /// Record that the masternode list reached `Synced` and try to fire. + /// + /// Called by the `EventBridge` from the sync-progress hot path; cheap and + /// idempotent. Fires the armed action the first time both conditions hold. + pub(super) fn on_masternodes_ready(&self) { + self.masternodes_ready.store(true, Ordering::SeqCst); + self.try_fire(); + } + + /// Run the start action iff armed, ready, and not already fired — claiming + /// the single-winner `fired` flag so the action runs exactly once even when + /// `arm` and `on_masternodes_ready` race. + fn try_fire(&self) { + if !self.should_fire() { + return; + } + // Claim the fire exactly once. The `should_fire` pre-check is a cheap + // early-out; this swap is the authoritative single-winner guard. + if self.fired.swap(true, Ordering::SeqCst) { + return; + } + let slot = self.action.lock_recover(); + if let Some(action) = slot.as_ref() { + tracing::info!("Masternode list synced; starting Platform sync coordinators"); + action(); + } + } + + /// Pure decision: the action may fire when masternodes are ready, an action + /// is armed, and it has not fired yet. Side-effect-free, unit-testable. + fn should_fire(&self) -> bool { + self.masternodes_ready() && self.action.lock_recover().is_some() && !self.has_fired() + } + + /// Clear the gate so a restart-in-place reconnect can re-arm it. + /// + /// Drops the installed action, clears the single-winner `fired` flag, and + /// resets `masternodes_ready` to `false`. The last is mandatory: a restart + /// rebuilds the SPV session, which re-syncs the masternode list from + /// scratch, so the coordinators must wait for a fresh `Synced` signal + /// before firing — starting them against a not-yet-synced masternode list + /// fires proof-verifying DAPI calls that get every queried node banned. + pub(super) fn reset(&self) { + *self.action.lock_recover() = None; + self.fired.store(false, Ordering::SeqCst); + self.masternodes_ready.store(false, Ordering::SeqCst); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + /// A start action that counts how many times it ran. + fn counting_action(counter: &Arc<AtomicUsize>) -> StartAction { + let counter = Arc::clone(counter); + Box::new(move || { + counter.fetch_add(1, Ordering::SeqCst); + }) + } + + #[test] + fn arm_before_ready_defers_then_fires_on_ready() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + + gate.arm(counting_action(&calls)); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "arming before masternodes are ready must NOT start coordinators" + ); + assert!(!gate.has_fired()); + + gate.on_masternodes_ready(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the ready signal must start coordinators exactly once" + ); + assert!(gate.has_fired()); + } + + #[test] + fn ready_before_arm_fires_immediately_on_arm() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + + gate.on_masternodes_ready(); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "ready with nothing armed yet starts nothing" + ); + + gate.arm(counting_action(&calls)); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "arming when already ready must start coordinators now" + ); + assert!(gate.has_fired()); + } + + #[test] + fn fires_exactly_once_across_repeated_signals() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + + gate.arm(counting_action(&calls)); + gate.on_masternodes_ready(); + // Repeated ready signals (upstream re-emits MasternodeStateUpdated) and + // a redundant arm must not start a second set of coordinators. + gate.on_masternodes_ready(); + gate.on_masternodes_ready(); + gate.arm(counting_action(&calls)); + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "coordinators must start EXACTLY once regardless of repeated signals" + ); + } + + #[test] + fn should_fire_decision_table() { + // Not ready, not armed → no. + let gate = CoordinatorGate::default(); + assert!(!gate.should_fire()); + + // Ready, not armed → no. + let gate = CoordinatorGate::default(); + gate.masternodes_ready.store(true, Ordering::SeqCst); + assert!(!gate.should_fire()); + + // Armed, not ready → no. + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + *gate.action.lock().unwrap() = Some(counting_action(&calls)); + assert!(!gate.should_fire()); + + // Armed and ready, not fired → yes. + gate.masternodes_ready.store(true, Ordering::SeqCst); + assert!(gate.should_fire()); + + // Already fired → no. + gate.fired.store(true, Ordering::SeqCst); + assert!(!gate.should_fire()); + } + + #[test] + fn single_winner_under_concurrent_fire() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = Arc::new(CoordinatorGate::default()); + gate.arm(counting_action(&calls)); + + let handles: Vec<_> = (0..16) + .map(|_| { + let gate = Arc::clone(&gate); + std::thread::spawn(move || gate.on_masternodes_ready()) + }) + .collect(); + for h in handles { + h.join().expect("thread panicked"); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "concurrent ready signals must still fire the action exactly once" + ); + } + + /// Restart-in-place: [`CoordinatorGate::reset`] must re-arm the gate so the + /// SAME instance fires the coordinators again on a reconnect — clearing the + /// installed action, the `fired` latch, and `masternodes_ready`. + #[test] + fn reset_re_arms_gate_for_restart_in_place() { + let calls = Arc::new(AtomicUsize::new(0)); + let gate = CoordinatorGate::default(); + + // First arm + ready → fires once. + gate.arm(counting_action(&calls)); + gate.on_masternodes_ready(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(gate.has_fired()); + assert!(gate.masternodes_ready()); + + // Reset clears action, the fired latch, and masternodes_ready. + gate.reset(); + assert!(!gate.has_fired(), "reset must clear the fired latch"); + assert!( + !gate.masternodes_ready(), + "reset must clear masternodes_ready so coordinators re-wait for a fresh sync" + ); + + // A ready signal after reset with nothing re-armed must not fire. + gate.on_masternodes_ready(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "ready with no action armed after reset must not fire" + ); + + // Re-arm: masternodes are ready again, so the action fires a SECOND + // time — the gate is reusable across a restart-in-place reconnect. + gate.arm(counting_action(&calls)); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "re-arming a reset gate must fire the coordinators again" + ); + } + + /// Regression guard for the WEAK capture at `WalletBackend::start`. + /// + /// The gate is reachable from the `EventBridge`, which the long-lived SPV + /// run loop holds, so the arming closure must capture WEAK coordinator + /// handles — a strong capture would pin the coordinators (and through them + /// the persister's advisory lock) past backend teardown and break the next + /// reconnect with `WalletStorageError::AlreadyOpen`. + /// + /// This mirrors that pattern with a stand-in coordinator `Arc<AtomicUsize>`: + /// downgrade it, capture only the `Weak` in the action, drop the strong ref + /// (simulating teardown), then fire. It goes RED if the closure captured a + /// strong `Arc` instead: the dropped-strong-ref `strong_count` would stay + /// `> 0` and `upgrade()` would return `Some` (started == 1), not `None`. + #[test] + fn weak_capture_does_not_pin_coordinator_past_teardown() { + let coordinator = Arc::new(AtomicUsize::new(0)); + let weak_to_coordinator = Arc::downgrade(&coordinator); + + // The action upgrades the weak handle and "starts" the coordinator, + // recording whether the upgrade observed a live coordinator. + let started = Arc::new(AtomicUsize::new(0)); + let observed_none = Arc::new(AtomicBool::new(false)); + let started_for_action = Arc::clone(&started); + let observed_none_for_action = Arc::clone(&observed_none); + let captured = Arc::downgrade(&coordinator); + + let gate = CoordinatorGate::default(); + gate.arm(Box::new(move || match captured.upgrade() { + Some(c) => { + c.fetch_add(1, Ordering::SeqCst); + started_for_action.fetch_add(1, Ordering::SeqCst); + } + None => observed_none_for_action.store(true, Ordering::SeqCst), + })); + + // Simulate backend teardown: the only strong ref to the coordinator is + // dropped. A strong capture in the armed closure would keep it alive. + drop(coordinator); + assert_eq!( + weak_to_coordinator.strong_count(), + 0, + "the gate's armed closure must NOT keep a strong coordinator ref alive" + ); + + // Firing after teardown must not panic and must no-op gracefully. + gate.on_masternodes_ready(); + + assert!(gate.has_fired(), "the gate still latches as fired"); + assert!( + observed_none.load(Ordering::SeqCst), + "the action must observe upgrade()==None after teardown" + ); + assert_eq!( + started.load(Ordering::SeqCst), + 0, + "no coordinator may be started once it has been torn down" + ); + } +} diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs new file mode 100644 index 000000000..01ff1e5b6 --- /dev/null +++ b/src/wallet_backend/dashpay.rs @@ -0,0 +1,2524 @@ +//! DashPay adapter for `WalletBackend`. +//! +//! Translates upstream presence-based DashPay state +//! (`platform_wallet::wallet::identity::types::dashpay::*`) into the +//! DET-shape `Stored*` records the UI and backend-task layer understand, and +//! owns the DET-local overlays those records need but upstream does not model. +//! +//! ## What this adapter owns +//! +//! - **Status derivation**: upstream stores DashPay state as presence +//! (a row in `sent_contact_requests` ⇒ outgoing pending; a row in +//! `established_contacts` ⇒ accepted; etc.). DET's `Stored*` records model +//! the same state as an explicit `status`. This module performs that +//! translation at read time — there is no cache and no extra source of +//! truth. +//! - **DET-local overlays via the k/v sidecar**: a small set of contact / +//! contact-request attributes have no upstream surface — `blocked`, +//! `rejected`, DET-local `created_at` / `updated_at` timestamps, private +//! memos, and per-contact address-index cursors. This module both reads and +//! writes them; missing keys yield safe defaults (not blocked, not rejected, +//! timestamps `0`). +//! - **Sync trigger**: [`WalletBackend::dashpay_sync`] refreshes upstream +//! contact requests and profiles before a read; the view itself observes +//! whatever state upstream holds afterwards. +//! +//! ## What this adapter does NOT do +//! +//! - It does not fetch profile / DPNS data from the network. Fields DET +//! populates from cross-references (a contact's display name from their +//! DashPay profile, a contact request's `to_username` from DPNS) come +//! through as `None`; those joins live outside this adapter. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::key_wallet::wallet::Wallet; +use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; +use dash_sdk::platform::Identifier; +use zeroize::Zeroizing; + +use platform_wallet::wallet::identity::types::dashpay::contact_request::ContactRequest; +use platform_wallet::wallet::identity::types::dashpay::established_contact::EstablishedContact; +use platform_wallet::wallet::identity::types::dashpay::payment::{ + PaymentDirection, PaymentEntry, PaymentStatus, +}; +use platform_wallet::wallet::identity::types::dashpay::profile::DashPayProfile; +use platform_wallet::{PlatformWallet, calculate_account_reference, derive_contact_xpub}; + +use crate::backend_task::error::TaskError; +use crate::model::dashpay::{ + ContactAddressIndex, ContactPrivateInfo, ContactRequestDirection, ContactRequestStatus, + ContactStatus, PaymentDirection as DetPaymentDirection, PaymentStatus as DetPaymentStatus, + StoredContact, StoredContactRequest, StoredPayment, StoredProfile, +}; +use crate::wallet_backend::kv::{DetKv, kv_get_or_default}; +use crate::wallet_backend::{DetScope, WalletBackend}; + +// --------------------------------------------------------------------------- +// Contact xpub derivation seam (DIP-14 / DIP-15) +// --------------------------------------------------------------------------- +// +// `key_wallet` / `platform_wallet` derivation types (`Wallet`, +// `ExtendedPubKey`, `ContactXpubData`) stay inside this module. Callers +// receive only the plain byte/integer material a DashPay contact-request +// document carries. This keeps the M-DONT-LEAK-TYPES boundary intact: the +// raw 64-byte HD seed and the upstream wallet type never cross out of the +// `wallet_backend` seam. + +/// Plain, type-leak-free material extracted from a contact relationship's +/// extended public key, ready to drop into a DashPay `contactRequest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactXpubMaterial { + /// Parent key fingerprint (first 4 bytes of HASH160 of the parent key). + pub parent_fingerprint: [u8; 4], + /// Chain code of the contact xpub (32 bytes). + pub chain_code: [u8; 32], + /// Compressed public key of the contact xpub (33 bytes). + pub public_key: [u8; 33], + /// DIP-15 account reference for the relationship. + pub account_reference: u32, +} + +/// Derive the contact-relationship xpub material from the wallet's real HD +/// seed, using upstream `platform_wallet::derive_contact_xpub` as the single +/// source of truth for the `m/9'/coin'/15'/account'/(sender)/(recipient)` +/// path. +/// +/// The published `public_key` / `chain_code` / `parent_fingerprint` are what +/// the contact pays into; the receive-side address derivation must therefore +/// run against the same seed and path. `account_reference` is computed via +/// upstream `calculate_account_reference` so the `ExtendedPubKey` never leaves +/// this seam. +/// +/// * `seed_bytes` - The wallet's 64-byte BIP-39 HD seed. +/// * `network` - Network for coin-type selection in the path. +/// * `account_index` - DIP-15 account index (hardened path segment). Both +/// callers pass `0`, so the path segment (`account 0'`) and the +/// `account_reference` HMAC below agree; a non-zero value here must be +/// threaded to the receive side too or the two would desync. +/// * `sender_id` - The sender (our) identity. +/// * `recipient_id` - The recipient (contact) identity. +/// * `sender_secret_key` - The sender's 32-byte ECDH secret, keying the +/// account-reference HMAC. +pub(crate) fn derive_contact_xpub_material( + seed_bytes: &[u8; 64], + network: Network, + account_index: u32, + sender_id: &Identifier, + recipient_id: &Identifier, + sender_secret_key: &[u8; 32], +) -> Result<ContactXpubMaterial, TaskError> { + let wallet = Wallet::from_seed_bytes(*seed_bytes, network, WalletAccountCreationOptions::None) + .map_err(|source| TaskError::SeedWalletBuildFailed { source })?; + + let data = derive_contact_xpub(&wallet, network, account_index, sender_id, recipient_id) + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + })?; + + let account_reference = + calculate_account_reference(sender_secret_key, &data.compact_xpub(), account_index, 0); + + Ok(ContactXpubMaterial { + parent_fingerprint: data.compact.parent_fingerprint, + chain_code: data.compact.chain_code, + public_key: data.compact.public_key, + account_reference, + }) +} + +/// Derive the DIP-0015 contactInfo encryption key pair from the wallet's real +/// HD seed. +/// +/// DIP-0015 roots both keys at the encryption root `m/9'/coin'/15'/0'`, then: +/// - **Key1** (`encToUserId`): `root/(2^16)'/index'` +/// - **Key2** (`privateData`): `root/(2^16 + 1)'/index'` +/// +/// The coin type is selected per network so testnet keys match the +/// spec-compliant counterparty. Both `contacts` (decrypt) and `contact_info` +/// (encrypt) callers derive through this single helper, so the BIP-32 +/// derivation lives in exactly one place and the raw seed never leaves the +/// `wallet_backend` seam. +/// +/// * `seed_bytes` - The wallet's 64-byte BIP-39 HD seed (borrowed). +/// * `network` - Network for coin-type selection in the path. +/// * `derivation_index` - The contactInfo document's derivation index. +#[allow(clippy::type_complexity)] +pub(crate) fn derive_contact_info_encryption_keys( + seed_bytes: &[u8; 64], + network: Network, + derivation_index: u32, +) -> Result<(Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>), TaskError> { + use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; + use std::str::FromStr; + + let derive_err = + |e: dash_sdk::dpp::key_wallet::bip32::Error| TaskError::ContactKeyDerivationFailed { + source: Box::new(e), + }; + + let master_xprv = ExtendedPrivKey::new_master(network, seed_bytes).map_err(derive_err)?; + + // Encryption root path: m/9'/coin'/15'/0' + let coin_type = crate::model::wallet::coin_type_for_network(network); + let root_path = + DerivationPath::from_str(&format!("m/9'/{coin_type}'/15'/0'")).map_err(derive_err)?; + + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let root = master_xprv + .derive_priv(&secp, &root_path) + .map_err(derive_err)?; + + // Key1 (encToUserId): root/(2^16)'/index' + let key1 = root + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65536).map_err(derive_err)?], + ) + .map_err(derive_err)? + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?], + ) + .map_err(derive_err)?; + + // Key2 (privateData): root/(2^16 + 1)'/index' + let key2 = root + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65537).map_err(derive_err)?], + ) + .map_err(derive_err)? + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?], + ) + .map_err(derive_err)?; + + Ok(( + Zeroizing::new(key1.private_key.secret_bytes()), + Zeroizing::new(key2.private_key.secret_bytes()), + )) +} + +// --------------------------------------------------------------------------- +// K/V sidecar key prefixes +// --------------------------------------------------------------------------- +// +// Two sidecar families (`timestamps`, `addr_map`) use `DetScope::Global` +// against the per-network upstream persister. The network already partitions +// the database file, so no `<network>:` prefix is needed inside the key. Four +// families (`blocked`, `rejected`, `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. + +/// Mark a contact as blocked. Value: empty (`()`). Presence is the signal. +/// Scope: [`DetScope::Identity(&owner)`] — blocking is the acting identity's +/// own decision, so the marker must not bleed across identities that share a +/// wallet. Key shape: `det:dashpay:blocked:<counterparty_b58>`. +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:<counterparty_b58>`. +const KV_PREFIX_REJECTED: &str = "det:dashpay:rejected:"; +/// 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:"; +/// DET-local private memo for a contact (nickname / notes / hidden). +/// Value: bincode-encoded [`ContactPrivateInfo`]. +/// Scope: [`DetScope::Identity(&owner)`]. Key shape: `det:dashpay:private:<contact_b58>`. +const KV_PREFIX_PRIVATE: &str = "det:dashpay:private:"; +/// Per-contact address index state (DIP-0015 send/receive cursors + bloom +/// registered count). Value: bincode-encoded [`ContactAddressIndex`]. +/// Scope: [`DetScope::Identity(&owner)`]. Key shape: `det:dashpay:address_index:<contact_b58>`. +const KV_PREFIX_ADDRESS_INDEX: &str = "det:dashpay:address_index:"; +/// Reverse lookup from a wallet address back to the `(owner, contact)` +/// relationship that derived it. Value: bincode-encoded contact +/// [`Identifier`] (the owner is already embedded in the key). +/// Key shape: `det:dashpay:addr_map:<owner_b58>:<address>`. +const KV_PREFIX_ADDR_MAP: &str = "det:dashpay:addr_map:"; + +/// Contact-request expiry threshold. A pending outgoing request older +/// than this is surfaced as `"expired"` rather than `"pending"`. DET +/// has no protocol-level expiry — this is purely a UX gate so the +/// outbox doesn't accumulate stale requests forever. +const DASHPAY_REQUEST_EXPIRY_DAYS: i64 = 7; + +// --------------------------------------------------------------------------- +// Public view +// --------------------------------------------------------------------------- + +/// Read-only view onto the upstream DashPay state, expressed in +/// DET-side `Stored*` shapes. +/// +/// Borrows the [`WalletBackend`] so its callers can hand a `DashpayView` +/// to existing code without taking ownership. +#[derive(Clone)] +pub struct DashpayView<'a> { + backend: &'a WalletBackend, +} + +impl<'a> DashpayView<'a> { + pub(super) fn new(backend: &'a WalletBackend) -> Self { + Self { backend } + } + + /// Resolve the `ManagedIdentity` for `owner` in whichever registered + /// wallet manages it, and hand it — together with the DET k/v sidecar — to + /// `f`. Returns `None` when no registered wallet manages `owner` (unknown + /// or wrong-network identity), so each caller maps that to its own empty + /// default. The wallet-state read guard is held for the closure's + /// duration, so `f` must stay synchronous (pure translation only). + async fn with_managed<R>( + &self, + owner: &Identifier, + f: impl FnOnce(&platform_wallet::wallet::identity::state::ManagedIdentity, &DetKv) -> R, + ) -> Option<R> { + let wallet = self.backend.find_wallet_for_identity(owner).await?; + let kv = self.backend.kv(); + let state = wallet.state().await; + let managed = state.identity_manager.managed_identity(owner)?; + Some(f(managed, &kv)) + } + + /// All contacts for `owner` — established (`accepted`), outstanding + /// outgoing (`pending`), and DET-local sidecar (`blocked`). + /// + /// Returns an empty vector when `owner` is unknown to upstream. + pub async fn contacts(&self, owner: &Identifier) -> Vec<StoredContact> { + self.with_managed(owner, |managed, kv| { + let dashpay = managed.dashpay(); + let mut out: Vec<StoredContact> = Vec::new(); + + // 1. Established (`accepted`) contacts. + for contact in dashpay.established_contacts().values() { + let contact_id = &contact.contact_identity_id; + let blocked = kv_contains(kv, owner, KV_PREFIX_BLOCKED, contact_id); + let status = if blocked { + ContactStatus::Blocked + } else { + ContactStatus::Accepted + }; + let (created_at, updated_at) = kv_timestamps(kv, contact_id); + out.push(established_to_det( + owner, contact, status, created_at, updated_at, + )); + } + + // 2. Sent-but-not-yet-reciprocated outgoing requests → `pending` contacts. + // Skip recipients we already have an established row for above. + for recipient_id in dashpay.sent_contact_requests().keys() { + if dashpay.established_contacts().contains_key(recipient_id) { + continue; + } + let blocked = kv_contains(kv, owner, KV_PREFIX_BLOCKED, recipient_id); + let status = if blocked { + ContactStatus::Blocked + } else { + ContactStatus::Pending + }; + let (created_at, updated_at) = kv_timestamps(kv, recipient_id); + out.push(request_to_det_contact( + owner, + recipient_id, + status, + created_at, + updated_at, + )); + } + + out + }) + .await + .unwrap_or_default() + } + + /// Outstanding contact requests for `owner` — sent (outgoing, status + /// derived from upstream presence + sidecar) and received (incoming, + /// status derived likewise). + /// + /// Returns an empty vector when `owner` is unknown to upstream. + pub async fn contact_requests(&self, owner: &Identifier) -> Vec<StoredContactRequest> { + self.with_managed(owner, |managed, kv| { + let dashpay = managed.dashpay(); + let mut out: Vec<StoredContactRequest> = Vec::new(); + + let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + + // Outgoing requests (`request_type = "sent"`). + for (recipient_id, request) in dashpay.sent_contact_requests().iter() { + let status = derive_request_status( + owner, + /* request_id_for_sidecar = */ recipient_id, + /* has_matching_established = */ + dashpay.established_contacts().contains_key(recipient_id), + request.created_at, + now_ms, + kv, + ); + out.push(request_to_det_request( + owner, + recipient_id, + request, + ContactRequestDirection::Sent, + status, + )); + } + + // Incoming requests (`request_type = "received"`). + for (sender_id, request) in dashpay.incoming_contact_requests().iter() { + let status = derive_request_status( + owner, + sender_id, + dashpay.established_contacts().contains_key(sender_id), + request.created_at, + now_ms, + kv, + ); + out.push(request_to_det_request( + owner, + sender_id, + request, + ContactRequestDirection::Received, + status, + )); + } + + out + }) + .await + .unwrap_or_default() + } + + /// Payment history for `owner`, newest entries first. Returns an + /// empty vector when `owner` is unknown to upstream. + pub async fn payments(&self, owner: &Identifier) -> Vec<StoredPayment> { + self.with_managed(owner, |managed, kv| { + let mut out: Vec<StoredPayment> = managed + .dashpay() + .payments + .iter() + .map(|(storage_key, entry)| payment_to_det(owner, storage_key, entry, kv)) + .collect(); + // Upstream stores payments keyed by the storage key in a BTreeMap + // (lexicographic order). DET's UI sorts by `created_at DESC`; since + // sidecar timestamps default to 0 when unset, fall back to that + // ordering — newest first when timestamps exist, otherwise stable on + // the storage key. + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + out + }) + .await + .unwrap_or_default() + } + + /// DashPay profile for `owner`, or `None` when upstream has none + /// (either `owner` is unknown, or its identity bucket has no + /// `DashPayProfile` yet). + pub async fn profile(&self, owner: &Identifier) -> Option<StoredProfile> { + self.with_managed(owner, |managed, kv| { + let profile = managed.dashpay().profile.as_ref()?; + let (created_at, updated_at) = kv_timestamps(kv, owner); + Some(profile_to_det(owner, profile, created_at, updated_at)) + }) + .await + .flatten() + } +} + +// --------------------------------------------------------------------------- +// Pure translators — unit-tested without an upstream backend. +// --------------------------------------------------------------------------- + +fn established_to_det( + owner: &Identifier, + contact: &EstablishedContact, + status: ContactStatus, + created_at: i64, + updated_at: i64, +) -> StoredContact { + StoredContact { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.contact_identity_id.to_buffer().to_vec(), + // Username / display_name / avatar_url / public_message come + // from DPNS + the contact's `DashPayProfile`, neither of which + // is reachable from `EstablishedContact` alone. D2 wires the + // cross-reads; D1 leaves them as `None`. + username: None, + display_name: contact.alias.clone(), + avatar_url: None, + public_message: contact.note.clone(), + contact_status: status, + created_at, + updated_at, + last_seen: None, + } +} + +fn request_to_det_contact( + owner: &Identifier, + counterparty: &Identifier, + status: ContactStatus, + created_at: i64, + updated_at: i64, +) -> StoredContact { + StoredContact { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: counterparty.to_buffer().to_vec(), + username: None, + display_name: None, + avatar_url: None, + public_message: None, + contact_status: status, + created_at, + updated_at, + last_seen: None, + } +} + +fn request_to_det_request( + owner: &Identifier, + counterparty: &Identifier, + request: &ContactRequest, + request_type: ContactRequestDirection, + status: ContactRequestStatus, +) -> StoredContactRequest { + let (from_id, to_id) = if request_type == ContactRequestDirection::Sent { + (owner, counterparty) + } else { + (counterparty, owner) + }; + StoredContactRequest { + // No autoincrement id at the upstream layer — DET's `id` column + // was a SQLite PK, not part of the protocol. D2 callers that + // need a stable handle should key on `(from, to)` instead. + id: 0, + from_identity_id: from_id.to_buffer().to_vec(), + to_identity_id: to_id.to_buffer().to_vec(), + // DPNS join lives outside this adapter (D2). + to_username: None, + // No `account_label` on the upstream model; the contract field + // is encrypted (`encrypted_account_label: Option<Vec<u8>>`) and + // surfacing it would leak ciphertext into a UX-facing string. + account_label: None, + request_type, + status, + // Upstream provides `created_at` directly — no sidecar read needed. + created_at: request.created_at as i64, + responded_at: None, + // DET-side UX expiry: `created_at` plus the threshold the status + // derivation uses (both in milliseconds). `None` only if the sum + // would overflow `i64`, which a real timestamp never reaches. + expires_at: request_expires_at_ms(request.created_at), + } +} + +fn payment_to_det( + owner: &Identifier, + storage_key: &str, + entry: &PaymentEntry, + kv: &DetKv, +) -> StoredPayment { + use crate::model::dashpay::payment_txid_from_storage_key; + + let (from_id, to_id, payment_type) = match entry.direction { + PaymentDirection::Sent => (owner, &entry.counterparty_id, DetPaymentDirection::Sent), + PaymentDirection::Received => { + (&entry.counterparty_id, owner, DetPaymentDirection::Received) + } + }; + let status = match entry.status { + PaymentStatus::Pending => DetPaymentStatus::Pending, + PaymentStatus::Confirmed => DetPaymentStatus::Confirmed, + PaymentStatus::Failed => DetPaymentStatus::Failed, + }; + // The upstream map key is `(txid, vout)` for incoming payments (a single + // tx can pay two contact outputs). Timestamps are keyed by the same + // composite key so each output keeps its own; the surfaced `tx_id` is the + // bare transaction id, which is what the UI displays and copies. + let (created_at, confirmed_at) = kv_payment_timestamps(kv, storage_key); + StoredPayment { + id: 0, + tx_id: payment_txid_from_storage_key(storage_key).to_string(), + from_identity_id: from_id.to_buffer().to_vec(), + to_identity_id: to_id.to_buffer().to_vec(), + amount: entry.amount_duffs as i64, + memo: entry.memo.clone(), + payment_type, + status, + created_at, + confirmed_at, + } +} + +fn profile_to_det( + owner: &Identifier, + profile: &DashPayProfile, + created_at: i64, + updated_at: i64, +) -> StoredProfile { + StoredProfile { + identity_id: owner.to_buffer().to_vec(), + display_name: profile.display_name.clone(), + bio: profile.bio.clone(), + avatar_url: profile.avatar_url.clone(), + avatar_hash: profile.avatar_hash.map(|h| h.to_vec()), + avatar_fingerprint: profile.avatar_fingerprint.map(|f| f.to_vec()), + // Raw avatar bytes are intentionally never on the upstream + // model (DIP-15: only the hash + fingerprint survive). DET's + // avatar_bytes column is post-fetch cache — outside this seam. + avatar_bytes: None, + public_message: profile.public_message.clone(), + created_at, + updated_at, + } +} + +/// Derive a contact-request status from upstream presence + sidecar. +/// +/// 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. +fn derive_request_status( + owner: &Identifier, + counterparty: &Identifier, + has_matching_established: bool, + created_at_ms: u64, + now_ms: u64, + kv: &DetKv, +) -> ContactRequestStatus { + if has_matching_established { + return ContactRequestStatus::Accepted; + } + if kv_contains(kv, owner, KV_PREFIX_REJECTED, counterparty) { + return ContactRequestStatus::Rejected; + } + let age_ms = now_ms.saturating_sub(created_at_ms); + if age_ms > request_expiry_threshold_ms() { + return ContactRequestStatus::Expired; + } + ContactRequestStatus::Pending +} + +/// The [`DASHPAY_REQUEST_EXPIRY_DAYS`] window expressed in milliseconds, the +/// unit upstream `created_at` timestamps use. +fn request_expiry_threshold_ms() -> u64 { + (DASHPAY_REQUEST_EXPIRY_DAYS as u64).saturating_mul(86_400_000) +} + +/// Wall-clock expiry for a contact request: `created_at` plus the UX expiry +/// window, both in milliseconds. `None` only if the sum overflows `i64`. +fn request_expires_at_ms(created_at_ms: u64) -> Option<i64> { + created_at_ms + .checked_add(request_expiry_threshold_ms()) + .and_then(|ms| i64::try_from(ms).ok()) +} + +// --------------------------------------------------------------------------- +// Internal: serialized send-index increment +// --------------------------------------------------------------------------- + +/// Atomically read-then-increment the persisted `next_send_index` for +/// `(owner, contact)` against `kv`. `lock` serializes the read-modify-write +/// window across the process. Returns the index value the caller should use +/// for the next outgoing payment; the persisted counter is advanced to +/// `returned_value + 1` before returning. +/// +/// Split out from [`WalletBackend::dashpay_increment_send_index`] so the +/// concurrency test can exercise the locking discipline without standing up +/// a full backend (which requires SDK + SQLite persister). +/// +/// # Panics +/// +/// Panics if `lock` is poisoned — same contract as the public wrapper. +pub(super) fn increment_send_index_locked( + lock: &std::sync::Mutex<()>, + kv: &DetKv, + owner: &Identifier, + contact: &Identifier, +) -> Result<u32, TaskError> { + let _guard = lock.lock().expect("dashpay address-index mutex poisoned"); + + let owner_buf = owner.to_buffer(); + let scope = DetScope::Identity(&owner_buf); + let key = sidecar_key(KV_PREFIX_ADDRESS_INDEX, contact); + let mut state: ContactAddressIndex = kv + .get::<ContactAddressIndex>(scope, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + let value = state.next_send_index; + state.next_send_index = state.next_send_index.saturating_add(1); + kv.put::<ContactAddressIndex>(scope, &key, &state) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + Ok(value) +} + +// --------------------------------------------------------------------------- +// K/V sidecar helpers +// --------------------------------------------------------------------------- + +/// Build a sidecar key as `<prefix><id_b58>`. The scope +/// ([`DetScope::Global`] vs [`DetScope::Identity`]) is applied by the caller, +/// not encoded here — a Global-scoped key carries the full entity id while an +/// Identity-scoped key carries only the counterparty id (the owner rides on +/// the scope), but in both cases the key body is identical, so one builder +/// serves both. +fn sidecar_key(prefix: &str, id: &Identifier) -> String { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + format!("{prefix}{}", id.to_string(Encoding::Base58)) +} + +/// Sidecar key for a `(owner, address)` reverse lookup. `address` is the +/// plain `Address::to_string()` form — the network's address-version byte +/// is already encoded into the string so no extra prefix is needed. +fn addr_map_sidecar_key(owner: &Identifier, address: &str) -> String { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + format!( + "{KV_PREFIX_ADDR_MAP}{}:{address}", + owner.to_string(Encoding::Base58) + ) +} + +/// Test the presence of an owner-scoped marker (blocked / rejected) for a +/// counterparty. The marker lives under `owner`'s [`DetScope::Identity`] so it +/// never bleeds across identities that share a wallet. +fn kv_contains(kv: &DetKv, owner: &Identifier, prefix: &str, counterparty: &Identifier) -> bool { + // Presence-only entries: value is `()`. `Ok(Some(_))` ⇒ present. + let owner_buf = owner.to_buffer(); + matches!( + kv.get::<()>( + DetScope::Identity(&owner_buf), + &sidecar_key(prefix, counterparty) + ), + Ok(Some(())) + ) +} + +fn kv_timestamps(kv: &DetKv, id: &Identifier) -> (i64, i64) { + let key = sidecar_key(KV_PREFIX_TIMESTAMPS, id); + kv_get_or_default(kv, DetScope::Global, &key, "dashpay_contact_timestamps") +} + +fn kv_payment_timestamps(kv: &DetKv, tx_id: &str) -> (i64, Option<i64>) { + let key = format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"); + kv_get_or_default(kv, DetScope::Global, &key, "dashpay_payment_timestamps") +} + +// --------------------------------------------------------------------------- +// WalletBackend integration +// --------------------------------------------------------------------------- + +impl WalletBackend { + /// Read-only DashPay accessor. Cheap to construct (borrow only). + pub fn dashpay_view(&self) -> DashpayView<'_> { + DashpayView::new(self) + } + + /// Trigger an upstream DashPay refresh (contact requests + profiles) + /// for the wallet that owns `owner`. Callers invoke this BEFORE a + /// read on user-initiated refresh actions so the [`DashpayView`] + /// observes fresh state. + /// + /// Returns `Ok(())` if the owner is not known to any registered + /// wallet — passive screen loads that pre-empt sync would otherwise + /// fail noisily on cold start before any wallet is wired. + pub async fn dashpay_sync( + &self, + owner: &Identifier, + ) -> Result<(), crate::backend_task::error::TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_sync: no managing wallet found; skipping" + ); + return Ok(()); + }; + let identity = wallet.identity(); + let dashpay = identity.dashpay(); + dashpay.sync_contact_requests().await.map_err(|e| { + crate::backend_task::error::TaskError::WalletBackend { + source: Box::new(e), + } + })?; + dashpay.sync_profiles().await.map_err(|e| { + crate::backend_task::error::TaskError::WalletBackend { + source: Box::new(e), + } + })?; + Ok(()) + } + + /// Persist a DashPay profile against the upstream `ManagedIdentity` for + /// `owner`, persisting the resulting changeset immediately. Pass `None` + /// to clear the profile. + /// + /// Returns `Ok(())` when no registered wallet manages `owner` — the + /// caller is operating on an out-of-wallet identity (e.g. observed + /// profile) and there is nothing to mirror locally. + pub async fn dashpay_set_profile( + &self, + owner: &Identifier, + profile: Option<DashPayProfile>, + ) -> Result<(), TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_set_profile: no managing wallet found; skipping" + ); + return Ok(()); + }; + let persister = wallet.persister().clone(); + let mut state = wallet.state_mut().await; + let Some(managed) = state.identity_manager.managed_identity_mut(owner) else { + return Ok(()); + }; + managed.set_dashpay_profile(profile, &persister); + Ok(()) + } + + /// Record a DashPay payment entry against the upstream `ManagedIdentity` + /// for `owner`. Upstream stores payments keyed by `tx_id` with + /// last-write-wins semantics, so this method is also the correct way + /// to update a payment's status (e.g. `Pending` → `Confirmed`). + /// + /// Returns `Ok(())` when no registered wallet manages `owner`. + pub async fn dashpay_record_payment( + &self, + owner: &Identifier, + tx_id: String, + entry: PaymentEntry, + ) -> Result<(), TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_record_payment: no managing wallet found; skipping" + ); + return Ok(()); + }; + let persister = wallet.persister().clone(); + let mut state = wallet.state_mut().await; + let Some(managed) = state.identity_manager.managed_identity_mut(owner) else { + return Ok(()); + }; + managed + .record_dashpay_payment(tx_id, entry, &persister) + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e.into()), + })?; + Ok(()) + } + + /// Toggle the DET-local "blocked" marker for `contact_id`, scoped to the + /// acting `owner` identity. The marker has no upstream counterpart — + /// DashPay does not block on-chain — so it lives entirely in the per-owner + /// sidecar that [`DashpayView`] reads at view time. Owner-scoping keeps one + /// identity's block list from colouring another's view of the same contact. + pub fn dashpay_mark_blocked( + &self, + owner: &Identifier, + contact_id: &Identifier, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_BLOCKED, contact_id); + self.kv() + .put::<()>(DetScope::Identity(&owner_buf), &key, &()) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Clear the DET-local "blocked" marker for `contact_id` under `owner`'s + /// scope. Idempotent — clearing an absent marker is `Ok(())`. + pub fn dashpay_unmark_blocked( + &self, + owner: &Identifier, + contact_id: &Identifier, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_BLOCKED, contact_id); + self.kv() + .delete(DetScope::Identity(&owner_buf), &key) + .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 + /// private to that identity. The sidecar key matches what [`DashpayView`] + /// consults when deriving request status. + pub fn dashpay_mark_rejected( + &self, + owner: &Identifier, + counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_REJECTED, counterparty_id); + self.kv() + .put::<()>(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. + pub fn dashpay_set_timestamps( + &self, + entity_id: &Identifier, + created_at: i64, + updated_at: i64, + ) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_TIMESTAMPS, entity_id); + self.kv() + .put::<(i64, i64)>(DetScope::Global, &key, &(created_at, updated_at)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Write DET-local `(created_at_ms, confirmed_at_ms)` timestamps for a + /// payment in the k/v sidecar, keyed by transaction id. Upstream + /// `PaymentEntry` carries no timestamps of its own, so this is the + /// authoritative source consulted by [`DashpayView::payments`]. + pub fn dashpay_set_payment_timestamps( + &self, + tx_id: &str, + created_at: i64, + confirmed_at: Option<i64>, + ) -> Result<(), TaskError> { + let key = format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"); + self.kv() + .put::<(i64, Option<i64>)>(DetScope::Global, &key, &(created_at, confirmed_at)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Read the DET-local private memo for `(owner, contact)`. Returns + /// `Ok(None)` when no memo has been written yet — callers should + /// treat that as an empty memo, not as an error. + pub fn dashpay_get_private_info( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result<Option<ContactPrivateInfo>, TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_PRIVATE, contact); + self.kv() + .get::<ContactPrivateInfo>(DetScope::Identity(&owner_buf), &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Upsert the DET-local private memo for `(owner, contact)`. + pub fn dashpay_set_private_info( + &self, + owner: &Identifier, + contact: &Identifier, + info: &ContactPrivateInfo, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_PRIVATE, contact); + self.kv() + .put::<ContactPrivateInfo>(DetScope::Identity(&owner_buf), &key, info) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Read the persisted address-index state for `(owner, contact)`. + /// Missing keys yield `Ok(None)` — callers treat that as "no payments + /// exchanged yet" and start from index 0. + pub fn dashpay_get_address_index( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result<Option<ContactAddressIndex>, TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_ADDRESS_INDEX, contact); + self.kv() + .get::<ContactAddressIndex>(DetScope::Identity(&owner_buf), &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Upsert the address-index state for `(owner, contact)`. This is the + /// non-incrementing setter — used by the receive-side path that knows + /// the new `highest_receive_index` or `bloom_registered_count` + /// outright. Concurrent writers race; callers that need atomic + /// read-modify-write (the send-side increment) must use + /// [`Self::dashpay_increment_send_index`] instead. + pub fn dashpay_set_address_index( + &self, + owner: &Identifier, + contact: &Identifier, + index: &ContactAddressIndex, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_ADDRESS_INDEX, contact); + self.kv() + .put::<ContactAddressIndex>(DetScope::Identity(&owner_buf), &key, index) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Atomically read-then-increment the `next_send_index` for + /// `(owner, contact)`. Returns the index value the caller should use + /// when deriving the next outgoing payment address; the persisted + /// counter is advanced to `returned_value + 1` before returning. + /// + /// Serializes across the process via a backend-internal mutex, so + /// concurrent send dispatches never hand out the same index. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. The mutex protects a + /// `()` payload; poisoning implies a prior panic mid-increment, which + /// is a programming error worth surfacing rather than masking. + pub fn dashpay_increment_send_index( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result<u32, TaskError> { + increment_send_index_locked( + &self.inner.dashpay_address_index_lock, + &self.kv(), + owner, + contact, + ) + } + + /// Resolve a wallet address back to `(contact, address_index)` — the + /// contact whose relationship with `owner` derived it and the index + /// at which the address was derived. `Ok(None)` means the address is + /// unknown to the DashPay overlay — e.g. a regular receiving address, + /// or a contact that pre-dates this sidecar. + pub fn dashpay_get_address_mapping( + &self, + owner: &Identifier, + address: &str, + ) -> Result<Option<(Identifier, u32)>, TaskError> { + let key = addr_map_sidecar_key(owner, address); + let value: Option<([u8; 32], u32)> = self + .kv() + .get::<([u8; 32], u32)>(DetScope::Global, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + Ok(value.map(|(bytes, idx)| (Identifier::from(bytes), idx))) + } + + /// Persist the `(owner, address) → (contact, address_index)` reverse + /// mapping used by the incoming-payment detector. `address_index` is + /// the position the address was derived at within the contact-scoped + /// receive xpub. + pub fn dashpay_set_address_mapping( + &self, + owner: &Identifier, + address: &str, + contact: &Identifier, + address_index: u32, + ) -> Result<(), TaskError> { + let key = addr_map_sidecar_key(owner, address); + self.kv() + .put::<([u8; 32], u32)>( + DetScope::Global, + &key, + &(contact.to_buffer(), address_index), + ) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Drop every Identity-scoped DashPay overlay for `owner` — the + /// per-contact private memos, address-index cursors, and the blocked / + /// rejected markers. + /// + /// The remaining Global-scoped overlays (timestamps, reverse address map) + /// are not owner-scoped and are swept by the `det:dashpay:` Global prefix in + /// [`crate::context::AppContext::clear_network_database`]; this method + /// covers the overlays that live under [`DetScope::Identity`] of the owner, + /// which that Global sweep can no longer reach. + pub fn dashpay_clear_owner_overlays(&self, owner: &Identifier) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let scope = DetScope::Identity(&owner_buf); + let kv = self.kv(); + for prefix in [ + KV_PREFIX_PRIVATE, + KV_PREFIX_ADDRESS_INDEX, + KV_PREFIX_BLOCKED, + KV_PREFIX_REJECTED, + ] { + let keys = kv + .list(scope, Some(prefix)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + for key in keys { + kv.delete(scope, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + } + } + Ok(()) + } + + /// Locate the `PlatformWallet` whose `IdentityManager` owns `identity_id`. + /// + /// Scans the sync wallet cache, then probes each wallet's + /// `identity_manager` for the id. `None` if no registered wallet + /// knows about it (e.g. pre-registration, wrong network, observed- + /// only identities that were never indexed). + async fn find_wallet_for_identity( + &self, + identity_id: &Identifier, + ) -> Option<Arc<PlatformWallet>> { + let wallets: Vec<Arc<PlatformWallet>> = { + // Snapshot the cached wallets so we don't hold the std RwLock + // across `await` boundaries. + let map = self.inner.wallets.read().ok()?; + map.values().cloned().collect() + }; + for wallet in wallets { + let state = wallet.state().await; + if state + .identity_manager + .managed_identity(identity_id) + .is_some() + { + drop(state); + return Some(wallet); + } + } + None + } +} + +// --------------------------------------------------------------------------- +// Tests — pure translators only. The wallet-resolving methods are +// covered by D2's integration tests once the read paths route through. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn id_from_byte(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn mk_request(sender: u8, recipient: u8, created_at: u64) -> ContactRequest { + ContactRequest::new( + id_from_byte(sender), + id_from_byte(recipient), + 0, + 0, + 0, + vec![0u8; 96], + 100_000, + created_at, + ) + } + + #[test] + fn established_translates_alias_into_display_name() { + let owner = id_from_byte(1); + let contact_id = id_from_byte(2); + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Buddy".to_string()); + contact.set_note("Met at conf".to_string()); + + let det = established_to_det(&owner, &contact, ContactStatus::Accepted, 1_000, 2_000); + assert_eq!(det.owner_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.contact_identity_id, contact_id.to_buffer().to_vec()); + assert_eq!(det.display_name.as_deref(), Some("Buddy")); + assert_eq!(det.public_message.as_deref(), Some("Met at conf")); + assert_eq!(det.contact_status, ContactStatus::Accepted); + assert_eq!(det.created_at, 1_000); + assert_eq!(det.updated_at, 2_000); + // Fields requiring DPNS / profile cross-read stay None in D1. + assert!(det.username.is_none()); + assert!(det.avatar_url.is_none()); + assert!(det.last_seen.is_none()); + } + + #[test] + fn request_translates_into_pending_contact() { + let owner = id_from_byte(1); + let recipient = id_from_byte(2); + + let det = request_to_det_contact(&owner, &recipient, ContactStatus::Pending, 0, 0); + assert_eq!(det.contact_status, ContactStatus::Pending); + assert_eq!(det.owner_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.contact_identity_id, recipient.to_buffer().to_vec()); + } + + #[test] + fn outgoing_request_translation_preserves_direction() { + let owner = id_from_byte(1); + let recipient = id_from_byte(2); + let request = mk_request(1, 2, 123); + + let det = request_to_det_request( + &owner, + &recipient, + &request, + ContactRequestDirection::Sent, + ContactRequestStatus::Pending, + ); + assert_eq!(det.from_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, recipient.to_buffer().to_vec()); + assert_eq!(det.request_type, ContactRequestDirection::Sent); + assert_eq!(det.status, ContactRequestStatus::Pending); + assert_eq!(det.created_at, 123); + // Encrypted label is never surfaced as a plaintext `account_label`. + assert!(det.account_label.is_none()); + } + + #[test] + fn incoming_request_translation_flips_direction() { + let owner = id_from_byte(1); + let sender = id_from_byte(2); + let request = mk_request(2, 1, 456); + + let det = request_to_det_request( + &owner, + &sender, + &request, + ContactRequestDirection::Received, + ContactRequestStatus::Pending, + ); + assert_eq!(det.from_identity_id, sender.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.request_type, ContactRequestDirection::Received); + } + + #[test] + fn sent_payment_translation_uses_owner_as_sender() { + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let entry = PaymentEntry::new_sent(counterparty, 12_345, Some("lunch".to_string())); + + let det = payment_to_det(&owner, "tx-abc", &entry, &empty_kv()); + assert_eq!(det.tx_id, "tx-abc"); + assert_eq!(det.from_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, counterparty.to_buffer().to_vec()); + assert_eq!(det.payment_type, DetPaymentDirection::Sent); + assert_eq!(det.status, DetPaymentStatus::Pending); + assert_eq!(det.amount, 12_345); + assert_eq!(det.memo.as_deref(), Some("lunch")); + assert_eq!(det.created_at, 0); + assert!(det.confirmed_at.is_none()); + } + + #[test] + fn received_payment_translation_uses_owner_as_recipient() { + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let entry = PaymentEntry::new_received(counterparty, 7_500, None); + + let det = payment_to_det(&owner, "tx-def", &entry, &empty_kv()); + assert_eq!(det.from_identity_id, counterparty.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.payment_type, DetPaymentDirection::Received); + assert_eq!(det.status, DetPaymentStatus::Confirmed); + } + + #[test] + fn composite_storage_key_surfaces_bare_txid() { + let owner = id_from_byte(1); + let c0 = id_from_byte(2); + let c1 = id_from_byte(3); + // Two outputs of ONE transaction to two different contacts: distinct + // storage keys, both surface the same bare txid but their own + // counterparty + amount (no last-write-wins clobber). + let e0 = PaymentEntry::new_received(c0, 1_000, None); + let e1 = PaymentEntry::new_received(c1, 2_000, None); + let d0 = payment_to_det(&owner, "txhash:0", &e0, &empty_kv()); + let d1 = payment_to_det(&owner, "txhash:1", &e1, &empty_kv()); + assert_eq!(d0.tx_id, "txhash"); + assert_eq!(d1.tx_id, "txhash"); + assert_eq!(d0.from_identity_id, c0.to_buffer().to_vec()); + assert_eq!(d1.from_identity_id, c1.to_buffer().to_vec()); + assert_eq!(d0.amount, 1_000); + assert_eq!(d1.amount, 2_000); + } + + #[test] + fn profile_translation_carries_hash_and_fingerprint() { + let owner = id_from_byte(1); + let profile = DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("Hello".into()), + avatar_url: Some("https://example.com/a.png".into()), + avatar_hash: Some([7u8; 32]), + avatar_fingerprint: Some([3u8; 8]), + public_message: Some("Public!".into()), + }; + + let det = profile_to_det(&owner, &profile, 11, 22); + assert_eq!(det.identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.display_name.as_deref(), Some("Alice")); + assert_eq!(det.bio.as_deref(), Some("Hello")); + assert_eq!(det.avatar_url.as_deref(), Some("https://example.com/a.png")); + assert_eq!(det.avatar_hash.as_deref(), Some(&[7u8; 32][..])); + assert_eq!(det.avatar_fingerprint.as_deref(), Some(&[3u8; 8][..])); + assert!( + det.avatar_bytes.is_none(), + "raw bytes never come through this seam" + ); + assert_eq!(det.created_at, 11); + assert_eq!(det.updated_at, 22); + } + + #[test] + fn request_status_derivation_uses_established_then_sidecar() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + // Fresh request, no expiry yet. + 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), + ContactRequestStatus::Accepted, + "matching established contact wins" + ); + assert_eq!( + derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + ContactRequestStatus::Pending, + "no established + no rejection sidecar + fresh = pending" + ); + } + + #[test] + fn rejected_request_status_reads_sidecar_when_present() { + 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), + &(), + ) + .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), + ContactRequestStatus::Rejected + ); + } + + #[test] + fn expired_request_status_when_older_than_threshold() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let now_ms: u64 = 10_000_000_000_000; + // Older than the 7-day threshold by one minute. + 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), + ContactRequestStatus::Expired, + "older-than-threshold pending request reports as expired" + ); + } + + #[test] + fn fresh_request_just_under_threshold_stays_pending() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let now_ms: u64 = 10_000_000_000_000; + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + // 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), + ContactRequestStatus::Pending + ); + } + + #[test] + fn expires_at_is_created_at_plus_threshold() { + let created_at_ms: u64 = 1_700_000_000_000; + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + assert_eq!( + request_expires_at_ms(created_at_ms), + Some((created_at_ms + threshold_ms) as i64), + "expiry is created_at plus the 7-day threshold, in ms" + ); + } + + #[test] + fn expires_at_none_on_overflow() { + assert_eq!( + request_expires_at_ms(u64::MAX), + None, + "an overflowing timestamp yields no expiry rather than wrapping" + ); + } + + #[test] + fn blocked_contact_overrides_accepted_status() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact_id = id_from_byte(2); + let owner_buf = owner.to_buffer(); + kv.put::<()>( + DetScope::Identity(&owner_buf), + &sidecar_key(KV_PREFIX_BLOCKED, &contact_id), + &(), + ) + .unwrap(); + + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Friend".into()); + + let status = if kv_contains(&kv, &owner, KV_PREFIX_BLOCKED, &contact_id) { + ContactStatus::Blocked + } else { + ContactStatus::Accepted + }; + let det = established_to_det(&owner, &contact, status, 0, 0); + assert_eq!(det.contact_status, ContactStatus::Blocked); + assert_eq!(det.display_name.as_deref(), Some("Friend")); + } + + #[test] + fn timestamps_default_to_zero_on_missing_sidecar() { + let kv = empty_kv(); + let id = id_from_byte(2); + assert_eq!(kv_timestamps(&kv, &id), (0, 0)); + } + + #[test] + fn timestamps_round_trip_through_sidecar() { + let kv = empty_kv(); + let id = id_from_byte(2); + kv.put::<(i64, i64)>( + DetScope::Global, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &id), + &(111, 222), + ) + .unwrap(); + assert_eq!(kv_timestamps(&kv, &id), (111, 222)); + } + + #[test] + fn payment_timestamps_round_trip() { + let kv = empty_kv(); + let tx_id = "tx-xyz"; + kv.put::<(i64, Option<i64>)>( + DetScope::Global, + &format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"), + &(100, Some(200)), + ) + .unwrap(); + assert_eq!(kv_payment_timestamps(&kv, tx_id), (100, Some(200))); + } + + /// 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. + /// + /// 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. + #[test] + fn d3_blocked_marker_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let owner_buf = owner.to_buffer(); + let contact = id_from_byte(7); + let key = sidecar_key(KV_PREFIX_BLOCKED, &contact); + // What `dashpay_mark_blocked` writes: + kv.put::<()>(DetScope::Identity(&owner_buf), &key, &()) + .unwrap(); + // What `DashpayView::contacts` reads: + assert!(kv_contains(&kv, &owner, KV_PREFIX_BLOCKED, &contact)); + + // And `dashpay_unmark_blocked` (delete) clears it. + kv.delete(DetScope::Identity(&owner_buf), &key).unwrap(); + assert!(!kv_contains(&kv, &owner, KV_PREFIX_BLOCKED, &contact)); + } + + #[test] + fn d3_rejected_marker_round_trips_through_sidecar_key() { + 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 + ); + } + + #[test] + fn d3_timestamp_sidecar_round_trips() { + let kv = empty_kv(); + let entity = id_from_byte(9); + // What `dashpay_set_timestamps` writes: + kv.put::<(i64, i64)>( + DetScope::Global, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &entity), + &(123, 456), + ) + .unwrap(); + // What `DashpayView::contacts` reads: + assert_eq!(kv_timestamps(&kv, &entity), (123, 456)); + } + + #[test] + fn d3_payment_timestamp_sidecar_round_trips() { + let kv = empty_kv(); + let tx_id = "abcd1234"; + // What `dashpay_set_payment_timestamps` writes: + kv.put::<(i64, Option<i64>)>( + DetScope::Global, + &format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"), + &(789, Some(1000)), + ) + .unwrap(); + // What `DashpayView::payments` reads: + assert_eq!(kv_payment_timestamps(&kv, tx_id), (789, Some(1000))); + } + + #[test] + fn d3_block_then_list_contacts_yields_blocked_status() { + // Simulates: send → block → list. After D3 wires + // `dashpay_mark_blocked`, the contact's status flips to + // "blocked" without touching the upstream `EstablishedContact` + // (DashPay has no on-chain block flag — DET sidecar is the + // source of truth). + let kv = empty_kv(); + let owner = id_from_byte(1); + let owner_buf = owner.to_buffer(); + let contact_id = id_from_byte(2); + + // Pre-state: a single established contact exists upstream. + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Pal".into()); + + // What `dashpay_mark_blocked(&owner, &contact_id)` writes: + kv.put::<()>( + DetScope::Identity(&owner_buf), + &sidecar_key(KV_PREFIX_BLOCKED, &contact_id), + &(), + ) + .unwrap(); + + // What the view derivation produces — same precedence as + // `DashpayView::contacts`: blocked wins over accepted. + let status = if kv_contains(&kv, &owner, KV_PREFIX_BLOCKED, &contact_id) { + ContactStatus::Blocked + } else { + ContactStatus::Accepted + }; + let det = established_to_det(&owner, &contact, status, 0, 0); + assert_eq!(det.contact_status, ContactStatus::Blocked); + assert_eq!(det.display_name.as_deref(), Some("Pal")); + } + + #[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 + // requests are not removed from `sent_contact_requests`). + let kv = empty_kv(); + let owner = id_from_byte(1); + let owner_buf = owner.to_buffer(); + let counterparty = id_from_byte(2); + kv.put::<()>( + DetScope::Identity(&owner_buf), + &sidecar_key(KV_PREFIX_REJECTED, &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); + assert_eq!(derived, ContactRequestStatus::Rejected); + + // And the threshold-expiry override does not fire for rejected + // 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); + assert_eq!(derived_old, ContactRequestStatus::Rejected); + } + + #[test] + fn d3_seven_day_old_pending_request_reports_expired() { + // Send → wait > 7 days → list → assert expired. The DET-side + // expiry threshold lives in `DASHPAY_REQUEST_EXPIRY_DAYS` and + // is a UX gate; upstream stores no protocol-level expiry. + let kv = empty_kv(); + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let now_ms: u64 = 50_000_000_000_000; + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + // 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), + ContactRequestStatus::Expired + ); + } + + /// F40: two identities that share a wallet must not see each other's + /// blocked / rejected markers. Identity A blocks/rejects a counterparty; + /// identity B's view of that same counterparty stays clean. + #[test] + fn markers_are_isolated_per_owner_identity() { + let kv = empty_kv(); + let owner_a = id_from_byte(1); + let owner_b = id_from_byte(2); + let counterparty = id_from_byte(3); + let a_buf = owner_a.to_buffer(); + + // Owner A blocks and rejects the counterparty. + kv.put::<()>( + DetScope::Identity(&a_buf), + &sidecar_key(KV_PREFIX_BLOCKED, &counterparty), + &(), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&a_buf), + &sidecar_key(KV_PREFIX_REJECTED, &counterparty), + &(), + ) + .unwrap(); + + // Owner A sees them... + assert!(kv_contains(&kv, &owner_a, KV_PREFIX_BLOCKED, &counterparty)); + assert!(kv_contains( + &kv, + &owner_a, + KV_PREFIX_REJECTED, + &counterparty + )); + + // ...owner B does not. + assert!( + !kv_contains(&kv, &owner_b, KV_PREFIX_BLOCKED, &counterparty), + "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" + ); + + 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), + ContactRequestStatus::Rejected, + "A's own rejection colours A's view" + ); + assert_eq!( + derive_request_status(&owner_b, &counterparty, false, created_at_ms, now_ms, &kv), + ContactRequestStatus::Pending, + "B's view of the same counterparty is unaffected" + ); + } + + // ------------------------------------------------------------------- + // D4b: address-index + private-info k/v primitives (key shape + + // round-trip via the same `DetKv` adapter used in production). The + // wallet-resolving methods on `WalletBackend` itself need an + // upstream backend and are covered by the e2e suite. + // ------------------------------------------------------------------- + + #[test] + fn d4b_contact_sidecar_key_is_contact_only_base58() { + // Wave 2: the owner moved into the `DetScope::Identity` scope, so + // the per-contact key carries only the contact, not `<owner>:<contact>`. + let contact = id_from_byte(2); + let key = sidecar_key(KV_PREFIX_PRIVATE, &contact); + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let expected = format!( + "det:dashpay:private:{}", + contact.to_string(Encoding::Base58) + ); + assert_eq!(key, expected); + } + + #[test] + fn d4b_addr_map_sidecar_key_carries_owner_and_address() { + let owner = id_from_byte(1); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + let key = addr_map_sidecar_key(&owner, addr); + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let expected = format!( + "det:dashpay:addr_map:{}:{}", + owner.to_string(Encoding::Base58), + addr + ); + assert_eq!(key, expected); + } + + #[test] + fn d4b_private_info_round_trips_in_owner_identity_scope() { + let kv = empty_kv(); + let owner = id_from_byte(1).to_buffer(); + let contact = id_from_byte(2); + let info = ContactPrivateInfo { + nickname: "Alice".into(), + notes: "met at conf".into(), + is_hidden: true, + }; + let key = sidecar_key(KV_PREFIX_PRIVATE, &contact); + let scope = DetScope::Identity(&owner); + kv.put::<ContactPrivateInfo>(scope, &key, &info).unwrap(); + let got: ContactPrivateInfo = kv + .get::<ContactPrivateInfo>(scope, &key) + .unwrap() + .expect("written value should round-trip"); + assert_eq!(got, info); + // Invisible from the Global scope it used to live in. + assert!( + kv.get::<ContactPrivateInfo>(DetScope::Global, &key) + .unwrap() + .is_none() + ); + } + + #[test] + fn d4b_private_info_missing_key_returns_none() { + let kv = empty_kv(); + let owner = id_from_byte(1).to_buffer(); + let contact = id_from_byte(2); + let key = sidecar_key(KV_PREFIX_PRIVATE, &contact); + assert!( + kv.get::<ContactPrivateInfo>(DetScope::Identity(&owner), &key) + .unwrap() + .is_none() + ); + } + + #[test] + fn d4b_address_index_round_trips_in_owner_identity_scope() { + let kv = empty_kv(); + let owner_id = id_from_byte(1); + let owner = owner_id.to_buffer(); + let contact = id_from_byte(2); + let idx = ContactAddressIndex { + owner_identity_id: owner_id.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 7, + highest_receive_index: 3, + bloom_registered_count: 20, + }; + let key = sidecar_key(KV_PREFIX_ADDRESS_INDEX, &contact); + let scope = DetScope::Identity(&owner); + kv.put::<ContactAddressIndex>(scope, &key, &idx).unwrap(); + let got = kv + .get::<ContactAddressIndex>(scope, &key) + .unwrap() + .expect("written value should round-trip"); + assert_eq!(got.next_send_index, 7); + assert_eq!(got.highest_receive_index, 3); + assert_eq!(got.bloom_registered_count, 20); + } + + #[test] + fn d4b_private_info_is_isolated_per_owner_scope() { + // The owner used to be encoded in the key; now it is the scope. + // Two owners sharing a contact must not see each other's memo. + let kv = empty_kv(); + let owner_a = id_from_byte(1).to_buffer(); + let owner_b = id_from_byte(2).to_buffer(); + let contact = id_from_byte(3); + let key = sidecar_key(KV_PREFIX_PRIVATE, &contact); + kv.put::<ContactPrivateInfo>( + DetScope::Identity(&owner_a), + &key, + &ContactPrivateInfo { + nickname: "for-a".into(), + notes: String::new(), + is_hidden: false, + }, + ) + .unwrap(); + assert!( + kv.get::<ContactPrivateInfo>(DetScope::Identity(&owner_b), &key) + .unwrap() + .is_none(), + "owner B must not see owner A's per-contact memo" + ); + } + + #[test] + fn d4b_address_mapping_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + let key = addr_map_sidecar_key(&owner, addr); + // D4c extended the value schema to carry the address_index alongside + // the contact id, so the incoming-payment detector can promote the + // receive cursor without a separate lookup. + kv.put::<([u8; 32], u32)>(DetScope::Global, &key, &(contact.to_buffer(), 7)) + .unwrap(); + let got: Option<([u8; 32], u32)> = + kv.get::<([u8; 32], u32)>(DetScope::Global, &key).unwrap(); + assert_eq!(got, Some((contact.to_buffer(), 7))); + } + + #[test] + fn d4b_contact_key_distinguishes_contacts() { + // The owner now lives in the scope; the key only distinguishes + // contacts within one owner's Identity scope. + let a = id_from_byte(1); + let b = id_from_byte(2); + let key_a = sidecar_key(KV_PREFIX_PRIVATE, &a); + let key_b = sidecar_key(KV_PREFIX_PRIVATE, &b); + assert_ne!(key_a, key_b, "distinct contacts must yield distinct keys"); + } + + // ------------------------------------------------------------------- + // D4c: concurrency contract for `dashpay_increment_send_index`. + // ------------------------------------------------------------------- + + /// 100 concurrent increment calls against the same `(owner, contact)` + /// must hand out exactly the values `0..=99`, no duplicates, and leave + /// the persisted counter at `100`. Exercises the same locking + /// discipline the public `WalletBackend::dashpay_increment_send_index` + /// relies on, via the extracted [`increment_send_index_locked`] helper — + /// constructing a real backend would pull in SDK + persister which the + /// unit-test target deliberately avoids. + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn d4c_concurrent_send_index_increments_yield_unique_values() { + let kv = empty_kv(); + let lock = Arc::new(std::sync::Mutex::new(())); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + + // Wrap kv in Arc so each task can move a clone of the handle. + let kv = Arc::new(kv); + + let mut handles = Vec::with_capacity(100); + for _ in 0..100 { + let lock = Arc::clone(&lock); + let kv = Arc::clone(&kv); + handles.push(tokio::spawn(async move { + increment_send_index_locked(&lock, &kv, &owner, &contact) + .expect("increment must succeed") + })); + } + + let mut values: Vec<u32> = Vec::with_capacity(100); + for h in handles { + values.push(h.await.expect("task panicked")); + } + values.sort_unstable(); + + let expected: Vec<u32> = (0..100).collect(); + assert_eq!( + values, expected, + "100 concurrent increments must return distinct values 0..=99" + ); + + // Final persisted counter advances to 100, read back from the + // owner's Identity scope where the increment helper now writes. + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_ADDRESS_INDEX, &contact); + let final_state: ContactAddressIndex = kv + .get::<ContactAddressIndex>(DetScope::Identity(&owner_buf), &key) + .expect("kv read") + .expect("counter must have been initialized"); + assert_eq!(final_state.next_send_index, 100); + } + + // ------------------------------------------------------------------- + // D4d / Wave 2: the network-clear path + // (`AppContext::clear_network_database`) must hit every overlay the + // adapter writes. Wave 2 split the overlays across two scopes: the + // Global-scoped markers/timestamps/addr-map come out in one + // `det:dashpay:` prefix sweep; the per-contact private memo and + // address-index moved to each owner's `DetScope::Identity` scope and + // are cleared per owner. A miss in either half leaks DET-only state + // across a "Clear network data" action. + // ------------------------------------------------------------------- + + /// D4d-Sweep1: the three Global overlays share the `det:dashpay:` + /// prefix and come out of one Global sweep; the four Identity-scoped + /// overlays do NOT (they live under the owner scope). Wave 2 + F40 moved + /// the blocked / rejected markers into the owner scope alongside the + /// private memo and address-index cursors. + #[test] + fn d4d_global_overlays_share_prefix_identity_overlays_do_not() { + let kv = empty_kv(); + let owner_id = id_from_byte(1); + let owner = owner_id.to_buffer(); + let contact = id_from_byte(2); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + + // Three Global overlays (timestamps, payment timestamps, addr map). + kv.put::<(i64, i64)>( + DetScope::Global, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &contact), + &(111, 222), + ) + .unwrap(); + kv.put::<(i64, Option<i64>)>( + DetScope::Global, + &format!("{KV_PREFIX_TIMESTAMPS}tx:abc123"), + &(333, Some(444)), + ) + .unwrap(); + kv.put::<([u8; 32], u32)>( + DetScope::Global, + &addr_map_sidecar_key(&owner_id, addr), + &(contact.to_buffer(), 1), + ) + .unwrap(); + + // Four Identity-scoped overlays under the owner. + kv.put::<ContactPrivateInfo>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_PRIVATE, &contact), + &ContactPrivateInfo::default(), + ) + .unwrap(); + kv.put::<ContactAddressIndex>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_ADDRESS_INDEX, &contact), + &ContactAddressIndex { + owner_identity_id: owner_id.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 1, + highest_receive_index: 0, + bloom_registered_count: 0, + }, + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_BLOCKED, &contact), + &(), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_REJECTED, &contact), + &(), + ) + .unwrap(); + + let global = kv + .list(DetScope::Global, Some("det:dashpay:")) + .expect("global sidecar listing must succeed"); + assert_eq!( + global.len(), + 3, + "three Global overlays enumerated: {global:?}" + ); + for k in &global { + assert!(k.starts_with("det:dashpay:"), "non-DashPay key: {k}"); + } + + // The Identity-scoped overlays are invisible to the Global sweep + // but present under the owner scope. + 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:?}"); + } + + /// D4d-Sweep2: the combined clear (Global prefix sweep + per-owner + /// Identity clear) drains every overlay — mirrors what + /// `AppContext::clear_network_database` does post-Wave-2. + #[test] + fn d4d_combined_clear_drains_global_and_owner_overlays() { + let kv = empty_kv(); + let owner = id_from_byte(1).to_buffer(); + let contact = id_from_byte(2); + + // A Global overlay (timestamps) plus the four owner-scoped overlays. + kv.put::<(i64, i64)>( + DetScope::Global, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &contact), + &(1, 2), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_BLOCKED, &contact), + &(), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_REJECTED, &contact), + &(), + ) + .unwrap(); + kv.put::<ContactPrivateInfo>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_PRIVATE, &contact), + &ContactPrivateInfo { + nickname: "alice".into(), + notes: "n".into(), + is_hidden: false, + }, + ) + .unwrap(); + kv.put::<ContactAddressIndex>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_ADDRESS_INDEX, &contact), + &ContactAddressIndex { + owner_identity_id: owner.to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 3, + highest_receive_index: 0, + bloom_registered_count: 0, + }, + ) + .unwrap(); + // Drop one unrelated global key to confirm the sweep is scoped. + kv.put::<u32>(DetScope::Global, "mainnet:scheduled_votes:1", &7) + .unwrap(); + + // Global prefix sweep. + for k in kv.list(DetScope::Global, Some("det:dashpay:")).unwrap() { + kv.delete(DetScope::Global, &k).unwrap(); + } + // Per-owner Identity-scope clear — mirrors `dashpay_clear_owner_overlays`. + for prefix in [ + KV_PREFIX_PRIVATE, + KV_PREFIX_ADDRESS_INDEX, + KV_PREFIX_BLOCKED, + KV_PREFIX_REJECTED, + ] { + for k in kv.list(DetScope::Identity(&owner), Some(prefix)).unwrap() { + kv.delete(DetScope::Identity(&owner), &k).unwrap(); + } + } + + assert!( + kv.list(DetScope::Global, Some("det:dashpay:")) + .unwrap() + .is_empty(), + "Global DashPay overlays must be empty after the sweep" + ); + assert!( + kv.list(DetScope::Identity(&owner), Some("det:dashpay:")) + .unwrap() + .is_empty(), + "owner-scoped DashPay overlays must be empty after the clear" + ); + // Unrelated key survives. + assert_eq!( + kv.get::<u32>(DetScope::Global, "mainnet:scheduled_votes:1") + .unwrap(), + Some(7) + ); + } + + /// D4d-Sweep3: the prefix sweep is precision-scoped — keys for + /// unrelated overlays (settings, scheduled votes, shielded sidecar, + /// etc.) must not be caught by `det:dashpay:`. + #[test] + fn d4d_prefix_sweep_skips_non_dashpay_keys() { + let kv = empty_kv(); + kv.put::<u32>(DetScope::Global, "mainnet:settings:v1", &1) + .unwrap(); + kv.put::<u32>(DetScope::Global, "mainnet:shielded:sync_cursor", &2) + .unwrap(); + kv.put::<u32>(DetScope::Global, "det:other_domain:x", &3) + .unwrap(); + + let keys = kv.list(DetScope::Global, Some("det:dashpay:")).unwrap(); + assert!( + keys.is_empty(), + "prefix sweep must not see non-DashPay overlays: {keys:?}" + ); + } + + // ------------------------------------------------------------------- + // In-memory KvStore for the translator tests. + // ------------------------------------------------------------------- + + fn empty_kv() -> DetKv { + use crate::wallet_backend::kv_test_support::InMemoryKv; + + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + // ----------------------------------------------------------------------- + // Contact xpub derivation seam + // ----------------------------------------------------------------------- + + const TEST_SEED: [u8; 64] = [0x42u8; 64]; + const TEST_SECRET: [u8; 32] = [0x07u8; 32]; + + // F3 pinned vectors — captured from the implementation for the fixed + // inputs `(TEST_SEED, Testnet, account_index 0, sender 0x11, recipient + // 0x22, TEST_SECRET)`. They guard the DashPay derivation seam against a + // silent upstream drift; update only with a deliberate review. + const ACCOUNT_REFERENCE_TESTNET_PINNED: u32 = 173_734_745; + const CONTACT_INFO_KEY1_TESTNET_PINNED: [u8; 32] = [ + 158, 54, 59, 142, 202, 216, 7, 142, 110, 245, 93, 36, 97, 242, 74, 190, 160, 34, 128, 221, + 103, 201, 77, 14, 76, 12, 80, 209, 66, 120, 131, 231, + ]; + const CONTACT_INFO_KEY2_TESTNET_PINNED: [u8; 32] = [ + 38, 164, 234, 130, 35, 156, 42, 172, 46, 176, 30, 207, 166, 199, 230, 203, 100, 89, 48, 37, + 54, 201, 175, 48, 191, 160, 21, 137, 219, 78, 222, 242, + ]; + + fn contact_ids() -> (Identifier, Identifier) { + (id_from_byte(0x11), id_from_byte(0x22)) + } + + /// F3 — pinned vector for `account_reference`. Locks the DIP-15 + /// account-reference HMAC against a fixed `(seed, sender, recipient, + /// secret)` so an upstream change to `calculate_account_reference` or the + /// xpub derivation path cannot silently shift the value contacts pay + /// against. The expected value was captured from the implementation and + /// pinned; a mismatch is a deliberate-review signal, not a flake. + #[test] + fn f3_account_reference_pinned_vector() { + let (sender, recipient) = contact_ids(); + let m = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive"); + assert_eq!( + m.account_reference, ACCOUNT_REFERENCE_TESTNET_PINNED, + "account_reference drifted from its pinned vector" + ); + } + + /// F3 — `account_reference` differs across networks for the same inputs, + /// because the coin-type segment of the derivation path differs. Guards + /// against a regression that would make testnet and mainnet collide. + #[test] + fn f3_account_reference_testnet_differs_from_mainnet() { + let (sender, recipient) = contact_ids(); + let testnet = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("testnet"); + let mainnet = derive_contact_xpub_material( + &TEST_SEED, + Network::Mainnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("mainnet"); + assert_ne!( + testnet.account_reference, mainnet.account_reference, + "testnet and mainnet account_reference must not collide" + ); + assert_ne!( + testnet.public_key, mainnet.public_key, + "testnet and mainnet contact xpub must not collide" + ); + } + + /// F3 — pinned vector for the DIP-0015 contactInfo encryption key pair. + /// Locks `derive_contact_info_encryption_keys` against a fixed + /// `(seed, network, index)` so an upstream BIP-32 change cannot silently + /// rotate the keys that decrypt existing contact-info documents. + #[test] + fn f3_contact_info_encryption_keys_pinned_vector() { + let (key1, key2) = + derive_contact_info_encryption_keys(&TEST_SEED, Network::Testnet, 0).expect("derive"); + assert_eq!( + *key1, CONTACT_INFO_KEY1_TESTNET_PINNED, + "contactInfo key1 drifted from its pinned vector" + ); + assert_eq!( + *key2, CONTACT_INFO_KEY2_TESTNET_PINNED, + "contactInfo key2 drifted from its pinned vector" + ); + // The two DIP-15 keys are derived under distinct hardened branches — + // they must never be equal. + assert_ne!(*key1, *key2, "key1 and key2 must differ"); + } + + /// F3 — the contactInfo encryption keys differ across networks for the + /// same `(seed, index)`, because the coin-type path segment differs. + #[test] + fn f3_contact_info_encryption_keys_testnet_differs_from_mainnet() { + let (t1, t2) = + derive_contact_info_encryption_keys(&TEST_SEED, Network::Testnet, 0).expect("testnet"); + let (m1, m2) = + derive_contact_info_encryption_keys(&TEST_SEED, Network::Mainnet, 0).expect("mainnet"); + assert_ne!(*t1, *m1, "key1 must differ across networks"); + assert_ne!(*t2, *m2, "key2 must differ across networks"); + } + + #[test] + fn contact_xpub_material_is_deterministic() { + let (sender, recipient) = contact_ids(); + let a = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive"); + let b = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive"); + assert_eq!(a, b, "same inputs must yield the same material"); + } + + #[test] + fn contact_xpub_material_differs_by_relationship() { + let (sender, recipient) = contact_ids(); + let forward = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive forward"); + let reverse = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &recipient, + &sender, + &TEST_SECRET, + ) + .expect("derive reverse"); + assert_ne!( + forward.public_key, reverse.public_key, + "the relationship is directional; swapping sender/recipient must change the key" + ); + } + + #[test] + fn contact_xpub_public_key_is_valid_compressed() { + let (sender, recipient) = contact_ids(); + let m = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive"); + assert!( + m.public_key[0] == 0x02 || m.public_key[0] == 0x03, + "compressed secp256k1 public key must start with 0x02 or 0x03" + ); + } + + /// Correctness substrate: on **mainnet**, the xpub the seam + /// publishes in the contact request equals the xpub DET's receive-side + /// derivation (`derive_dashpay_incoming_xpub`) produces from the same seed + /// and path. This pins the upstream `derive_contact_xpub` output against + /// DET's local DIP-14 path where they agree (both use coin-type 5'). + #[test] + fn seam_xpub_matches_receive_side_derivation_on_mainnet() { + use crate::model::dashpay_derivation::derive_dashpay_incoming_xpub; + + let (sender, recipient) = contact_ids(); + let seam = derive_contact_xpub_material( + &TEST_SEED, + Network::Mainnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("seam derive"); + + let local = + derive_dashpay_incoming_xpub(&TEST_SEED, Network::Mainnet, 0, &sender, &recipient) + .expect("local derive"); + + assert_eq!( + seam.public_key, + local.public_key.serialize(), + "published public key must match the receive-side xpub on mainnet" + ); + assert_eq!( + seam.chain_code, + local.chain_code.to_bytes(), + "published chain code must match the receive-side xpub on mainnet" + ); + assert_eq!( + seam.parent_fingerprint, + local.parent_fingerprint.to_bytes(), + "published parent fingerprint must match the receive-side xpub on mainnet" + ); + } + + /// Fund-routing invariant on **testnet**: the xpub the seam + /// publishes in the contact request equals the xpub DET's receive-side + /// derivation (`derive_dashpay_incoming_xpub`) produces from the same seed + /// and path. Both now select coin-type `1'` on testnet, so the contact + /// pays into addresses the user's wallet actually scans. + #[test] + fn seam_xpub_matches_receive_side_on_testnet() { + use crate::model::dashpay_derivation::derive_dashpay_incoming_xpub; + + let (sender, recipient) = contact_ids(); + let seam = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("seam derive"); + + let local = + derive_dashpay_incoming_xpub(&TEST_SEED, Network::Testnet, 0, &sender, &recipient) + .expect("local derive"); + + assert_eq!( + seam.public_key, + local.public_key.serialize(), + "published public key must match the receive-side xpub on testnet" + ); + assert_eq!( + seam.chain_code, + local.chain_code.to_bytes(), + "published chain code must match the receive-side xpub on testnet" + ); + assert_eq!( + seam.parent_fingerprint, + local.parent_fingerprint.to_bytes(), + "published parent fingerprint must match the receive-side xpub on testnet" + ); + } + + /// Fund-routing invariant, both networks, all published fields. + /// The send-side seam (`derive_contact_xpub_material`) and the receive-side + /// scanning derivation (`derive_dashpay_incoming_xpub`) must agree on the + /// full triple (public_key, chain_code, parent_fingerprint) for the same + /// `(sender, recipient)` on Mainnet AND Testnet. Any disagreement means a + /// contact pays into addresses the recipient never scans. + #[test] + fn seam_matches_receive_side_full_fields_on_both_networks() { + use crate::model::dashpay_derivation::derive_dashpay_incoming_xpub; + + let (sender, recipient) = contact_ids(); + for network in [Network::Mainnet, Network::Testnet] { + let seam = derive_contact_xpub_material( + &TEST_SEED, + network, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("seam derive"); + + let local = derive_dashpay_incoming_xpub(&TEST_SEED, network, 0, &sender, &recipient) + .expect("local derive"); + + assert_eq!( + seam.public_key, + local.public_key.serialize(), + "public key must match send↔receive on {network:?}" + ); + assert_eq!( + seam.chain_code, + local.chain_code.to_bytes(), + "chain code must match send↔receive on {network:?}" + ); + assert_eq!( + seam.parent_fingerprint, + local.parent_fingerprint.to_bytes(), + "parent fingerprint must match send↔receive on {network:?}" + ); + } + } + + /// The 32-byte ECDH secret key vs the 64-byte HD seed are different inputs: + /// the placeholder fed the secret key in as if it were the HD seed, which + /// produced an xpub unrelated to the wallet's real tree. This guards the + /// behavioural delta — the real HD seed must drive the derivation. + #[test] + fn hd_seed_drives_derivation_not_the_ecdh_secret() { + let (sender, recipient) = contact_ids(); + let from_seed = derive_contact_xpub_material( + &TEST_SEED, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive from real seed"); + + // Reproduce the old placeholder: 32-byte secret right-padded to a + // 64-byte "seed". This must NOT match the real-seed derivation. + let mut placeholder_seed = [0u8; 64]; + placeholder_seed[..32].copy_from_slice(&TEST_SECRET); + let from_placeholder = derive_contact_xpub_material( + &placeholder_seed, + Network::Testnet, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("derive from placeholder"); + + assert_ne!( + from_seed.public_key, from_placeholder.public_key, + "deriving from the real HD seed must differ from the old private-key placeholder" + ); + } + + /// SEC-W-001 multi-wallet fund-routing invariant. When a DashPay identity + /// has more than one associated wallet, the send-side wallet selection + /// (`QualifiedIdentity::dashpay_wallet_seed_hash`, used by + /// `contact_requests` / `contact_info`) and the receive-side selection + /// (`QualifiedIdentity::dashpay_wallet`, used by `incoming_payments`) MUST + /// resolve to the same wallet — the lowest seed hash — or a contact would + /// pay into addresses the recipient never scans. + /// + /// Proves the full chain: + /// 1. both accessors pick the same (lowest-hash) wallet; + /// 2. the contact xpub the send side publishes from the selected seed + /// equals the xpub the receive side scans from the selected seed + /// (published == scanned); + /// 3. the *other* wallet's seed derives a different xpub — i.e. picking the + /// wrong wallet would route funds astray, so the agreement is load-bearing. + #[test] + fn multi_wallet_send_and_receive_select_same_wallet() { + use crate::model::dashpay_derivation::derive_dashpay_incoming_xpub; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use crate::model::wallet::Wallet; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use std::collections::BTreeMap; + use std::sync::{Arc, RwLock}; + + let network = Network::Testnet; + let (sender, recipient) = contact_ids(); + + // Two distinct HD seeds → two wallets with distinct seed hashes. + let seed_a = [0x11u8; 64]; + let seed_b = [0x99u8; 64]; + let wallet_a = Wallet::new_from_seed(seed_a, network, None, None).expect("wallet a"); + let wallet_b = Wallet::new_from_seed(seed_b, network, None, None).expect("wallet b"); + let hash_a = wallet_a.seed_hash(); + let hash_b = wallet_b.seed_hash(); + assert_ne!(hash_a, hash_b, "test seeds must produce distinct hashes"); + + // The selected (lowest-hash) wallet's seed, and the other one. + let (selected_hash, selected_seed, other_seed) = if hash_a <= hash_b { + (hash_a, seed_a, seed_b) + } else { + (hash_b, seed_b, seed_a) + }; + + let mut associated_wallets = BTreeMap::new(); + associated_wallets.insert(hash_a, Arc::new(RwLock::new(wallet_a))); + associated_wallets.insert(hash_b, Arc::new(RwLock::new(wallet_b))); + + let identity = Identity::create_basic_identity(sender, PlatformVersion::latest()) + .expect("basic identity"); + let qi = 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, + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: IdentityStatus::Active, + network, + }; + + // (1) Both sides select the same wallet — the lowest seed hash. + let send_side = qi.dashpay_wallet_seed_hash().expect("send-side selection"); + let (recv_side, _) = qi.dashpay_wallet().expect("receive-side selection"); + assert_eq!( + send_side, recv_side, + "send-side and receive-side must select the same wallet" + ); + assert_eq!( + send_side, selected_hash, + "selection must be the lowest seed hash" + ); + + // (2) Published (send side) == scanned (receive side) for the selected seed. + let published = derive_contact_xpub_material( + &selected_seed, + network, + 0, + &sender, + &recipient, + &TEST_SECRET, + ) + .expect("send-side material"); + let scanned = derive_dashpay_incoming_xpub(&selected_seed, network, 0, &sender, &recipient) + .expect("receive-side xpub"); + assert_eq!( + published.public_key, + scanned.public_key.serialize(), + "published xpub must equal the scanned xpub for the selected wallet" + ); + + // (3) The other wallet's seed derives a different xpub — agreement matters. + let other_scanned = + derive_dashpay_incoming_xpub(&other_seed, network, 0, &sender, &recipient) + .expect("other-side xpub"); + assert_ne!( + published.public_key, + other_scanned.public_key.serialize(), + "the unselected wallet's seed must derive a different xpub" + ); + } +} diff --git a/src/wallet_backend/det_platform_signer.rs b/src/wallet_backend/det_platform_signer.rs new file mode 100644 index 000000000..54b52695c --- /dev/null +++ b/src/wallet_backend/det_platform_signer.rs @@ -0,0 +1,372 @@ +//! Just-in-time platform-address signer for the upstream +//! [`Signer<PlatformAddress>`] seam. +//! +//! The SDK's platform-funding flows (`top_up`, `transfer_address_funds`, +//! `withdraw_address_funds`) are signer-driven: the SDK calls +//! `signer.sign(platform_address, data)` to authorise each per-input +//! `AddressWitness`. [`DetPlatformSigner`] is DET's JIT implementation of +//! that trait — it **borrows** the HD seed held open by a +//! [`SecretAccess::with_secret_session`](crate::wallet_backend::SecretAccess::with_secret_session) +//! scope, maps the platform address to its DIP-17 derivation path through a +//! pre-built pure index, derives the signing key locally, and signs. +//! +//! The network is known up front (from the wallet context) rather than +//! brute-forced across all four networks, so signing derives against exactly +//! one coin type. +//! +//! Borrow discipline: the `'a` lifetime ties the signer to both the held seed +//! and the path index, so the signer cannot outlive the `with_secret_session` +//! scope. Never `Box`/`Arc`/return it — the SDK takes `&S`, which the borrow +//! satisfies without any `'static` coercion. + +use std::collections::BTreeMap; + +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress}; +use dash_sdk::dpp::async_trait::async_trait; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::platform_value::BinaryData; + +use crate::model::wallet::{DerivationPathHelpers, Wallet}; + +/// Pure (secret-free) map from a wallet's watched platform-payment addresses +/// to their DIP-17 derivation paths, for a single network. +/// +/// Built from `Wallet::watched_addresses` *before* entering the secret scope +/// so that [`DetPlatformSigner::can_sign_with`] — which the SDK calls in its +/// pre-flight checks — is a cheap, prompt-free membership test that never +/// touches the seed (Nagatha R-6). +#[derive(Debug, Clone)] +pub(crate) struct PlatformPathIndex { + network: Network, + by_address: BTreeMap<PlatformAddress, DerivationPath>, +} + +impl PlatformPathIndex { + /// Build the index from a wallet's watched addresses for `network`. + /// + /// Only platform-payment paths (`is_platform_payment`) whose stored Core + /// address converts to a [`PlatformAddress`] are included — the exact set + /// the legacy `get_platform_address_private_key` lookup could resolve. + pub(crate) fn from_wallet(wallet: &Wallet, network: Network) -> Self { + let by_address = wallet + .watched_addresses + .iter() + .filter(|(path, _)| path.is_platform_payment(network)) + .filter_map(|(path, info)| { + PlatformAddress::try_from(info.address.clone()) + .ok() + .map(|platform_address| (platform_address, path.clone())) + }) + .collect(); + Self { + network, + by_address, + } + } + + /// The DIP-17 derivation path for `address`, or `None` if it is not one of + /// the wallet's watched platform-payment addresses on this network. + fn path_for(&self, address: &PlatformAddress) -> Option<&DerivationPath> { + self.by_address.get(address) + } + + /// Whether this index can resolve a path for `address` (pure, no secret). + fn contains(&self, address: &PlatformAddress) -> bool { + self.by_address.contains_key(address) + } + + /// The number of indexed platform addresses (used in the redacted + /// `Debug` output of the signer). + fn len(&self) -> usize { + self.by_address.len() + } +} + +/// JIT [`Signer<PlatformAddress>`] backed by a **borrowed** HD seed and a +/// **borrowed** pure path index. +/// +/// Constructed inside a `with_secret_session` scope; never owns or copies the +/// plaintext seed. The lifetime `'a` ties the signer to both the held seed and +/// the index, so the borrow cannot escape the scope where the plaintext is +/// alive. +pub(crate) struct DetPlatformSigner<'a> { + seed: &'a [u8; 64], + network: Network, + index: &'a PlatformPathIndex, +} + +impl<'a> DetPlatformSigner<'a> { + /// Build a platform signer over the held seed for `network`, using + /// `index` to map addresses to derivation paths. Borrows both — no copy. + /// + /// `seed` is the borrow returned by + /// [`SecretPlaintext::expose_hd_seed`](crate::wallet_backend::SecretPlaintext::expose_hd_seed) + /// inside a `with_secret_session` scope; the `'a` lifetime keeps the signer + /// from outliving the held plaintext. + pub(crate) fn from_held( + seed: &'a [u8; 64], + network: Network, + index: &'a PlatformPathIndex, + ) -> Self { + debug_assert_eq!( + network, index.network, + "platform signer network must match the index network" + ); + Self { + seed, + network, + index, + } + } + + /// Resolve `platform_address` to its derivation path and derive the raw + /// 65-byte ECDSA signature over `data`, using the **same** primitive the + /// legacy `Wallet` platform signer used. Shared by `sign` and + /// `sign_create_witness` so both paths are byte-identical. + /// + /// Returns `ProtocolError` to match the upstream `Signer` trait surface + /// this helper feeds; the large-variant lint is the SDK's error shape, not + /// ours (same `allow` as the legacy `get_platform_address_private_key`). + #[allow(clippy::result_large_err)] + fn sign_with_address( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result<Vec<u8>, ProtocolError> { + if !platform_address.is_p2pkh() { + return Err(ProtocolError::Generic( + "Only P2PKH Platform addresses are currently supported for signing".to_string(), + )); + } + + let derivation_path = self.index.path_for(platform_address).ok_or_else(|| { + ProtocolError::Generic(format!( + "Platform address {:?} not found in wallet", + platform_address + )) + })?; + + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(self.seed, self.network) + .map_err(|e| ProtocolError::Generic(e.to_string()))?; + let private_key = extended_private_key.to_priv(); + + let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + + Ok(signature.to_vec()) + } +} + +impl std::fmt::Debug for DetPlatformSigner<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DetPlatformSigner") + .field("network", &self.network) + .field("indexed_addresses", &self.index.len()) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl Signer<PlatformAddress> for DetPlatformSigner<'_> { + async fn sign( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result<BinaryData, ProtocolError> { + let signature = self.sign_with_address(platform_address, data)?; + Ok(BinaryData::new(signature)) + } + + async fn sign_create_witness( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result<AddressWitness, ProtocolError> { + let signature = self.sign_with_address(platform_address, data)?; + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(signature), + }) + } + + fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { + platform_address.is_p2pkh() && self.index.contains(platform_address) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::wallet::{AddressInfo, DerivationPathReference, DerivationPathType}; + use dash_sdk::dpp::dashcore::Address; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use zeroize::Zeroizing; + + /// Reference platform-address signing — reproduces exactly what the + /// retired `Wallet` `Signer<PlatformAddress>` impl did: look up the address + /// in the index, derive the private key at its path from the seed, and sign + /// with the same `dashcore::signer::sign`. The parity tests compare + /// [`DetPlatformSigner`] against this independent reference so they still + /// prove fund-safety parity without the deleted impl. + fn reference_sign( + index: &PlatformPathIndex, + seed: &[u8; 64], + network: Network, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Vec<u8> { + let path = index.path_for(platform_address).expect("indexed path"); + let private_key = path + .derive_priv_ecdsa_for_master_seed(seed, network) + .expect("derive") + .to_priv(); + dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) + .expect("reference sign") + .to_vec() + } + + /// Build a minimal open wallet (via the public pure constructor) and wire + /// one platform-payment address into its watched/known maps — exactly the + /// shape the [`PlatformPathIndex`] consumes. No `AppContext` needed. + fn wallet_with_platform_address( + network: Network, + ) -> (Wallet, PlatformAddress, Zeroizing<[u8; 64]>) { + let seed = Zeroizing::new([0x5Au8; 64]); + let mut wallet = Wallet::new_from_seed(*seed, network, None, None).expect("build wallet"); + + let path = DerivationPath::platform_payment_path(network, 0, 0, 0); + let xprv = path + .derive_priv_ecdsa_for_master_seed(&*seed, network) + .expect("derive platform key"); + let secp = Secp256k1::new(); + let address = Address::p2pkh(&xprv.to_priv().public_key(&secp), network); + let platform_address = + PlatformAddress::try_from(address.clone()).expect("to platform address"); + + wallet.known_addresses.insert(address.clone(), path.clone()); + wallet.watched_addresses.insert( + path, + AddressInfo { + address, + path_type: DerivationPathType::CLEAR_FUNDS, + path_reference: DerivationPathReference::PlatformPayment, + }, + ); + + (wallet, platform_address, seed) + } + + /// FUND-SAFETY PARITY: `DetPlatformSigner` must produce byte-identical + /// `sign` output to a direct reference derivation (path-from-index → derive + /// → `dashcore::signer::sign`) for the same address and data, on every + /// network. A divergence here means wrong signatures and lost/failed funds. + #[tokio::test] + async fn platform_signer_parity_with_reference() { + for network in [Network::Testnet, Network::Mainnet] { + let (wallet, platform_address, seed) = wallet_with_platform_address(network); + let index = PlatformPathIndex::from_wallet(&wallet, network); + let det = DetPlatformSigner::from_held(&seed, network, &index); + + let data = b"fund-critical-parity-vector"; + + let reference_sig = reference_sign(&index, &seed, network, &platform_address, data); + let det_sig = det.sign(&platform_address, data).await.expect("det sign"); + assert_eq!( + reference_sig, + det_sig.to_vec(), + "sign() bytes diverged on {network:?}" + ); + + // The create-witness signature wraps the same `sign` output. + let det_witness = det + .sign_create_witness(&platform_address, data) + .await + .expect("det witness"); + let reference_witness = AddressWitness::P2pkh { + signature: BinaryData::new(reference_sig), + }; + assert_eq!( + format!("{reference_witness:?}"), + format!("{det_witness:?}"), + "sign_create_witness() diverged on {network:?}" + ); + + assert!( + det.can_sign_with(&platform_address), + "can_sign_with must be true for an indexed address on {network:?}" + ); + } + } + + /// PARITY: the key `DetPlatformSigner` signs with matches a direct + /// reference derivation at the indexed path exactly (same path, same + /// coin-type, same network) — the strongest fund-safety guarantee, + /// independent of the signing nonce. + #[tokio::test] + async fn platform_signer_derives_same_key_as_reference() { + for network in [Network::Testnet, Network::Mainnet] { + let (wallet, platform_address, seed) = wallet_with_platform_address(network); + let index = PlatformPathIndex::from_wallet(&wallet, network); + + // Reference: derive directly at the indexed path and sign. + let path = index.path_for(&platform_address).expect("indexed path"); + let reference_key = path + .derive_priv_ecdsa_for_master_seed(&*seed, network) + .expect("derive") + .to_priv(); + let data = b"key-parity-vector"; + let reference_sig = + dash_sdk::dpp::dashcore::signer::sign(data, reference_key.inner.as_ref()) + .expect("reference sign") + .to_vec(); + + // The signer's view of the same address must yield the same key. + let det = DetPlatformSigner::from_held(&seed, network, &index); + let det_sig = det.sign(&platform_address, data).await.expect("det sign"); + + assert_eq!( + reference_sig, + det_sig.to_vec(), + "derived key signatures diverged on {network:?}" + ); + } + } + + /// `can_sign_with` is false for an address the wallet does not watch, and + /// resolving such an address errors rather than mis-signing. + #[tokio::test] + async fn rejects_unknown_address() { + let network = Network::Testnet; + let (wallet, _addr, seed) = wallet_with_platform_address(network); + let index = PlatformPathIndex::from_wallet(&wallet, network); + let det = DetPlatformSigner::from_held(&seed, network, &index); + + // A different platform address (index 1) that was never registered. + let other_path = DerivationPath::platform_payment_path(network, 0, 0, 1); + let other_xprv = other_path + .derive_priv_ecdsa_for_master_seed(&*seed, network) + .unwrap(); + let secp = Secp256k1::new(); + let other_address = Address::p2pkh(&other_xprv.to_priv().public_key(&secp), network); + let other_platform = PlatformAddress::try_from(other_address).unwrap(); + + assert!(!det.can_sign_with(&other_platform)); + assert!(det.sign(&other_platform, b"data").await.is_err()); + } + + /// `Debug` never prints the seed bytes — only the network and address + /// count appear. + #[test] + fn debug_redacts_seed() { + let network = Network::Testnet; + let (wallet, _addr, seed) = wallet_with_platform_address(network); + let index = PlatformPathIndex::from_wallet(&wallet, network); + let det = DetPlatformSigner::from_held(&seed, network, &index); + let dbg = format!("{det:?}"); + assert!(dbg.contains("Testnet"), "network present: {dbg}"); + assert!(!dbg.contains("90, 90, 90"), "seed bytes leaked: {dbg}"); + } +} diff --git a/src/wallet_backend/det_signer.rs b/src/wallet_backend/det_signer.rs new file mode 100644 index 000000000..d9a708815 --- /dev/null +++ b/src/wallet_backend/det_signer.rs @@ -0,0 +1,487 @@ +//! Just-in-time soft-wallet signer for the upstream +//! [`key_wallet::signer::Signer`] seam. +//! +//! Upstream identity / payment / asset-lock flows are signer-driven: every +//! secp256k1 sign of a sighash and every public-key derivation goes through +//! an externally-injected [`Signer`]. [`DetSigner`] is DET's JIT +//! implementation — it **borrows** the plaintext secret held open by a +//! [`SecretAccess::with_secret_session`] scope and derives + signs locally, +//! with no host round-trip. +//! +//! The seed source is "borrow the held JIT secret" — never a by-value +//! `[u8; N]` snapshot (Smythe must-fix #2). The held secret zeroizes when +//! the `with_secret_session` scope ends, so the signer's borrow can never +//! outlive the plaintext. +//! +//! Two key sources: +//! - **HD seed** — the full [`Signer`] surface (BIP-32 derive at a path, +//! then ECDSA). Used by identity / payment / asset-lock flows. +//! - **Single key** — a path-free raw ECDSA over the held 32 bytes via +//! [`DetSigner::sign_single_key_ecdsa`]. Single keys carry no derivation +//! tree, so the path-based `Signer` surface does not apply to them. +//! +//! M-DONT-LEAK-TYPES: this type and its error live inside `wallet_backend`; +//! the upstream signer trait is the only seam that touches `key_wallet::*` +//! outside the module. +//! +//! The HD `Signer` surface (`from_held` + `sign_ecdsa` / `public_key`) is +//! wired into every signer-driven HD flow (payment / asset-lock / identity). +//! The single-key raw-ECDSA helper [`DetSigner::sign_single_key_ecdsa`] is +//! wired into [`WalletBackend::sign_single_key`](super::WalletBackend::sign_single_key), +//! the JIT single-key signing chokepoint. That chokepoint has no production +//! caller yet — single-key *send* is still stubbed upstream (design §0.4) — +//! so the path is exercised through unit tests until that send flow is +//! un-gated. + +use async_trait::async_trait; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::secp256k1::{self, Message, PublicKey, Secp256k1, ecdsa}; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; +use dash_sdk::dpp::key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; +use zeroize::Zeroizing; + +use crate::wallet_backend::secret_access::SecretPlaintext; + +/// Errors returned by [`DetSigner`]. Wired with `#[source]` so callers can +/// walk the cause chain; never carries user-facing prose or any secret +/// (CLAUDE.md error-variant rules). +#[derive(Debug, thiserror::Error)] +pub enum DetSignerError { + /// Derivation from the held seed failed. + #[error("just-in-time signer key derivation failed")] + Derive(#[source] dash_sdk::dpp::key_wallet::bip32::Error), + /// secp256k1 sign / digest construction failed. + #[error("just-in-time signer secp256k1 operation failed")] + Sign(#[source] secp256k1::Error), + /// The held secret is the wrong kind for the requested operation + /// (e.g. a single-key plaintext asked for path-based HD signing). + #[error("just-in-time signer received the wrong secret kind for this operation")] + WrongSecretKind, +} + +/// Hash a 32-byte digest into an ECDSA-ready [`Message`], surfacing the +/// upstream digest-length error as a typed signer error rather than +/// panicking. +fn message_from_digest(digest: [u8; 32]) -> Result<Message, DetSignerError> { + Message::from_digest_slice(&digest).map_err(DetSignerError::Sign) +} + +/// JIT [`Signer`] backed by a **borrowed** held secret. +/// +/// Constructed from a [`SecretPlaintext`] borrow inside a +/// `with_secret_session` scope; never owns or copies the plaintext bytes. +/// The lifetime `'a` ties the signer to the held secret, so the borrow +/// cannot escape the scope where the plaintext is alive. +pub(crate) struct DetSigner<'a> { + secret: HeldSecret<'a>, + network: Network, + /// `Signer::supported_methods` returns `&[SignerMethod]`; own the + /// backing storage so the borrow is sound. + supported_methods: [SignerMethod; 1], +} + +/// The borrowed key material a [`DetSigner`] operates on. +enum HeldSecret<'a> { + HdSeed(&'a Zeroizing<[u8; 64]>), + SingleKey(&'a Zeroizing<[u8; 32]>), +} + +impl<'a> DetSigner<'a> { + /// Build a signer over the held plaintext for `network`. Borrows the + /// secret — no copy. + pub(crate) fn from_held(plaintext: SecretPlaintext<'a>, network: Network) -> Self { + let secret = match plaintext { + SecretPlaintext::HdSeed(seed) => HeldSecret::HdSeed(seed), + // An identity key is a raw secp256k1 secret, same shape as a + // single key (no derivation tree) — `DetSigner` treats them + // identically. Identity-platform signing normally goes straight + // through the resolver, not here. + SecretPlaintext::SingleKey(key) | SecretPlaintext::IdentityKey(key) => { + HeldSecret::SingleKey(key) + } + }; + Self { + secret, + network, + supported_methods: [SignerMethod::Digest], + } + } + + /// Derive the BIP-32 extended private key at `path` from the held HD + /// seed. Errors with [`DetSignerError::WrongSecretKind`] if the held + /// secret is a single key (no derivation tree). The returned + /// `ExtendedPrivKey` zeroizes its secret scalar on drop. + fn derive_xprv(&self, path: &DerivationPath) -> Result<ExtendedPrivKey, DetSignerError> { + match &self.secret { + HeldSecret::HdSeed(seed) => path + .derive_priv_ecdsa_for_master_seed(seed.as_ref(), self.network) + .map_err(DetSignerError::Derive), + HeldSecret::SingleKey(_) => Err(DetSignerError::WrongSecretKind), + } + } + + /// Derive the secp256k1 secret at `path` from the held HD seed. Errors + /// with [`DetSignerError::WrongSecretKind`] if the held secret is a + /// single key (no derivation tree). + fn derive_secret(&self, path: &DerivationPath) -> Result<secp256k1::SecretKey, DetSignerError> { + Ok(self.derive_xprv(path)?.private_key) + } + + /// Sign `msg` (a 32-byte digest) with the held **single key** directly, + /// no derivation. Errors if the held secret is an HD seed. Used by the + /// JIT single-key signing chokepoint + /// ([`WalletBackend::sign_single_key`](super::WalletBackend::sign_single_key)). + pub(crate) fn sign_single_key_ecdsa( + &self, + msg: &[u8; 32], + ) -> Result<ecdsa::Signature, DetSignerError> { + match &self.secret { + HeldSecret::SingleKey(key) => { + let sk = + secp256k1::SecretKey::from_byte_array(key).map_err(DetSignerError::Sign)?; + let message = message_from_digest(*msg)?; + Ok(Secp256k1::signing_only().sign_ecdsa(&message, &sk)) + } + HeldSecret::HdSeed(_) => Err(DetSignerError::WrongSecretKind), + } + } +} + +impl std::fmt::Debug for DetSigner<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let kind = match self.secret { + HeldSecret::HdSeed(_) => "HdSeed", + HeldSecret::SingleKey(_) => "SingleKey", + }; + f.debug_struct("DetSigner") + .field("secret_kind", &kind) + .field("network", &self.network) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl Signer for DetSigner<'_> { + type Error = DetSignerError; + + fn supported_methods(&self) -> &[SignerMethod] { + &self.supported_methods + } + + async fn sign_ecdsa( + &self, + path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + let secret = self.derive_secret(path)?; + let secp = Secp256k1::signing_only(); + let msg = message_from_digest(sighash)?; + let signature = secp.sign_ecdsa(&msg, &secret); + let public_key = PublicKey::from_secret_key(&secp, &secret); + Ok((signature, public_key)) + } + + async fn public_key(&self, path: &DerivationPath) -> Result<PublicKey, Self::Error> { + let secret = self.derive_secret(path)?; + Ok(PublicKey::from_secret_key( + &Secp256k1::signing_only(), + &secret, + )) + } +} + +#[async_trait] +impl ExtendedPubKeySigner for DetSigner<'_> { + /// Derive the BIP-32 extended public key at `path` from the held HD seed + /// (public point + chain code + parent fingerprint), letting callers + /// non-hardened-derive descendants without a re-prompt. A single-key + /// secret has no derivation tree, so it returns + /// [`DetSignerError::WrongSecretKind`] rather than a leaf-only key — never + /// a panic. The derived `ExtendedPrivKey` zeroizes on drop; the returned + /// `ExtendedPubKey` carries only public material. + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result<ExtendedPubKey, Self::Error> { + let xprv = self.derive_xprv(path)?; + Ok(ExtendedPubKey::from_priv(&Secp256k1::signing_only(), &xprv)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::wallet::WalletSeedHash; + use crate::model::wallet::encryption::encrypt_message; + use crate::model::wallet::seed_envelope::StoredSeedEnvelope; + use crate::wallet_backend::secret_access::SecretAccess; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::single_key::open_secret_store; + use crate::wallet_backend::wallet_seed_store::WalletSeedView; + use std::sync::Arc; + + const SENTINEL_PASSPHRASE: &str = "correct-horse-battery-staple-SENTINEL"; + const SENTINEL_SEED: [u8; 64] = [0x5A; 64]; + + fn store_with_protected_hd( + dir: &std::path::Path, + seed_hash: &WalletSeedHash, + ) -> Arc<platform_wallet_storage::secrets::SecretStore> { + let store = Arc::new(open_secret_store(&dir.join("v.pwsvault")).expect("vault")); + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext: encrypted_seed, + salt, + nonce, + } = encrypt_message(&SENTINEL_SEED, SENTINEL_PASSPHRASE).expect("enc"); + let envelope = StoredSeedEnvelope { + encrypted_seed, + salt, + nonce, + password_hint: None, + uses_password: true, + xpub_encoded: vec![0xCD; 78], + }; + WalletSeedView::new(&store) + .set(seed_hash, &envelope) + .unwrap(); + store + } + + /// A held HD seed produces a usable signer that derives + signs, and + /// repeated signs at the same path return a stable public key (the + /// secret is deterministic from the seed). Proves the JIT signer pulls + /// the seed through `with_secret_session` without a re-prompt. + #[tokio::test] + async fn hd_signer_derives_and_signs_via_jit() { + let dir = tempfile::tempdir().unwrap(); + let seed_hash: WalletSeedHash = [0x11; 32]; + let store = store_with_protected_hd(dir.path(), &seed_hash); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = SecretAccess::new(store, prompt.clone(), Network::Testnet); + let scope = SecretScope::HdSeed { seed_hash }; + let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); + let sighash = [42u8; 32]; + + let same_pubkey = sa + .with_secret_session(&scope, async |session| { + let signer = DetSigner::from_held(session.plaintext(), Network::Testnet); + let (_s1, pk1) = signer.sign_ecdsa(&path, sighash).await.unwrap(); + let (_s2, pk2) = signer.sign_ecdsa(&path, sighash).await.unwrap(); + let pk3 = signer.public_key(&path).await.unwrap(); + Ok(pk1 == pk2 && pk1 == pk3) + }) + .await + .unwrap(); + assert!(same_pubkey, "two signs + a derive agree on the public key"); + assert_eq!(prompt.ask_count(), 1, "one prompt for the whole operation"); + } + + /// A pinned `(seed, path) → compressed pubkey` derivation vector for the + /// HD parity test. A change to the BIP-32 derivation, the secp256k1 + /// primitive, or the seed handling would break this exact hex — catching a + /// silent derivation regression that a self-consistent parity check alone + /// could miss. Computed from [`PARITY_SEED`] at `m/44'/1'/0'/0/0` on + /// Testnet (coin-type 1' = the network-independent BIP-44 testnet path). + const PINNED_TESTNET_PUBKEY_HEX: &str = + "03a9a554ad52bd61203ceee0c8ad65f60d019a1c43a73f657f1a080d9f321730f3"; + + /// The deterministic seed both parity vectors derive from. + const PARITY_SEED: [u8; 64] = [0x5A; 64]; + + /// FUND-SAFETY PARITY: `DetSigner::sign_ecdsa` (the HD signer surface that + /// authorises every payment / asset-lock / identity sign) must produce a + /// byte-identical signature AND public key to an independent reference + /// derivation (path → `derive_priv_ecdsa_for_master_seed` → + /// `secp.sign_ecdsa`) for the same seed, path, and digest — on every + /// network. A divergence here means wrong signatures and lost/failed funds. + /// + /// Mirrors the platform-signer parity test + /// (`det_platform_signer::tests::platform_signer_parity_with_reference`): + /// the reference recomputes the result without going through `DetSigner`, + /// so the test proves genuine parity, not self-consistency. A pinned + /// `(seed, path) → pubkey` vector additionally guards the derivation itself + /// against a regression that would still be internally consistent. + #[tokio::test] + async fn hd_signer_parity_with_reference() { + let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); + let sighash = [0x3Cu8; 32]; + + for network in [Network::Testnet, Network::Mainnet] { + // Independent reference: derive the key directly from the seed at + // the path, then sign + recover the public key with the same + // secp256k1 primitive `DetSigner` uses internally. + let secp = Secp256k1::new(); + let reference_sk = path + .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) + .expect("reference derive") + .private_key; + let reference_msg = Message::from_digest_slice(&sighash).expect("reference digest"); + let reference_sig = secp.sign_ecdsa(&reference_msg, &reference_sk); + let reference_pk = PublicKey::from_secret_key(&secp, &reference_sk); + + // The JIT signer over the same seed, held open through the + // chokepoint, must match byte-for-byte. + let held = Zeroizing::new(PARITY_SEED); + let signer = DetSigner::from_held(SecretPlaintext::HdSeed(&held), network); + let (det_sig, det_pk) = signer.sign_ecdsa(&path, sighash).await.expect("det sign"); + + assert_eq!( + reference_sig, det_sig, + "sign_ecdsa signature diverged from reference on {network:?}" + ); + assert_eq!( + reference_pk, det_pk, + "sign_ecdsa public key diverged from reference on {network:?}" + ); + + // The derive-only surface must agree with the signing surface. + let derive_only_pk = signer.public_key(&path).await.expect("public_key"); + assert_eq!( + det_pk, derive_only_pk, + "public_key disagreed with sign_ecdsa on {network:?}" + ); + } + } + + /// FUND-SAFETY PARITY: `DetSigner::extended_public_key` (the BIP-32 xpub + /// export callers use to non-hardened-derive descendant payment addresses + /// without a re-prompt) must reproduce, byte-for-byte, an independent + /// reference derivation (seed → `derive_priv_ecdsa_for_master_seed` → + /// `ExtendedPubKey::from_priv`) for the same seed, path, and network. A + /// silent divergence in the chain code or parent fingerprint would derive + /// the wrong descendants and misdirect funds. Its leaf point must also + /// agree with `public_key` at the same path. + #[tokio::test] + async fn hd_signer_extended_public_key_parity_with_reference() { + let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); + + for network in [Network::Testnet, Network::Mainnet] { + // Independent reference: derive the xprv straight from the seed at + // the path, then convert to the xpub with the same primitive. + let secp = Secp256k1::new(); + let reference_xprv = path + .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) + .expect("reference derive"); + let reference_xpub = ExtendedPubKey::from_priv(&secp, &reference_xprv); + + let held = Zeroizing::new(PARITY_SEED); + let signer = DetSigner::from_held(SecretPlaintext::HdSeed(&held), network); + let det_xpub = signer + .extended_public_key(&path) + .await + .expect("det extended_public_key"); + + // Field-level checks first so a dropped BIP-32 metadatum fails with + // a precise message before the full-struct assert catches the rest. + assert_eq!( + det_xpub.public_key, reference_xpub.public_key, + "extended_public_key point diverged from reference on {network:?}" + ); + assert_eq!( + det_xpub.chain_code, reference_xpub.chain_code, + "extended_public_key chain code diverged from reference on {network:?}" + ); + assert_eq!( + det_xpub, reference_xpub, + "extended_public_key diverged from reference on {network:?}" + ); + + // The xpub's leaf point must agree with the compressed-point surface. + let leaf = signer.public_key(&path).await.expect("public_key"); + assert_eq!( + det_xpub.public_key, leaf, + "extended_public_key point disagreed with public_key on {network:?}" + ); + } + } + + /// Pin the exact derived public key so a BIP-32 / coin-type / primitive + /// regression is caught even if it stays internally consistent. Testnet + /// path `m/44'/1'/0'/0/0` over [`PARITY_SEED`]. + #[test] + fn hd_derivation_matches_pinned_vector() { + let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); + let secp = Secp256k1::new(); + let sk = path + .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, Network::Testnet) + .expect("derive") + .private_key; + let pk = PublicKey::from_secret_key(&secp, &sk); + assert_eq!( + hex::encode(pk.serialize()), + PINNED_TESTNET_PUBKEY_HEX, + "derived pubkey drifted from the pinned vector — a derivation regression" + ); + } + + /// The held single-key plaintext signs raw ECDSA without derivation, + /// and asking the HD `Signer` surface of a single-key-held signer + /// returns the typed `WrongSecretKind` rather than mis-deriving. + #[tokio::test] + async fn single_key_signer_signs_raw_and_rejects_path() { + use dash_sdk::dpp::dashcore::PrivateKey; + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(open_secret_store(&dir.path().join("v.pwsvault")).expect("vault")); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + let view = crate::wallet_backend::single_key::SingleKeyView::from_views( + &store, + &index, + Network::Testnet, + None, + ); + let mut key_bytes = [0u8; 32]; + key_bytes[31] = 1; + let sk = secp256k1::SecretKey::from_byte_array(&key_bytes).unwrap(); + let wif = PrivateKey::new(sk, Network::Testnet).to_wif(); + let imported = view + .import_wif_with_passphrase( + &wif, + None, + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(zeroize::Zeroizing::new(SENTINEL_PASSPHRASE.to_string())), + hint: None, + }, + ) + .unwrap(); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = SecretAccess::new(Arc::clone(&store), prompt, Network::Testnet); + let scope = SecretScope::SingleKey { + address: imported.address, + }; + let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); + let msg = [7u8; 32]; + + sa.with_secret_session(&scope, async |session| { + let signer = DetSigner::from_held(session.plaintext(), Network::Testnet); + // Raw single-key sign succeeds. + signer.sign_single_key_ecdsa(&msg).expect("raw sign"); + // Path-based HD surface rejects a single-key secret. + let err = signer.sign_ecdsa(&path, msg).await.expect_err("wrong kind"); + assert!(matches!(err, DetSignerError::WrongSecretKind)); + // Extended-public-key export likewise has no derivation tree to + // walk for a single key — it rejects rather than mis-deriving. + let xpub_err = signer + .extended_public_key(&path) + .await + .expect_err("wrong kind"); + assert!(matches!(xpub_err, DetSignerError::WrongSecretKind)); + Ok(()) + }) + .await + .unwrap(); + } + + /// `Debug` redacts the secret — only the kind tag and network appear, + /// never the bytes. + #[test] + fn debug_redacts_held_secret() { + let seed = Zeroizing::new([0x42u8; 64]); + let signer = DetSigner::from_held(SecretPlaintext::HdSeed(&seed), Network::Testnet); + let dbg = format!("{signer:?}"); + assert!(dbg.contains("HdSeed"), "kind tag present: {dbg}"); + assert!(!dbg.contains("42, 42, 42"), "seed bytes leaked: {dbg}"); + assert_eq!(signer.supported_methods(), &[SignerMethod::Digest]); + } +} diff --git a/src/wallet_backend/event_bridge.rs b/src/wallet_backend/event_bridge.rs new file mode 100644 index 000000000..8d8fe4416 --- /dev/null +++ b/src/wallet_backend/event_bridge.rs @@ -0,0 +1,1551 @@ +//! Bridges upstream `platform-wallet` events into DET's frame loop. +//! +//! `EventBridge` implements `platform_wallet::PlatformEventHandler` (and its +//! `dash_spv::EventHandler` supertrait). Upstream owns chain sync; this is the +//! only path by which sync/wallet state changes reach DET. Each callback is +//! sync and must not block — it updates [`ConnectionStatus`] atomics and +//! nudges the frame loop with a non-blocking `TaskResult::Refresh`. The +//! visible-screen `display_task_result` / `refresh` then re-reads state +//! through `WalletBackend` accessors, exactly as the old reconcile path did. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dash_sdk::dash_spv::network::NetworkEvent; +use dash_sdk::dash_spv::sync::{SyncEvent, SyncProgress, SyncState}; +use platform_wallet::events::{EventHandler, PlatformEventHandler, WalletEvent}; +use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, +}; +use platform_wallet::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; + +use super::coordinator_gate::CoordinatorGate; +use super::snapshot::{SnapshotStore, incoming_payment_candidates, received_outputs_for_record}; +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::core::CoreItem; +use crate::context::connection_status::{ + ConnectionStatus, ShieldedSyncProgress, ShieldedTreeProgress, +}; +use crate::model::spv_status::SpvStatus; +use crate::model::wallet::{PlatformAddressEntry, PlatformAddressUpdates, WalletSeedHash}; +use crate::utils::egui_mpsc::SenderAsync; +use dash_sdk::dpp::key_wallet::managed_account::transaction_record::TransactionRecord; + +/// Maximum number of bytes of an upstream SPV error message kept for the UI +/// tooltip. The full message is logged; the stored snippet is truncated on a +/// char boundary so it never splits a multibyte sequence. +const SPV_ERROR_SNIPPET_MAX: usize = 200; + +/// DET-authored handler registered with `PlatformWalletManager` at +/// construction. Holds only cheap shared handles so it stays `Send + Sync` +/// and clone-free on the event hot path. +pub struct EventBridge { + connection_status: Arc<ConnectionStatus>, + task_result_sender: SenderAsync<TaskResult>, + snapshots: Arc<SnapshotStore>, + /// Quorum-readiness gate, shared with `WalletBackend`. Fired here when the + /// masternode list reaches `Synced` so the Platform/identity coordinators + /// start only once quorums are resolvable. + coordinator_gate: Arc<CoordinatorGate>, + /// Phase-E push writer: AppContext's frame-safe shielded balance snapshot + /// (credits, keyed by `WalletSeedHash`). Written by + /// `on_shielded_sync_completed`; read synchronously in the frame loop via + /// `AppContext::shielded_balance_credits`. + shielded_balances: Arc<Mutex<HashMap<WalletSeedHash, u64>>>, + /// Platform-address push writer: AppContext's frame-safe platform balance snapshot + /// (duffs, keyed by `WalletSeedHash`). Written by + /// `on_platform_address_sync_completed`; read synchronously in the frame loop via + /// `AppContext::platform_balance_duffs`. Only OWNED addresses (those whose + /// coordinator wallet-id matches the registered wallet) contribute to the sum — + /// no orphan inflation. + platform_balances: Arc<Mutex<HashMap<WalletSeedHash, u64>>>, + /// Platform-address sync-cursor push writer: AppContext's frame-safe + /// `(last_sync_timestamp, sync_height)` snapshot keyed by `WalletSeedHash`. + /// Written by `on_platform_address_sync_completed` from the same pass that + /// produces `platform_balances`; read synchronously in the frame loop via + /// `AppContext::platform_sync_info` to drive the "Addresses synced" label. + platform_sync_cursors: Arc<Mutex<HashMap<WalletSeedHash, (u64, u64)>>>, +} + +impl EventBridge { + pub(super) fn new( + connection_status: Arc<ConnectionStatus>, + task_result_sender: SenderAsync<TaskResult>, + snapshots: Arc<SnapshotStore>, + coordinator_gate: Arc<CoordinatorGate>, + shielded_balances: Arc<Mutex<HashMap<WalletSeedHash, u64>>>, + platform_balances: Arc<Mutex<HashMap<WalletSeedHash, u64>>>, + platform_sync_cursors: Arc<Mutex<HashMap<WalletSeedHash, (u64, u64)>>>, + ) -> Self { + Self { + connection_status, + task_result_sender, + snapshots, + coordinator_gate, + shielded_balances, + platform_balances, + platform_sync_cursors, + } + } + + /// Mark masternodes ready and release the Platform-sync gate when the + /// masternode-list phase has reached `Synced`. Idempotent — the gate fires + /// the coordinators at most once and the flag is a cheap atomic store, so + /// re-checking on every progress event is safe. + fn check_masternodes_ready(&self, progress: &SyncProgress) { + // `masternodes()` is `Err` until the manager has started reporting; a + // not-yet-started or still-syncing phase simply leaves the gate closed. + if progress + .masternodes() + .is_ok_and(|mn| mn.state() == SyncState::Synced) + { + self.connection_status.set_masternodes_ready(true); + self.coordinator_gate.on_masternodes_ready(); + } + } + + /// Nudge the frame loop. Non-blocking; a full channel is harmless because + /// `Refresh` is idempotent and the next event coalesces. + fn nudge_refresh(&self) { + let _ = self.task_result_sender.try_send(TaskResult::Refresh); + } + + /// Emit a `ReceivedAvailableUTXOTransaction` for any freshly-seen records + /// that pay into one of our wallet addresses. + /// + /// Best-effort nudge for the Create-Asset-Lock and identity-funding screens: + /// it fires only for records with a wallet-owned output, so for an asset-lock + /// tx only when that tx carries a wallet change output. Terminal + /// `RegisteredIdentity` drives final success regardless. Non-blocking; + /// records with no wallet-owned outputs are skipped. + fn emit_received_utxos<'a, I>(&self, records: I) + where + I: IntoIterator<Item = &'a TransactionRecord>, + { + for record in records { + if let Some((tx, outpoints_with_addresses)) = received_outputs_for_record(record) { + let result = BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(tx, outpoints_with_addresses), + ); + let _ = self + .task_result_sender + .try_send(TaskResult::Success(Box::new(result))); + } + } + } + + /// Emit a `DashPayIncomingDetected` for the received outputs of freshly- + /// seen records, so the app can run the detect-match-record path for any + /// that pay into a DashPay contact-receiving address. + /// + /// The matching (and recording) is async and KV-backed, so it cannot run + /// on this sync event callback — the candidates are forwarded as a typed + /// `TaskResult` and the backend task does the owner-scoped match. A record + /// with no received outputs is skipped, so non-incoming transactions never + /// produce an event. + fn emit_incoming_payment_candidates<'a, I>(&self, records: I) + where + I: IntoIterator<Item = &'a TransactionRecord>, + { + let mut candidates = Vec::new(); + for record in records { + candidates.extend(incoming_payment_candidates(record)); + } + if candidates.is_empty() { + return; + } + let result = BackendTaskSuccessResult::DashPayIncomingDetected(candidates); + let _ = self + .task_result_sender + .try_send(TaskResult::Success(Box::new(result))); + } + + fn apply_status(&self, status: SpvStatus) { + self.connection_status.set_spv_status(status); + self.connection_status.refresh_state(); + } +} + +impl std::fmt::Debug for EventBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EventBridge").finish_non_exhaustive() + } +} + +impl EventHandler for EventBridge { + fn on_progress(&self, progress: &SyncProgress) { + let status = if progress.is_synced() { + SpvStatus::Running + } else if progress.state() == SyncState::Error { + SpvStatus::Error + } else { + SpvStatus::Syncing + }; + // Publish the per-phase heights/targets so the UI can render a + // determinate progress bar, not just a coarse status label. + self.connection_status + .set_spv_sync_progress(Some(progress.clone())); + // Release the Platform-sync gate once the masternode list is synced — + // before the (slow) filter/block scan that `is_synced()` waits on. + self.check_masternodes_ready(progress); + self.apply_status(status); + self.nudge_refresh(); + } + + fn on_sync_event(&self, event: &SyncEvent) { + match event { + SyncEvent::SyncComplete { .. } => { + self.apply_status(SpvStatus::Running); + self.nudge_refresh(); + } + SyncEvent::ManagerError { manager, error } => { + // The manager id is a useful log dimension but internal jargon — + // keep it out of the stored error text the UI surfaces. The raw + // upstream message is stored verbatim (truncated) and shown only + // as a tooltip behind a fixed user-facing label. + tracing::error!(%manager, error, "SPV manager error"); + let limit = error.floor_char_boundary(SPV_ERROR_SNIPPET_MAX); + self.connection_status + .set_spv_last_error(Some(error[..limit].to_string())); + self.apply_status(SpvStatus::Error); + self.nudge_refresh(); + } + SyncEvent::BlockProcessed { .. } + | SyncEvent::ChainLockReceived { .. } + | SyncEvent::InstantLockReceived { .. } => { + // Wallet-relevant chain progress — re-read state next frame. + self.nudge_refresh(); + } + _ => {} + } + } + + fn on_network_event(&self, event: &NetworkEvent) { + if let NetworkEvent::PeersUpdated { + connected_count, .. + } = event + { + self.connection_status + .set_spv_connected_peers((*connected_count).min(u16::MAX as usize) as u16); + self.connection_status.refresh_state(); + self.nudge_refresh(); + } + } + + fn on_wallet_event(&self, event: &WalletEvent) { + // Accumulate the event's transaction records (upstream drops + // finalized records from memory — `keep-finalized-transactions` is + // off — so the snapshot's history is event-sourced), then recompute + // and publish the affected wallet's display snapshot off the + // lock-free balance + non-blocking UTXO read. UI re-reads next frame. + let wallet_id = match event { + WalletEvent::TransactionDetected { + wallet_id, record, .. + } => { + self.snapshots + .accumulate_transactions(wallet_id, std::iter::once(record.as_ref())); + // A wallet-relevant transaction just appeared off-chain (mempool + // or direct InstantSend) — surface its received UTXOs so a + // waiting funding screen advances. + self.emit_received_utxos(std::iter::once(record.as_ref())); + // Same first-seen record drives incoming DashPay contact- + // payment detection. + self.emit_incoming_payment_candidates(std::iter::once(record.as_ref())); + *wallet_id + } + WalletEvent::BlockProcessed { + wallet_id, + inserted, + updated, + matured, + .. + } => { + self.snapshots.accumulate_transactions( + wallet_id, + inserted.iter().chain(updated.iter()).chain(matured.iter()), + ); + // `inserted` records are first-seen-in-block — a funding tx DET + // missed during the mempool window. Surface those too so the + // funding screen still advances on a confirmed-first transaction. + self.emit_received_utxos(inserted.iter()); + // Detect contact payments first observed at block time (DET + // was offline during the mempool window). + self.emit_incoming_payment_candidates(inserted.iter()); + *wallet_id + } + WalletEvent::TransactionInstantLocked { + wallet_id, txid, .. + } => { + // Upstream tracked this off-chain tx as `TransactionDetected` + // first; this event supersedes that mempool status without + // re-sending the record — upgrade the accumulated entry in + // place so the tx-history status doesn't stay "Pending" + // forever while the balance has already moved. + self.snapshots.mark_instant_locked(wallet_id, *txid); + *wallet_id + } + WalletEvent::SyncHeightAdvanced { wallet_id, .. } => *wallet_id, + WalletEvent::ChainLockProcessed { wallet_id, .. } => { + // Upstream chain-lock notification: no transaction deltas to + // accumulate, but balances may shift from unconfirmed to + // confirmed — recompute and nudge the frame loop. + *wallet_id + } + }; + self.snapshots.recompute(&wallet_id); + self.nudge_refresh(); + } + + fn on_error(&self, error: &str) { + let limit = error.floor_char_boundary(SPV_ERROR_SNIPPET_MAX); + self.connection_status + .set_spv_last_error(Some(error[..limit].to_string())); + self.apply_status(SpvStatus::Error); + self.nudge_refresh(); + } +} + +impl PlatformEventHandler for EventBridge { + fn on_platform_address_sync_completed(&self, summary: &PlatformAddressSyncSummary) { + // PUSH PRODUCER — two outputs per wallet that synced successfully: + // + // 1. Frame-safe total-balance snapshot (`platform_balances` Mutex): the + // selector reads this synchronously each frame without blocking. + // + // 2. `PlatformAddressSyncPushed` task result: `AppState` routes this to + // `AppContext::apply_platform_address_push` which populates per-address + // `wallet.platform_address_info`, keeping the per-address tab current + // without a manual Refresh (fixes the cold-start mismatch). + // + // Only OWNED addresses appear in the coordinator result (the provider + // tracks exactly the wallets registered with the manager), so no orphan + // inflation is possible. Errored wallets leave both snapshots untouched. + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + + // Single pass: resolve `WalletId → WalletSeedHash` once per wallet, drop + // any wallet whose id is not registered (no DET mapping) or has no found + // addresses. Both outputs below reuse the resolved seed hash — no double + // lookup (QA-B2-003). + let resolved: PlatformAddressUpdates = summary_ok_platform_entries(summary) + .into_iter() + .filter_map(|(wallet_id, entries)| { + if entries.is_empty() { + return None; + } + self.snapshots + .seed_hash_for(&wallet_id) + .map(|seed_hash| (seed_hash, entries)) + }) + .collect(); + + // (1) Update the frame-safe total-balance snapshot. + // + // Sum ALL credits across owned addresses BEFORE dividing (sum-then-truncate): + // total_duffs = floor( Σ credits_i / 1000 ) + // + // This matches the per-address tab, which accumulates raw `platform_credits` + // per `AccountSummary` group and converts to duffs at display time. The + // alternative — truncate-then-sum ( Σ floor(credits_i/1000) ) — under-counts + // by up to n−1 duffs when individual balances are not whole-duff multiples + // (e.g. two addresses × 500 credits → truncate-then-sum = 0 duffs, + // sum-then-truncate = 1 duff). (QA-B2-001) + // + // One duff of sub-duff remainder is accepted for the wallet total; the + // precision loss is ≤ 0.001 DASH regardless of address count. + if let Ok(mut balances) = self.platform_balances.lock() { + for (seed_hash, entries) in &resolved { + let total_credits: u64 = entries.iter().map(|e| e.balance).sum(); + balances.insert(*seed_hash, total_credits / CREDITS_PER_DUFF); + } + } + + // (1b) Update the frame-safe sync-cursor snapshot so the "Addresses + // synced" label tracks every completed pass — independent of whether it + // found funds, so a never-funded or emptied wallet reads "synced", not + // "never synced". Resolve the seed hashes before locking (mirrors `(1)`). + let cursor_updates: Vec<(WalletSeedHash, (u64, u64))> = summary_ok_sync_cursors(summary) + .into_iter() + .filter_map(|(wallet_id, cursor)| { + self.snapshots + .seed_hash_for(&wallet_id) + .map(|seed_hash| (seed_hash, cursor)) + }) + .collect(); + if let Ok(mut cursors) = self.platform_sync_cursors.lock() { + for (seed_hash, cursor) in cursor_updates { + cursors.insert(seed_hash, cursor); + } + } + + // (1c) The cursor (1b) advances on every Ok pass, but the balance + // snapshot (1) only updates for found-non-empty wallets. On a true + // tree-absence (chain/devnet reset — addresses leave the found-set) the + // label reads "synced just now" beside the stale cached total until a + // later pass re-finds the address or the app restarts. Surface that + // divergence in logs. One summary line per pass; fires only on the rare + // reset (empty found-set + non-zero cached balance), never on a normal + // spend-to-zero (which keeps a zero node in `found`). + let stale = stale_after_empty_found( + summary, + |wallet_id| self.snapshots.seed_hash_for(wallet_id), + |seed_hash| { + self.platform_balances + .lock() + .ok() + .and_then(|balances| balances.get(seed_hash).copied()) + .unwrap_or(0) + }, + ); + if !stale.is_empty() { + let wallets: Vec<String> = stale.iter().map(hex::encode).collect(); + tracing::warn!( + wallets = ?wallets, + "Platform address sync completed with an empty found-set while a \ + non-zero cached balance remains (possible chain/tree reset); the \ + displayed balance may be stale until the next sync re-finds the \ + address or the app restarts." + ); + } + + // (2) Emit the per-address update so `wallet.platform_address_info` stays + // current without a manual Refresh. Non-blocking; a full channel means + // the coordinator retries on the next ~15 s pass. The frame-safe total + // (step 1) is already written. + if !resolved.is_empty() { + let result = BackendTaskSuccessResult::PlatformAddressSyncPushed { updates: resolved }; + let _ = self + .task_result_sender + .try_send(TaskResult::Success(Box::new(result))); + } + + self.nudge_refresh(); + } + + fn on_wallet_skipped_on_load( + &self, + wallet_id: platform_wallet::wallet::platform_wallet::WalletId, + reason: &platform_wallet::manager::load_outcome::SkipReason, + ) { + // Public wallet id + structural reason only; never a secret. The + // user-facing banner is raised in `register_persisted_wallets`. + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %reason, + "A saved wallet was skipped on load because its stored data is corrupt" + ); + } + + fn on_shielded_sync_progress(&self, cumulative_scanned: u64, block_height: u64) { + // Downloaded-notes progress for an in-flight pass — drives the + // shielded tab's "scanning" indicator. Network-scoped (one pass covers + // every viewing key), so it lands on ConnectionStatus, not per-wallet. + self.connection_status + .set_shielded_sync_progress(Some(ShieldedSyncProgress { + cumulative_scanned, + block_height, + })); + self.nudge_refresh(); + } + + fn on_shielded_tree_progress(&self, leaves_committed: u64, total_target: u64) { + // Committed-to-tree ("checked") progress for an in-flight pass. + self.connection_status + .set_shielded_tree_progress(Some(ShieldedTreeProgress { + leaves_committed, + total_target, + })); + self.nudge_refresh(); + } + + fn on_shielded_sync_completed(&self, summary: &ShieldedSyncPassSummary) { + // PUSH PRODUCER (Phase E): write each successfully-synced wallet's + // shielded balance into AppContext's frame-safe snapshot so the frame + // loop reads the current balance with no blocking coordinator call. + // The summary is keyed by upstream `WalletId`; map it to DET's + // `WalletSeedHash` through the snapshot registry. Skipped/errored + // wallets leave their prior balance untouched. + if let Ok(mut balances) = self.shielded_balances.lock() { + for (wallet_id, balance) in summary_ok_balances(summary) { + if let Some(seed_hash) = self.snapshots.seed_hash_for(&wallet_id) { + balances.insert(seed_hash, balance); + } + } + } + // The pass finished — clear the in-flight progress so the UI stops + // rendering the "scanning / checking" indicators. + self.connection_status.set_shielded_sync_progress(None); + self.connection_status.set_shielded_tree_progress(None); + self.nudge_refresh(); + } +} + +/// Collect per-address `(wallet_id, entries)` for every wallet whose sync +/// completed successfully in `summary`. Each entry carries the raw 20-byte +/// P2PKH hash, credits balance, nonce, and the DIP-17 `(account, index)` +/// coordinates from the sync tag — so the receiver can register the address's +/// derivation path exactly instead of reverse-deriving it. +/// +/// `Err` outcomes are skipped so their cached per-address data is left +/// untouched. Pure — no I/O — so it is unit-testable without a coordinator. +fn summary_ok_platform_entries( + summary: &PlatformAddressSyncSummary, +) -> Vec<([u8; 32], Vec<PlatformAddressEntry>)> { + summary + .wallet_results + .iter() + .filter_map(|(wallet_id, outcome)| match outcome { + WalletSyncOutcome::Ok(result) => { + let entries: Vec<PlatformAddressEntry> = result + .found + .iter() + .map( + |(((_, account, index), p2pkh), funds)| PlatformAddressEntry { + hash: p2pkh.to_bytes(), + balance: funds.balance, + nonce: funds.nonce, + account: *account, + index: Some(*index), + }, + ) + .collect(); + Some((*wallet_id, entries)) + } + WalletSyncOutcome::Err(_) => None, + }) + .collect() +} + +/// `(last_sync_timestamp, sync_height)` for every wallet whose pass completed +/// successfully, keyed by raw wallet id — independent of whether the pass found +/// funds. The cursor tracks "we synced", not "we found funds", so a +/// never-funded or emptied wallet reads "synced" rather than "never synced". +/// +/// The timestamp falls back to the pass wall-clock (`sync_unix_seconds`) when +/// the per-wallet result carries none, so a completed pass always yields a +/// non-zero cursor. Pure — no I/O — so it is unit-testable without a +/// coordinator. +fn summary_ok_sync_cursors(summary: &PlatformAddressSyncSummary) -> Vec<([u8; 32], (u64, u64))> { + summary + .wallet_results + .iter() + .filter_map(|(wallet_id, outcome)| match outcome { + WalletSyncOutcome::Ok(result) => { + let timestamp = if result.new_sync_timestamp > 0 { + result.new_sync_timestamp + } else { + summary.sync_unix_seconds + }; + Some((*wallet_id, (timestamp, result.new_sync_height))) + } + WalletSyncOutcome::Err(_) => None, + }) + .collect() +} + +/// Wallets whose pass completed successfully with an empty found-set while a +/// non-zero balance is still cached — the chain/tree-reset signal where the +/// advancing cursor disagrees with the stale found-gated balance snapshot. +/// +/// `resolve` maps an upstream wallet id to its DET seed hash (dropping +/// unregistered ids); `cached_balance` reads the snapshot total. Excludes the +/// benign cases: a non-empty found-set (normal pass, incl. spend-to-zero) and a +/// zero cached balance (never-funded). Pure — unit-testable without a +/// coordinator. +fn stale_after_empty_found( + summary: &PlatformAddressSyncSummary, + resolve: impl Fn(&[u8; 32]) -> Option<WalletSeedHash>, + cached_balance: impl Fn(&WalletSeedHash) -> u64, +) -> Vec<WalletSeedHash> { + summary + .wallet_results + .iter() + .filter_map(|(wallet_id, outcome)| match outcome { + WalletSyncOutcome::Ok(result) if result.found.is_empty() => { + let seed_hash = resolve(wallet_id)?; + (cached_balance(&seed_hash) > 0).then_some(seed_hash) + } + _ => None, + }) + .collect() +} + +/// Collect `(wallet_id, balance_credits)` for every wallet that synced +/// successfully in `summary`. Skipped (no bound shielded sub-wallet) and +/// errored wallets are excluded so their snapshot balance is left untouched. +/// Pure — no I/O — so it is unit-testable without a coordinator or a +/// registered wallet. +fn summary_ok_balances(summary: &ShieldedSyncPassSummary) -> Vec<([u8; 32], u64)> { + summary + .wallet_results + .iter() + .filter_map(|(wallet_id, outcome)| match outcome { + WalletShieldedOutcome::Ok(sync) => Some((*wallet_id, sync.balance_total())), + WalletShieldedOutcome::Skipped | WalletShieldedOutcome::Err(_) => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Shorthand for the shielded-balance snapshot handle the helpers wire. + type ShieldedBalancesHandle = Arc<Mutex<HashMap<WalletSeedHash, u64>>>; + /// Shorthand for the platform sync-cursor snapshot handle the helpers wire. + type PlatformCursorsHandle = Arc<Mutex<HashMap<WalletSeedHash, (u64, u64)>>>; + use crate::utils::egui_mpsc::EguiMpscAsync; + use dash_sdk::dpp::dashcore::{Address, Network, PublicKey, Transaction, TxOut}; + use dash_sdk::dpp::key_wallet::WalletCoreBalance; + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ + OutputDetail, OutputRole, TransactionDirection, + }; + use dash_sdk::dpp::key_wallet::transaction_checking::TransactionContext; + use dash_sdk::dpp::key_wallet::transaction_checking::transaction_router::TransactionType; + use std::collections::BTreeMap; + use std::sync::Arc; + + fn make_bridge() -> ( + EventBridge, + Arc<ConnectionStatus>, + tokio::sync::mpsc::Receiver<TaskResult>, + ) { + let (bridge, cs, rx, _gate) = make_bridge_with_gate(); + (bridge, cs, rx) + } + + fn make_bridge_with_gate() -> ( + EventBridge, + Arc<ConnectionStatus>, + tokio::sync::mpsc::Receiver<TaskResult>, + Arc<CoordinatorGate>, + ) { + let cs = Arc::new(ConnectionStatus::new()); + let (tx, rx) = + tokio::sync::mpsc::channel::<TaskResult>(8).with_egui_ctx(egui::Context::default()); + let gate = Arc::new(CoordinatorGate::default()); + let bridge = EventBridge::new( + Arc::clone(&cs), + tx, + Arc::new(SnapshotStore::new()), + Arc::clone(&gate), + Arc::new(Mutex::new(HashMap::new())), + Arc::new(Mutex::new(HashMap::new())), + Arc::new(Mutex::new(HashMap::new())), + ); + (bridge, cs, rx, gate) + } + + /// Like [`make_bridge`] but also returns the shielded-balances snapshot Arc + /// so shielded-event tests can assert the push writer's effect. + fn make_bridge_with_balances() -> ( + EventBridge, + Arc<ConnectionStatus>, + tokio::sync::mpsc::Receiver<TaskResult>, + ShieldedBalancesHandle, + ) { + let cs = Arc::new(ConnectionStatus::new()); + let (tx, rx) = + tokio::sync::mpsc::channel::<TaskResult>(8).with_egui_ctx(egui::Context::default()); + let gate = Arc::new(CoordinatorGate::default()); + let balances = Arc::new(Mutex::new(HashMap::new())); + let bridge = EventBridge::new( + Arc::clone(&cs), + tx, + Arc::new(SnapshotStore::new()), + gate, + Arc::clone(&balances), + Arc::new(Mutex::new(HashMap::new())), + Arc::new(Mutex::new(HashMap::new())), + ); + (bridge, cs, rx, balances) + } + + /// Like [`make_bridge`] but also returns the platform-balances and + /// sync-cursor snapshot Arcs so platform-address-sync-event tests can + /// assert the push writer's effect on both. + fn make_bridge_with_platform_balances() -> ( + EventBridge, + Arc<ConnectionStatus>, + tokio::sync::mpsc::Receiver<TaskResult>, + ShieldedBalancesHandle, // same type: Arc<Mutex<HashMap<WalletSeedHash, u64>>> + PlatformCursorsHandle, + ) { + let cs = Arc::new(ConnectionStatus::new()); + let (tx, rx) = + tokio::sync::mpsc::channel::<TaskResult>(8).with_egui_ctx(egui::Context::default()); + let gate = Arc::new(CoordinatorGate::default()); + let platform_balances = Arc::new(Mutex::new(HashMap::new())); + let platform_sync_cursors = Arc::new(Mutex::new(HashMap::new())); + let bridge = EventBridge::new( + Arc::clone(&cs), + tx, + Arc::new(SnapshotStore::new()), + gate, + Arc::new(Mutex::new(HashMap::new())), + Arc::clone(&platform_balances), + Arc::clone(&platform_sync_cursors), + ); + (bridge, cs, rx, platform_balances, platform_sync_cursors) + } + + fn drained_refresh(rx: &mut tokio::sync::mpsc::Receiver<TaskResult>) -> bool { + let mut saw_refresh = false; + while let Ok(r) = rx.try_recv() { + if matches!(r, TaskResult::Refresh) { + saw_refresh = true; + } + } + saw_refresh + } + + /// A funding address paying into our wallet. + fn funding_address() -> Address { + let pubkey = PublicKey::from_slice(&[0x02; 33]).unwrap(); + Address::p2pkh(&pubkey, Network::Testnet) + } + + /// A `TransactionDetected` record whose single output pays `value` into + /// `address` (role `Received`) — the funding-payment shape SPV reports. + fn received_record(address: &Address, value: u64) -> TransactionRecord { + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(address.clone()), + value, + }], + value as i64, + ) + } + + fn transaction_detected(record: TransactionRecord) -> WalletEvent { + WalletEvent::TransactionDetected { + wallet_id: [9u8; 32], + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: Vec::new(), + } + } + + /// Drain the channel and return the addresses of the first + /// `ReceivedAvailableUTXOTransaction` produced, if any. + fn drained_received_utxo_addresses( + rx: &mut tokio::sync::mpsc::Receiver<TaskResult>, + ) -> Option<Vec<Address>> { + while let Ok(r) = rx.try_recv() { + if let TaskResult::Success(result) = r + && let BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), + ) = *result + { + return Some( + outpoints_with_addresses + .into_iter() + .map(|(_, _, address)| address) + .collect(), + ); + } + } + None + } + + #[test] + fn sync_complete_sets_running_and_nudges() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_sync_event(&SyncEvent::SyncComplete { + header_tip: 100, + cycle: 0, + }); + assert_eq!(cs.spv_status(), SpvStatus::Running); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn manager_error_sets_error_status_and_records_message() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_sync_event(&SyncEvent::ManagerError { + manager: dash_sdk::dash_spv::sync::ManagerIdentifier::BlockHeader, + error: "boom".to_string(), + }); + assert_eq!(cs.spv_status(), SpvStatus::Error); + assert!(cs.spv_last_error().is_some_and(|e| e.contains("boom"))); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn peers_updated_nudges_refresh() { + let (bridge, _cs, mut rx) = make_bridge(); + bridge.on_network_event(&NetworkEvent::PeersUpdated { + connected_count: 3, + addresses: Vec::new(), + best_height: None, + }); + // ConnectionStatus has no public peer-count getter; its own tests + // cover the internal mutation. Here we assert the frame-loop nudge. + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn progress_default_maps_to_syncing() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_progress(&SyncProgress::default()); + // A default (no-manager) progress is neither synced nor errored. + assert_eq!(cs.spv_status(), SpvStatus::Syncing); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn progress_publishes_per_phase_heights() { + use dash_sdk::dash_spv::sync::{BlockHeadersProgress, ProgressPercentage}; + + let (bridge, cs, mut rx) = make_bridge(); + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Syncing); + headers.update_target_height(10_000); + headers.update_tip_height(4_200); + let mut progress = SyncProgress::default(); + progress.update_headers(headers); + + bridge.on_progress(&progress); + + assert_eq!(cs.spv_status(), SpvStatus::Syncing); + let stored = cs.spv_sync_progress().expect("progress published"); + let stored_headers = stored.headers().expect("headers phase present"); + assert_eq!(stored_headers.target_height(), 10_000); + assert_eq!(stored_headers.current_height(), 4_200); + assert!(drained_refresh(&mut rx)); + } + + /// A `SyncProgress` whose masternode phase is in `state`, headers still + /// mid-sync (so the overall pipeline is not yet "synced"). + fn progress_with_masternode_state(state: SyncState) -> SyncProgress { + use dash_sdk::dash_spv::sync::{BlockHeadersProgress, MasternodesProgress}; + + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Syncing); + headers.update_target_height(10_000); + headers.update_tip_height(4_200); + + let mut mn = MasternodesProgress::default(); + mn.set_state(state); + + let mut progress = SyncProgress::default(); + progress.update_headers(headers); + progress.update_masternodes(mn); + progress + } + + #[test] + fn masternodes_synced_progress_opens_gate_and_sets_flag() { + let (bridge, cs, mut rx, gate) = make_bridge_with_gate(); + // Arm the gate so it has a coordinator action to run (mirrors + // `WalletBackend::start`). A bare counter stands in for the real + // coordinator starts. + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_for_action = Arc::clone(&calls); + gate.arm(Box::new(move || { + calls_for_action.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "coordinators must not start before masternodes are synced" + ); + + bridge.on_progress(&progress_with_masternode_state(SyncState::Synced)); + + assert!( + cs.masternodes_ready(), + "a synced masternode phase must set the readiness flag" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a synced masternode phase must open the gate and start coordinators once" + ); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn masternodes_still_syncing_keeps_gate_closed() { + let (bridge, cs, _rx, gate) = make_bridge_with_gate(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_for_action = Arc::clone(&calls); + gate.arm(Box::new(move || { + calls_for_action.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + + bridge.on_progress(&progress_with_masternode_state(SyncState::Syncing)); + + assert!( + !cs.masternodes_ready(), + "a still-syncing masternode phase must not set the readiness flag" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "coordinators must stay closed while masternodes are still syncing" + ); + } + + #[test] + fn on_error_sets_error_and_records_message() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_error("network down"); + assert_eq!(cs.spv_status(), SpvStatus::Error); + assert!( + cs.spv_last_error() + .is_some_and(|e| e.contains("network down")) + ); + assert!(drained_refresh(&mut rx)); + } + + /// Drain the channel and return the first batch of incoming-payment + /// candidates the bridge produced, if any. + fn drained_incoming_candidates( + rx: &mut tokio::sync::mpsc::Receiver<TaskResult>, + ) -> Option<Vec<crate::model::dashpay::DetectedIncomingOutput>> { + while let Ok(r) = rx.try_recv() { + if let TaskResult::Success(result) = r + && let BackendTaskSuccessResult::DashPayIncomingDetected(candidates) = *result + { + return Some(candidates); + } + } + None + } + + #[test] + fn transaction_detected_emits_incoming_candidate() { + let (bridge, _cs, mut rx) = make_bridge(); + let funding = funding_address(); + + bridge.on_wallet_event(&transaction_detected(received_record(&funding, 77_000))); + + let candidates = + drained_incoming_candidates(&mut rx).expect("a received output yields a candidate"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].address, funding.to_string()); + assert_eq!(candidates[0].amount_duffs, 77_000); + } + + /// `TransactionInstantLocked` carries only a `txid`, no + /// `TransactionRecord`. It must still upgrade the already tracked + /// mempool record's status, not leave the tx-history row "Pending" + /// forever after the balance has already moved to Confirmed. + #[test] + fn transaction_instant_locked_upgrades_pending_record_and_nudges() { + use dash_sdk::dpp::dashcore::InstantLock; + + let (bridge, _cs, mut rx) = make_bridge(); + let funding = funding_address(); + let record = received_record(&funding, 100_000); + let txid = record.txid; + let wallet_id = [9u8; 32]; + + bridge.on_wallet_event(&transaction_detected(record)); + assert_eq!( + bridge.snapshots.transaction_status(&wallet_id, &txid), + Some(crate::model::wallet::TransactionStatus::Unconfirmed), + "the first-seen record starts Unconfirmed" + ); + + bridge.on_wallet_event(&WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }); + + assert_eq!( + bridge.snapshots.transaction_status(&wallet_id, &txid), + Some(crate::model::wallet::TransactionStatus::InstantSendLocked), + "the InstantLock event must upgrade the accumulated record's status" + ); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn block_processed_inserted_emits_incoming_candidate() { + let (bridge, _cs, mut rx) = make_bridge(); + let funding = funding_address(); + + bridge.on_wallet_event(&WalletEvent::BlockProcessed { + wallet_id: [9u8; 32], + height: 2_000, + chain_lock: None, + inserted: vec![received_record(&funding, 33_000)], + updated: Vec::new(), + matured: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: Vec::new(), + }); + + let candidates = drained_incoming_candidates(&mut rx) + .expect("a confirmed-first received output yields a candidate"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].amount_duffs, 33_000); + } + + #[test] + fn transaction_detected_emits_received_utxo_for_funding_address() { + let (bridge, _cs, mut rx) = make_bridge(); + let funding = funding_address(); + + bridge.on_wallet_event(&transaction_detected(received_record(&funding, 100_000))); + + let addresses = + drained_received_utxo_addresses(&mut rx).expect("a received UTXO event is produced"); + assert!( + addresses.contains(&funding), + "the funding address must surface so the waiting screen advances" + ); + } + + #[test] + fn block_processed_inserted_emits_received_utxo() { + let (bridge, _cs, mut rx) = make_bridge(); + let funding = funding_address(); + + bridge.on_wallet_event(&WalletEvent::BlockProcessed { + wallet_id: [9u8; 32], + height: 1_000, + chain_lock: None, + inserted: vec![received_record(&funding, 50_000)], + updated: Vec::new(), + matured: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: Vec::new(), + }); + + let addresses = drained_received_utxo_addresses(&mut rx) + .expect("a confirmed-first funding tx still produces the event"); + assert!(addresses.contains(&funding)); + } + + #[test] + fn shielded_sync_progress_event_sets_connection_status() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_shielded_sync_progress(4_096, 123_456); + let got = cs + .shielded_sync_progress() + .expect("downloaded-notes progress published"); + assert_eq!(got.cumulative_scanned, 4_096); + assert_eq!(got.block_height, 123_456); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn shielded_tree_progress_event_sets_connection_status() { + let (bridge, cs, mut rx) = make_bridge(); + bridge.on_shielded_tree_progress(2_048, 10_000); + let got = cs + .shielded_tree_progress() + .expect("committed-to-tree progress published"); + assert_eq!(got.leaves_committed, 2_048); + assert_eq!(got.total_target, 10_000); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn shielded_sync_completed_clears_progress_and_nudges() { + let (bridge, cs, mut rx, balances) = make_bridge_with_balances(); + // Simulate a pass mid-flight, then complete it. + bridge.on_shielded_sync_progress(10, 20); + bridge.on_shielded_tree_progress(30, 40); + assert!(cs.shielded_sync_progress().is_some()); + assert!(cs.shielded_tree_progress().is_some()); + + bridge.on_shielded_sync_completed(&ShieldedSyncPassSummary::default()); + + assert!( + cs.shielded_sync_progress().is_none(), + "completion must clear the downloaded-notes progress" + ); + assert!( + cs.shielded_tree_progress().is_none(), + "completion must clear the committed-to-tree progress" + ); + // No wallet registered in the snapshot store, so nothing is written. + assert!( + balances.lock().unwrap().is_empty(), + "an unresolved wallet id must not write a balance" + ); + assert!(drained_refresh(&mut rx)); + } + + #[test] + fn summary_ok_balances_extracts_only_successful_wallets() { + use platform_wallet::wallet::shielded::ShieldedSyncSummary; + + let mut summary = ShieldedSyncPassSummary::default(); + let ok = ShieldedSyncSummary { + balances: BTreeMap::from([(0u32, 1_000u64), (1u32, 234u64)]), + ..Default::default() + }; + summary + .wallet_results + .insert([1u8; 32], WalletShieldedOutcome::Ok(ok)); + summary + .wallet_results + .insert([2u8; 32], WalletShieldedOutcome::Skipped); + summary + .wallet_results + .insert([3u8; 32], WalletShieldedOutcome::Err("boom".to_string())); + + let got = summary_ok_balances(&summary); + assert_eq!( + got, + vec![([1u8; 32], 1_234)], + "only the Ok wallet contributes its summed balance_total" + ); + } + + /// An empty `PlatformAddressSyncSummary` must nudge the refresh loop and not + /// write any balance (nothing to resolve in the snapshot registry). + #[test] + fn platform_address_sync_completed_empty_summary_nudges_only() { + use platform_wallet::manager::platform_address_sync::PlatformAddressSyncSummary; + + let (bridge, _cs, mut rx, platform_balances, platform_sync_cursors) = + make_bridge_with_platform_balances(); + bridge.on_platform_address_sync_completed(&PlatformAddressSyncSummary::default()); + + assert!( + platform_balances.lock().unwrap().is_empty(), + "an empty summary must not write any balance" + ); + assert!( + platform_sync_cursors.lock().unwrap().is_empty(), + "an empty summary must not write any sync cursor" + ); + assert!( + drained_refresh(&mut rx), + "the frame loop must be nudged even on an empty summary" + ); + } + + /// A `WalletSyncOutcome::Err` must not write a balance for the failed wallet, + /// but the frame loop must still be nudged. + #[test] + fn platform_address_sync_completed_error_outcome_skipped() { + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + + let (bridge, _cs, mut rx, platform_balances, platform_sync_cursors) = + make_bridge_with_platform_balances(); + let mut summary = PlatformAddressSyncSummary::default(); + summary.wallet_results.insert( + [7u8; 32], + WalletSyncOutcome::Err("network error".to_string()), + ); + bridge.on_platform_address_sync_completed(&summary); + + assert!( + platform_balances.lock().unwrap().is_empty(), + "an errored wallet must not write a balance" + ); + assert!( + platform_sync_cursors.lock().unwrap().is_empty(), + "an errored wallet must not write a sync cursor" + ); + assert!(drained_refresh(&mut rx)); + } + + /// HAPPY PATH (QA-B-002): `summary_ok_platform_entries` extracts the correct + /// per-address data for `Ok` outcomes and skips `Err` outcomes entirely. + #[test] + fn summary_ok_platform_entries_extracts_only_successful_wallets() { + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + use dash_sdk::platform::address_sync::{AddressFunds, AddressSyncResult}; + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + use platform_wallet::wallet::PlatformAddressTag; + + let wallet_id_ok: [u8; 32] = [1u8; 32]; + let wallet_id_err: [u8; 32] = [2u8; 32]; + + let p2pkh_a = PlatformP2PKHAddress::new([0xAAu8; 20]); + let p2pkh_b = PlatformP2PKHAddress::new([0xBBu8; 20]); + + let mut result_ok: AddressSyncResult<PlatformAddressTag, PlatformP2PKHAddress> = + AddressSyncResult::default(); + result_ok.found.insert( + ((wallet_id_ok, 0u32, 0u32), p2pkh_a), + AddressFunds { + nonce: 1, + balance: 500_000, + as_of_height: 0, + }, + ); + result_ok.found.insert( + ((wallet_id_ok, 0u32, 1u32), p2pkh_b), + AddressFunds { + nonce: 2, + balance: 300_000, + as_of_height: 0, + }, + ); + + let mut summary = PlatformAddressSyncSummary::default(); + summary + .wallet_results + .insert(wallet_id_ok, WalletSyncOutcome::Ok(result_ok)); + summary.wallet_results.insert( + wallet_id_err, + WalletSyncOutcome::Err("network timeout".to_string()), + ); + + let got = summary_ok_platform_entries(&summary); + + // Only the Ok wallet should produce entries. + assert_eq!(got.len(), 1, "only one wallet had an Ok outcome"); + let (got_wallet_id, entries) = &got[0]; + assert_eq!(got_wallet_id, &wallet_id_ok); + assert_eq!(entries.len(), 2); + + // Check both addresses are present (order not guaranteed for BTreeMap). + let mut hash_set: Vec<[u8; 20]> = entries.iter().map(|e| e.hash).collect(); + hash_set.sort(); + assert!(hash_set.contains(&[0xAAu8; 20])); + assert!(hash_set.contains(&[0xBBu8; 20])); + + // Verify funds and DIP-17 coordinates are passed through correctly. + let entry_a = entries + .iter() + .find(|e| e.hash == [0xAAu8; 20]) + .expect("entry for p2pkh_a"); + assert_eq!(entry_a.balance, 500_000, "balance_credits for p2pkh_a"); + assert_eq!(entry_a.nonce, 1, "nonce for p2pkh_a"); + assert_eq!(entry_a.account, 0, "account for p2pkh_a"); + assert_eq!(entry_a.index, Some(0), "index for p2pkh_a"); + + let entry_b = entries + .iter() + .find(|e| e.hash == [0xBBu8; 20]) + .expect("entry for p2pkh_b"); + assert_eq!(entry_b.balance, 300_000, "balance_credits for p2pkh_b"); + assert_eq!(entry_b.nonce, 2, "nonce for p2pkh_b"); + assert_eq!(entry_b.account, 0, "account for p2pkh_b"); + assert_eq!(entry_b.index, Some(1), "index for p2pkh_b"); + } + + /// `summary_ok_sync_cursors` yields a `(timestamp, height)` cursor for every + /// Ok wallet — including one that found nothing, so a never-funded/emptied + /// wallet reads "synced" not "never synced" — skips only errored wallets, and + /// backfills a missing per-wallet timestamp from the pass clock. + #[test] + fn summary_ok_sync_cursors_extracts_cursor_with_timestamp_fallback() { + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + use dash_sdk::platform::address_sync::{AddressFunds, AddressSyncResult}; + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + use platform_wallet::wallet::PlatformAddressTag; + + let wallet_with_ts: [u8; 32] = [1u8; 32]; + let wallet_no_ts: [u8; 32] = [2u8; 32]; + let wallet_err: [u8; 32] = [3u8; 32]; + let wallet_empty: [u8; 32] = [4u8; 32]; + let p2pkh = PlatformP2PKHAddress::new([0xAAu8; 20]); + + // Result carries its own timestamp and height — passed straight through. + let mut with_ts: AddressSyncResult<PlatformAddressTag, PlatformP2PKHAddress> = + AddressSyncResult { + new_sync_timestamp: 1_700, + new_sync_height: 900, + ..Default::default() + }; + with_ts.found.insert( + ((wallet_with_ts, 0u32, 0u32), p2pkh), + AddressFunds { + nonce: 1, + balance: 1, + as_of_height: 0, + }, + ); + + // Found an address but the result carries no timestamp (0) — the pass + // wall-clock backfills it so the cursor is non-zero; height stays 0. + let mut no_ts: AddressSyncResult<PlatformAddressTag, PlatformP2PKHAddress> = + AddressSyncResult::default(); + no_ts.found.insert( + ((wallet_no_ts, 0u32, 0u32), p2pkh), + AddressFunds { + nonce: 1, + balance: 1, + as_of_height: 0, + }, + ); + + let mut summary = PlatformAddressSyncSummary { + sync_unix_seconds: 2_500, + ..Default::default() + }; + summary + .wallet_results + .insert(wallet_with_ts, WalletSyncOutcome::Ok(with_ts)); + summary + .wallet_results + .insert(wallet_no_ts, WalletSyncOutcome::Ok(no_ts)); + summary + .wallet_results + .insert(wallet_err, WalletSyncOutcome::Err("boom".to_string())); + summary.wallet_results.insert( + wallet_empty, + WalletSyncOutcome::Ok(AddressSyncResult::default()), + ); + + let cursors: std::collections::BTreeMap<[u8; 32], (u64, u64)> = + summary_ok_sync_cursors(&summary).into_iter().collect(); + + assert_eq!(cursors.len(), 3, "only the errored wallet is skipped"); + assert_eq!(cursors.get(&wallet_with_ts), Some(&(1_700, 900))); + assert_eq!( + cursors.get(&wallet_no_ts), + Some(&(2_500, 0)), + "missing per-wallet timestamp falls back to the pass clock" + ); + assert_eq!( + cursors.get(&wallet_empty), + Some(&(2_500, 0)), + "a successfully-synced wallet that found nothing still reads as synced" + ); + assert!( + !cursors.contains_key(&wallet_err), + "an errored pass writes no cursor" + ); + } + + /// `stale_after_empty_found` flags only the chain/tree-reset signal — an Ok + /// pass with an empty found-set AND a non-zero cached balance, resolvable to + /// a DET wallet. It excludes the benign cases: a non-empty found-set (normal + /// pass / spend-to-zero), a zero cached balance (never-funded), an errored + /// pass, and an unregistered wallet id. + #[test] + fn stale_after_empty_found_targets_only_reset_wallets() { + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + use dash_sdk::platform::address_sync::{AddressFunds, AddressSyncResult}; + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + use platform_wallet::wallet::PlatformAddressTag; + + let reset: [u8; 32] = [1u8; 32]; + let never_funded: [u8; 32] = [2u8; 32]; + let normal: [u8; 32] = [3u8; 32]; + let errored: [u8; 32] = [4u8; 32]; + let unresolved: [u8; 32] = [5u8; 32]; + + let empty = || AddressSyncResult::<PlatformAddressTag, PlatformP2PKHAddress>::default(); + let mut non_empty = empty(); + non_empty.found.insert( + ( + (normal, 0u32, 0u32), + PlatformP2PKHAddress::new([0xAAu8; 20]), + ), + AddressFunds { + nonce: 1, + balance: 100, + as_of_height: 0, + }, + ); + + let mut summary = PlatformAddressSyncSummary::default(); + summary + .wallet_results + .insert(reset, WalletSyncOutcome::Ok(empty())); + summary + .wallet_results + .insert(never_funded, WalletSyncOutcome::Ok(empty())); + summary + .wallet_results + .insert(normal, WalletSyncOutcome::Ok(non_empty)); + summary + .wallet_results + .insert(errored, WalletSyncOutcome::Err("boom".to_string())); + summary + .wallet_results + .insert(unresolved, WalletSyncOutcome::Ok(empty())); + + let cached: std::collections::BTreeMap<[u8; 32], u64> = [ + (reset, 100u64), + (never_funded, 0), + (normal, 100), + (unresolved, 100), + ] + .into_iter() + .collect(); + + let flagged = stale_after_empty_found( + &summary, + |w| (*w != unresolved).then_some(*w), + |sh| cached.get(sh).copied().unwrap_or(0), + ); + + assert_eq!( + flagged, + vec![reset], + "only the empty-found, non-zero-cached, resolvable wallet is flagged" + ); + } + + /// Prove the arithmetic is correct for pathological balances (QA-B2-001): + /// `sum-then-truncate` must be used, not `truncate-then-sum`. + /// + /// Two addresses of 500 credits each: + /// - `sum-then-truncate`: floor((500+500)/1000) = **1 duff** ✓ + /// - `truncate-then-sum`: floor(500/1000) + floor(500/1000) = **0 duffs** ✗ + /// + /// The selector and per-address tab must agree on 1 duff. + #[test] + fn platform_total_duffs_uses_sum_then_truncate_not_truncate_then_sum() { + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + use dash_sdk::platform::address_sync::{AddressFunds, AddressSyncResult}; + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + use platform_wallet::wallet::PlatformAddressTag; + + let wallet_id: [u8; 32] = [1u8; 32]; + let p2pkh_a = PlatformP2PKHAddress::new([0xAAu8; 20]); + let p2pkh_b = PlatformP2PKHAddress::new([0xBBu8; 20]); + + // 500 credits < 1 duff each; individually truncated to 0, but the + // wallet holds exactly 1000 credits = 1 duff in total. + let mut result: AddressSyncResult<PlatformAddressTag, PlatformP2PKHAddress> = + AddressSyncResult::default(); + result.found.insert( + ((wallet_id, 0u32, 0u32), p2pkh_a), + AddressFunds { + nonce: 1, + balance: 500, + as_of_height: 0, + }, + ); + result.found.insert( + ((wallet_id, 0u32, 1u32), p2pkh_b), + AddressFunds { + nonce: 2, + balance: 500, + as_of_height: 0, + }, + ); + + let mut summary = PlatformAddressSyncSummary::default(); + summary + .wallet_results + .insert(wallet_id, WalletSyncOutcome::Ok(result)); + + let got = summary_ok_platform_entries(&summary); + assert_eq!(got.len(), 1); + let (_, entries) = &got[0]; + assert_eq!(entries.len(), 2); + + // sum-then-truncate (correct): 1 duff + let total_credits: u64 = entries.iter().map(|e| e.balance).sum(); + assert_eq!(total_credits, 1000, "total credits = 1000"); + assert_eq!( + total_credits / CREDITS_PER_DUFF, + 1, + "sum-then-truncate gives 1 duff (correct)" + ); + + // truncate-then-sum (wrong): 0 duffs — demonstrate the bug we fixed + let wrong_total: u64 = entries.iter().map(|e| e.balance / CREDITS_PER_DUFF).sum(); + assert_eq!( + wrong_total, 0, + "truncate-then-sum gives 0 duffs (this was the bug)" + ); + } + + /// An `Ok` outcome for an UNREGISTERED wallet must not write a balance + /// (no entry in the snapshot registry to map `WalletId` → `WalletSeedHash`). + #[test] + fn platform_address_sync_completed_unregistered_wallet_not_written() { + use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + use dash_sdk::platform::address_sync::{AddressFunds, AddressSyncResult}; + use platform_wallet::manager::platform_address_sync::{ + PlatformAddressSyncSummary, WalletSyncOutcome, + }; + use platform_wallet::wallet::PlatformAddressTag; + + let (bridge, _cs, mut rx, platform_balances, platform_sync_cursors) = + make_bridge_with_platform_balances(); + + // Build a result with two found addresses (100 + 200 duffs in credits). + let wallet_id = [9u8; 32]; + let mut result: AddressSyncResult<PlatformAddressTag, PlatformP2PKHAddress> = + AddressSyncResult::default(); + result.found.insert( + ( + (wallet_id, 0u32, 0u32), + PlatformP2PKHAddress::new([0x11; 20]), + ), + AddressFunds { + nonce: 0, + balance: 100 * CREDITS_PER_DUFF, + as_of_height: 0, + }, + ); + result.found.insert( + ( + (wallet_id, 0u32, 1u32), + PlatformP2PKHAddress::new([0x22; 20]), + ), + AddressFunds { + nonce: 1, + balance: 200 * CREDITS_PER_DUFF, + as_of_height: 0, + }, + ); + + let mut summary = PlatformAddressSyncSummary::default(); + summary + .wallet_results + .insert(wallet_id, WalletSyncOutcome::Ok(result)); + + bridge.on_platform_address_sync_completed(&summary); + + // The wallet_id is NOT registered in the snapshot store, so it cannot be + // resolved to a WalletSeedHash — nothing should be written. + assert!( + platform_balances.lock().unwrap().is_empty(), + "an unregistered wallet must not write a balance (no seed_hash mapping)" + ); + assert!( + platform_sync_cursors.lock().unwrap().is_empty(), + "an unregistered wallet must not write a sync cursor (no seed_hash mapping)" + ); + assert!(drained_refresh(&mut rx)); + } +} diff --git a/src/wallet_backend/hydration.rs b/src/wallet_backend/hydration.rs new file mode 100644 index 000000000..895c1b513 --- /dev/null +++ b/src/wallet_backend/hydration.rs @@ -0,0 +1,784 @@ +//! Cold-boot wallet rehydration (T-W-01). +//! +//! The in-memory `BTreeMap<WalletSeedHash, Wallet>` is reconstructed from the +//! wallet-meta sidecar (alias / `is_main` / `core_wallet_name` / +//! `xpub_encoded`) and the encrypted-seed envelope inside the upstream +//! `SecretStore`. +//! +//! The reconstruction is intentionally cheap: it does NOT touch the +//! `wallet_addresses` table, derive any addresses, or unlock the seed. +//! The wallet starts in `Closed` state when the envelope is +//! password-protected; address bootstrap and identity discovery happen +//! later, through the same paths a freshly-imported wallet uses +//! (`AppContext::bootstrap_wallet_addresses` / +//! `AppContext::handle_wallet_unlocked`). +//! +//! Per-row failure is logged and swallowed: a single corrupt row must not +//! block the picker from listing the remaining wallets. These skips are +//! log-only — there is no reachable egui context here, so the skip reason +//! does not surface to the user. The migration banner is the recovery +//! surface for a full-vault failure. + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey; + +use crate::backend_task::error::TaskError; +use crate::model::wallet::meta::WalletMeta; +use crate::model::wallet::seed_envelope::StoredSeedEnvelope; +use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed, WalletSeedHash}; +use crate::wallet_backend::secret_seam::SecretScheme; +use std::collections::{BTreeMap, HashMap}; + +use super::WalletBackend; + +impl WalletBackend { + /// Rebuild the in-memory `BTreeMap<WalletSeedHash, Wallet>` for one + /// network from the sidecars. Returns `(seed_hash, Wallet)` pairs in + /// `WalletMeta::list` order (base58 seed-hash ascending). + /// + /// Wallets whose seed envelope is missing are skipped (the metadata + /// is orphaned; the picker has nothing to unlock). Wallets whose + /// `xpub_encoded` is empty or fails to decode are also skipped — the + /// picker cannot render addresses without a valid xpub. + /// + /// Per-wallet errors are logged and skipped; the function only + /// surfaces a `TaskError` when the sidecar accessor itself is wedged + /// (e.g. seed-store I/O failure). All other classes degrade + /// gracefully so the wallet picker can show whatever does parse. + pub fn hydrate_wallets_for_network( + &self, + network: Network, + ) -> Result<Vec<(WalletSeedHash, Wallet)>, TaskError> { + hydrate_hd_wallets_from_views(&self.wallet_seeds(), &self.wallet_meta(), network) + } +} + +/// Free-function version of [`WalletBackend::hydrate_wallets_for_network`] +/// driven by borrowed views. Exposed so benches and integration tests can +/// time cold-start hydration without standing up a full backend (no SDK, +/// no `PlatformWalletManager`, no per-network sync dir). +pub fn hydrate_hd_wallets_from_views( + seed_view: &super::wallet_seed_store::WalletSeedView<'_>, + meta_view: &super::wallet_meta::WalletMetaView<'_>, + network: Network, +) -> Result<Vec<(WalletSeedHash, Wallet)>, TaskError> { + let entries = meta_view.list(network); + let mut out = Vec::with_capacity(entries.len()); + for (seed_hash, meta) in entries { + match reconstruct_wallet(seed_view, &seed_hash, &meta) { + Ok(Some(wallet)) => out.push((seed_hash, wallet)), + Ok(None) => { + // Logged inside `reconstruct_wallet` — orphaned meta + // or empty xpub is a "skip and continue" path. + } + Err(e) => { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Failed to reconstruct wallet from sidecars; skipping", + ); + } + } + } + Ok(out) +} + +/// Reconstruct one `Wallet` from its `(WalletMeta, StoredSeedEnvelope)` +/// pair. Returns `Ok(None)` when the wallet must be skipped (envelope +/// missing, xpub absent or undecodable) — the call site logs and moves +/// on. Errors propagate only when the sidecar accessor itself errored. +fn reconstruct_wallet( + seed_view: &super::wallet_seed_store::WalletSeedView<'_>, + seed_hash: &WalletSeedHash, + meta: &WalletMeta, +) -> Result<Option<Wallet>, TaskError> { + // Branch on the raw-seam at-rest scheme BEFORE reading the seed. A Tier-2 + // protected seed is intentionally unreadable at cold boot (the object + // password is not available without a prompt), so probing the scheme first + // keeps a `get_raw` (which would surface `NeedsPassword`) off the protected + // path and lets the wallet still render closed. + match seed_view.scheme(seed_hash)? { + // Tier-2 protected: reconstruct a closed (watch-only) wallet from the + // public master xpub in `WalletMeta` — never read the seed. The unlock + // gesture later supplies the password through the JIT chokepoint. + SecretScheme::Protected => { + let envelope = StoredSeedEnvelope { + encrypted_seed: Vec::new(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: meta.password_hint.clone(), + uses_password: true, + xpub_encoded: meta.xpub_encoded.clone(), + }; + return reconstruct_from_envelope(seed_hash, envelope, meta); + } + // Tier-1 raw seed present (precedence raw > legacy). A migrated + // no-password wallet has no envelope — its seed rides raw under + // `seed.raw.v1` and its non-secret metadata (xpub) lives in `WalletMeta`. + SecretScheme::Unprotected => { + let raw = seed_view + .get_raw(seed_hash)? + .ok_or(TaskError::SecretSeamMissing)?; + let envelope = StoredSeedEnvelope { + encrypted_seed: raw.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: meta.password_hint.clone(), + uses_password: false, + xpub_encoded: meta.xpub_encoded.clone(), + }; + return reconstruct_from_envelope(seed_hash, envelope, meta); + } + // No raw value yet — fall through to the legacy `envelope.v1` reader and + // its eager/lazy migration below. + SecretScheme::Absent => {} + } + + let envelope = match seed_view.get(seed_hash)? { + Some(e) => e, + None => { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + "Wallet meta has no matching seed envelope; skipping", + ); + return Ok(None); + } + }; + + // EAGER migration (dialog-free): a no-password legacy envelope holds the + // raw seed verbatim. Re-store it raw (vault-FIRST) then drop the legacy + // envelope so the at-rest plaintext-equivalent form is gone. Crash-safe and + // idempotent — `set_raw` upserts, and a crash before `delete` leaves both + // forms with raw preferred next load. A password envelope is left for the + // lazy unlock migration. + if !envelope.uses_password + && envelope.encrypted_seed.len() == EXPECTED_SEED_LEN as usize + && let Ok(seed) = <[u8; 64]>::try_from(envelope.encrypted_seed.as_slice()) + { + // Keep the extracted raw seed in `Zeroizing` so the stack copy wipes on + // drop, matching every other raw-seed site introduced by this work. + let seed = zeroize::Zeroizing::new(seed); + if let Err(e) = seed_view.set_raw(seed_hash, &seed) { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Eager no-password seed migration deferred (raw write failed)", + ); + } else if let Err(e) = seed_view.delete(seed_hash) { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Eager seed migration left a redundant legacy envelope (delete failed)", + ); + } + } + + reconstruct_from_envelope(seed_hash, envelope, meta) +} + +/// Decode the master xpub (envelope copy preferred, `WalletMeta` fallback) and +/// assemble the `Wallet`. Shared by the raw-seam and legacy-envelope paths in +/// [`reconstruct_wallet`]. `Ok(None)` (skip + log) when the xpub is absent or +/// undecodable. +fn reconstruct_from_envelope( + seed_hash: &WalletSeedHash, + envelope: StoredSeedEnvelope, + meta: &WalletMeta, +) -> Result<Option<Wallet>, TaskError> { + let xpub_bytes: &[u8] = if !envelope.xpub_encoded.is_empty() { + &envelope.xpub_encoded + } else { + &meta.xpub_encoded + }; + + if xpub_bytes.is_empty() { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + "Wallet entry has no master xpub; skipping", + ); + return Ok(None); + } + + let master_bip44_ecdsa_extended_public_key = match ExtendedPubKey::decode(xpub_bytes) { + Ok(x) => x, + Err(e) => { + tracing::warn!( + target = "wallet_backend::hydration", + seed_hash = %hex::encode(seed_hash), + error = ?e, + "Failed to decode master xpub for wallet; skipping", + ); + return Ok(None); + } + }; + + let wallet = wallet_from_envelope( + *seed_hash, + envelope, + meta, + master_bip44_ecdsa_extended_public_key, + )?; + Ok(Some(wallet)) +} + +/// Expected plaintext length of a BIP-39 seed (BIP39 mnemonics produce a +/// 64-byte seed via PBKDF2). Surfaced as a constant so error variants +/// can speak in concrete numbers. +const EXPECTED_SEED_LEN: u32 = 64; + +/// Assemble the final `Wallet` from its parts. Mirrors the legacy +/// `db.get_wallets` row → `Wallet` mapping (`encrypted_seed` becomes the +/// plaintext seed when `uses_password = false`; otherwise the closed +/// envelope stays encrypted until the user unlocks it). +fn wallet_from_envelope( + seed_hash: WalletSeedHash, + envelope: StoredSeedEnvelope, + meta: &WalletMeta, + master_bip44_ecdsa_extended_public_key: ExtendedPubKey, +) -> Result<Wallet, TaskError> { + let StoredSeedEnvelope { + encrypted_seed, + salt, + nonce, + password_hint, + uses_password, + xpub_encoded: _, + } = envelope; + + let closed = ClosedKeyItem { + seed_hash, + encrypted_seed: encrypted_seed.clone(), + salt, + nonce, + password_hint, + }; + + let wallet_seed = if uses_password { + WalletSeed::Closed(closed) + } else { + // Non-password envelopes store the raw 64-byte seed verbatim. A + // length mismatch is a corrupt envelope: surface a typed error + // carrying the wallet label and observed length. The caller logs + // and skips it (log-only — no egui context is reachable here). The + // envelope is only length-checked to prove it is well-formed; the + // open wallet parks no plaintext seed (R3). + if encrypted_seed.len() != EXPECTED_SEED_LEN as usize { + let label = if meta.alias.is_empty() { + let hex_hash = hex::encode(seed_hash); + format!("{}…", &hex_hash[..hex_hash.len().min(12)]) + } else { + meta.alias.clone() + }; + return Err(TaskError::SeedLengthInvalid { + wallet_label: label, + got: encrypted_seed.len() as u32, + expected: EXPECTED_SEED_LEN, + }); + } + WalletSeed::Open(OpenWalletSeed { + wallet_info: closed, + }) + }; + + Ok(Wallet { + wallet_seed, + uses_password, + master_bip44_ecdsa_extended_public_key, + // Not persisted: re-derived just-in-time from the seed on first unlock + // (see `Wallet::ensure_platform_payment_account_xpub`). + platform_payment_account_xpub: None, + known_addresses: BTreeMap::new(), + watched_addresses: BTreeMap::new(), + alias: if meta.alias.is_empty() { + None + } else { + Some(meta.alias.clone()) + }, + identities: HashMap::new(), + is_main: meta.is_main, + platform_address_info: BTreeMap::new(), + core_wallet_name: meta.core_wallet_name.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::wallet::{ClosedKeyItem, Wallet}; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use crate::wallet_backend::wallet_seed_store::WalletSeedView; + use dash_sdk::dpp::dashcore::Network; + use platform_wallet_storage::secrets::SecretStore; + use std::sync::Arc; + + /// Build a real BIP44 master pubkey from a seed so the encoded bytes + /// survive the `ExtendedPubKey::decode` round trip. + fn xpub_bytes_for(seed: [u8; 64], network: Network) -> Vec<u8> { + let w = Wallet::new_from_seed(seed, network, None, None).expect("wallet"); + w.master_bip44_ecdsa_extended_public_key.encode().to_vec() + } + + fn seed_hash_for(seed: [u8; 64]) -> WalletSeedHash { + ClosedKeyItem::compute_seed_hash(&seed) + } + + /// TC-W-001 — a wallet whose `WalletMeta` + `StoredSeedEnvelope` sit + /// in the sidecars is rebuilt verbatim: alias, is_main, + /// core_wallet_name, master xpub, and the un-encrypted seed all match. + #[test] + fn tc_w_001_reconstructs_non_password_wallet_from_sidecars() { + let seed = [0x42u8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: "paycheque".into(), + is_main: true, + core_wallet_name: Some("local-dashd".into()), + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + // Stand-in for `WalletSeedView::get` — direct decode of the + // envelope, no upstream vault required. + let master = ExtendedPubKey::decode(&envelope.xpub_encoded).expect("xpub decodes"); + let wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) + .expect("wallet rebuilt"); + + assert_eq!(wallet.alias.as_deref(), Some("paycheque")); + assert!(wallet.is_main); + assert_eq!(wallet.core_wallet_name.as_deref(), Some("local-dashd")); + assert!( + wallet.is_open(), + "non-password envelope must rehydrate open" + ); + assert_eq!(wallet.seed_hash(), seed_hash_for(seed)); + } + + /// TC-W-009 — a password-protected wallet's alias and `is_main` + /// survive reconstruction without unlocking; the wallet stays closed + /// so the unlock UI still has work to do. + #[test] + fn tc_w_009_password_wallet_metadata_preserved_locked() { + let seed = [0x77u8; 64]; + let network = Network::Mainnet; + let xpub = xpub_bytes_for(seed, network); + + let envelope = StoredSeedEnvelope { + encrypted_seed: vec![0xAB; 80], + salt: vec![0x01; 16], + nonce: vec![0x02; 12], + password_hint: Some("granny's birthday".into()), + uses_password: true, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: "savings".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + let master = ExtendedPubKey::decode(&envelope.xpub_encoded).expect("xpub decodes"); + let wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) + .expect("wallet rebuilt"); + + assert_eq!(wallet.alias.as_deref(), Some("savings")); + assert!(!wallet.is_main); + assert!(!wallet.is_open(), "password envelope must stay closed"); + assert!(wallet.uses_password); + assert_eq!(wallet.password_hint().as_deref(), Some("granny's birthday")); + } + + /// Empty alias on `WalletMeta` (the "fresh install, never named" + /// shape from the migration writer) maps back to `None` — matches + /// the legacy `Option<String>` column shape that downstream code + /// branches on. + #[test] + fn empty_alias_maps_to_none() { + let seed = [0xDDu8; 64]; + let xpub = xpub_bytes_for(seed, Network::Testnet); + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: String::new(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + let master = ExtendedPubKey::decode(&envelope.xpub_encoded).expect("xpub decodes"); + let wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) + .expect("wallet rebuilt"); + assert!(wallet.alias.is_none()); + } + + fn fresh_secret_store(dir: &std::path::Path) -> Arc<SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(crate::wallet_backend::single_key::open_secret_store(&path).expect("open vault")) + } + + /// TC-W-001 (sidecar round-trip) — `reconstruct_wallet` returns a + /// `Wallet` with the same shape as the per-field assembly path, + /// when fed a `WalletSeedView` that read what the migration writer + /// would have produced. + #[test] + fn tc_w_001_round_trip_through_real_seed_view() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0xABu8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let hash = seed_hash_for(seed); + view.set(&hash, &envelope).expect("set"); + + let meta = WalletMeta { + alias: "primary".into(), + is_main: true, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt"); + assert_eq!(wallet.alias.as_deref(), Some("primary")); + assert!(wallet.is_main); + assert!(wallet.is_open()); + assert_eq!(wallet.seed_hash(), hash); + } + + /// TS-EAGER-01 / TS-EAGER-04 — a no-password legacy envelope is eagerly + /// migrated on load: the raw `seed.raw.v1` is written, the legacy + /// `envelope.v1` is deleted, and a reload reads via the raw seam. Running + /// the load twice is idempotent (second pass already-raw, legacy gone). + #[test] + fn ts_eager_01_no_password_seed_migrates_on_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0x5Au8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let hash = seed_hash_for(seed); + view.set( + &hash, + &StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }, + ) + .expect("seed legacy envelope"); + let meta = WalletMeta { + alias: "eager".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + // First load migrates. + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt"); + assert!(wallet.is_open()); + // Raw present and equals the seed; legacy gone. + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + assert!( + view.legacy_envelope_get(&hash).unwrap().is_none(), + "legacy envelope deleted after eager migration" + ); + + // Second load is idempotent — reads via the raw seam, no error, + // legacy still absent, raw byte-identical. + let wallet2 = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt again"); + assert!(wallet2.is_open()); + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + assert!(view.legacy_envelope_get(&hash).unwrap().is_none()); + } + + /// TS-CRASH-01 (read half) — the legal mid-migration state (raw present + /// AND legacy still present) loads from the RAW value; the leftover legacy + /// is cleaned up. No key loss, no error. + #[test] + fn ts_crash_01_raw_wins_and_legacy_is_cleaned() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0x6Bu8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let hash = seed_hash_for(seed); + // Both forms present (crash after raw write, before legacy delete). + view.set_raw(&hash, &seed).expect("raw"); + view.set( + &hash, + &StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }, + ) + .expect("legacy too"); + let meta = WalletMeta { + alias: "midmig".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error") + .expect("rebuilt"); + assert!(wallet.is_open()); + assert_eq!(*view.get_raw(&hash).unwrap().unwrap(), seed); + } + + /// Orphan path — a `WalletMeta` entry whose envelope is missing is + /// returned as `Ok(None)` from `reconstruct_wallet` so the picker + /// can keep listing the survivors. + #[test] + fn orphan_meta_without_envelope_returns_none() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0xCDu8; 64]; + let xpub = xpub_bytes_for(seed, Network::Testnet); + let meta = WalletMeta { + alias: "orphan".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: false, + password_hint: None, + }; + let result = reconstruct_wallet(&view, &seed_hash_for(seed), &meta).expect("no error"); + assert!(result.is_none(), "missing envelope must collapse to None"); + } + + /// Empty xpub (legacy entry written before T-W-00.5) collapses to + /// `None` — the picker has nothing to derive from. + #[test] + fn empty_xpub_returns_none() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0xEEu8; 64]; + let hash = seed_hash_for(seed); + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: Vec::new(), + }; + view.set(&hash, &envelope).expect("set"); + let meta = WalletMeta { + alias: "no-xpub".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, + }; + let result = reconstruct_wallet(&view, &hash, &meta).expect("no error"); + assert!(result.is_none(), "empty xpub must collapse to None"); + } + + /// A non-password envelope whose `encrypted_seed` is not 64 bytes + /// surfaces [`TaskError::SeedLengthInvalid`] with the alias-as-label + /// and the observed length, instead of silently degrading to a + /// closed wallet. + #[test] + fn sec_008_non_64_byte_seed_surfaces_typed_error() { + let seed = [0xBEu8; 64]; + let xpub = xpub_bytes_for(seed, Network::Testnet); + let envelope = StoredSeedEnvelope { + encrypted_seed: vec![0x33; 16], // wrong length on purpose + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: "shorty".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub.clone(), + uses_password: false, + password_hint: None, + }; + let master = ExtendedPubKey::decode(&xpub).expect("xpub decodes"); + let err = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) + .expect_err("typed error"); + match err { + TaskError::SeedLengthInvalid { + wallet_label, + got, + expected, + } => { + assert_eq!(wallet_label, "shorty"); + assert_eq!(got, 16); + assert_eq!(expected, 64); + } + other => panic!("expected SeedLengthInvalid, got {other:?}"), + } + } + + /// TC-W-008 (rename-shape half) — assigning a new alias to a + /// reconstructed wallet preserves seed-hash / xpub / is_main so the + /// only observable diff after the rename is the alias itself. + /// Locks the "rename does not invalidate the wallet" invariant. + #[test] + fn tc_w_008_rename_only_touches_alias() { + let seed = [0x55u8; 64]; + let xpub = xpub_bytes_for(seed, Network::Testnet); + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: xpub.clone(), + }; + let meta = WalletMeta { + alias: "old".into(), + is_main: true, + core_wallet_name: None, + xpub_encoded: xpub.clone(), + uses_password: false, + password_hint: None, + }; + let master = ExtendedPubKey::decode(&xpub).expect("xpub decodes"); + let mut wallet = wallet_from_envelope(seed_hash_for(seed), envelope, &meta, master) + .expect("wallet rebuilt"); + let original_hash = wallet.seed_hash(); + let original_xpub = wallet.master_bip44_ecdsa_extended_public_key.encode(); + + wallet.alias = Some("new".to_string()); + + assert_eq!(wallet.seed_hash(), original_hash); + assert_eq!( + wallet.master_bip44_ecdsa_extended_public_key.encode(), + original_xpub + ); + assert!(wallet.is_main); + assert_eq!(wallet.alias.as_deref(), Some("new")); + } + + /// Regression for the cold-boot disappearance of a Tier-2-protected HD + /// seed: a seed re-wrapped under its own object password (keep-protection) + /// must rehydrate as a CLOSED wallet, not be skipped. Before the + /// scheme-first branch, `reconstruct_wallet` called `get_raw` on the + /// protected label, which surfaced `NeedsPassword`; the `?` then dropped the + /// wallet from the picker on every launch. + #[test] + fn tier2_protected_seed_reconstructs_closed_and_is_listed() { + use crate::wallet_backend::wallet_meta::WalletMetaView; + use platform_wallet_storage::secrets::SecretString; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = fresh_secret_store(dir.path()); + let view = WalletSeedView::new(&store); + + let seed = [0x91u8; 64]; + let network = Network::Testnet; + let xpub = xpub_bytes_for(seed, network); + let hash = seed_hash_for(seed); + + // Keep-protection migration shape: the seed lives Tier-2 under its own + // object password at `seed.raw.v1`; no legacy envelope remains. + let password = SecretString::new("correct-horse-battery"); + view.set_protected(&hash, &seed, &password) + .expect("set_protected"); + assert_eq!( + view.scheme(&hash).expect("scheme"), + SecretScheme::Protected, + "seed must read back as Tier-2 protected without a password", + ); + + let meta = WalletMeta { + alias: "savings".into(), + is_main: false, + core_wallet_name: None, + xpub_encoded: xpub, + uses_password: true, + password_hint: Some("the usual".into()), + }; + + // Direct reconstruction returns Ok(Some(closed)) — never an Err. + let wallet = reconstruct_wallet(&view, &hash, &meta) + .expect("no error from a protected seed") + .expect("protected wallet must rehydrate, not be skipped"); + assert!(!wallet.is_open(), "protected seed must rehydrate closed"); + assert!(wallet.uses_password); + assert_eq!(wallet.seed_hash(), hash); + assert_eq!(wallet.password_hint().as_deref(), Some("the usual")); + + // And it appears in the cold-boot enumeration (not skipped). + let meta_kv = std::sync::Arc::new(crate::wallet_backend::DetKv::from_store( + std::sync::Arc::new(InMemoryKv::default()), + )); + let meta_view = WalletMetaView::new(&meta_kv); + meta_view.set(network, &hash, &meta).expect("persist meta"); + let listed = + hydrate_hd_wallets_from_views(&view, &meta_view, network).expect("hydration ok"); + assert!( + listed.iter().any(|(h, _)| *h == hash), + "the protected wallet must appear in the cold-boot listing", + ); + } +} diff --git a/src/wallet_backend/identity_key_store.rs b/src/wallet_backend/identity_key_store.rs new file mode 100644 index 000000000..08147345c --- /dev/null +++ b/src/wallet_backend/identity_key_store.rs @@ -0,0 +1,519 @@ +//! Raw identity-private-key storage over the secret seam. +//! +//! Each identity private key is stored as raw 32 bytes in the upstream vault +//! through [`SecretSeam`], scoped to the identity id +//! (`Identifier::to_buffer()`) under the label +//! `identity_key_priv.<target_tag>.<key_id>`. There is NO DET-side envelope — +//! the key bytes ride raw (the no-serialization invariant), and the `InVault` +//! placeholder in the `QualifiedIdentity` blob is the only on-disk marker that +//! the key exists. +//! +//! The keys are fetched per-use through +//! [`SecretAccess`](crate::wallet_backend::SecretAccess) at sign time and never +//! resident in memory as plaintext. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::KeyID; +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, +}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::qualified_identity::encrypted_key_storage::VaultBoundKey; +use crate::wallet_backend::secret_access::identity_key_from_bytes; +use crate::wallet_backend::secret_prompt::SecretScope; +use crate::wallet_backend::secret_seam::{SecretScheme, SecretSeam}; + +/// Borrowed view over the secret seam for one identity's private keys. Cheap +/// to construct — callers build one per operation. +pub struct IdentityKeyView<'a> { + secret_store: &'a Arc<SecretStore>, + /// The identity id (`Identifier::to_buffer()`) used as the vault scope. + identity_id: [u8; 32], +} + +impl<'a> IdentityKeyView<'a> { + /// Borrow the seam for the identity scoped by `identity_id`. + pub fn new(secret_store: &'a Arc<SecretStore>, identity_id: [u8; 32]) -> Self { + Self { + secret_store, + identity_id, + } + } + + fn scope(&self) -> SecretWalletId { + SecretWalletId::from(self.identity_id) + } + + fn seam(&self) -> SecretSeam<'_> { + SecretSeam::new(self.secret_store) + } + + /// Store one identity key's raw 32 bytes Tier-1 (keyless), overwriting any + /// prior **unprotected** value. + /// + /// R2 downgrade guard: refuses to overwrite a Tier-2 + /// (`Protected`) label with a keyless write, returning + /// [`TaskError::IdentityKeyProtectionDowngrade`]. This is the keyless + /// migration write path ([`Self::store_all`]); it must never silently strip + /// an opted-in identity's protection (e.g. a later `AddKeyToIdentity` that + /// re-saves an existing protected key). The deliberate opt-out downgrade + /// uses [`Self::store_unprotected`] instead, which bypasses the guard. + pub fn store( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + key: &[u8; 32], + ) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + // Known, low-risk: this scheme-probe-then-write is a theoretical + // check-then-act TOCTOU, bounded in practice by the upstream secret + // store's single-writer lock and the UI in-flight gate that serialises + // protect/unprotect/add-key on one identity. The identity-level + // fail-closed guard in the save path is the primary defense; this + // per-label check is defense in depth. + if self + .seam() + .scheme(&self.scope(), &label) + .map_err(identity_flavored)? + == SecretScheme::Protected + { + tracing::warn!( + target = "wallet_backend::identity_key_store", + identity = %hex::encode(self.identity_id), + "Refused a keyless write over a password-protected identity key", + ); + return Err(TaskError::IdentityKeyProtectionDowngrade); + } + self.seam() + .put_secret(&self.scope(), &label, &SecretBytes::from_slice(key)) + .map_err(identity_flavored) + } + + /// Store one identity key's raw 32 bytes Tier-2 (sealed under the + /// identity's object `password`), overwriting any prior value at the SAME + /// label — the in-place Tier-1→Tier-2 opt-in upsert. After this the label's + /// scheme flips to `Protected` with no second key and no delete. + pub fn store_protected( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + key: &[u8; 32], + password: &SecretString, + ) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam() + .put_secret_protected( + &self.scope(), + &label, + &SecretBytes::from_slice(key), + password, + ) + .map_err(identity_flavored) + } + + /// Intentional Tier-1 (raw) write that REPLACES any Tier-2 value at the + /// label — the deliberate opt-out downgrade (Tier-2→Tier-1 in place). + /// Unlike [`Self::store`] it does NOT refuse a `Protected` label: removing + /// protection is its whole job. Only the `UnprotectIdentityKeys` migration + /// calls this. + pub fn store_unprotected( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + key: &[u8; 32], + ) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam() + .put_secret(&self.scope(), &label, &SecretBytes::from_slice(key)) + .map_err(identity_flavored) + } + + /// The at-rest [`SecretScheme`] of one identity key — `Protected` (Tier-2), + /// `Unprotected` (Tier-1 raw), or `Absent`. Used by the migration tasks to + /// skip already-converted keys (idempotent re-run) and by the UI to detect a + /// partially-protected identity ("Finish protecting"). + pub fn scheme( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + ) -> Result<SecretScheme, TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam() + .scheme(&self.scope(), &label) + .map_err(identity_flavored) + } + + /// Read one identity key's raw 32 bytes from its Tier-2 envelope, unsealing + /// with the identity's object `password`. `None` if absent. A wrong + /// password surfaces as [`TaskError::IdentityKeyPassphraseIncorrect`] (no + /// oracle); the bytes wipe on drop ([`Zeroizing`]). + pub fn get_protected( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + password: &SecretString, + ) -> Result<Option<Zeroizing<[u8; 32]>>, TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + let Some(bytes) = self + .seam() + .get_secret_protected(&self.scope(), &label, password) + .map_err(protected_flavored)? + else { + return Ok(None); + }; + let key = identity_key_from_bytes(bytes.expose_secret())?; + Ok(Some(Zeroizing::new(key))) + } + + /// Store every `(target, key_id) → raw 32 bytes` pair. Used by the + /// migration after `KeyStorage::take_plaintext_for_vault` — call this + /// BEFORE rewriting the QI blob (vault-first ordering). + pub fn store_all(&self, keys: &[VaultBoundKey]) -> Result<(), TaskError> { + for ((target, key_id), bytes) in keys { + self.store(target, *key_id, bytes)?; + } + Ok(()) + } + + /// Read one identity key's raw 32 bytes, or `None` if absent. Wrapped in + /// [`Zeroizing`] so it wipes on drop. + pub fn get( + &self, + target: &PrivateKeyTarget, + key_id: KeyID, + ) -> Result<Option<Zeroizing<[u8; 32]>>, TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + let Some(bytes) = self + .seam() + .get_secret(&self.scope(), &label) + .map_err(identity_flavored)? + else { + return Ok(None); + }; + let key = identity_key_from_bytes(bytes.expose_secret())?; + Ok(Some(Zeroizing::new(key))) + } + + /// Idempotent delete of one identity key. + pub fn delete(&self, target: &PrivateKeyTarget, key_id: KeyID) -> Result<(), TaskError> { + let label = SecretScope::identity_key_label(target, key_id); + self.seam() + .delete_secret(&self.scope(), &label) + .map_err(identity_flavored) + } + + /// Delete every `(target, key_id)` listed. Idempotent. Used on identity + /// removal (`purge_identity_scope`) to leave no orphaned raw secret. + pub fn delete_all( + &self, + keys: impl IntoIterator<Item = (PrivateKeyTarget, KeyID)>, + ) -> Result<(), TaskError> { + for (target, key_id) in keys { + self.delete(&target, key_id)?; + } + Ok(()) + } +} + +/// Re-flavor a generic seam error as the identity-key-domain variant so a vault +/// failure on an identity key surfaces with identity-specific banner copy. Any +/// non-`SecretSeam` error passes through unchanged. +pub(crate) fn identity_flavored(e: TaskError) -> TaskError { + match e { + TaskError::SecretSeam { source } => TaskError::IdentityKeyVault { source }, + other => other, + } +} + +/// Like [`identity_flavored`], but maps a wrong-password unseal to the typed +/// [`TaskError::IdentityKeyPassphraseIncorrect`] (no oracle) so the opt-out +/// migration can surface a clean "that password is not correct" rather than a +/// generic storage error. Any other seam error keeps the identity-vault flavor. +fn protected_flavored(e: TaskError) -> TaskError { + match e { + TaskError::SecretSeam { source } if matches!(*source, SecretStoreError::WrongPassword) => { + TaskError::IdentityKeyPassphraseIncorrect + } + other => identity_flavored(other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::open_secret_store; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + /// Store/get/delete round-trip for one identity key through the seam. + #[test] + fn store_get_delete_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0x11u8; 32]); + let key = [0xAB; 32]; + + view.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3, &key) + .expect("store"); + let got = view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("get") + .expect("present"); + assert_eq!(*got, key); + + view.delete(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("delete"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("get after delete") + .is_none() + ); + // Idempotent delete. + view.delete(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .expect("delete twice"); + } + + /// Distinct targets and identities do not collide. + #[test] + fn scopes_and_targets_do_not_collide() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let a = IdentityKeyView::new(&store, [0xA1u8; 32]); + let b = IdentityKeyView::new(&store, [0xB2u8; 32]); + + a.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x01; 32]) + .unwrap(); + a.store(&PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0, &[0x02; 32]) + .unwrap(); + b.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x03; 32]) + .unwrap(); + + assert_eq!( + *a.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x01; 32] + ); + assert_eq!( + *a.get(&PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0) + .unwrap() + .unwrap(), + [0x02; 32], + "distinct targets under one identity do not collide" + ); + assert_eq!( + *b.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x03; 32], + "distinct identity scopes do not collide" + ); + } + + /// `store_all` / `delete_all` operate over the migration's bound-key list. + #[test] + fn store_all_then_delete_all() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xCC; 32]); + let bound: Vec<VaultBoundKey> = vec![ + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + Zeroizing::new([0x10; 32]), + ), + ( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 2), + Zeroizing::new([0x20; 32]), + ), + ]; + view.store_all(&bound).expect("store_all"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .unwrap() + .is_some() + ); + + view.delete_all([ + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 2), + ]) + .expect("delete_all"); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 2) + .unwrap() + .is_none() + ); + } + + /// Opt-in store/read: a key sealed Tier-2 reads back with the + /// password (`get_protected`), the label reports `Protected`, and a + /// keyless `get` (Tier-1 read of a protected value) fails rather than + /// leaking — the seam refuses the implicit downgrade. + #[test] + fn protected_store_get_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xD1u8; 32]); + let key = [0xAB; 32]; + let pw = SecretString::new("identity-object-passwd"); + + view.store_protected(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5, &key, &pw) + .expect("store_protected"); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5) + .unwrap(), + SecretScheme::Protected, + ); + let got = view + .get_protected(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5, &pw) + .expect("get_protected") + .expect("present"); + assert_eq!(*got, key); + // Keyless read of a protected value fails (no silent downgrade). + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5) + .is_err(), + "a keyless read of a protected identity key must fail" + ); + } + + /// A wrong password yields the typed `IdentityKeyPassphraseIncorrect` (no + /// oracle), not a storage error. + #[test] + fn protected_get_wrong_password_is_typed() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xD2u8; 32]); + view.store_protected( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x01; 32], + &SecretString::new("right-password-here"), + ) + .expect("store_protected"); + let err = view + .get_protected( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &SecretString::new("wrong-password-here"), + ) + .expect_err("wrong password"); + assert!( + matches!(err, TaskError::IdentityKeyPassphraseIncorrect), + "expected IdentityKeyPassphraseIncorrect, got {err:?}" + ); + } + + /// Opt-in is an in-place upsert at the SAME label: a Tier-1 raw key + /// overwritten by `store_protected` flips the label's scheme to `Protected` + /// with no second key — the design's no-blob-rewrite, no-orphan property. + #[test] + fn opt_in_upsert_replaces_tier1_in_place() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xD3u8; 32]); + let key = [0x42; 32]; + + view.store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3, &key) + .expect("tier-1 store"); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .unwrap(), + SecretScheme::Unprotected, + ); + + let pw = SecretString::new("seal-this-identity-pw"); + view.store_protected(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3, &key, &pw) + .expect("opt-in upsert"); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3) + .unwrap(), + SecretScheme::Protected, + "in-place Tier-1→Tier-2 upsert flips the scheme", + ); + assert_eq!( + *view + .get_protected(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 3, &pw) + .unwrap() + .unwrap(), + key, + ); + } + + /// R2 downgrade guard: the keyless `store` refuses to overwrite a + /// `Protected` label (it would silently strip protection), while the + /// deliberate `store_unprotected` opt-out IS allowed to downgrade. + #[test] + fn keyless_store_refuses_to_downgrade_protected_key() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xD4u8; 32]); + let pw = SecretString::new("protect-then-attack-pw"); + view.store_protected( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x11; 32], + &pw, + ) + .expect("seal tier-2"); + + // The guarded keyless path is refused — protection is NOT stripped. + let err = view + .store(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x22; 32]) + .expect_err("keyless write over Protected must be refused"); + assert!( + matches!(err, TaskError::IdentityKeyProtectionDowngrade), + "expected IdentityKeyProtectionDowngrade, got {err:?}" + ); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap(), + SecretScheme::Protected, + "the refused write left the key protected", + ); + + // The deliberate opt-out downgrade IS allowed. + view.store_unprotected(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0, &[0x33; 32]) + .expect("intentional downgrade"); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap(), + SecretScheme::Unprotected, + "store_unprotected performs the intended Tier-2→Tier-1 downgrade", + ); + assert_eq!( + *view + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .unwrap() + .unwrap(), + [0x33; 32], + ); + } + + /// `store_all` is unchanged for fresh/unprotected identities (the guard + /// only fires over a `Protected` label) — the steady-state migration write + /// keeps working byte-for-byte. + #[test] + fn store_all_unaffected_for_unprotected_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = IdentityKeyView::new(&store, [0xD5u8; 32]); + let bound: Vec<VaultBoundKey> = vec![( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 9), + Zeroizing::new([0x55; 32]), + )]; + view.store_all(&bound).expect("store_all on fresh identity"); + assert_eq!( + view.scheme(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 9) + .unwrap(), + SecretScheme::Unprotected, + ); + } +} diff --git a/src/wallet_backend/identity_meta.rs b/src/wallet_backend/identity_meta.rs new file mode 100644 index 000000000..c29111800 --- /dev/null +++ b/src/wallet_backend/identity_meta.rs @@ -0,0 +1,200 @@ +//! DET-side identity-metadata view. +//! +//! [`IdentityMetaView`] is the only doorway DET code uses to read or write +//! [`IdentityMeta`] (the password hint shown next to the sign-time prompt) for +//! an identity whose keys are password-protected. It is a thin +//! [`SidecarView`](crate::wallet_backend::sidecar::SidecarView) wrapper over a +//! shared [`DetKv`] handle pointing at `det-app.sqlite`, serialising every entry +//! under a colon-prefixed, network-scoped key: +//! +//! ```text +//! <network>:identity_meta:<identity_id_base58> +//! ``` +//! +//! Network-prefixed keys + the global (`DetScope::Global`) scope mean the +//! cross-network `det-app.sqlite` file is the right store (one file, one schema, +//! easy backup), and the 32-byte identity id is the stable DET-level identifier. +//! +//! This sidecar is **display-only** — it never gates whether a prompt fires +//! (the at-rest vault scheme does). Every read path is therefore infallible at +//! the value level: a missing key returns `None`, a corrupt blob is logged and +//! treated as absent so the prompt degrades to "no hint" rather than failing. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; + +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::identity_meta::IdentityMeta; +use crate::wallet_backend::DetKv; +use crate::wallet_backend::kv::{KvAdapterError, map_kv_storage_error}; +#[cfg(test)] +use crate::wallet_backend::sidecar::{SidecarId, sidecar_key}; +use crate::wallet_backend::sidecar::{SidecarScope, SidecarValue, SidecarView}; + +/// Colon-separated namespace shared across networks. The full key is +/// `<network>:identity_meta:<identity_id_base58>`. +pub(crate) const KEY_INFIX: &str = ":identity_meta:"; + +/// Build the canonical k/v key for an identity's metadata blob. The generic +/// view builds keys itself; this mirror exists for key-shape tests. +#[cfg(test)] +pub(crate) fn key_for(network: Network, identity_id: &SidecarId) -> String { + sidecar_key(network, KEY_INFIX, identity_id) +} + +impl SidecarValue for IdentityMeta {} + +/// Typed identity-metadata sidecar. A thin, display-only wrapper over the +/// generic [`SidecarView`]: identity metadata (the password hint shown next to +/// the sign-time prompt) is `Global`-scoped and never gates whether a prompt +/// fires, so every read degrades to `None` on error. +pub struct IdentityMetaView<'a>(SidecarView<'a, IdentityMeta>); + +impl<'a> IdentityMetaView<'a> { + /// Borrow a [`DetKv`] handle as a typed identity-metadata view. + pub fn new(kv: &'a Arc<DetKv>) -> Self { + Self(SidecarView::new( + kv, + KEY_INFIX, + SidecarScope::Global, + map_kv_error_to_task_error, + )) + } + + /// All `(identity_id, meta)` pairs persisted for `network`. A single + /// corrupt row is logged and skipped rather than poisoning the listing. + pub fn list(&self, network: Network) -> Vec<([u8; 32], IdentityMeta)> { + self.0.list(network) + } + + /// Fetch the metadata for a single identity. `None` when the key is absent + /// or the blob fails to decode (logged) — the sidecar is cosmetic, so a + /// read never fails the caller. + pub fn get(&self, network: Network, identity_id: &[u8; 32]) -> Option<IdentityMeta> { + self.0.get(network, identity_id) + } + + /// Upsert the metadata for a single identity. Re-writing the same value is + /// an idempotent overwrite (DetKv upserts by key). + pub fn set( + &self, + network: Network, + identity_id: &[u8; 32], + meta: &IdentityMeta, + ) -> Result<(), TaskError> { + self.0.set(network, identity_id, meta) + } + + /// Delete the metadata for a single identity. Idempotent — a missing key + /// returns `Ok(())`. + pub fn delete(&self, network: Network, identity_id: &[u8; 32]) -> Result<(), TaskError> { + self.0.delete(network, identity_id) + } +} + +/// Identity-meta adapter errors funnel into [`TaskError::IdentityMetaStorage`] +/// so the banner copy matches the surface ("identity details"). +fn map_kv_error_to_task_error(e: KvAdapterError) -> TaskError { + map_kv_storage_error(e, |source| TaskError::IdentityMetaStorage { source }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use dash_sdk::dpp::dashcore::base58; + + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + fn meta(hint: Option<&str>) -> IdentityMeta { + IdentityMeta { + password_hint: hint.map(str::to_string), + } + } + + /// ID-META-VIEW-001 — a written meta round-trips through `get` and shows up + /// in `list` for the same network. + #[test] + fn set_then_get_round_trips() { + let kv = kv(); + let view = IdentityMetaView::new(&kv); + let id = [0x11; 32]; + let m = meta(Some("granny's birthday")); + view.set(Network::Testnet, &id, &m).expect("set"); + assert_eq!(view.get(Network::Testnet, &id), Some(m.clone())); + assert_eq!(view.list(Network::Testnet), vec![(id, m)]); + } + + /// ID-META-VIEW-002 — `set` overwrites; updating the hint is one upsert. + #[test] + fn set_overwrites_existing_entry() { + let kv = kv(); + let view = IdentityMetaView::new(&kv); + let id = [0x22; 32]; + view.set(Network::Mainnet, &id, &meta(Some("old"))) + .expect("first set"); + view.set(Network::Mainnet, &id, &meta(Some("new"))) + .expect("second set"); + assert_eq!(view.get(Network::Mainnet, &id), Some(meta(Some("new")))); + } + + /// ID-META-VIEW-003 — `list` does not leak entries from other networks (the + /// `<network>:` prefix is the partition). + #[test] + fn list_partitions_by_network() { + let kv = kv(); + let view = IdentityMetaView::new(&kv); + let a = [0x33; 32]; + let b = [0x44; 32]; + view.set(Network::Testnet, &a, &meta(Some("on testnet"))) + .unwrap(); + view.set(Network::Mainnet, &b, &meta(Some("on mainnet"))) + .unwrap(); + assert_eq!( + view.list(Network::Testnet), + vec![(a, meta(Some("on testnet")))] + ); + assert_eq!( + view.list(Network::Mainnet), + vec![(b, meta(Some("on mainnet")))] + ); + } + + /// ID-META-VIEW-004 — `delete` is idempotent. + #[test] + fn delete_is_idempotent() { + let kv = kv(); + let view = IdentityMetaView::new(&kv); + let id = [0x55; 32]; + view.delete(Network::Testnet, &id).expect("delete absent"); + view.set(Network::Testnet, &id, &meta(Some("x"))).unwrap(); + view.delete(Network::Testnet, &id).expect("first delete"); + view.delete(Network::Testnet, &id).expect("second delete"); + assert_eq!(view.get(Network::Testnet, &id), None); + } + + /// ID-META-VIEW-005 — `get` on a missing key returns `None` rather than + /// erroring (graceful-degradation contract). + #[test] + fn get_missing_returns_none() { + let kv = kv(); + let view = IdentityMetaView::new(&kv); + assert_eq!(view.get(Network::Devnet, &[0x66; 32]), None); + } + + /// ID-META-VIEW-006 — the canonical key shape uses base58 for the 32-byte + /// identity id; locks the shape so a future change needs a migration. + #[test] + fn key_for_uses_base58_identity_id() { + let id = [0xAB; 32]; + let key = key_for(Network::Mainnet, &id); + assert!(key.starts_with("mainnet:identity_meta:")); + let suffix = key.trim_start_matches("mainnet:identity_meta:"); + assert_eq!(base58::decode(suffix).expect("base58").as_slice(), &id[..]); + } +} diff --git a/src/wallet_backend/identity_ops.rs b/src/wallet_backend/identity_ops.rs new file mode 100644 index 000000000..80fce81bf --- /dev/null +++ b/src/wallet_backend/identity_ops.rs @@ -0,0 +1,414 @@ +//! Identity registration / top-up and platform-address funding on +//! [`WalletBackend`]. +//! +//! These methods own the seed-bearing identity lifecycle: they open a +//! just-in-time [`SecretAccess`](super::SecretAccess) session, provision the +//! identity-funding accounts the watch-only live wallet lacks, and drive the +//! upstream orchestrated register / top-up / asset-lock funding pipelines. +//! Upstream errors are classified into typed `TaskError` variants by the +//! `map_identity_*` / `map_platform_address_fund_error` helpers. + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; + +use super::{ + DetPlatformSigner, DetSigner, PlatformPathIndex, WalletBackend, map_identity_register_error, + map_identity_top_up_error, map_platform_address_fund_error, +}; + +/// The two identity-funding account flavours DET provisions. Parsing the +/// caller's intent into this once (at [`WalletBackend::ensure_identity_funding_accounts`]) +/// removes the repeated `AccountType` matches — and their `unreachable!` arms — +/// inside [`WalletBackend::provision_identity_funding_account`], and makes the +/// unsupported-account-type case unrepresentable. +#[derive(Clone, Copy)] +enum Funding { + /// The identity-registration funding account. + Registration, + /// The per-identity top-up funding account at the given registration index. + TopUp(u32), +} + +impl WalletBackend { + /// Register a new identity on Platform funded by an asset lock built and + /// tracked-to-finality by the upstream `AssetLockManager`. Returns the + /// persisted [`Identity`](dash_sdk::platform::Identity). + /// + /// Wraps upstream `IdentityWallet::register_identity_with_funding` — + /// upstream handles asset-lock build/broadcast, IS→CL fallback with the + /// CL-height-too-low retry, the actual `PutIdentity` submission, and the + /// asset-lock cleanup. The DET retry loop around `UnknownVersionError` + /// and the manual IS-proof-invalid fallback are no longer needed at the + /// caller — upstream owns both paths. + /// + /// `funding` is the upstream funding selector — `FromWalletBalance` + /// builds a fresh asset lock, `FromExistingAssetLock` resumes from a + /// tracked outpoint (the wallet-backend tracker is the single source of + /// asset-lock state). + pub async fn register_identity( + &self, + seed_hash: &WalletSeedHash, + identity_index: u32, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + keys_map: std::collections::BTreeMap<u32, dash_sdk::dpp::identity::IdentityPublicKey>, + identity_signer: &crate::model::qualified_identity::QualifiedIdentity, + settings: Option<dash_sdk::platform::transition::put_settings::PutSettings>, + ) -> Result<dash_sdk::platform::Identity, TaskError> { + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + // Re-provisioning idempotent. Run inside the session so the + // seed is available for hardened xpub derivation (the live + // wallet is watch-only). + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletStateInconsistent)?; + self.ensure_identity_funding_accounts(seed_hash, seed, identity_index) + .await?; + let asset_lock_signer = + DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + wallet + .identity() + .register_identity_with_funding( + funding, + identity_index, + keys_map, + identity_signer, + &asset_lock_signer, + settings, + ) + .await + .map_err(map_identity_register_error) + }) + .await + } + + /// Ensure `identity` (wallet-owned at HD `identity_index`) is registered in + /// the upstream `IdentityManager` for `seed_hash`, so identity ops that look + /// the identity up there (currently: top-up) can find it. + /// + /// Idempotent: a no-op once the identity is managed, and a concurrent + /// `IdentityAlreadyExists` is treated as success. Touches only public-key + /// data — never the seed — so it is safe to call while the wallet is LOCKED. + /// + /// Returns `true` when this call newly registered the identity, `false` when + /// it was already managed — so the reconcile driver logs only real changes. + /// + /// # Errors + /// [`TaskError::WalletNotLoaded`] if the wallet is not yet upstream + /// registered; [`TaskError::WalletStateInconsistent`] if the resolved wallet + /// has no manager entry; [`TaskError::WalletBackend`] on an upstream add + /// failure other than the swallowed `IdentityAlreadyExists`. + pub(crate) async fn ensure_identity_managed( + &self, + seed_hash: &WalletSeedHash, + identity: &dash_sdk::platform::Identity, + identity_index: u32, + ) -> Result<bool, TaskError> { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let id = identity.id(); + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + + // Read-lock fast path: once the identity is managed (steady state, and + // after the first reconcile persists it), skip the write lock entirely. + { + let wm = wallet.wallet_manager().read().await; + if wm + .get_wallet_info(&wallet_id) + .is_some_and(|info| info.identity_manager.identity(&id).is_some()) + { + return Ok(false); + } + } + + let persister = wallet.persister().clone(); + let mut wm = wallet.wallet_manager().write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + // Re-check under the write lock — another task may have raced us in. + if info.identity_manager.identity(&id).is_some() { + return Ok(false); + } + match info.identity_manager.add_identity( + identity.clone(), + identity_index, + wallet_id, + &persister, + ) { + Ok(()) => Ok(true), + Err(platform_wallet::error::PlatformWalletError::IdentityAlreadyExists(_)) => Ok(false), + Err(e) => Err(TaskError::WalletBackend { + source: Box::new(e), + }), + } + } + + /// Top up an existing identity's credit balance from this wallet's + /// UTXOs. Returns the post-top-up identity balance (credits). + /// + /// Wraps upstream `IdentityWallet::top_up_identity_with_funding` — + /// upstream handles asset-lock build/broadcast, IS→CL fallback, the + /// `TopUpIdentity` submission, and the asset-lock cleanup. The + /// caller-side IS-proof-invalid fallback and `UnknownVersionError` + /// retry are no longer needed. + /// + /// `funding` is the upstream funding selector — `FromWalletBalance` + /// builds a fresh asset lock, `FromExistingAssetLock` resumes from a + /// tracked outpoint. + pub async fn top_up_identity( + &self, + seed_hash: &WalletSeedHash, + identity: &dash_sdk::platform::Identity, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + identity_index: u32, + settings: Option<dash_sdk::platform::transition::put_settings::PutSettings>, + ) -> Result<u64, TaskError> { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.id(); + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + // Run inside the session so the seed is available for + // hardened xpub derivation (the live wallet is watch-only). + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletStateInconsistent)?; + self.ensure_identity_funding_accounts(seed_hash, seed, identity_index) + .await?; + // Op-seam guard: register the identity in the upstream manager + // so the top-up lookup finds it, covering the unlock → + // immediate-top-up race the reconcile subtask can lose. + // Seed-free and idempotent. + self.ensure_identity_managed(seed_hash, identity, identity_index) + .await?; + let asset_lock_signer = + DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + wallet + .identity() + .top_up_identity_with_funding( + &identity_id, + funding, + &asset_lock_signer, + settings, + ) + .await + .map_err(|e| map_identity_top_up_error(identity_id, e)) + }) + .await + } + + /// Whether `address` is already revealed in this wallet's upstream + /// platform-payment pool (account 0). This is the exact membership query the + /// orchestrated `fund_from_asset_lock` pre-flight runs, so a `true` here + /// means the orchestrator will accept the recipient. The pool holds only the + /// wallet's own revealed platform addresses, so a `true` also implies + /// ownership. No reveal side effect: an owned-but-unrevealed address reads + /// `false`, and the caller falls back to the manual path. + pub(crate) async fn platform_address_in_pool( + &self, + seed_hash: &WalletSeedHash, + address: &dash_sdk::dpp::address_funds::PlatformAddress, + ) -> Result<bool, TaskError> { + use dash_sdk::dpp::address_funds::PlatformAddress; + use dash_sdk::dpp::key_wallet::PlatformP2PKHAddress; + + let PlatformAddress::P2pkh(hash) = address else { + return Ok(false); + }; + let p2pkh = PlatformP2PKHAddress::new(*hash); + + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + let manager = wallet.wallet_manager().read().await; + Ok(manager + .get_wallet_info(&wallet_id) + .and_then(|info| { + info.core_wallet + .platform_payment_managed_account_at_index(0) + }) + .is_some_and(|account| account.contains_platform_address(&p2pkh))) + } + + /// Fund wallet-owned platform addresses from a Core asset lock through the + /// upstream orchestration pipeline. + /// + /// Wraps `PlatformAddressWallet::fund_from_asset_lock` — upstream owns the + /// full recovery pipeline: asset-lock build/broadcast (for + /// `FromWalletBalance`), `submit_with_cl_height_retry`, the InstantSend → + /// ChainLock fallback, and `consume_asset_lock` on acceptance (so the lock + /// is never left reusable on an ambiguous failure). + /// + /// Callers must pass only destination addresses already revealed in this + /// wallet's upstream platform-payment pool (gate with + /// [`Self::platform_address_in_pool`]) — the orchestrator's pre-flight + /// rejects any other recipient. The `address_signer` authorises per-output + /// witnesses; the `asset_lock_signer` signs the outer state transition + /// against the lock's credit-output key. Neither signer copies the seed — + /// both borrow it from the held session. + /// + /// `platform_account_index` selects the platform-payment account (DET uses + /// 0). The `addresses` map must contain exactly one `None`-amount entry (the + /// remainder recipient); the lock is consumed in full. + // Mirrors the upstream `fund_from_asset_lock` surface; each argument is a + // distinct, required input to that orchestrator. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn fund_platform_address( + &self, + seed_hash: &WalletSeedHash, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + platform_account_index: u32, + addresses: std::collections::BTreeMap< + dash_sdk::dpp::address_funds::PlatformAddress, + Option<dash_sdk::dpp::balances::credits::Credits>, + >, + fee_strategy: dash_sdk::dpp::address_funds::AddressFundsFeeStrategy, + path_index: &PlatformPathIndex, + settings: Option<dash_sdk::platform::transition::put_settings::PutSettings>, + ) -> Result<(), TaskError> { + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let address_signer = + DetPlatformSigner::from_held(seed, self.inner.network, path_index); + let asset_lock_signer = + DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + wallet + .platform() + .fund_from_asset_lock( + funding, + platform_account_index, + addresses, + fee_strategy, + &address_signer, + &asset_lock_signer, + settings, + ) + .await + .map(|_changeset| ()) + .map_err(map_platform_address_fund_error) + }) + .await + } + + // UPSTREAM GAP: rs-platform-wallet has no identity-funding-account + // registrar (sibling to register_contact_account). Contained exception — + // key_wallet plumbing lives ONLY here, never leaks past WalletBackend. + // Do not replicate; do not collapse the dual-insert; do not use the + // funds-bearing API. Tracked: upstream-contribution TODO 9cdcfb25. + // + // `peek_next_funding_address` reads BOTH `wallet.accounts.*` (xpub + // source) AND `wallet_info.accounts.*` (mutable managed account), so the + // account must exist in both collections. `load()` rebuilds + // `IdentityRegistration` from the manifest; per-index + // `IdentityTopUp{registration_index}` enters the manifest only after a + // register/top-up, so a reloaded already-registered identity needs this + // re-provision. Idempotent: probes both collections and no-ops if present + // (no error-string parsing — direct membership checks). + // + // `seed` must be the wallet's HD seed so the hardened account xpub can be + // derived — the live wallet is watch-only and cannot derive hardened paths + // itself. Mirrors the pattern in `register_contact_receiving_accounts`. + async fn provision_identity_funding_account( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + funding: Funding, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::key_wallet::AccountType; + use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreKeysAccount; + + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + let mut wm = wallet.wallet_manager().write().await; + let (kw, info) = wm + .get_wallet_mut_and_info_mut(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + + let (in_wallet, in_managed) = match funding { + Funding::Registration => ( + kw.accounts.identity_registration.is_some(), + info.core_wallet.accounts.identity_registration.is_some(), + ), + Funding::TopUp(registration_index) => ( + kw.accounts.identity_topup.contains_key(&registration_index), + info.core_wallet + .accounts + .identity_topup + .contains_key(&registration_index), + ), + }; + if in_wallet && in_managed { + return Ok(()); + } + + if !in_wallet { + let account_type = match funding { + Funding::Registration => AccountType::IdentityRegistration, + Funding::TopUp(registration_index) => { + AccountType::IdentityTopUp { registration_index } + } + }; + // The live wallet is watch-only: calling `add_account(…, None)` would + // try to derive a hardened path from an absent private key and fail + // with "Watch-only wallet has no private key". Derive the xpub from a + // short-lived seed wallet instead and pass it as `Some(xpub)`. + let seed_wallet = self.seed_wallet(seed)?; + + let path = account_type + .derivation_path(self.inner.network) + .map_err(|source| TaskError::IdentityFundingAccountProvisionFailed { source })?; + + let account_xpub = seed_wallet + .derive_extended_public_key(&path) + .map_err(|source| TaskError::IdentityFundingAccountProvisionFailed { source })?; + + kw.add_account(account_type, Some(account_xpub)) + .map_err(|source| TaskError::IdentityFundingAccountProvisionFailed { source })?; + } + + let derived = match funding { + Funding::Registration => kw.accounts.identity_registration.as_ref(), + Funding::TopUp(registration_index) => { + kw.accounts.identity_topup.get(&registration_index) + } + } + .ok_or(TaskError::WalletStateInconsistent)?; + + let managed = ManagedCoreKeysAccount::from_account(derived); + info.core_wallet + .accounts + .insert_keys_bearing_account(managed) + .map_err(|source| TaskError::IdentityFundingAccountProvisionFailed { source })?; + Ok(()) + } + + /// Provision the identity-registration funding account and the per- + /// identity top-up funding account for the given wallet identity index. + /// Idempotent; safe to call before every asset-lock and on every reload. + /// + /// `seed` must be held for the duration of this call (obtained from + /// `SecretPlaintext::expose_hd_seed` inside a `with_secret_session` scope). + pub async fn ensure_identity_funding_accounts( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + registration_index: u32, + ) -> Result<(), TaskError> { + self.provision_identity_funding_account(seed_hash, seed, Funding::Registration) + .await?; + self.provision_identity_funding_account(seed_hash, seed, Funding::TopUp(registration_index)) + .await + } +} diff --git a/src/wallet_backend/kv.rs b/src/wallet_backend/kv.rs new file mode 100644 index 000000000..43f1cd062 --- /dev/null +++ b/src/wallet_backend/kv.rs @@ -0,0 +1,553 @@ +//! Typed key/value adapter on top of the upstream `KvStore`. +//! +//! [`DetKv`] is the DET-side façade for the upstream +//! [`platform_wallet_storage::KvStore`]: it serializes values with +//! `bincode` behind a one-byte schema version prefix, validates the +//! schema byte on read, and exposes a small `get / put / delete / list` +//! surface keyed by [`DetScope`]. +//! +//! ## Scope seam +//! +//! Callers address entries with [`DetScope`] — a DET-owned enum that +//! never exposes the upstream `ObjectId` / `WalletId` types. The mapping +//! to upstream [`platform_wallet_storage::ObjectId`] happens in exactly +//! one place ([`to_object_id`]) so the wallet-backend seam stays clean. +//! [`DetScope::Global`] and [`DetScope::Wallet`] are used for cross-network +//! settings and per-wallet data respectively. [`DetScope::Identity`] is +//! active: identities, top-up history, scheduled votes, and the DashPay +//! `private` / `address_index` overlays are all identity-scoped. +//! +//! All keys carried by this adapter follow a colon-separated namespace +//! convention, with a mandatory `<network>:` prefix for global slots so +//! mainnet / testnet / devnet entries cannot collide inside the same +//! upstream database file. See the documentation on the consumer +//! callers (e.g. settings storage) for the canonical key schema. +//! +//! ## Encoding +//! +//! Each value is `[ SCHEMA_VERSION (1B) | bincode(payload) ]`, where the +//! payload is encoded with [`bincode::serde::encode_to_vec`] using +//! `bincode::config::standard()`. Reads validate the leading byte and +//! return [`KvAdapterError::SchemaVersion`] if it does not match the +//! adapter's `SCHEMA_VERSION` constant. Bumping the version is a +//! deliberate breaking change — readers will refuse mismatched blobs +//! rather than guessing. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use platform_wallet_storage::{KvError, KvStore, ObjectId, SqlitePersister}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; + +/// Stable lowercase network token (`mainnet` / `testnet` / `devnet` / +/// `regtest`) used across the wallet-backend sidecars, SPV storage paths, and +/// migration sentinels. +/// +/// Hardcoded rather than derived from [`Network`]'s `Display` so an upstream +/// change to that impl cannot silently shift already-persisted on-disk keys. +pub(crate) fn network_prefix(network: Network) -> &'static str { + match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Devnet => "devnet", + Network::Regtest => "regtest", + } +} + +/// Schema version prefix prepended to every encoded value, the envelope +/// that makes stored blobs migratable. +/// +/// `bincode::config::standard()` is positional and non-self-describing: +/// fields are written in declaration order with no names or tags, so +/// adding, removing, or reordering a payload struct's fields is a +/// format-breaking change for already-stored blobs (a `#[serde(default)]` +/// attribute does not rescue an old blob — there are no field names to +/// match). This version byte is the only forward-compatibility mechanism: +/// bump it whenever a payload struct's wire shape changes, then teach +/// readers to migrate or reject the old version explicitly. +pub const SCHEMA_VERSION: u8 = 1; + +/// DET-side metadata scope. Maps onto the upstream object-scoped key/value +/// store without leaking the upstream `ObjectId` / `WalletId` types past +/// the wallet-backend seam. +/// +/// `Global` survives wallet deletion; every other variant anchors its +/// metadata to a parent object that cascades on removal. `Wallet` borrows +/// a [`WalletSeedHash`] (transparently the same `[u8; 32]` the upstream +/// store uses as its `WalletId`). `Identity` is active — identities, +/// top-up history, scheduled votes, and the DashPay `private` / +/// `address_index` overlays are all identity-scoped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetScope<'a> { + /// Global app metadata; no parent, survives wallet deletion. + Global, + /// Per-wallet metadata; cascades when the wallet is removed. + Wallet(&'a WalletSeedHash), + /// Per-identity metadata; cascades when the identity is removed. Active: + /// identities, top-up history, scheduled votes, and the DashPay + /// `private` / `address_index` overlays are all identity-scoped. + Identity(&'a [u8; 32]), +} + +/// Map a DET-side [`DetScope`] onto the upstream [`ObjectId`]. The single +/// chokepoint where the upstream scope type is constructed — keeps +/// `ObjectId` / `WalletId` confined to this module. +fn to_object_id(scope: DetScope<'_>) -> ObjectId { + match scope { + DetScope::Global => ObjectId::Global, + DetScope::Wallet(seed_hash) => ObjectId::Wallet(*seed_hash), + DetScope::Identity(identity_id) => ObjectId::Identity(*identity_id), + } +} + +/// Errors returned by the [`DetKv`] adapter. +#[derive(Debug, thiserror::Error)] +pub enum KvAdapterError { + /// The underlying key/value store rejected an operation. The store accepts + /// scoped writes whose parent object does not exist yet (an `AFTER DELETE` + /// trigger reaps the metadata if the object is later removed), so there is + /// no FK-violation variant to promote — every store error maps here. + #[error("kv store error")] + Store(#[source] KvError), + + /// A stored value's schema version byte did not match + /// [`SCHEMA_VERSION`]. Treated as a hard error rather than a silent + /// fallback so corrupted or future-format blobs are loud. + #[error("kv value has unexpected schema version {found} (expected {expected})")] + SchemaVersion { expected: u8, found: u8 }, + + /// A stored value was empty — the leading schema byte is mandatory. + #[error("kv value is empty (missing schema version byte)")] + Truncated, + + /// `bincode` failed to encode a value for storage. + #[error("kv value encode failed")] + Encode(#[from] bincode::error::EncodeError), + + /// `bincode` failed to decode a stored value. + #[error("kv value decode failed")] + Decode(#[from] bincode::error::DecodeError), +} + +/// Typed key/value adapter. Cheap to clone (`Arc<dyn KvStore>` inside). +#[derive(Clone)] +pub struct DetKv { + store: Arc<dyn KvStore + Send + Sync>, +} + +impl DetKv { + /// Wrap an upstream [`SqlitePersister`] as the backing store. + pub fn new(persister: Arc<SqlitePersister>) -> Self { + Self { store: persister } + } + + /// Construct from any [`KvStore`] implementor. Used by tests that + /// want to inject an in-memory backend. + pub fn from_store(store: Arc<dyn KvStore + Send + Sync>) -> Self { + Self { store } + } + + /// Read and decode the value bound to `(scope, key)`. Returns + /// `Ok(None)` when the key is absent. + pub fn get<T: DeserializeOwned>( + &self, + scope: DetScope<'_>, + key: &str, + ) -> Result<Option<T>, KvAdapterError> { + let raw = self + .store + .get(&to_object_id(scope), key) + .map_err(KvAdapterError::Store)?; + let Some(bytes) = raw else { + return Ok(None); + }; + let (&first, rest) = bytes.split_first().ok_or(KvAdapterError::Truncated)?; + if first != SCHEMA_VERSION { + return Err(KvAdapterError::SchemaVersion { + expected: SCHEMA_VERSION, + found: first, + }); + } + let (value, _) = + bincode::serde::decode_from_slice::<T, _>(rest, bincode::config::standard())?; + Ok(Some(value)) + } + + /// Encode and upsert the value bound to `(scope, key)`. + pub fn put<T: Serialize>( + &self, + scope: DetScope<'_>, + key: &str, + value: &T, + ) -> Result<(), KvAdapterError> { + let mut buf = Vec::with_capacity(64); + buf.push(SCHEMA_VERSION); + let body = bincode::serde::encode_to_vec(value, bincode::config::standard())?; + buf.extend_from_slice(&body); + self.store + .put(&to_object_id(scope), key, &buf) + .map_err(KvAdapterError::Store)?; + Ok(()) + } + + /// Idempotent delete — a missing key returns `Ok(())`. + pub fn delete(&self, scope: DetScope<'_>, key: &str) -> Result<(), KvAdapterError> { + self.store + .delete(&to_object_id(scope), key) + .map_err(KvAdapterError::Store)?; + Ok(()) + } + + /// List keys in the given scope. `prefix = None` returns every + /// key in the scope. The upstream store escapes pattern + /// metacharacters in `prefix`, so any colon-separated namespace + /// is treated literally. + pub fn list( + &self, + scope: DetScope<'_>, + prefix: Option<&str>, + ) -> Result<Vec<String>, KvAdapterError> { + self.store + .list_keys(&to_object_id(scope), prefix) + .map_err(KvAdapterError::Store) + } +} + +impl std::fmt::Debug for DetKv { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DetKv").finish_non_exhaustive() + } +} + +/// Read `(scope, key)` as `T`, treating a decode/store failure as absence. +/// +/// Absence (`Ok(None)`) returns `None` silently — the steady state for an +/// unwritten optional slot. Only an error is logged (at `warn`), tagged with +/// `context` so the failing sidecar is identifiable. The single home for the +/// "read a best-effort k/v value, degrade to absent on error" pattern. +pub(crate) fn kv_get_logged<T: DeserializeOwned>( + kv: &DetKv, + scope: DetScope<'_>, + key: &str, + context: &str, +) -> Option<T> { + match kv.get::<T>(scope, key) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + target: "wallet_backend::kv", + context, + key = %key, + error = ?e, + "Failed to read k/v value; treating as absent", + ); + None + } + } +} + +/// [`kv_get_logged`] with a [`Default`] fallback for both absence and error. +/// Callers that always want a concrete value (never an `Option`) use this. +pub(crate) fn kv_get_or_default<T: DeserializeOwned + Default>( + kv: &DetKv, + scope: DetScope<'_>, + key: &str, + context: &str, +) -> T { + kv_get_logged(kv, scope, key, context).unwrap_or_default() +} + +/// Wrap a [`KvAdapterError`] into the caller's chosen [`TaskError`] variant. +/// +/// The single boxing/funnel point shared by every offline-cache and sidecar +/// mapper: `into_variant` names which surface failed (avatar cache, contact +/// profile, wallet/identity metadata) so the banner copy matches while the +/// error chain is preserved through the boxed `#[source]`. +pub(crate) fn map_kv_storage_error( + e: KvAdapterError, + into_variant: impl FnOnce(Box<KvAdapterError>) -> TaskError, +) -> TaskError { + into_variant(Box::new(e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn fixture() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + #[derive(Debug, PartialEq, Eq, Serialize, serde::Deserialize)] + struct Sample { + name: String, + value: u64, + } + + /// K1: a value written with `put` round-trips through `get` with the + /// schema byte transparently stripped. + #[test] + fn put_then_get_round_trips() { + let kv = fixture(); + let v = Sample { + name: "alpha".to_string(), + value: 42, + }; + kv.put(DetScope::Global, "mainnet:settings:v1", &v).unwrap(); + let got: Option<Sample> = kv.get(DetScope::Global, "mainnet:settings:v1").unwrap(); + assert_eq!(got, Some(v)); + } + + /// K2: a missing key is signalled with `Ok(None)`, never an error + /// — callers branch on the option, not on a sentinel error. + #[test] + fn get_missing_returns_none() { + let kv = fixture(); + let got: Option<Sample> = kv.get(DetScope::Global, "mainnet:settings:v1").unwrap(); + assert!(got.is_none()); + } + + /// K3: a global put and a per-wallet put under the same key do not + /// alias — they live in independent scopes (mirrors the upstream + /// partitioned-table contract). + #[test] + fn global_and_wallet_scopes_are_independent() { + let kv = fixture(); + let wallet: WalletSeedHash = [7u8; 32]; + let g = Sample { + name: "global".to_string(), + value: 1, + }; + let w = Sample { + name: "wallet".to_string(), + value: 2, + }; + kv.put(DetScope::Global, "shared", &g).unwrap(); + kv.put(DetScope::Wallet(&wallet), "shared", &w).unwrap(); + let got_g: Option<Sample> = kv.get(DetScope::Global, "shared").unwrap(); + let got_w: Option<Sample> = kv.get(DetScope::Wallet(&wallet), "shared").unwrap(); + assert_eq!(got_g, Some(g)); + assert_eq!(got_w, Some(w)); + } + + /// K4: `put` upserts — a second write of the same key replaces the + /// previous value rather than failing on conflict. + #[test] + fn put_upserts() { + let kv = fixture(); + kv.put( + DetScope::Global, + "k", + &Sample { + name: "first".to_string(), + value: 1, + }, + ) + .unwrap(); + kv.put( + DetScope::Global, + "k", + &Sample { + name: "second".to_string(), + value: 2, + }, + ) + .unwrap(); + let got: Sample = kv.get(DetScope::Global, "k").unwrap().unwrap(); + assert_eq!(got.name, "second"); + assert_eq!(got.value, 2); + } + + /// K5: `delete` is idempotent — deleting a missing key is `Ok(())`, + /// matching the upstream KvStore contract. + #[test] + fn delete_is_idempotent() { + let kv = fixture(); + kv.delete(DetScope::Global, "absent").unwrap(); + kv.put( + DetScope::Global, + "k", + &Sample { + name: "v".to_string(), + value: 0, + }, + ) + .unwrap(); + kv.delete(DetScope::Global, "k").unwrap(); + kv.delete(DetScope::Global, "k").unwrap(); + let got: Option<Sample> = kv.get(DetScope::Global, "k").unwrap(); + assert!(got.is_none()); + } + + /// K6: a corrupted leading byte surfaces as `SchemaVersion` — the + /// adapter never silently misinterprets a foreign blob as a valid + /// value. + #[test] + fn schema_mismatch_is_loud() { + let store = Arc::new(InMemoryKv::default()); + // Bypass the adapter to plant a value with the wrong leading byte. + let mut raw = vec![SCHEMA_VERSION.wrapping_add(1)]; + raw.extend( + bincode::serde::encode_to_vec( + &Sample { + name: "x".to_string(), + value: 0, + }, + bincode::config::standard(), + ) + .unwrap(), + ); + store.put(&ObjectId::Global, "k", &raw).unwrap(); + let kv = DetKv::from_store(store); + match kv.get::<Sample>(DetScope::Global, "k") { + Err(KvAdapterError::SchemaVersion { expected, found }) => { + assert_eq!(expected, SCHEMA_VERSION); + assert_eq!(found, SCHEMA_VERSION.wrapping_add(1)); + } + other => panic!("expected SchemaVersion error, got {other:?}"), + } + } + + /// K7: a zero-byte stored value is reported as `Truncated` rather + /// than panicking or returning an empty success. + #[test] + fn empty_blob_is_truncated() { + let store = Arc::new(InMemoryKv::default()); + store.put(&ObjectId::Global, "k", &[]).unwrap(); + let kv = DetKv::from_store(store); + match kv.get::<Sample>(DetScope::Global, "k") { + Err(KvAdapterError::Truncated) => {} + other => panic!("expected Truncated, got {other:?}"), + } + } + + /// K8: `list` honours the prefix filter and returns keys in the + /// scope only — global and per-wallet listings stay partitioned. + #[test] + fn list_respects_scope_and_prefix() { + let kv = fixture(); + let wallet: WalletSeedHash = [3u8; 32]; + let v = Sample { + name: "v".to_string(), + value: 0, + }; + kv.put(DetScope::Global, "mainnet:settings:v1", &v).unwrap(); + kv.put(DetScope::Global, "mainnet:scheduled_votes:1", &v) + .unwrap(); + kv.put(DetScope::Global, "testnet:settings:v1", &v).unwrap(); + kv.put(DetScope::Wallet(&wallet), "dashpay:contact:abc", &v) + .unwrap(); + + let mut globals = kv.list(DetScope::Global, Some("mainnet:")).unwrap(); + globals.sort(); + assert_eq!( + globals, + vec![ + "mainnet:scheduled_votes:1".to_string(), + "mainnet:settings:v1".to_string(), + ] + ); + + let wallet_keys = kv.list(DetScope::Wallet(&wallet), None).unwrap(); + assert_eq!(wallet_keys, vec!["dashpay:contact:abc".to_string()]); + + let no_match = kv.list(DetScope::Global, Some("regtest:")).unwrap(); + assert!(no_match.is_empty()); + } + + /// K10: `network_prefix` is a stable lowercase token per network — the + /// on-disk key shape must not shift. + #[test] + fn network_prefix_is_stable() { + assert_eq!(network_prefix(Network::Mainnet), "mainnet"); + assert_eq!(network_prefix(Network::Testnet), "testnet"); + assert_eq!(network_prefix(Network::Devnet), "devnet"); + assert_eq!(network_prefix(Network::Regtest), "regtest"); + } + + /// K11: `kv_get_or_default` returns the stored value when present and the + /// `Default` for both a missing key and a corrupt (wrong-schema) blob. + #[test] + fn kv_get_or_default_present_missing_and_corrupt() { + let store = Arc::new(InMemoryKv::default()); + let kv = DetKv::from_store(store.clone()); + + kv.put(DetScope::Global, "present", &7u64).unwrap(); + let got: u64 = kv_get_or_default(&kv, DetScope::Global, "present", "k11"); + assert_eq!(got, 7); + + let missing: u64 = kv_get_or_default(&kv, DetScope::Global, "absent", "k11"); + assert_eq!(missing, 0); + + // Plant a blob with a bad leading schema byte: read degrades to default. + store + .put( + &ObjectId::Global, + "corrupt", + &[SCHEMA_VERSION.wrapping_add(1), 0x00], + ) + .unwrap(); + let corrupt: u64 = kv_get_or_default(&kv, DetScope::Global, "corrupt", "k11"); + assert_eq!(corrupt, 0); + } + + /// K12: `kv_get_logged` reports absence and read failure as `None`, and the + /// stored value as `Some`. + #[test] + fn kv_get_logged_reports_none_on_absence_and_error() { + let kv = fixture(); + assert_eq!( + kv_get_logged::<u64>(&kv, DetScope::Global, "absent", "k12"), + None + ); + kv.put(DetScope::Global, "present", &42u64).unwrap(); + assert_eq!( + kv_get_logged::<u64>(&kv, DetScope::Global, "present", "k12"), + Some(42) + ); + } + + /// K9: an upstream store failure surfaces on the generic `Store` arm. + /// The store now accepts writes whose parent object is absent, so there + /// is no FK-violation variant to special-case. + #[test] + fn store_error_rides_store_arm() { + struct FailingKv; + impl KvStore for FailingKv { + fn get(&self, _scope: &ObjectId, _key: &str) -> Result<Option<Vec<u8>>, KvError> { + Ok(None) + } + fn put(&self, _scope: &ObjectId, _key: &str, _value: &[u8]) -> Result<(), KvError> { + Err(KvError::LockPoisoned) + } + fn delete(&self, _scope: &ObjectId, _key: &str) -> Result<(), KvError> { + Ok(()) + } + fn list_keys( + &self, + _scope: &ObjectId, + _prefix: Option<&str>, + ) -> Result<Vec<String>, KvError> { + Ok(Vec::new()) + } + } + let kv = DetKv::from_store(Arc::new(FailingKv)); + let wallet: WalletSeedHash = [9u8; 32]; + match kv.put( + DetScope::Wallet(&wallet), + "k", + &Sample { + name: "x".to_string(), + value: 0, + }, + ) { + Err(KvAdapterError::Store(KvError::LockPoisoned)) => {} + other => panic!("expected Store(LockPoisoned), got {other:?}"), + } + } +} diff --git a/src/wallet_backend/kv_test_support.rs b/src/wallet_backend/kv_test_support.rs new file mode 100644 index 000000000..b8810bd8b --- /dev/null +++ b/src/wallet_backend/kv_test_support.rs @@ -0,0 +1,73 @@ +//! Shared in-memory [`KvStore`] test fake — the canonical fixture every +//! `DetKv`-backed test wires against instead of hand-rolling its own copy. +//! +//! Was independently duplicated across 14 files (`wallet_backend/kv.rs` plus +//! 12 other test modules in `wallet_backend/`, `context/`, and +//! `backend_task/migration/`) with byte-identical `get`/`put`/`delete` +//! bodies. Consolidated here following the `leak_test_support` pattern. + +use std::sync::Mutex; + +use platform_wallet_storage::{KvError, KvStore, ObjectId}; + +/// In-memory `KvStore` implementation for adapter/view tests. +/// +/// Models every [`ObjectId`] scope FK-free (no parent-existence checks) so +/// callers can be exercised without a real `SqlitePersister`: +/// - each scope is an independent slot; +/// - `put` is upsert; +/// - `delete` is idempotent; +/// - `list_keys` supports an optional prefix and returns sorted keys. +/// +/// Upstream `ObjectId` is not `Ord`, so the backing store is a flat `Vec` +/// scanned by `PartialEq` rather than a map. LIKE-pattern escaping is +/// irrelevant here — colon separators are not pattern metacharacters — so +/// prefix matching is plain `str::starts_with`. +#[derive(Default)] +pub(crate) struct InMemoryKv { + slots: Mutex<Vec<(ObjectId, String, Vec<u8>)>>, +} + +impl KvStore for InMemoryKv { + fn get(&self, scope: &ObjectId, key: &str) -> Result<Option<Vec<u8>>, KvError> { + Ok(self + .slots + .lock() + .unwrap() + .iter() + .find(|(s, k, _)| s == scope && k == key) + .map(|(_, _, v)| v.clone())) + } + + fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + let mut slots = self.slots.lock().unwrap(); + if let Some(slot) = slots.iter_mut().find(|(s, k, _)| s == scope && k == key) { + slot.2 = value.to_vec(); + } else { + slots.push((scope.clone(), key.to_string(), value.to_vec())); + } + Ok(()) + } + + fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.slots + .lock() + .unwrap() + .retain(|(s, k, _)| !(s == scope && k == key)); + Ok(()) + } + + fn list_keys(&self, scope: &ObjectId, prefix: Option<&str>) -> Result<Vec<String>, KvError> { + let pred = |k: &str| -> bool { prefix.is_none_or(|p| k.starts_with(p)) }; + let mut keys: Vec<String> = self + .slots + .lock() + .unwrap() + .iter() + .filter(|(s, k, _)| s == scope && pred(k)) + .map(|(_, k, _)| k.clone()) + .collect(); + keys.sort(); + Ok(keys) + } +} diff --git a/src/wallet_backend/leak_test_support.rs b/src/wallet_backend/leak_test_support.rs new file mode 100644 index 000000000..2a6bd4f05 --- /dev/null +++ b/src/wallet_backend/leak_test_support.rs @@ -0,0 +1,50 @@ +//! Shared no-leak assertion for secret-path tests — the seam, sidecar, QI-blob, +//! and `ClosedSingleKey`-Debug leak cases call one implementation. +//! +//! The decimal-array check is load-bearing: a `#[derive(Debug)]` on `[u8; N]` +//! leaks the `[160, 167, …]` decimal form. Hex alone would falsely pass against +//! a derived-Debug leak that emits that decimal shape. + +/// Assert `rendered` exposes `secret` in NONE of the forms a sink could leak +/// it: lowercase hex and the `[160, 167, …]` decimal-array form. Works for any +/// secret length (32-byte keys, 64-byte seeds). +pub(crate) fn assert_no_leak_bytes(rendered: &str, secret: &[u8], context: &str) { + let hex = hex::encode(secret); + let decimal_array = format!( + "[{}]", + secret + .iter() + .map(|b| b.to_string()) + .collect::<Vec<_>>() + .join(", ") + ); + assert!( + !rendered.contains(&hex), + "{context} leaked the raw secret (hex): {rendered}" + ); + assert!( + !rendered.contains(&decimal_array), + "{context} leaked the raw secret (byte array): {rendered}" + ); +} + +/// A recognizable 32-byte secret. A full 32-byte collision with unrelated +/// bytes is astronomically improbable, so finding it anywhere in a rendering +/// means the raw key bytes leaked. +pub(crate) fn distinctive_secret_32() -> [u8; 32] { + let mut bytes = [0u8; 32]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = 0xA0 ^ (i as u8).wrapping_mul(7); + } + bytes +} + +/// A recognizable 64-byte secret, the seed-length analogue of +/// [`distinctive_secret_32`]. +pub(crate) fn distinctive_secret_64() -> [u8; 64] { + let mut bytes = [0u8; 64]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = 0xC0 ^ (i as u8).wrapping_mul(5); + } + bytes +} diff --git a/src/wallet_backend/loader.rs b/src/wallet_backend/loader.rs new file mode 100644 index 000000000..715a65b9f --- /dev/null +++ b/src/wallet_backend/loader.rs @@ -0,0 +1,60 @@ +//! Persisted-wallet load seam (G2) — DET-opaque outcome types. +//! +//! [`WalletBackend::load_from_persistor_seedless`](super::WalletBackend::load_from_persistor_seedless) +//! drives the upstream **seedless / watch-only** rehydration API +//! (`PlatformWalletManager::load_from_persistor`, PR #3692): for every +//! wallet **already present in the upstream persistor**, balances, UTXOs, +//! identities, and contacts come back at launch with no seed in memory. +//! Signing keys enter memory later, on demand, when a signing operation +//! pulls the seed just-in-time through the +//! [`SecretAccess`](crate::wallet_backend::SecretAccess) chokepoint. +//! +//! That load is **read-only**: it does NOT register or re-register +//! wallets. It can only return what the persistor already holds. The +//! persistor is populated at the two seed-bearing moments — +//! `WalletBackend::register_wallet_from_seed` (W1, create/import) and +//! `WalletBackend::ensure_upstream_registered` (W2, cold-boot +//! reconciliation). If those have never run for a wallet (fresh install, +//! post-reset, migrated/sidecar-only), the persistor is empty for it and +//! the load brings back nothing — exactly the funds-invisible state the +//! W1/W2 writers exist to prevent. +//! +//! The outcome types here are DET-opaque: [`LoadedWallets`] carries only +//! DET's [`WalletSeedHash`] keys and a DET-side [`PersistedLoadSkip`] +//! reason — no `platform_wallet` / `key_wallet` type crosses the seam. + +use crate::model::wallet::WalletSeedHash; + +/// Outcome of a persisted-wallet load pass, mapped to DET-opaque types. +/// +/// Carries no upstream `platform-wallet` type. A non-empty `skipped` +/// list is still a **success**: a single corrupt persisted row is +/// reported and the remaining wallets load. A whole-load failure is a +/// `TaskError` from [`WalletBackend::load_from_persistor_seedless`], not +/// a populated `skipped`. +/// +/// [`WalletBackend::load_from_persistor_seedless`]: super::WalletBackend::load_from_persistor_seedless +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LoadedWallets { + /// Wallets now registered with the backend, keyed by DET's seed hash. + pub loaded: Vec<WalletSeedHash>, + /// Wallets present on disk but skipped because their persisted row + /// was unusable. The [`WalletSeedHash`] is present only when the + /// skipped wallet could be matched to a DET sidecar entry; an + /// unmatched skip carries `None`. + pub skipped: Vec<(Option<WalletSeedHash>, PersistedLoadSkip)>, +} + +/// DET-opaque reason a persisted wallet row was skipped during load. +/// +/// Mirrors the upstream corrupt-row families without leaking the +/// upstream `CorruptKind` type or any row-derived bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersistedLoadSkip { + /// The wallet row has no usable account manifest to rebuild from. + MissingManifest, + /// The persisted account xpub failed to parse. + MalformedXpub, + /// Any other structural decode/projection failure on the row. + DecodeError, +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs new file mode 100644 index 000000000..36eb3827c --- /dev/null +++ b/src/wallet_backend/mod.rs @@ -0,0 +1,3264 @@ +//! The wallet orchestration seam. +//! +//! `WalletBackend` wraps the upstream `PlatformWalletManager` and is the +//! orchestration layer for everything wallet-related — seed-snapshot +//! ownership, the identity-funding-account chokepoint, and signer +//! construction. It is NOT a type-translation layer: project invariant +//! **M-PLATFORM-WALLET-FIRST-PARTY** allows `platform_wallet`, +//! `key_wallet`, and `platform_wallet_storage` types to appear freely on +//! its public surface. Callers route through `WalletBackend` for the +//! orchestration value, not because the upstream types are hidden. +//! +//! Boundaries that still hold (responsibility, not type leak): +//! 1. No upstream types in DET's SQLite schema (`database/`). +//! 2. No upstream types in MCP tool schemas (`src/mcp/tools/**`). +//! 3. No raw upstream `Display` in user-facing strings — upstream errors +//! go to `BannerHandle::with_details(e)` only. +//! +//! `Clone` is `O(1)` via `Arc<Inner>` (M-SERVICES-CLONE); the type is +//! `Send + Sync`. See +//! `docs/ai-design/2026-05-18-platform-wallet-migration/backend-architecture.md`. + +#[cfg(any(test, feature = "bench"))] +pub mod auth_pubkey_cache; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod auth_pubkey_cache; +mod avatar_cache; +mod contact_profile_cache; +mod coordinator_gate; +mod dashpay; +mod det_platform_signer; +mod det_signer; +mod event_bridge; +#[cfg(any(test, feature = "bench"))] +pub mod hydration; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod hydration; +pub mod identity_key_store; +#[cfg(any(test, feature = "bench"))] +pub mod identity_meta; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod identity_meta; +mod identity_ops; +mod kv; +#[cfg(test)] +pub(crate) mod kv_test_support; +#[cfg(test)] +pub(crate) mod leak_test_support; +mod loader; +mod payments; +pub(crate) mod poison; +pub mod secret_access; +pub mod secret_prompt; +pub mod secret_seam; +mod shielded; +mod sidecar; +#[cfg(any(test, feature = "bench"))] +pub mod single_key; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod single_key; +pub mod single_key_entry; +mod snapshot; +mod token_balance; +mod versioned_bincode; +#[cfg(any(test, feature = "bench"))] +pub mod wallet_meta; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod wallet_meta; +#[cfg(any(test, feature = "bench"))] +pub mod wallet_seed_store; +#[cfg(not(any(test, feature = "bench")))] +pub(crate) mod wallet_seed_store; + +pub use dashpay::DashpayView; +pub(crate) use dashpay::{derive_contact_info_encryption_keys, derive_contact_xpub_material}; + +pub(crate) use det_platform_signer::{DetPlatformSigner, PlatformPathIndex}; +pub(crate) use det_signer::{DetSigner, DetSignerError}; +pub use identity_key_store::IdentityKeyView; +pub use identity_meta::IdentityMetaView; +pub use secret_access::{ + PromptMeta, SecretAccess, SecretPlaintext, SecretSession, VerifiedIdentityPassword, +}; +pub use secret_prompt::{ + NullSecretPrompt, RememberPolicy, SecretPrompt, SecretPromptCancelled, SecretPromptReply, + SecretPromptRequest, SecretPromptRetry, SecretScope, +}; +pub use secret_seam::SecretSeam; + +use coordinator_gate::CoordinatorGate; + +pub use auth_pubkey_cache::AuthPubkeyCacheView; +pub use avatar_cache::AvatarCacheView; +pub use contact_profile_cache::{CachedContactProfile, ContactProfileCacheView}; +pub use event_bridge::EventBridge; +pub(crate) use kv::network_prefix; +pub use kv::{DetKv, DetScope, KvAdapterError, SCHEMA_VERSION as KV_SCHEMA_VERSION}; +pub use loader::{LoadedWallets, PersistedLoadSkip}; +pub use single_key::SingleKeyView; +use snapshot::SnapshotStore; +pub use snapshot::{DetUtxo, DetWalletBalance, WalletSnapshot}; +use token_balance::TokenBalanceStore; +pub use token_balance::UpstreamTokenBalances; +pub use wallet_meta::WalletMetaView; +pub use wallet_seed_store::WalletSeedView; + +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use dash_sdk::Sdk; +use dash_sdk::dash_spv::ClientConfig; +use dash_sdk::dash_spv::client::config::MempoolStrategy; +use dash_sdk::dash_spv::types::ValidationMode; +use dash_sdk::dpp::dashcore::Network; +use platform_wallet::manager::PlatformWalletManager; +use platform_wallet_storage::secrets::SecretStore; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +use crate::app::TaskResult; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::context::connection_status::ConnectionStatus; +use crate::model::selected_identity::SelectedIdentity; +use crate::model::selected_wallet::SelectedWallet; +use crate::model::wallet::{PlatformAddressEntry, WalletSeedHash}; +use crate::ui::MessageType; +use crate::ui::components::message_banner::{BannerHandle, OptionBannerExt}; +use crate::utils::egui_mpsc::SenderAsync; + +/// The upstream persister DET consumes. Authored upstream (PR #3625) — DET +/// does not write its own persister (removal-inventory: consume, don't +/// reimplement). +type DetPersister = SqlitePersister; + +/// Which side of a contact relationship +/// [`WalletBackend::record_contact_request`] writes into the local +/// wallet-manager. Selects the upstream `add_*_contact_request` call and the +/// warning wording for the missing-managed-identity case. +#[derive(Clone, Copy)] +enum ContactRequestRecord { + /// Our outgoing request — recorded into `sent_contact_requests`. + Sent, + /// A peer's incoming request — recorded into `incoming_contact_requests`. + Incoming, +} + +/// One-shot latch guarding chain-sync startup. The upstream +/// `SpvRuntime::spawn_in_background` unconditionally spawns a fresh run loop +/// per call, so [`WalletBackend::start`] uses this to spawn exactly once even +/// when invoked repeatedly (Connect clicked twice, eager-init plus a manual +/// click). +#[derive(Debug, Default)] +struct StartLatch(AtomicBool); + +impl StartLatch { + /// Returns `true` exactly once — on the first call. Every later call + /// returns `false`. Atomic swap, so concurrent callers race to a single + /// winner. + fn try_begin(&self) -> bool { + !self.0.swap(true, Ordering::SeqCst) + } + + /// Whether the latch has been triggered. + fn is_started(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + + /// Re-arm the latch so [`WalletBackend::start`] can spawn the run loop + /// again on a reused backend. Used by the restart-in-place teardown + /// ([`WalletBackend::stop_in_place`]); without it the one-shot + /// `try_begin` would refuse the reconnect's start. + fn reset(&self) { + self.0.store(false, Ordering::SeqCst); + } +} + +/// Default BIP-44 account index for wallet receive/send operations. DET has +/// always operated account 0; multi-account support is out of P2 scope. +const DEFAULT_BIP44_ACCOUNT: u32 = 0; + +/// Number of times [`WalletBackend::resolve_registered_wallet`] re-probes the +/// upstream wallet manager before concluding a wallet is genuinely absent. +/// Tolerates the brief window where a concurrent registration has created the +/// wallet upstream but the manager has not finished exposing it via +/// `get_wallet` — the loser of that race must not spuriously fail. +const REGISTRATION_RESOLVE_RETRIES: u32 = 5; + +/// Delay between the re-probes counted by [`REGISTRATION_RESOLVE_RETRIES`]. +/// Five tries at 20ms bound the wait to ~80ms in the (rare) genuinely-absent +/// case while comfortably covering the in-flight-registration window. +const REGISTRATION_RESOLVE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); + +/// Upstream `WalletId` = `SHA256(root_xpub || root_chain_code)`, distinct +/// from DET's `WalletSeedHash` = `SHA256(seed_bytes)`. The map is the bridge: +/// populated once per wallet at registration, read by every DET-keyed call. +type WalletId = [u8; 32]; + +/// Per-wallet platform-address warm-start seed: `(seed_hash, owned +/// [`PlatformAddressEntry`] list, optional (timestamp, height) cursor)`. +type PlatformWarmStartSeed = Vec<( + WalletSeedHash, + Vec<PlatformAddressEntry>, + Option<(u64, u64)>, +)>; + +struct Inner { + pwm: PlatformWalletManager<DetPersister>, + /// Shared handle to the same persister `pwm` consumes. Kept so the + /// typed key/value adapter ([`DetKv`]) can read/write app data + /// alongside wallet state without opening a second connection. + persister: Arc<DetPersister>, + /// Display-only snapshot store (balance/tx/utxo), pushed by the + /// `EventBridge`. See [`snapshot`]. DISPLAY-ONLY — never feeds coin + /// selection (A04 fund-safety gate). + snapshots: Arc<SnapshotStore>, + /// Lock-free per-`(identity, token)` balance snapshot, refreshed off the + /// UI thread from the upstream `IdentitySyncManager` and read on the + /// frame thread via [`Self::token_balances`]. See [`token_balance`]. + token_balances: Arc<TokenBalanceStore>, + /// `WalletSeedHash` → upstream `WalletId`. See [`WalletId`]. + id_map: std::sync::RwLock<std::collections::BTreeMap<WalletSeedHash, WalletId>>, + /// Cache of `Arc<PlatformWallet>` keyed by `WalletId`, populated at + /// registration. Lets sync code reach an upstream wallet handle without an + /// async hop (e.g. DashPay address-pool scanning). + wallets: std::sync::RwLock< + std::collections::BTreeMap<WalletId, Arc<platform_wallet::PlatformWallet>>, + >, + /// Optional peer `host:port` for Devnet/Regtest or a user-selected local + /// node. `None` ⇒ DNS-seed discovery (Mainnet/Testnet default). + peer: Option<std::net::SocketAddr>, + network: Network, + spv_storage_dir: std::path::PathBuf, + /// Serializes DashPay address-index increments across the process. The + /// `DetKv` adapter has no atomic read-modify-write primitive, so the + /// `dashpay_increment_send_index` path takes this mutex around its + /// get-then-put cycle. Contention is negligible — outgoing-payment + /// dispatch is user-initiated and rare relative to lock acquisition + /// cost. + dashpay_address_index_lock: std::sync::Mutex<()>, + /// Handle to the warning banner raised when persisted wallets are skipped + /// on load. A re-entrant load pass (via [`WalletBackend::ensure_wallets_registered`]) + /// supersedes this banner rather than stacking a second, possibly + /// contradictory one. Interior mutability because the backend is + /// `Arc`-shared and `register_persisted_wallets` runs on `&self`. See + /// [`raise_skipped_wallets_banner`]. + skipped_wallets_banner: std::sync::Mutex<Option<BannerHandle>>, + /// Encrypted secret vault. Holds imported single-key WIFs + /// (`single_key_priv.*` labels, see [`single_key`]) and HD-wallet + /// BIP-39 seeds (`envelope.v1` labels under `WalletId(seed_hash)`, see + /// [`wallet_seed_store`]). [`Self::secret_access`] decrypts seeds + /// just-in-time from this vault for each signing operation; no + /// long-lived plaintext seed cache exists. + secret_store: Arc<SecretStore>, + /// Cross-network app-level k/v store at `<data_dir>/det-app.sqlite`. + /// Backs the DET-owned wallet-metadata sidecar (alias / `is_main` / + /// `core_wallet_name`) — see [`wallet_meta`] (T-W-00). Shared with + /// `AppContext::app_kv` so settings and wallet meta both write into + /// the same persister. + app_kv: Arc<DetKv>, + /// In-memory index of imported single-key entries, keyed by their + /// P2PKH address. Drives `SingleKeyView::list` without enumerating + /// the (non-enumerable) secret store. Seeded on cold boot from the + /// k/v sidecar by `hydrate_context_wallets` (T-W-01b) and kept in + /// sync by `SingleKeyView::import_wif` / `forget`. + single_key_index: std::sync::RwLock< + std::collections::BTreeMap<String, crate::model::single_key::ImportedKey>, + >, + /// The just-in-time secret chokepoint. Constructed over the same + /// [`Self::secret_store`] with the host-chosen [`SecretPrompt`]; seeded + /// with prompt-copy metadata at hydration. Every signing / derivation + /// consumer obtains plaintext through this — HD seeds via + /// [`SecretScope::HdSeed`], imported keys via [`SecretScope::SingleKey`] + /// — so no long-lived plaintext seed or single-key cache exists. + secret_access: SecretAccess, + /// Guards [`WalletBackend::start`] so chain sync spawns exactly once. + /// See [`StartLatch`]. + start_latch: StartLatch, + /// Quorum-readiness gate for the Platform/identity sync coordinators. + /// Shared with the `EventBridge`: `start` arms it, the bridge fires it when + /// the masternode list reaches `Synced`. See [`CoordinatorGate`]. + coordinator_gate: Arc<CoordinatorGate>, + /// Frame-loop result channel, used by [`WalletBackend::start`] to nudge + /// `AppState` to run the all-wallets identity sweep once Platform is ready. + /// A cheap owned clone of the same sender the `EventBridge` holds. + task_result_sender: SenderAsync<TaskResult>, +} + +/// The single wallet entry point. See module docs. +#[derive(Clone)] +pub struct WalletBackend { + inner: Arc<Inner>, +} + +impl std::fmt::Debug for WalletBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletBackend") + .field("network", &self.inner.network) + .finish_non_exhaustive() + } +} + +impl WalletBackend { + /// Construct the backend: open the upstream SQLite persister, build the + /// `PlatformWalletManager` with the DET `EventBridge`, then register every + /// persisted wallet via [`Self::load_from_persistor_seedless`] (per + /// registration, upstream `create_wallet_from_seed_bytes` also rehydrates + /// persisted identity/address state — see g2-mock-boundary.md §G2.1 and the + /// upstream-reality note in the P2 recommendation). + /// + /// Does NOT start chain sync — call [`Self::start`] after construction. + pub async fn new( + ctx: &Arc<AppContext>, + sdk: Arc<Sdk>, + connection_status: Arc<ConnectionStatus>, + task_result_sender: SenderAsync<TaskResult>, + prompt: Arc<dyn SecretPrompt>, + ) -> Result<Self, TaskError> { + let network = ctx.network; + let spv_storage_dir = Self::resolve_spv_storage_dir(ctx.data_dir(), network)?; + + let persister_config = + SqlitePersisterConfig::new(spv_storage_dir.join("platform-wallet.sqlite")); + let persister = Arc::new( + SqlitePersister::open(persister_config) + .map_err(TaskError::from_wallet_storage_open_error)?, + ); + + // Reuse the vault handle `AppContext` already opened at boot. The file + // backend holds an exclusive advisory lock for the handle's lifetime, + // so opening a second handle here would fail with `AlreadyLocked` — and + // `register_wallet` must be able to write seed-envelope sidecars through + // the same handle before the backend is wired. + let secret_store = ctx.secret_store(); + + let snapshots = Arc::new(SnapshotStore::new()); + + let coordinator_gate = Arc::new(CoordinatorGate::default()); + + let bridge = Arc::new(EventBridge::new( + connection_status, + task_result_sender.clone(), + Arc::clone(&snapshots), + Arc::clone(&coordinator_gate), + // The shielded sync-completed callback writes per-wallet balances + // into AppContext's frame-safe snapshot. + Arc::clone(&ctx.shielded_balances), + // Platform-address push writer: the platform address sync-completed callback + // writes per-wallet owned-only balances into AppContext's frame-safe snapshot. + Arc::clone(&ctx.platform_balances), + // ...and the matching `(timestamp, height)` sync-cursor snapshot that + // drives the "Addresses synced" status label. + Arc::clone(&ctx.platform_sync_cursors), + )); + + let pwm = PlatformWalletManager::new(sdk, Arc::clone(&persister), bridge); + + // Wire the upstream shielded coordinator into the manager. + // + // Uses a dedicated SQLite file (`platform-wallet-shielded.sqlite`) owned + // entirely by the upstream coordinator — the single source of truth for + // all Orchard state. The coordinator starts empty — no wallets are bound + // until `ensure_shielded_bound` runs (on wallet unlock). Subsequent + // calls with the same path are idempotent (upstream no-ops). + pwm.configure_shielded(spv_storage_dir.join("platform-wallet-shielded.sqlite")) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + })?; + + let peer = Self::spv_primary_peer_socket(ctx, network); + + let app_kv = ctx.app_kv(); + + // The JIT chokepoint shares the same encrypted vault and is given the + // host-chosen prompt (egui host in the GUI, `NullSecretPrompt` + // headless). Wave C migrates consumers onto it; constructed now so + // the prompt round-trips and the seam is live. + let secret_access = SecretAccess::new(Arc::clone(&secret_store), prompt, network); + + let backend = Self { + inner: Arc::new(Inner { + pwm, + persister, + snapshots, + token_balances: Arc::new(TokenBalanceStore::new()), + id_map: std::sync::RwLock::new(std::collections::BTreeMap::new()), + wallets: std::sync::RwLock::new(std::collections::BTreeMap::new()), + peer, + network, + spv_storage_dir, + dashpay_address_index_lock: std::sync::Mutex::new(()), + skipped_wallets_banner: std::sync::Mutex::new(None), + secret_store, + single_key_index: std::sync::RwLock::new(std::collections::BTreeMap::new()), + app_kv, + secret_access, + start_latch: StartLatch::default(), + coordinator_gate, + task_result_sender, + }), + }; + + // T-W-01 cold-boot: rebuild `ctx.wallets` from the wallet-meta + + // seed-envelope sidecars before the loader runs. The legacy + // `db.get_wallets` row → `Wallet` mapping moved here once the + // sidecars became the authoritative source. `register_persisted_wallets` + // expects `ctx.wallets` to be populated so it can re-provision + // identity funding accounts (a5538dc8) for every persisted + // identity, so hydration must precede registration. + backend.hydrate_context_wallets(ctx)?; + + backend.register_persisted_wallets(ctx).await?; + + Ok(backend) + } + + /// Refill `ctx.wallets` and `ctx.single_key_wallets` from the + /// sidecars for the active network. Idempotent: a re-run overwrites + /// with the same reconstructed wallets keyed by `seed_hash` / + /// `key_hash`. Entries already present in the maps (e.g. created + /// during the current process before the backend was wired) are + /// preserved — sidecar entries only fill gaps so freshly-created + /// wallets are never clobbered. + /// + /// Called once during [`Self::new`] (cold boot) and again by the + /// `finish_unwire` migration after it populates the sidecars on first boot + /// (F140) — at `new` time the sidecars are still empty, so without the + /// post-migration re-run migrated wallets stay invisible until the second + /// restart. + pub(crate) fn hydrate_context_wallets(&self, ctx: &Arc<AppContext>) -> Result<(), TaskError> { + let view = self.single_key(); + view.rehydrate_index()?; + let single_key_wallets = view.hydrate_wallets(); + let reconstructed = self.hydrate_wallets_for_network(ctx.network)?; + + // Seed the JIT chokepoint's prompt-copy metadata so a passphrase + // prompt can show the wallet alias / password hint and the key + // nickname / hint. Absent metadata degrades to a generic label, so + // this is best-effort and runs even when no wallets reconstruct. + self.seed_secret_access_meta(&reconstructed); + + if reconstructed.is_empty() && single_key_wallets.is_empty() { + return Ok(()); + } + { + let mut wallets = ctx.wallets.write()?; + for (seed_hash, wallet) in reconstructed { + wallets + .entry(seed_hash) + .or_insert_with(|| Arc::new(std::sync::RwLock::new(wallet))); + } + if !wallets.is_empty() { + ctx.has_wallet + .store(true, std::sync::atomic::Ordering::Relaxed); + } + } + if !single_key_wallets.is_empty() { + let mut sk = ctx.single_key_wallets.write()?; + for (key_hash, wallet) in single_key_wallets { + sk.entry(key_hash) + .or_insert_with(|| Arc::new(std::sync::RwLock::new(wallet))); + } + if !sk.is_empty() { + ctx.has_wallet + .store(true, std::sync::atomic::Ordering::Relaxed); + } + } + Ok(()) + } + + /// Run the seedless load to bring back persisted wallets watch-only. + /// Identity-funding re-provision is deferred to the asset-lock chokepoint + /// (which obtains the seed just-in-time), so this pass loads, logs, and + /// raises a warning banner for any skipped wallet. + async fn register_persisted_wallets(&self, ctx: &Arc<AppContext>) -> Result<(), TaskError> { + let outcome = self.load_from_persistor_seedless(ctx).await?; + tracing::info!( + loaded = outcome.loaded.len(), + skipped = outcome.skipped.len(), + "Persisted-wallet load pass complete" + ); + for (seed_hash, reason) in &outcome.skipped { + tracing::warn!( + wallet = ?seed_hash.map(hex::encode), + ?reason, + "Skipped a corrupt persisted wallet row on load" + ); + } + { + let mut handle = self + .inner + .skipped_wallets_banner + .lock() + .unwrap_or_else(|e| e.into_inner()); + raise_skipped_wallets_banner(ctx.egui_ctx(), outcome.skipped.len(), &mut handle); + } + + // `load()` rebuilds `IdentityRegistration` from the manifest, but + // per-index `IdentityTopUp{registration_index}` enters the manifest + // only after a register/top-up, so a reloaded already-registered + // identity lacks it. Re-deriving needs the seed (absent under seedless + // load), so every identity asset lock re-provisions at the + // `create_asset_lock_proof` chokepoint. Nothing to do at load time. + for seed_hash in &outcome.loaded { + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Deferring identity-funding provision to the asset-lock chokepoint (seed obtained just-in-time)" + ); + } + Ok(()) + } + + /// Seedless watch-only load over the upstream PR #3692 rehydration + /// API. One `load_from_persistor()` call rebuilds every wallet **the + /// persistor already holds** as a watch-only entry (no seed touched); + /// each registered upstream `WalletId` is then resolved to its DET + /// [`WalletSeedHash`] by matching the loaded wallet's BIP44 account + /// xpub against the `xpub_encoded` DET persisted in its sidecar. + /// + /// READ-ONLY: this does not register wallets. A wallet absent from the + /// persistor (never created/imported under the W1/W2 writers, or + /// post-reset) is not loaded here — it is registered by + /// [`Self::register_wallet_from_seed`] (W1) or + /// [`Self::ensure_upstream_registered`] (W2) the next time its seed is + /// in hand, after which this path rebuilds it on subsequent boots. + /// + /// Fund-routing gate (HIGH): a loaded wallet whose BIP44 account + /// xpub matches **no** sidecar entry is rejected — never registered, + /// never displayed. The match is the published-xpub == scanned-xpub + /// invariant by construction: the watch-only wallet is rebuilt from + /// the persisted account manifest, and DET keys off the same + /// account xpub it published at create time. + /// + /// Idempotent: the upstream `load_from_persistor()` rejects a wallet + /// that is already registered, so it runs only when the manager is + /// empty. A re-run (relaunch simulation, migration replay) re-resolves + /// the already-registered wallets without a second upstream load. + /// + /// No upstream type escapes this method: `WalletId` / `PlatformWallet` + /// are mapped to [`LoadedWallets`] (DET [`WalletSeedHash`]) before + /// returning. + pub(super) async fn load_from_persistor_seedless( + &self, + ctx: &Arc<AppContext>, + ) -> Result<LoadedWallets, TaskError> { + // 1. Build the account-xpub -> WalletSeedHash bridge from DET's + // sidecars. Seedless: `xpub_encoded` is the persisted + // `m/44'/coin'/0'` account xpub (see model/wallet/meta.rs). + let bridge: std::collections::HashMap<Vec<u8>, WalletSeedHash> = self + .wallet_meta() + .list(ctx.network) + .into_iter() + .filter(|(_, meta)| !meta.xpub_encoded.is_empty()) + .map(|(seed_hash, meta)| (meta.xpub_encoded, seed_hash)) + .collect(); + + // 2. One persister load pass, only when the manager is empty. + // A non-empty `skipped` is success; `Err` is reserved for + // whole-load failures. On a re-run the manager already holds + // the wallets, so the upstream load (which rejects duplicates) + // is skipped and only the resolution below runs. + let skipped = if self.inner.pwm.wallet_ids().await.is_empty() { + let outcome = self.inner.pwm.load_from_persistor().await.map_err(|e| { + TaskError::WalletBackend { + source: Box::new(e), + } + })?; + outcome + .skipped() + .iter() + .map(|(_wallet_id, reason)| (None, persisted_load_skip_from_upstream(reason))) + .collect() + } else { + Vec::new() + }; + + // 3. Resolve every currently-registered wallet to its DET seed + // hash via the fund-routing gate, registering it in the + // DET-keyed maps. Driven off the manager's live wallet set so + // the path is identical on first load and re-run. + let mut loaded = Vec::new(); + for wallet_id in self.inner.pwm.wallet_ids().await { + let Some(pw) = self.inner.pwm.get_wallet(&wallet_id).await else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "Registered wallet not retrievable from manager; skipping" + ); + continue; + }; + + // Fund-routing gate: resolve the DET seed hash by matching + // the loaded wallet's BIP44 account xpub against the bridge. + // An unmatched wallet is rejected. + let account_xpub = self.bip44_account_xpub_encoded(&pw).await; + let Some(seed_hash) = account_xpub.as_ref().and_then(|x| bridge.get(x).copied()) else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "Loaded wallet xpub matches no persisted DET wallet; rejecting (fund-routing gate)" + ); + continue; + }; + + self.inner.id_map.write()?.insert(seed_hash, wallet_id); + self.inner + .wallets + .write()? + .insert(wallet_id, Arc::clone(&pw)); + self.inner + .snapshots + .register_wallet(seed_hash, wallet_id, pw); + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Watch-only wallet registered with backend (seedless)" + ); + loaded.push(seed_hash); + } + + Ok(LoadedWallets { loaded, skipped }) + } + + /// `ExtendedPubKey::encode()` bytes of the loaded watch-only wallet's + /// BIP44 account 0 xpub, or `None` when the wallet has no BIP44 + /// account. Read seedlessly off the rebuilt watch-only manifest. + async fn bip44_account_xpub_encoded( + &self, + pw: &platform_wallet::PlatformWallet, + ) -> Option<Vec<u8>> { + let guard = pw.state().await; + bip44_account0_xpub(guard.wallet().accounts.all_accounts()).map(|x| x.encode().to_vec()) + } + + /// Build a short-lived signable wallet from the raw HD `seed`, with the + /// default account set, so hardened account xpubs and the `WalletId` can be + /// derived — the live wallet is watch-only and cannot derive hardened paths + /// itself. Callers read only the derived public material; the seed is + /// borrowed from an open secret session and never retained here. + fn seed_wallet( + &self, + seed: &[u8; 64], + ) -> Result<dash_sdk::dpp::key_wallet::wallet::Wallet, TaskError> { + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + UpstreamWallet::from_seed_bytes( + *seed, + self.inner.network, + WalletAccountCreationOptions::Default, + ) + .map_err(|source| TaskError::SeedWalletBuildFailed { source }) + } + + /// The upstream `WalletId = SHA256(root_xpub ‖ chaincode)` and BIP44 + /// account-0 xpub bytes for the given seed, computed WITHOUT registering. + /// + /// Builds the same `key_wallet::Wallet` the upstream + /// `create_wallet_from_seed_bytes` builds internally and reads its + /// already-computed `wallet_id` (the idempotency probe key) and account + /// xpub (the fund-routing gate's expected value). DET cannot derive the + /// `WalletId` from its sidecar account xpub — BIP44 hardens every level + /// above the account — so the seed is required; this is only ever called + /// where the seed is already in hand. + fn upstream_identity_from_seed( + &self, + seed: &[u8; 64], + ) -> Result<(WalletId, Vec<u8>), TaskError> { + let wallet = self.seed_wallet(seed)?; + let account_xpub = bip44_account0_xpub(wallet.accounts.all_accounts()) + .map(|x| x.encode().to_vec()) + // A freshly-built default wallet always has its BIP44 account 0; + // its absence is an internal inconsistency, not an xpub mismatch. + .ok_or(TaskError::WalletStateInconsistent)?; + Ok((wallet.wallet_id, account_xpub)) + } + + // TODO: receive-address reuse (tracked by TC-012). Two consecutive + // `next_receive_address()` calls return the SAME address: upstream + // `next_unused` returns the lowest UNUSED receive address until it is + // actually used on-chain — funds-safe BIP-44 keypool behavior, but not the + // "fresh address each call" UX the Receive flow wants. The fix is a + // reserve-on-hand-out API that must propagate three layers before DET can + // adopt it: + // 1. dashpay/rust-dashcore#818 "feat(key-wallet): reserve receive + // addresses on hand-out" — adds `next_unused_and_reserve` + // (+ reserve/release/sweep); ready-for-review, NOT yet merged. + // 2. dashpay/platform — surface it as + // `CoreWallet::next_receive_address_and_reserve_for_account` (the + // pinned rev still calls the old non-reserving path). + // 3. DET — bump the platform dep, then switch + // `next_receive_address()` to the reserving variant. + // Until all three land, `next_receive_address` stays on `next_unused` + // (funds-safe) and tc_012's "advances each call" assertion is pinned + // PENDING; tc_012b's gap-window funds-safety assertion stays active. + /// Register a wallet with the upstream SPV backend from its seed, so the + /// upstream persistor is populated and the wallet's addresses are watched + /// (W1 — create/import write path; regression fix). + /// + /// The upstream `create_wallet_from_seed_bytes` is the only writer to the + /// `platform-wallet.sqlite` persistor; the seedless cold-boot loader only + /// reads it. Without this call nothing ever populates the persistor, so a + /// fresh / reset / migrated install never watches the wallet and received + /// funds stay invisible at 100% sync. + /// + /// `birth_height_override` is the SPV scan-window floor — `None` scans from + /// the current tip (fresh wallet), `Some(0)` from genesis (imported/ + /// recovered wallet that may already hold funds). See + /// [`crate::model::wallet::birth_height`]. + /// + /// Idempotent: a wallet already in the upstream manager is a no-op, and an + /// upstream `WalletAlreadyExists` is mapped to `Ok` so a W1/W2 race never + /// double-watches. The `seed` is borrowed for the call only and never + /// parked. + pub(crate) async fn register_wallet_from_seed( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + birth_height_override: Option<u32>, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::error::PlatformWalletError; + + // Idempotency probe: if this seed's wallet is already registered in the + // DET maps, there is nothing to do — never re-register, never + // double-watch. The cheap `seed_hash` lookup runs FIRST so the common + // already-registered case (W1 and W2 can both fire per boot) skips the + // expensive seed derivation in `upstream_identity_from_seed`. + if self.inner.id_map.read()?.contains_key(seed_hash) { + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Wallet already registered with backend; skipping upstream register" + ); + return Ok(()); + } + + // Not registered: derive the upstream identity (BIP32 from the seed) + // now, since the resolve / create paths below both need it. + let (wallet_id, expected_account_xpub) = self.upstream_identity_from_seed(seed)?; + + if self.inner.pwm.get_wallet(&wallet_id).await.is_some() { + // Present upstream but absent from the DET maps (e.g. a prior + // partial run): resolve it into the maps without a second create. + return self + .resolve_registered_wallet(*seed_hash, wallet_id, &expected_account_xpub) + .await; + } + + // Write the persistor via the sole upstream writer. A concurrent + // registration that wins the race surfaces as `WalletAlreadyExists`, + // which is success for our purposes (the wallet IS registered). + match self + .inner + .pwm + .create_wallet_from_seed_bytes( + self.inner.network, + seed, + WalletAccountCreationOptions::Default, + birth_height_override, + ) + .await + { + Ok(_pw) => {} + Err(PlatformWalletError::WalletAlreadyExists(_)) => { + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Upstream reports wallet already exists; treating as registered" + ); + } + Err(e) => { + return Err(TaskError::WalletBackend { + source: Box::new(e), + }); + } + } + + self.resolve_registered_wallet(*seed_hash, wallet_id, &expected_account_xpub) + .await?; + tracing::info!( + wallet = %hex::encode(seed_hash), + birth_height = ?birth_height_override, + "Registered wallet with upstream SPV backend" + ); + Ok(()) + } + + /// Cold-boot / first-unlock reconciliation: register a wallet present in + /// DET sidecars but absent from the upstream persistor (W2). + /// + /// Migrated installs, wallets created before this fix, and post-reset + /// states land with an empty upstream persistor, so the seedless cold-boot + /// loader has nothing to read. This re-populates the persistor the first + /// time the seed becomes available — prompt-free for unprotected wallets at + /// boot, on the unlock gesture for protected ones. The caller already holds + /// the plaintext seed inside a JIT secret scope, so this introduces no new + /// password prompt (preserves the watch-only-at-boot contract). + /// + /// Imported/recovered/migrated wallets may hold deposits made before + /// registration, so this always uses `Some(0)` (genesis) — the only floor + /// that guarantees a pre-existing deposit is found. Idempotent: a wallet + /// already registered is a no-op. + pub(crate) async fn ensure_upstream_registered( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + ) -> Result<(), TaskError> { + use crate::model::wallet::birth_height::{WalletOrigin, registration_birth_height}; + + if self.inner.id_map.read()?.contains_key(seed_hash) { + return Ok(()); + } + self.register_wallet_from_seed( + seed_hash, + seed, + registration_birth_height(WalletOrigin::Imported), + ) + .await + } + + /// Resolve one just-registered upstream wallet into the DET-keyed maps via + /// the account-xpub fund-routing gate, identical in spirit to the gate the + /// seedless loader applies per wallet. + /// + /// Fund-routing gate (HIGH): the registered wallet's BIP44 account xpub + /// MUST equal `expected_account_xpub` — DET's published xpub for this + /// seed. A mismatch means the upstream wallet would route funds for a + /// different seed than DET believes it holds, so it is rejected (never + /// inserted into the maps, never displayed) rather than silently trusted. + async fn resolve_registered_wallet( + &self, + seed_hash: WalletSeedHash, + wallet_id: WalletId, + expected_account_xpub: &[u8], + ) -> Result<(), TaskError> { + // A concurrent registration that won the create race may sit between + // inserting the wallet upstream and exposing it through `get_wallet`, so + // a single probe can read `None` even though the wallet IS being + // registered. Re-poll a few times before declaring it missing — this is + // the TOCTOU tolerance for the A→B window the loser can land in + // (CWE-362/367). The fund-routing xpub gate below is unchanged. + let mut pw = None; + for attempt in 0..REGISTRATION_RESOLVE_RETRIES { + if let Some(found) = self.inner.pwm.get_wallet(&wallet_id).await { + pw = Some(found); + break; + } + if attempt + 1 < REGISTRATION_RESOLVE_RETRIES { + tokio::time::sleep(REGISTRATION_RESOLVE_BACKOFF).await; + } + } + let Some(pw) = pw else { + return Err(TaskError::WalletBackend { + source: Box::new(platform_wallet::error::PlatformWalletError::WalletNotFound( + hex::encode(wallet_id), + )), + }); + }; + + let registered_xpub = self.bip44_account_xpub_encoded(&pw).await; + if registered_xpub.as_deref() != Some(expected_account_xpub) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + "Registered wallet xpub does not match DET's published xpub; rejecting (fund-routing gate)" + ); + return Err(TaskError::WalletRegistrationXpubMismatch); + } + + self.inner.id_map.write()?.insert(seed_hash, wallet_id); + self.inner + .wallets + .write()? + .insert(wallet_id, Arc::clone(&pw)); + self.inner + .snapshots + .register_wallet(seed_hash, wallet_id, pw); + Ok(()) + } + + /// Wipe every piece of DET-local state for a forgotten wallet — the + /// encrypted seed-envelope vault, the session secret cache, the wallet-meta + /// sidecar, and the in-memory `id_map`/`wallets`/snapshot registration. + /// (Orchard state lives in the upstream coordinator now and is detached by + /// [`Self::remove_upstream_wallet`].) + /// + /// This is the synchronous secret-bearing cleanup. The upstream + /// (watch-only, seedless) persistor removal is the sole async step and is + /// driven separately via [`Self::remove_upstream_wallet`]. Persisted secret + /// state is removed before the in-memory handle, so a mid-failure crash + /// never leaves a recoverable seed behind a forgotten in-memory entry. + /// Resilient to partial failure: each step is logged and the rest still + /// run. Idempotent — forgetting an unknown wallet is a no-op success. + pub(crate) fn forget_wallet_local_state( + &self, + seed_hash: &WalletSeedHash, + wallet_id: Option<WalletId>, + ) -> Result<(), TaskError> { + // Seed vault — delete BOTH the raw `seed.raw.v1` (the current form) and + // the legacy `envelope.v1`. Idempotent on both; a wallet may be in + // either form (raw post-migration, legacy pre-migration), so removal + // must clear whichever is present to leave no recoverable seed. + if let Err(e) = self.wallet_seeds().delete_raw(seed_hash) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to delete raw seed from vault" + ); + } + if let Err(e) = self.wallet_seeds().delete(seed_hash) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to delete seed envelope from vault" + ); + } + + // Session secret cache (any remembered plaintext seed). + self.inner.secret_access.forget(&Self::hd_scope(seed_hash)); + + // DET wallet-meta sidecar. + if let Err(e) = self.wallet_meta().delete(self.inner.network, seed_hash) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to delete wallet-meta sidecar" + ); + } + + // Plaintext Orchard state (notes + nullifier cursor) now lives in the + // upstream coordinator store; `remove_upstream_wallet` detaches it. + + // DET-side avatar cache. Avatars live in the cross-network + // Global scope keyed by URL, not partitioned per wallet, so a forgotten + // wallet's contact avatars would otherwise survive deletion forever. + // It is a rebuild-on-view cache, so clearing the whole thing on any + // wallet removal is correct and cheap. + if let Err(e) = self.avatar_cache().clear() { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to clear avatar cache during wallet removal" + ); + } + + // In-memory maps + snapshot registration. + if let Some(wallet_id) = wallet_id { + self.inner.id_map.write()?.remove(seed_hash); + self.inner.wallets.write()?.remove(&wallet_id); + self.inner.snapshots.forget_wallet(seed_hash, &wallet_id); + } + + Ok(()) + } + + /// The upstream `WalletId` DET has registered for `seed_hash`, if any. + /// Sync, lock-free-ish (one read lock). Used by the sync wallet-removal + /// path to drive the async upstream persistor removal off the main thread. + pub(crate) fn registered_wallet_id(&self, seed_hash: &WalletSeedHash) -> Option<WalletId> { + self.inner.id_map.read().ok()?.get(seed_hash).copied() + } + + /// Reset the upstream shielded coordinator for this network: quiesces the + /// 60-second sync loop and empties the coordinator's per-subwallet store so + /// the next bind cold-resyncs from index 0. Idempotent and a no-op when + /// shielded support was never configured. Used by the "delete all local + /// data" sweep. + pub(crate) async fn clear_shielded(&self) -> Result<(), TaskError> { + self.inner + .pwm + .clear_shielded() + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + }) + } + + /// Remove a wallet from the upstream `platform-wallet.sqlite` persistor + /// (also detaches the shielded coordinator). The watch-only persistor row + /// carries no seed, so this is safe to drive asynchronously after the sync + /// secret-bearing cleanup has already run. A `WalletNotFound` race is + /// success. + pub(crate) async fn remove_upstream_wallet( + &self, + wallet_id: &WalletId, + ) -> Result<(), TaskError> { + use platform_wallet::error::PlatformWalletError; + match self.inner.pwm.remove_wallet(wallet_id).await { + Ok(_) | Err(PlatformWalletError::WalletNotFound(_)) => Ok(()), + Err(e) => Err(TaskError::WalletBackend { + source: Box::new(e), + }), + } + } + + /// Permanently wipe the DET-local state of EVERY wallet on this network — + /// the "delete all local data" sweep (F60). Enumerates every persisted HD + /// wallet (the wallet-meta sidecar) and every imported single key (the + /// single-key sidecar), so it reaches wallets that were never loaded into + /// memory this session, not just the in-memory set. + /// + /// Synchronous: it wipes the secret-bearing state (seed-envelope vault, + /// single-key vault, sidecars, shielded notes, session cache, in-memory + /// maps) with no runtime. Returns the upstream `WalletId`s whose watch-only + /// persistor rows still need the async [`Self::remove_upstream_wallet`] + /// removal — the caller drives those off-thread. Resilient to partial + /// failure. + pub(crate) fn forget_all_wallets_local(&self) -> Vec<WalletId> { + let network = self.inner.network; + + // HD wallets: enumerate from the persisted wallet-meta sidecar so a + // never-loaded wallet is still wiped. + let mut upstream_ids = Vec::new(); + for (seed_hash, _meta) in self.wallet_meta().list(network) { + let wallet_id = self.registered_wallet_id(&seed_hash); + if let Some(id) = wallet_id { + upstream_ids.push(id); + } + if let Err(e) = self.forget_wallet_local_state(&seed_hash, wallet_id) { + tracing::warn!( + wallet = %hex::encode(seed_hash), + error = ?e, + "Failed to wipe local HD wallet state during clear-all" + ); + } + } + + // Single-key wallets: enumerate from the persisted single-key sidecar + // and forget each (vault row + sidecar meta + index entry). + let single_key = self.single_key(); + for key in single_key.list_persisted() { + if let Err(e) = single_key.forget(&key.address) { + tracing::warn!( + address = %key.address, + error = ?e, + "Failed to forget single-key wallet during clear-all" + ); + } + } + + // Belt-and-suspenders: drop any remaining session-cached secrets + // (single-key forget does not clear the session cache). + self.forget_all_secrets(); + + upstream_ids + } + + /// Start chain sync and the periodic upstream coordinators. + /// + /// Upstream has no single `PlatformWalletManager::start()`; this + /// orchestrates the parts: `SpvRuntime::spawn_in_background(ClientConfig)` + /// plus the platform-address / identity / shielded sync coordinators. + /// + /// `SpvRuntime::start()` only *constructs* the client; the network and + /// sync loop runs inside `SpvRuntime::run()`, which itself calls + /// `start()` and which `spawn_in_background()` drives on the tokio + /// runtime. Sync failures surface asynchronously via the upstream run + /// task and the `EventBridge` `on_error` callback, not from this call. + /// + /// Idempotent: the first call latches a started flag and spawns the run + /// loop; subsequent calls return `Ok(())` without spawning a second loop. + /// + /// SPV is spawned immediately, but the Platform/identity sync coordinators + /// are gated on masternode-list readiness via [`CoordinatorGate`]: starting + /// them before quorums exist fires proof-verifying DAPI calls that fail + /// locally and get every node banned by the SDK. The gate fires them once, + /// either now (if masternodes already synced) or when the `EventBridge` + /// reports the masternode list reached `Synced`. + pub async fn start(&self) -> Result<(), TaskError> { + if !self.inner.start_latch.try_begin() { + tracing::debug!("Wallet backend chain sync already started; ignoring"); + return Ok(()); + } + + let config = self.build_client_config(); + + // New API: `start(config)` (async, initializes the SPV client) is called first, + // then `spawn_run_loop()` (sync, spawns the background run-loop task). + // The old `spawn_in_background(config)` combined both steps. + let spv = self.inner.pwm.spv_arc(); + spv.start(config) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + })?; + spv.spawn_run_loop(); + + // Defer the coordinator starts behind the quorum-readiness gate. The + // gate is reachable from the `EventBridge`, which the long-lived SPV + // run loop holds, so the action captures WEAK handles: a strong capture + // would pin the coordinators (and through them the persister's advisory + // lock) for as long as the SPV task lives, surviving backend teardown + // and blocking the next reconnect's persister open. At fire time the + // backend is live, so the upgrade always succeeds. + let platform_address_sync = Arc::downgrade(&self.inner.pwm.platform_address_sync_arc()); + let identity_sync = Arc::downgrade(&self.inner.pwm.identity_sync_arc()); + // Shielded sync coordinator (Orchard note scanning). Runs the + // background `ShieldedSyncManager` loop once masternodes are ready. + // `configure_shielded` (called in `new()`) opens the coordinator's + // SQLite file up front; `start()` here just fires the scan loop. + // If no wallets have called `bind_shielded` yet, each pass produces + // an empty summary and returns immediately — safe no-op. + let shielded_sync = Arc::downgrade(&self.inner.pwm.shielded_sync_arc()); + // Owned clone of the frame-loop sender: the gate closure fires exactly + // once per session (single-winner `fired`), so this nudges `AppState` to + // run the all-wallets identity sweep at most once, right when Platform + // is provably reachable. Cloning the sender avoids capturing any + // `Weak<AppContext>` in this run-loop closure. + let task_result_sender = self.inner.task_result_sender.clone(); + self.inner.coordinator_gate.arm(Box::new(move || { + match platform_address_sync.upgrade() { + Some(coordinator) => coordinator.start(), + None => tracing::warn!( + coordinator = "platform-address-sync", + "Coordinator start skipped: backend torn down before the quorum gate fired" + ), + } + match identity_sync.upgrade() { + Some(coordinator) => coordinator.start(), + None => tracing::warn!( + coordinator = "identity-sync", + "Coordinator start skipped: backend torn down before the quorum gate fired" + ), + } + match shielded_sync.upgrade() { + Some(coordinator) => coordinator.start(), + None => tracing::warn!( + coordinator = "shielded-sync", + "Coordinator start skipped: backend torn down before the quorum gate fired" + ), + } + // Platform is reachable now — ask the frame loop to start the + // all-wallets identity discovery sweep. Non-blocking and fired once + // (single-winner gate): a full 256-deep channel would drop this and + // the sweep would not run until a reconnect re-arms the gate, but the + // user can always run discovery manually, so the drop is tolerated. + let _ = task_result_sender.try_send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::PlatformReadyDiscoverIdentities, + ))); + })); + + Ok(()) + } + + /// Whether chain sync has been started on this backend. + /// + /// Reflects the latch set by [`Self::start`]; used by tests and by callers + /// that want to avoid a redundant spawn attempt. + pub fn is_started(&self) -> bool { + self.inner.start_latch.is_started() + } + + /// Stop all upstream background tasks. Idempotent. + /// + /// Stops the SPV run-loop task **first**, then quiesces the sync-manager + /// coordinators. Ordering matters for the platform-open registry: + /// + /// The spawned SPV background task holds `Arc<SpvRuntime>`, which + /// transitively keeps `Arc<SqlitePersister>` alive via the chain + /// `SpvRuntime → event_manager → BalanceUpdateHandler → wallets → + /// Arc<PlatformWallet> → WalletPersister::inner`. The upstream + /// `platform-wallet-storage` crate uses a process-global `REGISTRY` + /// (`OnceLock<Mutex<HashSet<PathBuf>>>`) to enforce a single open per + /// path; a path is removed from the registry only in + /// `Drop<SqlitePersister>`. A still-running SPV task prevents the last + /// `Arc<SqlitePersister>` from dropping, so the path stays registered and + /// the next `WalletBackend::new` (reconnect) fails with + /// `WalletStorageError::AlreadyOpen`. + /// + /// Calling `SpvRuntime::stop()` here joins the background task + /// (abort-with-15 s timeout), releasing those transitive refs. Once the + /// task is gone, the remaining refs are all structural (inside + /// `PlatformWalletManager`) and are released synchronously when the + /// `WalletBackend` itself is dropped by the caller. + /// + /// Coordinator ordering: stopping the SPV *producer* before the + /// *consumers* is safe — no new events can arrive, and any in-flight + /// `sync_now` pass will complete before the subsequent `quiesce()` returns. + pub async fn shutdown(&self) { + // Stop the SPV run-loop first: joins/aborts the background task so the + // transitive Arc<SqlitePersister> it holds is released before the + // manager tears down its coordinators. Errors here are non-fatal — + // teardown must proceed regardless. + if let Err(e) = self.inner.pwm.spv().stop().await { + tracing::warn!( + error = ?e, + "SPV run loop did not stop cleanly during shutdown; continuing teardown" + ); + } + // `pwm.shutdown()` quiesces the periodic coordinators — draining any + // in-flight pass and its persister / host-callback fan-out — then drains + // the wallet-event adapter task. Best-effort: a non-clean report flags a + // still-live worker or orphan, which teardown proceeds past regardless — + // log it rather than surface it. + let report = self.inner.pwm.shutdown().await; + if !report.all_clean() { + tracing::warn!( + ?report, + "Wallet manager shutdown did not complete cleanly; continuing teardown" + ); + } + } + + /// Stop chain sync **in place**, keeping this backend (and its + /// `Arc<SqlitePersister>`) alive so a same-network reconnect can restart + /// on the SAME instance via [`Self::start`] — the persister DB is never + /// closed/reopened, so the reconnect cannot hit + /// `WalletStorageError::AlreadyOpen` (the root of the B-2 bug) by + /// construction. + /// + /// Unlike [`Self::shutdown`], this deliberately does **not** call + /// `pwm.shutdown()`: that cancels and joins the wallet-event adapter task, + /// which has no re-create path, so a subsequent restart would lose event + /// processing. Instead it stops the restartable pieces only: + /// + /// 1. `pwm.spv().stop()` — stops/joins the SPV run loop (releasing the SPV + /// storage advisory lock) while leaving the `SpvRuntime` and its + /// `PlatformEventManager` in place for the next `spawn_in_background`. + /// 2. `quiesce()` the three sync coordinators (cancel + drain the in-flight + /// pass) via their `Arc` accessors — NOT `pwm.shutdown()` — so the event + /// adapter keeps running. + /// 3. Re-arm the DET start gates ([`StartLatch::reset`] + + /// [`CoordinatorGate::reset`]) so the reconnect's `start()` spawns the + /// run loop again and re-fires the coordinators once masternodes re-sync. + /// + /// SPV is stopped before the coordinators (producer before consumers), + /// mirroring [`Self::shutdown`]'s ordering. + /// + /// This is the live same-network disconnect path + /// ([`AppContext::stop_spv`](crate::context::AppContext::stop_spv) calls it). + /// + /// SAFETY (restart-in-place vs the upstream coordinators): restarting the + /// SAME coordinator instance is race-free because every coordinator clears + /// its cancel slot under a `background_generation` guard in the pinned + /// platform rev — `identity_sync`, `shielded_sync`, and (since b4506492) + /// `platform_address_sync`. Without that guard a lagging old thread could + /// clobber a freshly-installed cancel token, leaking an uncancellable / + /// duplicate loop; the guard makes the stale thread observe the bumped + /// generation and stand down. + pub async fn stop_in_place(&self) { + // 1. Stop the SPV run loop first (producer), keeping the SpvRuntime. + if let Err(e) = self.inner.pwm.spv().stop().await { + tracing::warn!( + error = ?e, + "SPV run loop did not stop cleanly during stop_in_place; continuing" + ); + } + // 2. Quiesce the coordinators (consumers) directly — do NOT call + // `pwm.shutdown()`, which would also tear down the non-restartable + // wallet-event adapter. + self.inner.pwm.platform_address_sync_arc().quiesce().await; + self.inner.pwm.identity_sync_arc().quiesce().await; + self.inner.pwm.shielded_sync_arc().quiesce().await; + // 3. Re-arm the DET start gates for the next start() on this backend. + self.inner.start_latch.reset(); + self.inner.coordinator_gate.reset(); + } + + /// Number of wallets currently registered with the backend. + #[cfg(test)] + pub async fn wallet_count(&self) -> usize { + self.inner.pwm.wallet_ids().await.len() + } + + /// Typed key/value adapter backed by the same upstream persister as + /// wallet state. Used for DET-owned application data (settings, + /// scheduled votes, DashPay overlays, etc.) that has no upstream + /// schema of its own. See [`DetKv`] for namespacing conventions and + /// the schema-version envelope. + pub fn kv(&self) -> DetKv { + DetKv::new(Arc::clone(&self.inner.persister)) + } + + /// Persisted platform-address warm-start data, read straight from the + /// persister so the per-address tab, total balance, and "Addresses synced" + /// label can render the last-synced snapshot on cold boot — + /// network-independent, before the coordinator's first (network) pass. + /// + /// Per wallet: owned [`PlatformAddressEntry`] values (each carrying its + /// DIP-17 `account`/`index` recovered from the persisted provider state) + /// plus the persisted `(timestamp, height)` cursor when a prior sync + /// completed. One full persister read; on failure returns empty and the + /// first coordinator push warms the UI once the network is reachable. + pub(crate) fn persisted_platform_address_warm_start(&self) -> PlatformWarmStartSeed { + use platform_wallet::changeset::PlatformWalletPersistence; + let start = match self.inner.persister.load() { + Ok(start) => start, + Err(e) => { + tracing::debug!(error = ?e, "platform-address warm-start: persister load failed"); + return Vec::new(); + } + }; + let per_wallet: Vec<(WalletId, Vec<PlatformAddressEntry>, u64, u64)> = start + .platform_addresses + .into_iter() + .map(|(wallet_id, state)| { + let entries: Vec<PlatformAddressEntry> = state + .per_account + .iter() + .flat_map(|(&account, account_state)| { + account_state.found().iter().map(move |(p2pkh, funds)| { + PlatformAddressEntry { + hash: p2pkh.to_bytes(), + balance: funds.balance, + nonce: funds.nonce, + account, + // Recover the DIP-17 index from the account's + // index↔address bijection so the address is + // registered exactly on warm-start; `None` (a + // found address absent from the bimap should not + // happen) falls back to reverse-derivation. + index: account_state.addresses().get_by_right(p2pkh).copied(), + } + }) + }) + .collect(); + (wallet_id, entries, state.sync_timestamp, state.sync_height) + }) + .collect(); + platform_warm_start_seed(per_wallet, |wallet_id| { + self.inner.snapshots.seed_hash_for(wallet_id) + }) + } + + /// Per-`(identity, token)` balance view (T6 seam). Reads the lock-free + /// snapshot last published by [`Self::refresh_token_balances`] off the + /// upstream `IdentitySyncManager`. Infallible, frame-safe. See + /// [`token_balance`] for the syncing-vs-zero contract. + pub fn token_balances(&self) -> UpstreamTokenBalances { + UpstreamTokenBalances::new(&self.inner.token_balances) + } + + /// Reflect a proof-derived post-transaction balance in the token-balance + /// snapshot immediately, before the next upstream sync pass confirms it. + /// Synchronous and lock-free. Used by token mutation tasks (mint / + /// transfer / burn / purchase / claim) for instant UI feedback. + pub fn apply_known_token_balance( + &self, + identity_id: dash_sdk::platform::Identifier, + token_id: dash_sdk::platform::Identifier, + balance: u64, + ) { + self.inner + .token_balances + .apply(identity_id, token_id, balance); + } + + /// Shared handle to the encrypted secret store backing imported-key + /// material. Most callers should reach for [`Self::single_key`] + /// instead — this accessor exists for the migration engine + /// (T-SK-02), which writes legacy WIFs back into the vault. + pub fn secret_store(&self) -> &Arc<SecretStore> { + &self.inner.secret_store + } + + /// The just-in-time secret chokepoint. O(1)-clone handle; signing, + /// shielded bind, and DashPay derivation reach for this to obtain + /// plaintext through [`SecretAccess::with_secret`] / + /// [`SecretAccess::with_secret_session`] rather than any long-lived + /// seed cache. + pub fn secret_access(&self) -> SecretAccess { + self.inner.secret_access.clone() + } + + /// Clear every session-cached secret in the JIT chokepoint, zeroizing + /// them. Called on network switch and teardown (the `AppContext` drops + /// the per-network backend on switch, but this is the explicit, eager + /// belt-and-suspenders path the design mandates). + pub fn forget_all_secrets(&self) { + self.inner.secret_access.forget_all(); + } + + /// Seed the JIT chokepoint's prompt-copy metadata from the reconstructed + /// HD wallets and the rehydrated single-key index. Best-effort: missing + /// metadata degrades to a generic prompt label, never an error. + fn seed_secret_access_meta( + &self, + reconstructed: &[(WalletSeedHash, crate::model::wallet::Wallet)], + ) { + let wallet_meta: std::collections::BTreeMap<WalletSeedHash, PromptMeta> = reconstructed + .iter() + .map(|(seed_hash, wallet)| { + ( + *seed_hash, + PromptMeta { + alias: wallet.alias.clone(), + password_hint: wallet.password_hint().clone(), + }, + ) + }) + .collect(); + self.inner.secret_access.set_wallet_meta(wallet_meta); + + if let Ok(index) = self.inner.single_key_index.read() { + self.inner.secret_access.set_single_key_index(index.clone()); + } + } + + /// View over the single-key (imported WIF) operations. The view + /// borrows the secret store, the in-memory address index, and the + /// cross-network app k/v sidecar that persists imported-key metadata; + /// all three are cheap to construct, so callers can build one per + /// operation. Passphrase-protected signing goes through + /// [`Self::sign_single_key`] (the JIT chokepoint), not this view. + pub fn single_key(&self) -> SingleKeyView<'_> { + SingleKeyView { + secret_store: &self.inner.secret_store, + index: &self.inner.single_key_index, + network: self.inner.network, + app_kv: Some(&self.inner.app_kv), + } + } + + /// Sign a 32-byte digest with the imported key at `address`, obtaining + /// the key just-in-time through the JIT chokepoint. A passphrase- + /// protected key prompts once; an unprotected key signs with no prompt + /// (the chokepoint's unprotected fast-path). The decrypted key is + /// borrowed by a [`DetSigner`] for the single sign and zeroized when the + /// scope ends. + /// + /// Has no production caller yet — single-key *send* is still stubbed + /// upstream — but is the documented signing chokepoint for that flow and + /// is exercised by unit tests until it is un-gated. + pub async fn sign_single_key( + &self, + address: &str, + msg: &[u8; 32], + ) -> Result<dash_sdk::dpp::dashcore::secp256k1::ecdsa::Signature, TaskError> { + let scope = SecretScope::SingleKey { + address: address.to_string(), + }; + self.inner + .secret_access + .with_secret(&scope, |plaintext| { + let signer = DetSigner::from_held(plaintext, self.inner.network); + signer + .sign_single_key_ecdsa(msg) + .map_err(|source| TaskError::SingleKeySignFailed { source }) + }) + .await + } + + /// View over the DET-owned wallet-metadata sidecar (alias / + /// `is_main` / `core_wallet_name`). Backed by the cross-network + /// app-level k/v store; see [`WalletMetaView`] (T-W-00) for the + /// key schema. The view borrows a shared `Arc<DetKv>` handle, so + /// callers may build one per operation rather than threading it. + pub fn wallet_meta(&self) -> WalletMetaView<'_> { + WalletMetaView::new(&self.inner.app_kv) + } + + /// View over the DET-owned identity-metadata sidecar (the password hint for + /// an identity whose keys are password-protected). Backed by the + /// same cross-network app-level k/v store as [`Self::wallet_meta`]; see + /// [`IdentityMetaView`] for the key schema. Display-only — it never gates + /// whether a sign-time prompt fires (the vault scheme does). + pub fn identity_meta(&self) -> IdentityMetaView<'_> { + IdentityMetaView::new(&self.inner.app_kv) + } + + /// Replace the JIT chokepoint's identity prompt-copy index from the loaded + /// identities (alias) and their persisted hints ([`Self::identity_meta`]). + /// Display-only: it never decides whether to prompt (the vault scheme + /// does). Best-effort — a missing hint degrades to "no hint", never an + /// error. Called whenever identities are (re)loaded so the sign-time prompt + /// for an opted-in identity shows its label and hint. + pub fn seed_identity_prompt_index( + &self, + identities: &[crate::model::qualified_identity::QualifiedIdentity], + ) { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let network = self.inner.network; + let meta_view = self.identity_meta(); + let index: std::collections::BTreeMap<[u8; 32], secret_access::PromptMeta> = identities + .iter() + .map(|qi| { + let id = qi.identity.id().to_buffer(); + let password_hint = meta_view.get(network, &id).and_then(|m| m.password_hint); + ( + id, + secret_access::PromptMeta { + alias: Some(qi.to_string()), + password_hint, + }, + ) + }) + .collect(); + self.inner.secret_access.set_identity_prompt_index(index); + } + + /// View over the DET-owned identity-authentication public-key cache + /// (D4b). Backed by the same cross-network app-level k/v store as + /// [`Self::wallet_meta`], keyed per wallet under + /// `DetScope::Wallet(seed_hash)`; see [`AuthPubkeyCacheView`] for the + /// key schema. The cache memoises the hardened-path identity-auth + /// pubkeys so the steady-state read is seed-free. + pub fn auth_pubkey_cache(&self) -> AuthPubkeyCacheView<'_> { + AuthPubkeyCacheView::new(&self.inner.app_kv) + } + + /// View over the DET-owned avatar image cache. Backed by the + /// same cross-network app-level k/v store as [`Self::wallet_meta`], keyed + /// by avatar URL under [`DetScope::Global`]. Upstream persists only the + /// avatar hash and fingerprint, never the bytes, so this is the only place + /// a contact's avatar image survives offline / between views. + pub fn avatar_cache(&self) -> AvatarCacheView<'_> { + AvatarCacheView::new(&self.inner.app_kv) + } + + /// View over the DET-owned contact-profile cache. A contact's + /// profile belongs to an out-of-wallet identity and is never rehydrated + /// upstream, so this cache is the only offline source of a contact's + /// display name, avatar URL, bio, and DPNS username between network reads. + pub fn contact_profile_cache(&self) -> ContactProfileCacheView<'_> { + ContactProfileCacheView::new(&self.inner.app_kv) + } + + /// View over the encrypted HD wallet seed vault (T-W-00.5-v2). + /// Each wallet's full seed envelope (ciphertext + salt + nonce + + /// `uses_password` + hint + master xpub) lives behind one upstream + /// `SecretStore` entry keyed by `WalletId(seed_hash)`. The vault's + /// Argon2id + XChaCha20-Poly1305 layer protects the envelope at + /// rest; DET's own AES-GCM envelope is preserved inside it so the + /// per-wallet password UX is unchanged. + pub fn wallet_seeds(&self) -> WalletSeedView<'_> { + WalletSeedView::new(&self.inner.secret_store) + } + + /// Per-network storage directory under `<data_dir>/spv/<network>/`. + /// + /// Hosts the upstream `platform-wallet.sqlite` persister file and any + /// other per-network sidecar databases DET maintains (e.g. the shielded + /// commitment tree at `shielded-commitment-tree.sqlite`). + pub fn spv_storage_dir(&self) -> &std::path::Path { + &self.inner.spv_storage_dir + } + + /// Read the persisted [`SelectedWallet`] pointer for this network. + /// + /// Returns [`SelectedWallet::default`] (both fields `None`) when the + /// blob is absent (fresh install, never selected) or the stored + /// value fails to decode (corrupt / future schema). Backed by the + /// same per-network [`SqlitePersister`] as wallet state — selection + /// is per-network by construction, no key prefix needed. + pub fn get_selected_wallet(&self) -> SelectedWallet { + kv::kv_get_or_default( + &self.kv(), + DetScope::Global, + SelectedWallet::KV_KEY, + "selected_wallet", + ) + } + + /// Persist the [`SelectedWallet`] pointer to this network's wallet + /// k/v store. + pub fn set_selected_wallet(&self, selected: &SelectedWallet) -> Result<(), KvAdapterError> { + self.kv() + .put(DetScope::Global, SelectedWallet::KV_KEY, selected) + } + + /// Read the persisted [`SelectedIdentity`] pointer for this network. + /// + /// Returns [`SelectedIdentity::default`] (`None`) when the blob is absent + /// (fresh install, never selected) or fails to decode. Backed by the same + /// per-network persister as wallet state — selection is per-network by + /// construction. + pub fn get_selected_identity(&self) -> SelectedIdentity { + kv::kv_get_or_default( + &self.kv(), + DetScope::Global, + SelectedIdentity::KV_KEY, + "selected_identity", + ) + } + + /// Persist the [`SelectedIdentity`] pointer to this network's wallet + /// k/v store. + pub fn set_selected_identity(&self, selected: &SelectedIdentity) -> Result<(), KvAdapterError> { + self.kv() + .put(DetScope::Global, SelectedIdentity::KV_KEY, selected) + } + + /// Resolve a quorum public key from the upstream SPV masternode state. + /// This is the SDK proof-verification path (fed by `SpvProvider`). + pub async fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], TaskError> { + self.inner + .pwm + .spv() + .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + }) + } + + /// Whether chain sync has not yet reached the tip. + pub async fn is_syncing(&self) -> bool { + match self.inner.pwm.spv().sync_progress().await { + Some(p) => !p.is_synced(), + None => false, + } + } + + /// Map a DET `WalletSeedHash` to the upstream wallet handle. + async fn resolve_wallet( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Arc<platform_wallet::PlatformWallet>, TaskError> { + let wallet_id = *self + .inner + .id_map + .read()? + .get(seed_hash) + .ok_or(TaskError::WalletNotLoaded)?; + self.inner + .pwm + .get_wallet(&wallet_id) + .await + .ok_or(TaskError::WalletStateInconsistent) + } + + /// Derive the next unused receive address for the wallet's default BIP-44 + /// account, as a DET address string. + pub async fn next_receive_address( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<String, TaskError> { + let wallet = self.resolve_wallet(seed_hash).await?; + let addr = wallet + .core() + .next_receive_address_for_account(DEFAULT_BIP44_ACCOUNT) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + })?; + Ok(addr.to_string()) + } + + /// The BIP-44 external (receive) addresses SPV currently watches for the + /// wallet's default account, as DET address strings. + /// + /// This is the SPV-monitored gap-limit window: only deposits to one of + /// these addresses are seen by the wallet. The Receive flow must only ever + /// hand out an address from this set — see the funds-safety regression in + /// `tests/backend-e2e/wallet_tasks.rs`. + /// + /// Takes the upstream manager's blocking read lock, so this is for sync + /// (UI-thread) callers only — never call it from inside a tokio task. From + /// async, wrap it in `tokio::task::block_in_place`. + pub fn monitored_receive_addresses( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Vec<String>, TaskError> { + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + + let wallet_id = *self + .inner + .id_map + .read()? + .get(seed_hash) + .ok_or(TaskError::WalletNotLoaded)?; + let standard = AccountType::Standard { + index: DEFAULT_BIP44_ACCOUNT, + standard_account_type: StandardAccountType::BIP44Account, + }; + let addresses = self + .inner + .pwm + .account_address_pools_blocking(&wallet_id, &standard) + .into_iter() + // pool_type 0 == External (receive); change addresses are pool 1. + .filter(|pool| pool.pool_type == 0) + .flat_map(|pool| pool.addresses.into_iter().map(|info| info.address)) + .collect(); + Ok(addresses) + } + + /// Register every established contact's DIP-15 receiving account so the SPV + /// layer watches the addresses each contact pays us at. + /// + /// The receiving-account path `m/9'/coin'/15'/0'/owner/friend` is hardened, + /// so the upstream `IdentityWallet::register_contact_account` — which derives + /// from the live wallet — returns a watch-only error on the wallets DET + /// rehydrates at boot (they cannot do hardened derivation). This derives the + /// account xpub from a seed-built (signable) wallet instead and inserts the + /// managed `DashpayReceivingFunds` account directly: the contained + /// seed-bearing dual-insert exception, sibling to + /// [`Self::provision_identity_funding_account`]. + /// + /// Upstream keeps the managed account in runtime state only, so this re-runs + /// on every cold boot / unlock and is idempotent (the account-map insert + /// overwrites in place). Only **newly-added** accounts trigger a + /// `bump_monitor_revision`: the `dash-spv` mempool sync manager (shared via + /// one `Arc`) checks the aggregate revision on each 100ms tick and rebuilds + /// the peer bloom filter when it changes. The tick only runs when the mempool + /// manager is in `SyncState::Synced`; if registration happens before SPV sync + /// completes, the accounts are already in the wallet when `activate_all_peers` + /// sends the initial `FilterLoad` at `SyncEvent::FiltersSyncComplete`, so + /// the addresses are watched regardless of sync phase. + /// + /// The caller must already hold the wallet's JIT seed (this runs inside + /// `bootstrap_wallet_addresses_jit`'s secret scope), so a locked wallet is + /// never reached and never prompted. `contacts` are `(owner, contact)` + /// identity-id pairs. Returns the number of **newly-inserted** accounts + /// (0 on idempotent re-registration). + pub(crate) async fn register_contact_receiving_accounts( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + contacts: &[( + dash_sdk::platform::Identifier, + dash_sdk::platform::Identifier, + )], + ) -> Result<usize, TaskError> { + use dash_sdk::dpp::key_wallet::Account; + use dash_sdk::dpp::key_wallet::AccountType; + use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreFundsAccount; + use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use dash_sdk::dpp::key_wallet::managed_account::managed_account_type::ManagedAccountType; + if contacts.is_empty() { + return Ok(0); + } + let network = self.inner.network; + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + + // The DIP-15 receiving path is hardened, so derive the account xpubs + // from a signable seed-built wallet — the live wallet is watch-only and + // cannot derive hardened paths. Built once, reused for every contact. + let seed_wallet = self.seed_wallet(seed)?; + + let mut accounts = Vec::with_capacity(contacts.len()); + for (owner, contact) in contacts { + // Account 0': upstream `DashpayReceivingFunds` hardcodes account 0' + // and every DET caller has only ever used account 0. + let account_type = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let path = match account_type.derivation_path(network) { + Ok(path) => path, + Err(error) => { + tracing::debug!(%error, "Skipping contact account: derivation path failed"); + continue; + } + }; + match seed_wallet.derive_extended_public_key(&path) { + Ok(account_xpub) => accounts.push(Account { + parent_wallet_id: Some(wallet_id), + account_type, + network, + account_xpub, + is_watch_only: false, + }), + Err(error) => { + tracing::debug!(%error, "Skipping contact account: xpub derivation failed"); + } + } + } + if accounts.is_empty() { + return Ok(0); + } + + // Insert under the manager write lock — purely synchronous, no await + // held. Track accounts that are genuinely new (map len growth) so we + // only bump monitor_revision — and thus trigger a bloom-filter rebuild + // — when the set actually changes, not on every idempotent re-run. + let mut wm = wallet.wallet_manager().write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + let before = info.core_wallet.accounts.dashpay_receival_accounts.len(); + for account in &accounts { + let managed = ManagedCoreFundsAccount::from_account(account); + if let Err(error) = info + .core_wallet + .accounts + .insert_funds_bearing_account(managed) + { + tracing::debug!(%error, "Skipping contact account: managed insert failed"); + } + } + let newly_inserted = info + .core_wallet + .accounts + .dashpay_receival_accounts + .len() + .saturating_sub(before); + // Only bump the monitor revision when new accounts were added: a + // revision bump on every unlock would cause a spurious bloom-filter + // rebuild on each boot even when the contact set is unchanged. + if newly_inserted > 0 { + for account in info.core_wallet.accounts.all_funding_accounts_mut() { + if matches!( + account.managed_account_type(), + ManagedAccountType::DashpayReceivingFunds { .. } + ) { + account.bump_monitor_revision(); + } + } + } + Ok(newly_inserted) + } + + /// Count `DashpayReceivingFunds` accounts currently registered in the + /// wallet-manager for `seed_hash`. Used in integration tests to assert that + /// [`Self::register_contact_receiving_accounts`] actually wired contacts + /// into the live wallet-manager state. + pub async fn dashpay_receiving_account_count(&self, seed_hash: &WalletSeedHash) -> usize { + let Ok(wallet) = self.resolve_wallet(seed_hash).await else { + return 0; + }; + let wallet_id = wallet.wallet_id(); + let wm = wallet.wallet_manager().read().await; + wm.get_wallet_info(&wallet_id) + .map(|info| info.core_wallet.accounts.dashpay_receival_accounts.len()) + .unwrap_or(0) + } + + /// Record a successfully-sent contact request in the upstream + /// wallet-manager's in-memory `sent_contact_requests` map. + /// + /// After a contact-request state transition is accepted by Platform, + /// the local `ManagedIdentity` must be updated so that `dashpay_sync` + /// can later auto-establish the contact when the peer's reciprocal + /// request arrives. The upstream auto-establishment gate in + /// `add_incoming_contact_request` only promotes an identity to + /// `established_contacts` when `sent_contact_requests[peer]` already + /// exists locally. DET's custom `send_contact_request_with_proof` + /// bypasses `IdentityWallet::send_contact_request_with_external_signer` + /// and therefore never writes to that map without this explicit call. + /// + /// Non-fatal when the managed identity is not yet in the manager — + /// logs a warning and returns `Ok(())` since the state transition was + /// already committed to Platform. + pub(crate) async fn record_sent_contact_request( + &self, + seed_hash: &WalletSeedHash, + owner_id: &dash_sdk::platform::Identifier, + contact_request: platform_wallet::ContactRequest, + ) -> Result<(), TaskError> { + self.record_contact_request( + seed_hash, + owner_id, + contact_request, + ContactRequestRecord::Sent, + ) + .await + } + + /// Shared body for [`Self::record_sent_contact_request`] and + /// [`Self::record_incoming_contact_request`]: record `contact_request` on + /// the given `direction` into `owner_id`'s local wallet-manager, persisting + /// the resulting changeset. + /// + /// Non-fatal when the managed identity is not yet in the manager — logs a + /// direction-specific warning and returns `Ok(())` since the state + /// transition was already committed to Platform. + async fn record_contact_request( + &self, + seed_hash: &WalletSeedHash, + owner_id: &dash_sdk::platform::Identifier, + contact_request: platform_wallet::ContactRequest, + direction: ContactRequestRecord, + ) -> Result<(), TaskError> { + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + let persister = wallet.persister().clone(); + let mut wm = wallet.wallet_manager().write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + match info.identity_manager.managed_identity_mut(owner_id) { + Some(managed) => { + let recorded = match direction { + ContactRequestRecord::Sent => { + managed.add_sent_contact_request(contact_request, &persister) + } + ContactRequestRecord::Incoming => { + managed.add_incoming_contact_request(contact_request, &persister) + } + }; + recorded.map_err(|e| TaskError::WalletBackend { + source: Box::new(e.into()), + })?; + } + None => match direction { + ContactRequestRecord::Sent => tracing::warn!( + owner_id = %owner_id, + "record_sent_contact_request: managed identity not \ + found; state transition committed but local manager \ + not updated", + ), + ContactRequestRecord::Incoming => tracing::warn!( + owner_id = %owner_id, + "record_incoming_contact_request: managed identity not \ + found; auto-establishment will depend on dashpay_sync", + ), + }, + } + Ok(()) + } + + /// Record a peer's incoming contact request in the accepter's local + /// wallet-manager **before** sending the reciprocal request. + /// + /// Called by `accept_contact_request` with the sender's CR document + /// that was just fetched from Platform. Pre-populating + /// `incoming_contact_requests[sender]` means that when + /// `record_sent_contact_request` fires for the accepter's outgoing + /// CR immediately afterwards, `add_sent_contact_request` finds the + /// matching incoming entry and auto-establishes the contact + /// in-process — no `dashpay_sync` round-trip required. + /// + /// Without this call the accept path has a dead-end: after + /// `record_sent_contact_request` populates `sent[A]`, + /// `sync_contact_requests` sees `sent[A]` and skips A's incoming + /// document (its skip guard is `sent || incoming || established`), + /// so `add_incoming_contact_request` is never called and + /// `established_contacts` stays empty. + /// + /// Non-fatal when the managed identity is absent — logs a warning + /// and returns `Ok(())`. + pub(crate) async fn record_incoming_contact_request( + &self, + seed_hash: &WalletSeedHash, + owner_id: &dash_sdk::platform::Identifier, + contact_request: platform_wallet::ContactRequest, + ) -> Result<(), TaskError> { + self.record_contact_request( + seed_hash, + owner_id, + contact_request, + ContactRequestRecord::Incoming, + ) + .await + } + + /// Re-run the seedless watch-only load pass (idempotent): the upstream + /// `load_from_persistor` rebuilds each wallet watch-only and re-provisions + /// identity funding accounts per loaded wallet. + pub async fn ensure_wallets_registered(&self, ctx: &Arc<AppContext>) -> Result<(), TaskError> { + self.register_persisted_wallets(ctx).await + } + + // ----------------------------------------------------------------- + // Read accessors — DET-typed display surface (P4a). + // + // These read the EventBridge-pushed `WalletSnapshot`. They are + // synchronous, lock-free, and infallible: an absent (pre-first-sync) + // snapshot yields empties, which the UI renders as "syncing". The + // snapshot is DISPLAY-ONLY — coin selection / tx construction MUST go + // through `send_payment` / `create_asset_lock_proof` (A04 gate). + // ----------------------------------------------------------------- + + /// Confirmed / unconfirmed / total balance for the wallet. + pub fn wallet_balance(&self, seed_hash: &WalletSeedHash) -> DetWalletBalance { + self.inner.snapshots.snapshot(seed_hash).balance + } + + /// Full transaction history for the wallet (event-sourced). + pub fn transaction_history( + &self, + seed_hash: &WalletSeedHash, + ) -> Vec<crate::model::wallet::WalletTransaction> { + self.inner + .snapshots + .snapshot(seed_hash) + .transactions + .clone() + } + + /// Current unspent outputs for the wallet. DISPLAY-ONLY — never feed + /// these into coin selection (A04 fund-safety gate). + pub fn utxos(&self, seed_hash: &WalletSeedHash) -> Vec<DetUtxo> { + self.inner.snapshots.snapshot(seed_hash).utxos.clone() + } + + /// UTXO-derived per-address balances for the wallet. + pub fn address_balances( + &self, + seed_hash: &WalletSeedHash, + ) -> std::collections::BTreeMap<dash_sdk::dpp::dashcore::Address, u64> { + self.inner + .snapshots + .snapshot(seed_hash) + .address_balances + .clone() + } + + /// Authoritative derivation path for every generated address of the wallet, + /// from the lock-free display snapshot. Lets the account-summary view + /// categorize funded addresses DET's `watched_addresses` bookkeeping has not + /// indexed yet, so none are dropped from the per-category tab totals. + pub fn address_paths( + &self, + seed_hash: &WalletSeedHash, + ) -> std::collections::BTreeMap< + dash_sdk::dpp::dashcore::Address, + dash_sdk::dpp::key_wallet::bip32::DerivationPath, + > { + self.inner + .snapshots + .snapshot(seed_hash) + .address_paths + .clone() + } + + /// The SPV-watched BIP-44 external (receive) addresses from the lock-free + /// display snapshot, as strings. + /// + /// UI-thread safe (no blocking lock), unlike + /// [`Self::monitored_receive_addresses`]. The Receive list reads this so it + /// only ever shows watched addresses. Empty before the first sync publishes + /// a snapshot — the UI then asks the backend to derive one. + pub fn snapshot_monitored_receive_addresses(&self, seed_hash: &WalletSeedHash) -> Vec<String> { + self.inner + .snapshots + .snapshot(seed_hash) + .monitored_receive_addresses + .clone() + } + + /// Whether a (post-first-sync) snapshot has been published for the + /// wallet. `false` ⇒ render the "syncing" state, not a zero balance. + pub fn has_snapshot(&self, seed_hash: &WalletSeedHash) -> bool { + self.inner.snapshots.has_snapshot(seed_hash) + } + + /// Whether the wallet is registered with the upstream manager (its + /// addresses are being watched by the `SpvRuntime`). This is the + /// pre-sync registration signal — distinct from [`Self::has_snapshot`], + /// which only flips after the first wallet event arrives. + pub fn is_wallet_registered(&self, seed_hash: &WalletSeedHash) -> bool { + self.inner + .id_map + .read() + .map(|m| m.contains_key(seed_hash)) + .unwrap_or(false) + } + + /// List the wallet's tracked asset locks (built, broadcast, + /// instant-locked, chain-locked, or consumed). The upstream + /// `AssetLockManager` is the single source of truth — the DET-side + /// `Wallet.unused_asset_locks` mirror was removed. + /// + /// Async only: UI screens fetch this off the frame loop via + /// [`WalletTask::ListTrackedAssetLocks`](crate::backend_task::wallet::WalletTask::ListTrackedAssetLocks). + pub async fn list_tracked_asset_locks( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Vec<platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock>, TaskError> + { + let wallet = self.resolve_wallet(seed_hash).await?; + Ok(wallet.asset_locks().list_tracked_locks().await) + } + + /// Register (or update) an identity's watched-token list with the upstream + /// `IdentitySyncManager` so its background loop fetches their balances. + /// + /// Idempotent and ordering-stable: a not-yet-registered identity is added + /// fresh; an already-registered one has its watch list replaced via + /// `update_watched_tokens`, which preserves the identity's + /// `last_sync_unix` and per-token balances rather than resetting them to + /// "syncing". Pass the full set of tokens DET tracks for the identity; + /// passing an empty set clears the watch list. + pub async fn register_identity_tokens( + &self, + identity_id: dash_sdk::platform::Identifier, + token_ids: Vec<dash_sdk::platform::Identifier>, + ) { + let identity_sync = self.inner.pwm.identity_sync(); + if identity_sync + .state_for_identity(&identity_id) + .await + .is_some() + { + identity_sync + .update_watched_tokens(identity_id, token_ids) + .await; + } else { + identity_sync + .register_identity(identity_id, token_ids) + .await; + } + } + + /// Stop the upstream `IdentitySyncManager` from watching a single + /// `(identity, token)` pair so its background loop no longer fetches that + /// token's balance and the pair drops out of the next published snapshot. + /// + /// Reads the identity's current watched-token set, removes `token_id`, and + /// replaces the set via `update_watched_tokens` — which preserves the + /// remaining tokens' cached balances. A no-op if the identity isn't + /// registered or wasn't watching the token. Upstream `Identifier` types + /// stay inside this seam; callers pass and receive DET-side identifiers. + pub async fn unwatch_identity_token( + &self, + identity_id: dash_sdk::platform::Identifier, + token_id: dash_sdk::platform::Identifier, + ) { + let identity_sync = self.inner.pwm.identity_sync(); + let Some(state) = identity_sync.state_for_identity(&identity_id).await else { + return; + }; + let remaining: Vec<dash_sdk::platform::Identifier> = state + .tokens + .iter() + .map(|info| info.token_id) + .filter(|t| *t != token_id) + .collect(); + if remaining.len() == state.tokens.len() { + return; + } + identity_sync + .update_watched_tokens(identity_id, remaining) + .await; + self.refresh_token_balances().await; + } + + /// Force one immediate upstream token-balance sync pass, then republish + /// DET's snapshot. Use after registering watched tokens so a user-initiated + /// "Refresh" reflects the latest balances without waiting for the + /// background loop's next tick. A no-op pass (already syncing) still + /// refreshes the snapshot from whatever state is current. + pub async fn sync_token_balances_now(&self) { + self.inner.pwm.identity_sync().sync_now().await; + self.refresh_token_balances().await; + } + + /// Republish the lock-free token-balance snapshot from the upstream + /// `IdentitySyncManager`'s current state. Async — call from a backend + /// task, never the egui frame; the frame reads the published snapshot via + /// [`Self::token_balances`]. + /// + /// Only identities that have completed at least one sync pass + /// (`last_sync_unix != 0`) contribute rows; an unsynced identity is + /// omitted so the UI renders it as "syncing", not a zero balance. Upstream + /// `IdentityTokenSyncState` / `IdentityTokenSyncInfo` / `TokenAmount` are + /// converted to DET types here so they never cross the seam. + pub async fn refresh_token_balances(&self) { + let all_state = self.inner.pwm.identity_sync().all_state().await; + let synced = all_state.into_values().filter_map(|state| { + if state.last_sync_unix == 0 { + return None; + } + let balances = state + .tokens + .into_iter() + .map(|info| (info.token_id, info.balance)) + .collect::<std::collections::BTreeMap<_, _>>(); + Some((state.identity_id, balances)) + }); + self.inner + .token_balances + .publish(TokenBalanceStore::snapshot_from(synced)); + } + + /// The [`SecretScope`] that addresses the HD seed for `seed_hash`. + fn hd_scope(seed_hash: &WalletSeedHash) -> SecretScope { + SecretScope::HdSeed { + seed_hash: *seed_hash, + } + } + + fn build_client_config(&self) -> ClientConfig { + // Scan from genesis so historical wallet transactions are found via + // compact block filters. + let mut config = ClientConfig::new(self.inner.network) + .with_storage_path(self.inner.spv_storage_dir.clone()) + .with_validation_mode(ValidationMode::Full) + .with_start_height(0) + .with_mempool_tracking(MempoolStrategy::BloomFilter); + if let Some(peer) = self.inner.peer { + config.add_peer(peer); + } + config + } + + /// Resolve an explicit SPV peer for local networks. Devnet/Regtest have + /// no DNS seeds, so a `core_host` peer is required there; Mainnet/Testnet + /// fall back to DNS-seed discovery (`None`). + fn spv_primary_peer_socket( + ctx: &Arc<AppContext>, + network: Network, + ) -> Option<std::net::SocketAddr> { + use std::net::ToSocketAddrs; + /// Default Core P2P port on Devnet. + const DEVNET_P2P_PORT: u16 = 20001; + /// Default Core P2P port on Regtest. + const REGTEST_P2P_PORT: u16 = 19899; + + let port = match network { + Network::Devnet => DEVNET_P2P_PORT, + Network::Regtest => REGTEST_P2P_PORT, + _ => return None, + }; + let cfg = ctx.config.read().ok()?; + let host = cfg.core_host.as_deref()?; + format!("{host}:{port}").to_socket_addrs().ok()?.next() + } + + fn resolve_spv_storage_dir( + app_data_dir: &Path, + network: Network, + ) -> Result<std::path::PathBuf, TaskError> { + let mut dir = app_data_dir.to_path_buf(); + dir.push("spv"); + dir.push(kv::network_prefix(network)); + std::fs::create_dir_all(&dir).map_err(|source| TaskError::FileSystem { source })?; + Ok(dir) + } +} + +/// The BIP44 account-0 extended public key among `accounts`, or `None` when +/// there is no BIP44 account-0. +/// +/// The single definition of the fund-routing gate's account predicate: DET +/// resolves a watch-only wallet to its seed by matching this exact account +/// xpub, so the predicate must not drift between the seedless-load path and the +/// pre-registration probe. +fn bip44_account0_xpub<'a>( + accounts: impl IntoIterator<Item = &'a dash_sdk::dpp::key_wallet::Account>, +) -> Option<dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey> { + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + accounts + .into_iter() + .find(|a| { + matches!( + a.account_type, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + ) + }) + .map(|a| a.account_xpub) +} + +/// Map a [`PlatformWalletError`] from any shielded operation to the correct +/// [`TaskError`] variant. +/// +/// **Exhaustive — no `_` arm** on the outer match so a future upstream variant +/// addition forces a review here instead of silently falling through to +/// [`TaskError::WalletBackend`]. +/// +/// [`ShieldedSpendUnconfirmed`](platform_wallet::error::PlatformWalletError::ShieldedSpendUnconfirmed) +/// is pre-flighted before the exhaustive match because `operation` is +/// `&'static str` (not an enum): we copy it out without consuming `e`, then +/// route to the correct per-op `*ConfirmationUnknown` variant. Unknown +/// `operation` values (future upstream ops) fall through to `WalletBackend`. +fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> TaskError { + use platform_wallet::error::PlatformWalletError as P; + + // Pre-flight: route ShieldedSpendUnconfirmed by operation name. + // `operation` is `&'static str` (Copy) — extract without consuming `e`. + let maybe_op = match &e { + P::ShieldedSpendUnconfirmed { operation, .. } => Some(*operation), + _ => None, + }; + if let Some(op) = maybe_op { + return match op { + "shield" => TaskError::ShieldCreditsConfirmationUnknown { + source: Box::new(e), + }, + "transfer" => TaskError::ShieldedTransferConfirmationUnknown { + source: Box::new(e), + }, + "unshield" => TaskError::UnshieldConfirmationUnknown { + source: Box::new(e), + }, + "withdraw" => TaskError::ShieldedWithdrawalConfirmationUnknown { + source: Box::new(e), + }, + // Future operation name from a newer upstream — fall through to + // WalletBackend rather than silently discarding the error. + _ => TaskError::WalletBackend { + source: Box::new(e), + }, + }; + } + + // Exhaustive match — no `_` arm. + match e { + P::ShieldedNotBound => TaskError::ShieldedNotBound, + + // Asset-lock finality failures (IS deadline / IS-expired / CL fallback). + other @ (P::FinalityTimeout(_) + | P::AssetLockProofWait(_) + | P::AssetLockExpired(_) + | P::AssetLockNotChainLocked(_)) => TaskError::AssetLockFinalityTimeout { + source: Box::new(other), + }, + + // Handled by the pre-flight above. Kept as a defensive fallthrough + // rather than `unreachable!`: this is a funds-safety path, so if the + // pre-flight ever stops covering a case it must degrade to the generic + // wrapper, never panic mid-operation. + other @ P::ShieldedSpendUnconfirmed { .. } => TaskError::WalletBackend { + source: Box::new(other), + }, + + // Every remaining variant → generic WalletBackend wrapper. + other @ (P::WalletCreation(_) + | P::RehydrationTopologyUnsupported { .. } + | P::WalletNotFound(_) + | P::WalletAlreadyExists(_) + | P::IdentityAlreadyExists(_) + | P::IdentityNotFound(_) + | P::NoPrimaryIdentity + | P::InvalidIdentityData(_) + | P::ContactRequestNotFound(_) + | P::IdentityIndexNotSet(_) + | P::DashpayReceivingAccountAlreadyExists { .. } + | P::DashpayExternalAccountAlreadyExists { .. } + | P::AssetLockTransaction(_) + | P::TransactionBroadcast(_) + | P::TransactionBroadcastUnconfirmed(_) + | P::TransactionBuild(_) + | P::NoSpendableInputs { .. } + | P::Sdk(_) + | P::AddressSync(_) + | P::AddressOperation(_) + | P::OnlyOutputAddressesFunded { .. } + | P::OnlyDustInputs { .. } + | P::ChangeBelowMinimumOutput { .. } + | P::InputSumOverflow + | P::AddressNotFound(_) + | P::KeyDerivation(_) + | P::RehydrationPoolMismatch { .. } + | P::RehydrationPoolTypeMismatch { .. } + | P::PersisterLoad(_) + | P::Persistence(_) + | P::SeedMismatch { .. } + | P::WalletLocked + | P::SpvAlreadyRunning + | P::NoWalletsConfigured + | P::SpvError(_) + | P::TokenError(_) + | P::ShieldedNoUnspentNotes + | P::ShieldedInsufficientBalance { .. } + | P::ShieldedBuildError(_) + | P::ShieldedBroadcastFailed(_) + | P::ShieldedBroadcastUnconfirmed { .. } + | P::ShieldedNoRecordedAnchor(_) + | P::ShieldedSyncFailed(_) + | P::ShieldedTreeUpdateFailed(_) + | P::ShieldedStoreError(_) + | P::ShieldedMerkleWitnessUnavailable(_) + | P::ShieldedKeyDerivation(_) + | P::AddressNonceMismatch { .. } + | P::ShieldedShutdownIncomplete { .. }) => TaskError::WalletBackend { + source: Box::new(other), + }, + } +} + +/// Classify a `PlatformWalletError` returned from +/// `register_identity_with_funding` into a typed `TaskError`. Network / +/// broadcast rejections become `IdentityCreateRejected`; asset-lock +/// finality failures become `AssetLockFinalityTimeout`; everything else +/// falls through to the generic `WalletBackend` wrapper. Structural match +/// — never parses error strings. +fn map_identity_register_error(e: platform_wallet::error::PlatformWalletError) -> TaskError { + match identity_op_error_kind(&e) { + IdentityOpErrorKind::Rejected => TaskError::IdentityCreateRejected { + source: Box::new(e), + }, + IdentityOpErrorKind::FinalityTimeout => TaskError::AssetLockFinalityTimeout { + source: Box::new(e), + }, + // Registration creates the identity, so it cannot legitimately raise a + // "not managed" lookup error — fold into the generic envelope. + IdentityOpErrorKind::NotManaged | IdentityOpErrorKind::Other => TaskError::WalletBackend { + source: Box::new(e), + }, + } +} + +/// Same as [`map_identity_register_error`] but for the top-up façade — +/// the `identity_id` is carried into the rejection variant so the user- +/// facing message can reference the affected identity. +fn map_identity_top_up_error( + identity_id: dash_sdk::platform::Identifier, + e: platform_wallet::error::PlatformWalletError, +) -> TaskError { + match identity_op_error_kind(&e) { + IdentityOpErrorKind::Rejected => TaskError::IdentityTopUpRejected { + identity_id, + source: Box::new(e), + }, + IdentityOpErrorKind::FinalityTimeout => TaskError::AssetLockFinalityTimeout { + source: Box::new(e), + }, + IdentityOpErrorKind::NotManaged => TaskError::IdentityNotManaged { + identity_id, + source: Box::new(e), + }, + IdentityOpErrorKind::Other => TaskError::WalletBackend { + source: Box::new(e), + }, + } +} + +/// Shape persisted platform-address state into per-wallet warm-start seed data. +/// +/// Resolves each upstream wallet id to its DET [`WalletSeedHash`] via `resolve` +/// and derives the `(timestamp, height)` cursor — present only when a prior sync +/// completed (`sync_timestamp > 0`), so a never-synced wallet stays "never +/// synced". Wallets that resolve to no DET seed, or that carry neither owned +/// addresses nor a cursor, are dropped. Pure — no I/O, no upstream types — so it +/// is unit-testable without a persister. +fn platform_warm_start_seed( + per_wallet: Vec<(WalletId, Vec<PlatformAddressEntry>, u64, u64)>, + resolve: impl Fn(&WalletId) -> Option<WalletSeedHash>, +) -> PlatformWarmStartSeed { + per_wallet + .into_iter() + .filter_map(|(wallet_id, entries, sync_timestamp, sync_height)| { + let seed_hash = resolve(&wallet_id)?; + let cursor = (sync_timestamp > 0).then_some((sync_timestamp, sync_height)); + (!entries.is_empty() || cursor.is_some()).then_some((seed_hash, entries, cursor)) + }) + .collect() +} + +/// Map an orchestrated platform-address funding error to a typed `TaskError`. +/// Shares the identity-flow bucketing: an asset-lock finality timeout reuses +/// [`TaskError::AssetLockFinalityTimeout`], a network/broadcast rejection lands +/// in [`TaskError::PlatformAddressFundRejected`], and everything else falls +/// through to the generic [`TaskError::WalletBackend`] envelope. +fn map_platform_address_fund_error(e: platform_wallet::error::PlatformWalletError) -> TaskError { + match identity_op_error_kind(&e) { + IdentityOpErrorKind::Rejected => TaskError::PlatformAddressFundRejected { + source: Box::new(e), + }, + IdentityOpErrorKind::FinalityTimeout => TaskError::AssetLockFinalityTimeout { + source: Box::new(e), + }, + // Platform-address funding does not consult the identity manager, so a + // "not managed" classification is not meaningful here — fold into the + // generic envelope alongside the other preconditions. + IdentityOpErrorKind::NotManaged | IdentityOpErrorKind::Other => TaskError::WalletBackend { + source: Box::new(e), + }, + } +} + +/// Map an upstream load-skip reason to its DET-opaque equivalent, +/// dropping any row-derived string so no upstream detail crosses the +/// seam. +fn persisted_load_skip_from_upstream( + reason: &platform_wallet::manager::load_outcome::SkipReason, +) -> PersistedLoadSkip { + use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason}; + match reason { + SkipReason::CorruptPersistedRow { kind } => match kind { + CorruptKind::MissingManifest => PersistedLoadSkip::MissingManifest, + CorruptKind::MalformedXpub => PersistedLoadSkip::MalformedXpub, + CorruptKind::DecodeError(_) => PersistedLoadSkip::DecodeError, + // `CorruptKind` is `#[non_exhaustive]`: a future structural + // family folds into the generic decode-failure bucket so no + // upstream detail crosses the seam. + _ => PersistedLoadSkip::DecodeError, + }, + // `SkipReason` is `#[non_exhaustive]`: any future skip reason + // surfaces as a generic decode failure rather than breaking the + // build or leaking row-derived detail across the seam. + _ => PersistedLoadSkip::DecodeError, + } +} + +/// Builds the calm, user-facing banner text for wallets skipped on +/// persisted load, or `None` if nothing was skipped. Never includes +/// row-derived detail (seed hashes, upstream reason strings) — those +/// stay in the logs. +pub fn skipped_wallets_banner_text(skipped: usize) -> Option<String> { + match skipped { + 0 => None, + 1 => Some( + "1 saved wallet couldn't be opened. Re-add it from its recovery phrase to restore it." + .to_string(), + ), + n => Some(format!( + "{n} saved wallets couldn't be opened. Re-add them from their recovery phrases to restore them." + )), + } +} + +/// Raise the skipped-persisted-wallets warning banner for this load pass, +/// superseding any banner a previous pass left behind so a re-entrant call +/// (e.g. via [`WalletBackend::ensure_wallets_registered`]) with a different +/// skip count never stacks a second, contradictory banner alongside the +/// first. A pass with zero skips clears a stale banner from an earlier pass +/// rather than leaving it stuck. +fn raise_skipped_wallets_banner( + ctx: &egui::Context, + skipped_count: usize, + banner_handle: &mut Option<BannerHandle>, +) { + match skipped_wallets_banner_text(skipped_count) { + Some(text) => banner_handle.raise_persistent(ctx, text, MessageType::Warning), + None => banner_handle.take_and_clear(), + } +} + +/// Bucket for `PlatformWalletError`s coming out of identity register / top-up. +enum IdentityOpErrorKind { + /// Network or broadcast rejected the submission (SDK error or asset-lock + /// transaction broadcast failure). + Rejected, + /// Asset-lock proof finalization (IS → CL fallback) failed to produce a + /// usable proof — IS deadline elapsed, IS expired with no CL fallback, or + /// the wait helper itself failed. + FinalityTimeout, + /// The identity is not registered in the wallet's active set, so a lookup + /// op (top-up) cannot find it — retrying the same op cannot help; the + /// identity must be reloaded. + NotManaged, + /// Anything else — preconditions, wallet state, builder failures. + Other, +} + +/// Map `PlatformWalletError` variants to coarse buckets. Exhaustive on the +/// upstream enum — no `_` arm — so a future variant addition forces a +/// review here instead of silently falling through. +fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> IdentityOpErrorKind { + use platform_wallet::error::PlatformWalletError as P; + match e { + // Network / broadcast rejections. + P::Sdk(_) | P::TransactionBroadcast(_) => IdentityOpErrorKind::Rejected, + + // Asset-lock finality failures (IS deadline / IS-expired / CL fallback). + P::FinalityTimeout(_) + | P::AssetLockProofWait(_) + | P::AssetLockExpired(_) + | P::AssetLockNotChainLocked(_) => IdentityOpErrorKind::FinalityTimeout, + + // The identity is absent from the wallet's active set — a missing + // manager registration, not a transient fault. + P::IdentityNotFound(_) | P::IdentityIndexNotSet(_) => IdentityOpErrorKind::NotManaged, + + // Everything else — preconditions, wallet state, builder errors. + P::WalletCreation(_) + | P::WalletNotFound(_) + | P::WalletAlreadyExists(_) + | P::IdentityAlreadyExists(_) + | P::NoPrimaryIdentity + | P::InvalidIdentityData(_) + | P::ContactRequestNotFound(_) + | P::DashpayReceivingAccountAlreadyExists { .. } + | P::DashpayExternalAccountAlreadyExists { .. } + | P::AssetLockTransaction(_) + | P::TransactionBuild(_) + | P::NoSpendableInputs { .. } + | P::AddressSync(_) + | P::AddressOperation(_) + | P::OnlyOutputAddressesFunded { .. } + | P::OnlyDustInputs { .. } + | P::ChangeBelowMinimumOutput { .. } + | P::InputSumOverflow + | P::AddressNotFound(_) + | P::KeyDerivation(_) + | P::WalletLocked + | P::SpvAlreadyRunning + | P::NoWalletsConfigured + | P::SpvError(_) + | P::TokenError(_) + | P::ShieldedNoUnspentNotes + | P::ShieldedInsufficientBalance { .. } + | P::ShieldedBuildError(_) + | P::ShieldedBroadcastFailed(_) + | P::ShieldedSyncFailed(_) + | P::ShieldedTreeUpdateFailed(_) + | P::ShieldedStoreError(_) + | P::ShieldedMerkleWitnessUnavailable(_) + | P::ShieldedKeyDerivation(_) + | P::ShieldedNoRecordedAnchor(_) + | P::ShieldedNotBound + | P::PersisterLoad(_) + | P::Persistence(_) + | P::SeedMismatch { .. } + // Broadcast was accepted but its execution result is unconfirmed — the + // op may already be on chain, so it is neither a rejection nor a + // finality timeout. Bucket as Other; the upstream contract says the + // caller must not re-submit (the next sync reconciles). + | P::TransactionBroadcastUnconfirmed(_) + | P::ShieldedBroadcastUnconfirmed { .. } + | P::ShieldedSpendUnconfirmed { .. } + | P::RehydrationTopologyUnsupported { .. } + | P::RehydrationPoolMismatch { .. } + // A rehydration structural invariant broke (a discovery probe and its + // real address pool disagreed on chain order); fail-closed as a + // precondition, unrelated to identity registration. Bucket as Other. + | P::RehydrationPoolTypeMismatch { .. } + // Address nonce desync and an incomplete shielded-worker shutdown are + // both precondition/state faults unrelated to identity registration. + | P::AddressNonceMismatch { .. } + | P::ShieldedShutdownIncomplete { .. } => IdentityOpErrorKind::Other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The start latch is one-shot: `try_begin` returns `true` only on the + /// first call, so `WalletBackend::start` spawns the SPV run loop exactly + /// once even when called repeatedly (Connect clicked twice, eager-init plus + /// a manual click). This guards against a double-SPV-spawn regression. + #[test] + fn start_latch_fires_once() { + let latch = StartLatch::default(); + assert!(!latch.is_started(), "fresh latch must not be started"); + assert!(latch.try_begin(), "first try_begin must win"); + assert!( + latch.is_started(), + "latch must report started after winning" + ); + assert!(!latch.try_begin(), "second try_begin must lose"); + assert!(!latch.try_begin(), "third try_begin must lose"); + assert!(latch.is_started(), "latch stays started"); + } + + /// Restart-in-place: `reset()` re-arms the one-shot latch so `try_begin` + /// wins again on a reused backend (the reconnect's `start()`). + #[test] + fn start_latch_reset_allows_restart() { + let latch = StartLatch::default(); + assert!(latch.try_begin(), "first begin wins"); + assert!(!latch.try_begin(), "second begin refused while latched"); + assert!(latch.is_started()); + + latch.reset(); + assert!(!latch.is_started(), "reset must clear the latch"); + assert!(latch.try_begin(), "begin wins again after reset"); + assert!(!latch.try_begin(), "and re-latches one-shot after reset"); + } + + /// Concurrent callers race to a single winner — exactly one thread sees + /// `try_begin() == true`. Pins the atomic-swap contract that prevents two + /// SPV run loops from racing against the same data directory. + #[test] + fn start_latch_single_winner_under_contention() { + use std::sync::Arc as StdArc; + use std::sync::atomic::AtomicUsize; + + let latch = StdArc::new(StartLatch::default()); + let winners = StdArc::new(AtomicUsize::new(0)); + + let handles: Vec<_> = (0..16) + .map(|_| { + let latch = StdArc::clone(&latch); + let winners = StdArc::clone(&winners); + std::thread::spawn(move || { + if latch.try_begin() { + winners.fetch_add(1, Ordering::SeqCst); + } + }) + }) + .collect(); + for h in handles { + h.join().expect("thread panicked"); + } + + assert_eq!( + winners.load(Ordering::SeqCst), + 1, + "exactly one caller may win the start latch" + ); + assert!(latch.is_started()); + } + + /// I2: a network/broadcast rejection from `register_identity_with_funding` + /// maps to the dedicated `IdentityCreateRejected` envelope (not the generic + /// `WalletBackend` fallback). Structural — no string parsing. + #[test] + fn map_identity_register_error_classifies_rejection() { + let inner = platform_wallet::error::PlatformWalletError::TransactionBroadcast( + "rejected".to_string(), + ); + let mapped = map_identity_register_error(inner); + assert!( + matches!(mapped, TaskError::IdentityCreateRejected { .. }), + "Expected IdentityCreateRejected, got: {mapped:?}" + ); + } + + /// I3: an asset-lock finality failure surfaced during identity register + /// maps to `AssetLockFinalityTimeout`, regardless of which finality + /// sub-variant fired upstream. + #[test] + fn map_identity_register_error_classifies_finality_timeout() { + use dash_sdk::dpp::dashcore::hashes::Hash; + let outpoint = dash_sdk::dpp::dashcore::OutPoint::new( + dash_sdk::dpp::dashcore::Txid::from_byte_array([0u8; 32]), + 0, + ); + let inner = platform_wallet::error::PlatformWalletError::FinalityTimeout(outpoint); + let mapped = map_identity_register_error(inner); + assert!( + matches!(mapped, TaskError::AssetLockFinalityTimeout { .. }), + "Expected AssetLockFinalityTimeout, got: {mapped:?}" + ); + } + + /// I4: precondition / wallet-state failures fall through to the generic + /// `WalletBackend` envelope — they are neither rejections nor finality + /// timeouts. + #[test] + fn map_identity_register_error_falls_through_for_other() { + let inner = platform_wallet::error::PlatformWalletError::WalletLocked; + let mapped = map_identity_register_error(inner); + assert!( + matches!(mapped, TaskError::WalletBackend { .. }), + "Expected WalletBackend fallthrough, got: {mapped:?}" + ); + } + + /// I5: the top-up façade carries the identity_id into the rejection + /// variant so the user-facing message references the affected identity. + #[test] + fn map_identity_top_up_error_carries_identity_id() { + let identity_id = dash_sdk::platform::Identifier::random(); + let inner = platform_wallet::error::PlatformWalletError::TransactionBroadcast( + "rejected".to_string(), + ); + let mapped = map_identity_top_up_error(identity_id, inner); + match mapped { + TaskError::IdentityTopUpRejected { + identity_id: got, .. + } => assert_eq!(got, identity_id, "identity_id must be preserved"), + other => panic!("Expected IdentityTopUpRejected, got: {other:?}"), + } + } + + /// A top-up against an identity the wallet has not registered + /// (`IdentityNotFound` / `IdentityIndexNotSet`) maps to the dedicated + /// `IdentityNotManaged` envelope — not the "retry in a moment" fallback — + /// carrying the id, and its message names the id and tells the user to + /// reload the identity. + #[test] + fn map_identity_top_up_error_not_managed_is_actionable() { + use platform_wallet::error::PlatformWalletError as P; + for inner in [ + P::IdentityNotFound(dash_sdk::platform::Identifier::random()), + P::IdentityIndexNotSet(dash_sdk::platform::Identifier::random()), + ] { + let identity_id = dash_sdk::platform::Identifier::random(); + let mapped = map_identity_top_up_error(identity_id, inner); + let TaskError::IdentityNotManaged { + identity_id: got, .. + } = &mapped + else { + panic!("Expected IdentityNotManaged, got: {mapped:?}"); + }; + assert_eq!(*got, identity_id, "identity_id must be preserved"); + let msg = mapped.to_string(); + assert!( + msg.contains(&format!("{identity_id}")), + "message must name the identity id: {msg}" + ); + let lower = msg.to_lowercase(); + assert!( + lower.contains("reload"), + "message must tell the user to reload the identity: {msg}" + ); + assert!( + !lower.contains("retry in a moment"), + "must not be the transient-retry fallback: {msg}" + ); + } + } + + /// Registration creates the identity, so a `not-managed` classification on + /// the register path folds into the generic `WalletBackend` envelope rather + /// than the top-up-only `IdentityNotManaged` variant. + #[test] + fn map_identity_register_error_not_managed_folds_to_generic() { + let inner = platform_wallet::error::PlatformWalletError::IdentityNotFound( + dash_sdk::platform::Identifier::random(), + ); + assert!( + matches!( + map_identity_register_error(inner), + TaskError::WalletBackend { .. } + ), + "register path must not produce IdentityNotManaged" + ); + } + + /// Cold-boot warm-start shaping: a funded wallet seeds its addresses + a + /// cursor; a successfully-synced-but-empty wallet seeds the cursor alone (so + /// the label reads "synced", not "never synced"); a wallet that never + /// completed a sync (`sync_timestamp == 0`, no addresses) is dropped; and an + /// unresolvable wallet id is dropped. + #[test] + fn platform_warm_start_seed_shapes_entries_and_gates_cursor() { + let funded: WalletId = [1u8; 32]; + let synced_empty: WalletId = [2u8; 32]; + let never_synced: WalletId = [3u8; 32]; + let unresolved: WalletId = [4u8; 32]; + + let entry = |hash: [u8; 20], balance: u64, nonce: u32| PlatformAddressEntry { + hash, + balance, + nonce, + account: 0, + index: Some(0), + }; + let per_wallet: Vec<(WalletId, Vec<PlatformAddressEntry>, u64, u64)> = vec![ + (funded, vec![entry([0xAAu8; 20], 500, 7)], 1_700, 900), + (synced_empty, vec![], 1_700, 900), + (never_synced, vec![], 0, 0), + (unresolved, vec![entry([0xBBu8; 20], 1, 1)], 1_700, 900), + ]; + + // Identity resolver, except `unresolved` maps to no DET seed hash. + let out: std::collections::BTreeMap<_, _> = + platform_warm_start_seed(per_wallet, |w| (*w != unresolved).then_some(*w)) + .into_iter() + .map(|(seed_hash, entries, cursor)| (seed_hash, (entries, cursor))) + .collect(); + + assert_eq!( + out.len(), + 2, + "never-synced and unresolvable wallets are dropped" + ); + + let funded_out = out.get(&funded).expect("funded wallet seeds"); + assert_eq!(funded_out.0, vec![entry([0xAAu8; 20], 500, 7)]); + assert_eq!(funded_out.1, Some((1_700, 900))); + + let empty_out = out + .get(&synced_empty) + .expect("synced-empty wallet seeds a cursor"); + assert!(empty_out.0.is_empty(), "no addresses to seed"); + assert_eq!( + empty_out.1, + Some((1_700, 900)), + "a completed sync warm-starts the cursor even with no funds" + ); + + assert!( + !out.contains_key(&never_synced), + "no timestamp and no addresses leaves the wallet never-synced" + ); + assert!( + !out.contains_key(&unresolved), + "an unresolvable wallet id is dropped" + ); + } + + /// A network/broadcast rejection from the orchestrated platform-address + /// funding maps to the dedicated `PlatformAddressFundRejected` envelope + /// (not the generic `WalletBackend` fallback). Structural — no string + /// parsing. + #[test] + fn map_platform_address_fund_error_classifies_rejection() { + let inner = platform_wallet::error::PlatformWalletError::TransactionBroadcast( + "rejected".to_string(), + ); + let mapped = map_platform_address_fund_error(inner); + assert!( + matches!(mapped, TaskError::PlatformAddressFundRejected { .. }), + "Expected PlatformAddressFundRejected, got: {mapped:?}" + ); + } + + /// An asset-lock finality failure surfaced during orchestrated platform + /// funding reuses the shared `AssetLockFinalityTimeout` envelope. + #[test] + fn map_platform_address_fund_error_classifies_finality_timeout() { + use dash_sdk::dpp::dashcore::hashes::Hash; + let outpoint = dash_sdk::dpp::dashcore::OutPoint::new( + dash_sdk::dpp::dashcore::Txid::from_byte_array([0u8; 32]), + 0, + ); + let inner = platform_wallet::error::PlatformWalletError::FinalityTimeout(outpoint); + let mapped = map_platform_address_fund_error(inner); + assert!( + matches!(mapped, TaskError::AssetLockFinalityTimeout { .. }), + "Expected AssetLockFinalityTimeout, got: {mapped:?}" + ); + } + + /// Precondition / wallet-state failures fall through to the generic + /// `WalletBackend` envelope. + #[test] + fn map_platform_address_fund_error_falls_through_for_other() { + let inner = platform_wallet::error::PlatformWalletError::WalletLocked; + let mapped = map_platform_address_fund_error(inner); + assert!( + matches!(mapped, TaskError::WalletBackend { .. }), + "Expected WalletBackend fallthrough, got: {mapped:?}" + ); + } + + /// The orchestrator-vs-manual gate is decided by upstream's own pool + /// membership: `WalletBackend::platform_address_in_pool` converts a + /// `PlatformAddress` to the upstream `PlatformP2PKHAddress` and asks + /// `ManagedPlatformAccount::contains_platform_address`. This pins that exact + /// semantic offline — building a real upstream `ManagedPlatformAccount` from + /// a known platform-payment account xpub and asserting an address inside the + /// pre-generated pool is recognised while a foreign one is not. (The full + /// helper needs a resolved `PlatformWallet`, which isn't constructible + /// offline; this covers the membership logic the helper delegates to.) + #[test] + fn upstream_pool_membership_distinguishes_in_pool_from_foreign() { + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; + use dash_sdk::dpp::key_wallet::managed_account::address_pool::AddressPoolType; + use dash_sdk::dpp::key_wallet::{ + AddressPool, KeySource, ManagedPlatformAccount, PlatformP2PKHAddress, + }; + + let network = Network::Testnet; + let seed = [7u8; 64]; + + // DIP-17 platform-payment account path: m/9'/coin'/17'/0'/0' (coin 1' on + // testnet). The pool appends the non-hardened leaf, matching DET's + // `platform_payment_path`. + let account_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 17 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Hardened { index: 0 }, + ]); + let account_xpub = account_path + .derive_pub_ecdsa_for_master_seed(&seed, network) + .expect("derive account xpub"); + + let pool = AddressPool::new( + account_path, + AddressPoolType::Absent, + 20, + network, + &KeySource::Public(account_xpub), + ) + .expect("build pool"); + let account = ManagedPlatformAccount::new(0, 0, pool, false); + + // An address the pool actually generated is recognised as in-pool. + let in_pool = *account + .all_platform_addresses() + .first() + .expect("pre-generated pool is non-empty"); + assert!( + account.contains_platform_address(&in_pool), + "a pre-generated pool address must be recognised" + ); + + // A foreign address (not derived from this account) is not in the pool. + let foreign = PlatformP2PKHAddress::new([0xAB; 20]); + assert!( + !account.contains_platform_address(&foreign), + "a foreign address must not be recognised as in-pool" + ); + } + + /// No skips yields no banner; one or many skips yields singular/plural + /// text with no row-derived detail. + #[test] + fn skipped_wallets_banner_text_singular_plural_and_none() { + assert_eq!(skipped_wallets_banner_text(0), None); + assert_eq!( + skipped_wallets_banner_text(1), + Some( + "1 saved wallet couldn't be opened. Re-add it from its recovery phrase to restore it." + .to_string() + ) + ); + assert_eq!( + skipped_wallets_banner_text(3), + Some( + "3 saved wallets couldn't be opened. Re-add them from their recovery phrases to restore them." + .to_string() + ) + ); + } + + /// A re-entrant load pass supersedes the previous skipped-wallets banner + /// instead of stacking a second one, and a zero-skip pass clears it. Drives + /// [`raise_skipped_wallets_banner`] directly (no `WalletBackend`/`AppContext`) + /// and asserts against the rendered banner so a regression that drops the + /// call, swaps the Warning type, reads the wrong count, or stacks banners + /// fails here. + #[test] + fn raise_skipped_wallets_banner_replaces_and_clears() { + use crate::ui::components::MessageBanner; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let singular = skipped_wallets_banner_text(1).expect("one skip yields text"); + let plural = skipped_wallets_banner_text(3).expect("three skips yield text"); + let mut handle: Option<BannerHandle> = None; + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(MessageBanner::show_global); + + // Pass 1: one skip → singular copy under the Warning icon. + raise_skipped_wallets_banner(&harness.ctx, 1, &mut handle); + harness.run(); + assert!( + harness.query_by_label(singular.as_str()).is_some(), + "first pass must render the singular skip copy", + ); + assert!( + harness.query_by_label("\u{26A0}").is_some(), + "skip banner must carry the Warning icon, not another type", + ); + + // Pass 2: re-entrant call with a different count must replace, not stack. + raise_skipped_wallets_banner(&harness.ctx, 3, &mut handle); + harness.run(); + assert!( + harness.query_by_label(plural.as_str()).is_some(), + "second pass must render the new plural copy", + ); + assert!( + harness.query_by_label(singular.as_str()).is_none(), + "the superseded singular banner must be gone, not stacked", + ); + + // Pass 3: zero skips clears the banner. + raise_skipped_wallets_banner(&harness.ctx, 0, &mut handle); + harness.run(); + assert!( + harness.query_by_label(plural.as_str()).is_none(), + "a zero-skip pass must clear the banner", + ); + assert!( + harness.query_by_label("\u{26A0}").is_none(), + "no Warning icon must remain after clearing", + ); + } + + /// The upstream load-skip families map 1:1 onto the DET-opaque + /// [`PersistedLoadSkip`] and drop the row-derived string so no + /// upstream detail crosses the seam. + #[test] + fn skip_reason_maps_to_det_opaque_variants() { + use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason}; + let cases = [ + ( + CorruptKind::MissingManifest, + PersistedLoadSkip::MissingManifest, + ), + (CorruptKind::MalformedXpub, PersistedLoadSkip::MalformedXpub), + ( + CorruptKind::DecodeError("secret-ish detail".into()), + PersistedLoadSkip::DecodeError, + ), + ]; + for (kind, expected) in cases { + let reason = SkipReason::CorruptPersistedRow { kind }; + assert_eq!(persisted_load_skip_from_upstream(&reason), expected); + } + } + + /// The seedless bridge keys off the BIP44 account xpub. The DET + /// account xpub (`Wallet::new_from_seed`) must equal the upstream + /// account xpub for the SAME seed, so the watch-only wallet rebuilt + /// from the persisted manifest resolves back to DET's seed hash. + /// Locks the cryptographic invariant the + /// [`WalletBackend::load_from_persistor_seedless`] gate relies on. + #[test] + fn bridge_account_xpub_matches_upstream_for_same_seed() { + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + + // DET side: what DET persists as `xpub_encoded`. + let det = crate::model::wallet::Wallet::new_from_seed(seed, network, None, None) + .expect("DET wallet"); + let det_xpub = det.master_bip44_ecdsa_extended_public_key.encode().to_vec(); + + // Upstream side: the watch-only manifest carries the same account + // xpub on the BIP44 account. + let up = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("upstream wallet"); + let up_xpub = bip44_account0_xpub(up.accounts.all_accounts()) + .map(|x| x.encode().to_vec()) + .expect("upstream BIP44 account"); + + assert_eq!( + det_xpub, up_xpub, + "DET and upstream must agree on the BIP44 account xpub for the same seed" + ); + + // A bridge built from DET's sidecar resolves the matching xpub to + // the DET seed hash, and rejects a non-matching xpub. + let seed_hash = det.seed_hash(); + let bridge: std::collections::HashMap<Vec<u8>, WalletSeedHash> = + std::iter::once((det_xpub.clone(), seed_hash)).collect(); + assert_eq!( + bridge.get(&up_xpub).copied(), + Some(seed_hash), + "matching account xpub must resolve to the DET seed hash" + ); + + let other = crate::model::wallet::Wallet::new_from_seed([0x99u8; 64], network, None, None) + .expect("other wallet"); + let other_xpub = other + .master_bip44_ecdsa_extended_public_key + .encode() + .to_vec(); + assert!( + !bridge.contains_key(&other_xpub), + "a non-matching account xpub must be rejected by the gate" + ); + } + + /// `WalletId` is independent of the account-xpub depth: the same seed yields + /// the same `WalletId` whether or not BIP44 accounts are created. An upstream + /// `get_wallet(wallet_id)` hit therefore proves the entry shares this seed's + /// root, so the fund-routing gate's account-xpub comparison is the only thing + /// that can vary — the invariant the gate relies on. + #[test] + fn wallet_id_is_independent_of_account_creation() { + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + + let with_accounts = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("wallet with accounts"); + let without_accounts = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::None) + .expect("wallet without accounts"); + + assert_eq!( + with_accounts.wallet_id, without_accounts.wallet_id, + "WalletId must not depend on which accounts were created" + ); + } + + /// Issue #7 regression guard: on a FRESH wallet the fund-routing gate's two + /// BIP44 account-0 xpubs must agree byte-for-byte. Two independent + /// `from_seed_bytes(Default)` builds (the gate's expected vs just-created + /// sides), DET's published `master_bip44_ecdsa_extended_public_key`, and a + /// bincode persist round-trip (the watch-only reload path) must ALL encode + /// identically — same depth/parent_fingerprint/child_number, not just + /// pubkey+chaincode. If this ever diverges, the gate rejects fresh wallets + /// and every wallet dead-ends in `WalletNotLoaded`. Empirically the layers + /// agree (issue #7 is not a pure-derivation/persistence defect). + #[test] + fn fresh_bip44_account0_xpub_is_stable_across_gate_sides() { + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + + let find_bip44_0 = |w: &UpstreamWallet| { + bip44_account0_xpub(w.accounts.all_accounts()) + .expect("a fresh Default wallet must contain a BIP44 account-0") + }; + + let a = find_bip44_0( + &UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("wallet a"), + ); + let b = find_bip44_0( + &UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("wallet b"), + ); + assert_eq!( + a.encode(), + b.encode(), + "two fresh from_seed_bytes BIP44 account-0 xpubs must encode identically" + ); + + let det = crate::model::wallet::Wallet::new_from_seed(seed, network, None, None) + .expect("DET wallet"); + assert_eq!( + det.master_bip44_ecdsa_extended_public_key.encode(), + a.encode(), + "DET's published xpub must match the upstream-derived one (the bridge invariant)" + ); + + // Watch-only reload path: the persister stores the xpub as a bincode + // blob and the loader reads it back. That round-trip must not change the + // encoding, or a freshly-registered wallet would fail the gate on the + // next boot. + use platform_wallet::changeset::AccountRegistrationEntry; + let entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: a, + }; + let cfg = bincode::config::standard(); + let bytes = bincode::serde::encode_to_vec(&entry, cfg).expect("encode entry"); + let (decoded, _): (AccountRegistrationEntry, usize) = + bincode::serde::decode_from_slice(&bytes, cfg).expect("decode entry"); + assert_eq!( + decoded.account_xpub.encode(), + a.encode(), + "bincode round-trip must preserve the account xpub encoding" + ); + } + + /// `map_shielded_op_error` must route an ambiguous post-broadcast + /// `ShieldedSpendUnconfirmed` to the per-operation `*ConfirmationUnknown` + /// variant keyed off `operation`, so the UI surfaces the correct + /// "do not re-submit" message and never falsely reports success. A wrong + /// route here is a funds-safety bug (the user re-spends thinking it failed), + /// so this guards the exact string keys upstream emits. + #[test] + fn map_shielded_op_error_routes_spend_unconfirmed_by_operation() { + use platform_wallet::error::PlatformWalletError as P; + let unconfirmed = |op: &'static str| P::ShieldedSpendUnconfirmed { + operation: op, + reason: "ambiguous broadcast".to_string(), + }; + + assert!(matches!( + map_shielded_op_error(unconfirmed("shield")), + TaskError::ShieldCreditsConfirmationUnknown { .. } + )); + assert!(matches!( + map_shielded_op_error(unconfirmed("transfer")), + TaskError::ShieldedTransferConfirmationUnknown { .. } + )); + assert!(matches!( + map_shielded_op_error(unconfirmed("unshield")), + TaskError::UnshieldConfirmationUnknown { .. } + )); + assert!(matches!( + map_shielded_op_error(unconfirmed("withdraw")), + TaskError::ShieldedWithdrawalConfirmationUnknown { .. } + )); + // An operation name from a newer upstream must fall through to the + // generic wrapper rather than silently mis-routing. + assert!(matches!( + map_shielded_op_error(unconfirmed("future-op")), + TaskError::WalletBackend { .. } + )); + } + + /// The not-bound / not-configured shielded preconditions map to their + /// dedicated typed variants (not the generic `WalletBackend` wrapper) so + /// callers can react and the UI can guide the user to unlock / restart. + #[test] + fn map_shielded_op_error_maps_bind_preconditions() { + use platform_wallet::error::PlatformWalletError as P; + assert!(matches!( + map_shielded_op_error(P::ShieldedNotBound), + TaskError::ShieldedNotBound + )); + } +} diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs new file mode 100644 index 000000000..a1d1c0812 --- /dev/null +++ b/src/wallet_backend/payments.rs @@ -0,0 +1,267 @@ +//! Payment and asset-lock operations on [`WalletBackend`] — the funds-signing +//! path. +//! +//! Every seed-bearing operation here opens a single just-in-time +//! [`SecretAccess`](super::SecretAccess) session so the HD seed is decrypted +//! once, borrowed by the [`DetSigner`] for signing, and zeroized when the +//! scope ends. `send_payment` builds and broadcasts a BIP-44 payment; +//! `create_asset_lock_proof` builds a non-identity asset lock and returns its +//! one-time credit-output key. + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; + +use super::{DEFAULT_BIP44_ACCOUNT, DetSigner, SecretPlaintext, WalletBackend}; + +impl WalletBackend { + /// Derive the secp256k1 [`PrivateKey`](dash_sdk::dpp::dashcore::PrivateKey) at `path` from a held HD seed. + /// Used after `create_asset_lock_proof` to obtain the one-time + /// credit-output key needed to sign DET-retained non-identity state + /// transitions (Platform-address top-up, shielded deposit). The seed is + /// the one already held open by the surrounding `with_secret_session` + /// scope, so this never re-prompts. + fn derive_private_key_from_held( + &self, + plaintext: SecretPlaintext<'_>, + path: &dash_sdk::dpp::key_wallet::bip32::DerivationPath, + ) -> Result<dash_sdk::dpp::dashcore::PrivateKey, TaskError> { + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let xprv = path + .derive_priv_ecdsa_for_master_seed(seed, self.inner.network) + .map_err(|source| TaskError::WalletBackend { + source: Box::new(platform_wallet::error::PlatformWalletError::KeyDerivation( + source.to_string(), + )), + })?; + Ok(xprv.to_priv()) + } + + /// Test-only probe that the chokepoint can decrypt the seed for + /// `seed_hash` without a prompt (the no-password / unprotected fast-path) + /// AND that the resulting [`DetSigner`] actually produces a signature. + /// Mirrors the production signing precondition so a regression on the + /// no-password cold-boot path — decrypt or sign — is caught. The + /// unprotected seed resolves with no interaction. + #[cfg(test)] + pub(crate) async fn assert_can_sign( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use dash_sdk::dpp::key_wallet::signer::Signer; + + let scope = Self::hd_scope(seed_hash); + let path: DerivationPath = "m/44'/1'/0'/0/0" + .parse() + .expect("static derivation path parses"); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let signer = DetSigner::from_held(session.plaintext(), self.inner.network); + // Drive a real sign, not just signer construction: a derive or + // sign regression must fail here. + signer + .sign_ecdsa(&path, [0x11u8; 32]) + .await + .map_err(|_| TaskError::SingleKeyCryptoFailure)?; + Ok(()) + }) + .await + } + + /// Build, sign, and broadcast a payment from the wallet's default BIP-44 + /// account to `recipients` (`(address, duffs)`). Returns the txid. + /// + /// One [`SecretAccess::with_secret_session`](super::SecretAccess::with_secret_session) scope wraps the whole build: + /// the seed is decrypted just-in-time (one prompt for a passphrase- + /// protected wallet, none for a no-password wallet), borrowed by the + /// [`DetSigner`] for every input sign, and zeroized when the scope ends. + pub async fn send_payment( + &self, + seed_hash: &WalletSeedHash, + recipients: Vec<(dash_sdk::dpp::dashcore::Address, u64)>, + ) -> Result<dash_sdk::dpp::dashcore::Txid, TaskError> { + use dash_sdk::dpp::key_wallet::account::account_type::StandardAccountType; + use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::BuilderError; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let signer = DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + let wallet_id = wallet.wallet_id(); + + // Assemble and sign under one uninterrupted hold of the + // wallet-manager write lock: `set_funding` reads the funding + // account's free UTXOs and `build_signed` reserves the ones it + // selects. Holding the lock across both closes the + // read-then-reserve window a concurrent build could otherwise use + // to double-select the same UTXO. The guard drops at the end of + // this block, before the broadcast re-acquires the lock. + let tx = { + let mut wm = wallet.wallet_manager().write().await; + let (kw_wallet, info) = wm + .get_wallet_and_info_mut(&wallet_id) + .ok_or(TaskError::WalletStateInconsistent)?; + + let account = kw_wallet + .get_bip44_account(DEFAULT_BIP44_ACCOUNT) + .ok_or(TaskError::WalletStateInconsistent)?; + let current_height = info.core_wallet.synced_height(); + let managed_account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&DEFAULT_BIP44_ACCOUNT) + .ok_or(TaskError::WalletStateInconsistent)?; + + let mut builder = TransactionBuilder::new() + .set_current_height(current_height) + .set_selection_strategy(SelectionStrategy::LargestFirst) + .set_funding(managed_account, account); + for (address, amount) in &recipients { + builder = builder.add_output(address, *amount); + } + + let (tx, _fee) = builder + .build_signed(&signer, |addr| { + managed_account.address_derivation_path(&addr) + }) + .await + .map_err(|source| { + // Give balance-specific and input-count-specific + // advice only for the failures that are actually + // about balance or input count — every other + // `BuilderError` variant falls back to the + // generic `WalletPaymentBuildFailed` message + // rather than misdirecting the user to "check + // your balance" for e.g. a signing failure. + match source { + BuilderError::InsufficientFunds { + available, + required, + } => TaskError::InsufficientFunds { + available, + required, + }, + BuilderError::TooManyInputs { count, max } => { + TaskError::WalletPaymentTooManyInputs { count, max } + } + other => TaskError::WalletPaymentBuildFailed { + source: Box::new(other), + }, + } + })?; + tx + }; + + // Broadcast through the wallet's own `SpvBroadcaster`, releasing + // the build's UTXO reservation on a definitive pre-send rejection + // so an immediate retry can reselect those inputs. Preserves the + // reservation reconciliation the removed `core().send_to_addresses` + // performed. + wallet + .core() + .broadcast_transaction_releasing_reservation( + StandardAccountType::BIP44Account, + DEFAULT_BIP44_ACCOUNT, + &tx, + ) + .await + .map_err(|source| TaskError::WalletBackend { + source: Box::new(source), + })?; + Ok(tx.txid()) + }) + .await + } + + /// Build, track, and broadcast a **non-identity** asset lock via the + /// upstream `AssetLockManager`. `funding_type` selects the funding + /// derivation; `identity_index` is the funding-account derivation index + /// (ignored for non-identity funding types). Returns the finalized + /// asset-lock proof, its one-time credit-output private key (derived + /// locally from the wallet seed at the path upstream selected), and the + /// txid. + /// + /// For identity-funded asset locks + /// (`AssetLockFundingType::IdentityRegistration` / + /// `AssetLockFundingType::IdentityTopUp`) the upstream + /// `IdentityWallet::*_with_funding` orchestrators submit the + /// Platform-side state transition themselves and never expose a + /// credit-output `PrivateKey` — use [`Self::register_identity`] / + /// [`Self::top_up_identity`] instead. + pub(crate) async fn create_asset_lock_proof( + &self, + seed_hash: &WalletSeedHash, + amount_duffs: u64, + funding_type: platform_wallet::AssetLockFundingType, + identity_index: u32, + ) -> Result< + ( + dash_sdk::dpp::prelude::AssetLockProof, + dash_sdk::dpp::dashcore::PrivateKey, + dash_sdk::dpp::dashcore::Txid, + ), + TaskError, + > { + use platform_wallet::AssetLockFundingType; + + // One held-seed scope covers account provisioning, the funding-input + // signer, and the credit-output key derivation, so the whole operation + // prompts at most once and the seed zeroizes when the scope ends. + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + // Identity asset locks fund from the IdentityRegistration / + // IdentityTopUp HD accounts, which the upstream persister never + // reconstructs (a5538dc8). Provision them here — the single + // chokepoint every asset-lock caller funnels through — so no + // call site can bypass it. Idempotent. Non-identity funding + // types are no-ops. Exhaustive — a new upstream variant must + // force a review here instead of silently falling through. + // Must run inside the session so the seed is available for + // hardened xpub derivation (the live wallet is watch-only). + match funding_type { + AssetLockFundingType::IdentityRegistration + | AssetLockFundingType::IdentityTopUp => { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletStateInconsistent)?; + self.ensure_identity_funding_accounts(seed_hash, seed, identity_index) + .await?; + } + AssetLockFundingType::IdentityTopUpNotBound + | AssetLockFundingType::IdentityInvitation + | AssetLockFundingType::AssetLockAddressTopUp + | AssetLockFundingType::AssetLockShieldedAddressTopUp => {} + } + let signer = DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + let (proof, credit_output_path, out_point) = wallet + .asset_locks() + .create_funded_asset_lock_proof( + amount_duffs, + DEFAULT_BIP44_ACCOUNT, + funding_type, + identity_index, + &signer, + ) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + })?; + let private_key = + self.derive_private_key_from_held(session.plaintext(), &credit_output_path)?; + Ok((proof, private_key, out_point.txid)) + }) + .await + } +} diff --git a/src/wallet_backend/poison.rs b/src/wallet_backend/poison.rs new file mode 100644 index 000000000..55c994bc9 --- /dev/null +++ b/src/wallet_backend/poison.rs @@ -0,0 +1,127 @@ +//! Poison-recovery for locks guarding **derived, rebuildable** in-memory state. +//! +//! The free functions ([`read_recover`], [`write_recover`], [`lock_recover`]) +//! and their extension-trait forms ([`RwLockRecover`], [`MutexRecover`]) recover +//! a poisoned guard (`PoisonError::into_inner`) instead of propagating the +//! poison. They apply across the app wherever a lock protects state that is not +//! a source of truth and can always be re-derived: the wallet backend's +//! single-key in-memory index (rebuilt from the k/v sidecar plus the secret +//! vault) and secret session cache (a TTL'd convenience over the vault); the +//! loaded-wallet map and per-screen view state (`RwLock<Wallet>`, +//! `RwLock<WalletFundedScreenStep>`, the settings cache); the Identities / DPNS / +//! token-search screen caches (`Mutex`, re-fetched on demand); the coordinator +//! gate's action slot; and the SQLite connection mutex (a `rusqlite::Connection` +//! carries no cross-call invariant a panic can break). +//! +//! A thread that panics mid-update poisons the lock, but the guarded value is at +//! worst missing or holding a single stale entry — never a corruption that risks +//! funds — and the next read re-derives it. Failing every subsequent operation +//! because an unrelated thread panicked (the default poison behavior) would turn +//! a recoverable, self-healing subsystem into a dead one, so recovery is the +//! correct policy. Use these helpers for rebuildable state only — a lock +//! guarding a non-reconstructable invariant should keep failing (via the blanket +//! `From<PoisonError<T>>` for `TaskError::LockPoisoned`), and a lock whose +//! poison must fail loud for fund safety (the DashPay address-index mutex) keeps +//! its explicit `.expect(...)`. + +use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +/// Acquire a read guard, recovering it if the lock is poisoned. +/// +/// Recovery is safe for the rebuildable in-memory state these locks guard (see +/// the module docs); a poisoned lock yields the same guard the happy path would. +pub(crate) fn read_recover<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> { + lock.read().unwrap_or_else(PoisonError::into_inner) +} + +/// Acquire a write guard, recovering it if the lock is poisoned. +/// +/// Recovery is safe for the rebuildable in-memory state these locks guard (see +/// the module docs); a poisoned lock yields the same guard the happy path would. +pub(crate) fn write_recover<T>(lock: &RwLock<T>) -> RwLockWriteGuard<'_, T> { + lock.write().unwrap_or_else(PoisonError::into_inner) +} + +/// Ergonomic `.read_recover()` / `.write_recover()` on `RwLock`, delegating to +/// [`read_recover`] / [`write_recover`]. +/// +/// Lets call sites read as naturally as `.read().unwrap()` while routing every +/// lock acquisition through the single recovery policy — no panic on a poisoned +/// lock. Same contract as the free functions: rebuildable in-memory state only. +/// A lock guarding a non-reconstructable invariant must keep failing via the +/// blanket `From<PoisonError<T>>` for `TaskError::LockPoisoned` instead. +pub(crate) trait RwLockRecover<T> { + /// Acquire a read guard, recovering a poisoned lock instead of panicking. + fn read_recover(&self) -> RwLockReadGuard<'_, T>; + /// Acquire a write guard, recovering a poisoned lock instead of panicking. + fn write_recover(&self) -> RwLockWriteGuard<'_, T>; +} + +impl<T> RwLockRecover<T> for RwLock<T> { + fn read_recover(&self) -> RwLockReadGuard<'_, T> { + read_recover(self) + } + fn write_recover(&self) -> RwLockWriteGuard<'_, T> { + write_recover(self) + } +} + +/// Acquire a `Mutex` guard, recovering it if the lock is poisoned. +/// +/// Recovery is safe for the rebuildable in-memory state these locks guard (see +/// the module docs); a poisoned lock yields the same guard the happy path would. +pub(crate) fn lock_recover<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Ergonomic `.lock_recover()` on `Mutex`, delegating to [`lock_recover`]. +/// +/// The `Mutex` analog of [`RwLockRecover`]: routes lock acquisition through the +/// single recovery policy so a thread panicking mid-update never wedges the +/// subsystem. Same contract — rebuildable in-memory state only. +pub(crate) trait MutexRecover<T> { + /// Acquire the guard, recovering a poisoned lock instead of panicking. + fn lock_recover(&self) -> MutexGuard<'_, T>; +} + +impl<T> MutexRecover<T> for Mutex<T> { + fn lock_recover(&self) -> MutexGuard<'_, T> { + lock_recover(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + /// A thread that panics while holding the write lock poisons it; both + /// helpers must still hand back a usable guard exposing the value written + /// before the panic — proving the subsystem self-heals instead of wedging. + #[test] + fn recovers_a_poisoned_lock() { + let lock = Arc::new(RwLock::new(41u32)); + + let poisoner = Arc::clone(&lock); + let handle = std::thread::spawn(move || { + let mut guard = poisoner.write().expect("first writer"); + *guard = 42; + panic!("poison the lock while holding the write guard"); + }); + assert!(handle.join().is_err(), "the poisoning thread must panic"); + assert!( + lock.is_poisoned(), + "the lock must be poisoned after the panic" + ); + + // Read recovery sees the value written before the panic. + assert_eq!(*read_recover(&lock), 42, "read_recover yields the value"); + // Write recovery yields a usable guard the operation can proceed with. + *write_recover(&lock) = 7; + assert_eq!( + *read_recover(&lock), + 7, + "write_recover yields a usable guard" + ); + } +} diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs new file mode 100644 index 000000000..bd48eb470 --- /dev/null +++ b/src/wallet_backend/secret_access.rs @@ -0,0 +1,2706 @@ +//! The just-in-time secret chokepoint. +//! +//! [`SecretAccess`] is the single doorway through which every plaintext +//! secret is obtained. A consumer that needs a seed or imported key calls +//! [`SecretAccess::with_secret`] (one-shot) or +//! [`SecretAccess::with_secret_session`] (one prompt, many signs in one +//! operation), and receives the plaintext **by borrow inside a closure**. +//! The plaintext never crosses the closure boundary and zeroizes when the +//! closure returns. +//! +//! Resolution order for each call: +//! 1. session cache (only populated when the user opted in; TTL honored); +//! 2. else, an **unprotected** scope (a migrated raw secret, or a no-password +//! HD wallet / no-passphrase imported key) resolves **without prompting** — +//! the chokepoint reads it directly with no passphrase; +//! 3. else prompt via [`SecretPrompt`] for the passphrase, decrypt the +//! stored secret just-in-time, optionally promote to the session cache, +//! run the closure, then zeroize. +//! +//! Secret hygiene: +//! - **Closure form, no storable guard.** [`SecretPlaintext`] and +//! [`SecretSession`] are bound to the closure's lifetime; they cannot be +//! parked across awaits outside the chokepoint. +//! - **Borrow-only.** The closure borrows `&Zeroizing<…>`; neither helper +//! type is `Clone`, and there is no `Deref` to the raw bytes — access is +//! via explicit `expose_*` returning a borrow. +//! - **Boxed session secrets.** Cached plaintext lives behind `Box` so a +//! `HashMap` rehash never leaves an un-wiped inline copy; eviction and +//! `forget*` drop the `Box`, zeroizing it. +//! +//! M-DONT-LEAK-TYPES: this type and its borrowed handles stay inside the +//! `wallet_backend` seam. The UI sees only the +//! [`secret_prompt`](crate::wallet_backend::secret_prompt) contract. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::Instant; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::KeyID; +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, +}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::single_key::ImportedKey; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::encryption::{DecryptError, decrypt_message}; +use crate::model::wallet::seed_envelope::StoredSeedEnvelope; +use crate::wallet_backend::identity_key_store::identity_flavored; +use crate::wallet_backend::poison::{read_recover, write_recover}; +use crate::wallet_backend::secret_prompt::{ + RememberPolicy, SecretPrompt, SecretPromptRequest, SecretPromptRetry, SecretScope, +}; +use crate::wallet_backend::secret_seam::{SecretScheme, SecretSeam}; +use crate::wallet_backend::single_key::{label_for_address, single_key_namespace_id}; +use crate::wallet_backend::single_key_entry::SingleKeyEntry; +use crate::wallet_backend::wallet_seed_store::WalletSeedView; + +/// Length of an HD BIP-39 seed. +const HD_SEED_LEN: usize = 64; +/// Length of an imported single-key secret. +const SINGLE_KEY_LEN: usize = 32; +/// Vault label for a raw (migrated) HD seed, distinct from the legacy +/// `envelope.v1` so the loader can tell raw from legacy by label presence. +pub(crate) const SEED_RAW_LABEL: &str = "seed.raw.v1"; + +/// Borrowed, kind-tagged plaintext handed to a [`SecretAccess::with_secret`] +/// closure. Lives only for the closure call. No `Clone`, no `Deref` to raw +/// bytes — read via [`SecretPlaintext::expose_hd_seed`] / +/// [`SecretPlaintext::expose_single_key`], which return a borrow tied to +/// the closure's lifetime. +pub enum SecretPlaintext<'a> { + /// A 64-byte HD wallet seed. + HdSeed(&'a Zeroizing<[u8; HD_SEED_LEN]>), + /// A 32-byte imported single-key secret. + SingleKey(&'a Zeroizing<[u8; SINGLE_KEY_LEN]>), + /// A 32-byte identity private key, read raw from the vault per-use. + IdentityKey(&'a Zeroizing<[u8; SINGLE_KEY_LEN]>), +} + +impl SecretPlaintext<'_> { + /// Borrow the 64-byte HD seed, or `None` if this is a single-key + /// plaintext. + pub fn expose_hd_seed(&self) -> Option<&[u8; HD_SEED_LEN]> { + match self { + // Deref through `Zeroizing` explicitly: `[u8; N]` also + // implements `AsRef<PushBytes>` (dashcore), which makes a bare + // `.as_ref()` ambiguous. + SecretPlaintext::HdSeed(s) => Some(&***s), + _ => None, + } + } + + /// Borrow the 32-byte single-key secret, or `None` if this is an HD + /// seed plaintext. + pub fn expose_single_key(&self) -> Option<&[u8; SINGLE_KEY_LEN]> { + match self { + SecretPlaintext::SingleKey(k) => Some(&***k), + _ => None, + } + } + + /// Borrow the 32-byte identity private key, or `None` for the other + /// kinds. The plaintext is borrowed for the closure only and zeroizes + /// on return — it is never resident. + pub fn expose_identity_key(&self) -> Option<&[u8; SINGLE_KEY_LEN]> { + match self { + SecretPlaintext::IdentityKey(k) => Some(&***k), + _ => None, + } + } +} + +/// Within-operation secret handle for [`SecretAccess::with_secret_session`]. +/// Holds one decrypted secret for the whole closure so a multi-sign +/// operation prompts at most once. Borrowed-only; dropped (zeroized) when +/// the closure returns. +pub struct SecretSession<'a> { + plaintext: &'a Plaintext, +} + +impl SecretSession<'_> { + /// Borrow the held plaintext as a [`SecretPlaintext`] for a single + /// derive/sign step. May be called many times within the operation + /// without re-prompting. + pub fn plaintext(&self) -> SecretPlaintext<'_> { + self.plaintext.borrow() + } +} + +/// Owned decrypted plaintext, kept on the chokepoint's stack for the +/// duration of one operation (or boxed in the session cache). Zeroizes on +/// drop. Never leaves `wallet_backend`. +enum Plaintext { + HdSeed(Zeroizing<[u8; HD_SEED_LEN]>), + SingleKey(Zeroizing<[u8; SINGLE_KEY_LEN]>), + IdentityKey(Zeroizing<[u8; SINGLE_KEY_LEN]>), +} + +impl Plaintext { + fn borrow(&self) -> SecretPlaintext<'_> { + match self { + Plaintext::HdSeed(s) => SecretPlaintext::HdSeed(s), + Plaintext::SingleKey(k) => SecretPlaintext::SingleKey(k), + Plaintext::IdentityKey(k) => SecretPlaintext::IdentityKey(k), + } + } + + /// An owned, op-scoped `Zeroizing` copy of this plaintext. Used only to + /// lift a cached secret off the cache lock so the consuming closure can + /// run without holding it. The copy zeroizes on drop. + fn to_op_copy(&self) -> Plaintext { + match self { + Plaintext::HdSeed(s) => Plaintext::HdSeed(Zeroizing::new(**s)), + Plaintext::SingleKey(k) => Plaintext::SingleKey(Zeroizing::new(**k)), + Plaintext::IdentityKey(k) => Plaintext::IdentityKey(Zeroizing::new(**k)), + } + } +} + +/// A session-cache entry: the boxed plaintext plus its expiry policy. +/// +/// The plaintext is boxed so a `HashMap` rehash moves only the `Box` pointer, +/// never the secret bytes — no un-wiped inline copy is left behind. +/// `expires_at = None` means "until app close". +struct SessionEntry { + plaintext: Box<Plaintext>, + expires_at: Option<Instant>, +} + +impl SessionEntry { + fn is_expired(&self, now: Instant) -> bool { + self.expires_at.is_some_and(|deadline| now >= deadline) + } +} + +/// O(1)-clone handle to the JIT secret chokepoint (M-SERVICES-CLONE). +#[derive(Clone)] +pub struct SecretAccess { + inner: Arc<SecretAccessInner>, +} + +impl std::fmt::Debug for SecretAccess { + /// Redacts every field — the vault, prompt, caches, and meta could all + /// surface secret material. Prints only the network so embedding types + /// (e.g. `QualifiedIdentity`) can derive `Debug` without leaking + /// (M-PUBLIC-DEBUG, M-DONT-LEAK-TYPES). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretAccess") + .field("network", &self.inner.network) + .finish_non_exhaustive() + } +} + +struct SecretAccessInner { + /// The encrypted vault — decrypt-on-demand source of truth. + secret_store: Arc<SecretStore>, + /// HD wallet meta (seed hash → password hint / alias) for prompt copy. + wallet_meta: RwLock<BTreeMap<WalletSeedHash, PromptMeta>>, + /// Single-key index (address → alias / hint / has_passphrase) for + /// prompt copy and the unprotected fast-path check. + single_key_index: RwLock<BTreeMap<String, ImportedKey>>, + /// Identity prompt-copy index (identity id → alias / password hint) for + /// the sign-time prompt of an opted-in (Tier-2) identity. Display-only; + /// the vault scheme — not this index — gates whether a prompt fires. + identity_prompt_index: RwLock<BTreeMap<[u8; 32], PromptMeta>>, + /// The UI seam. `dyn` so the host is chosen at construction. + prompt: Arc<dyn SecretPrompt>, + /// Opt-in session cache. Empty by default; a scope lands here only on + /// a non-`None` [`RememberPolicy`]. Values boxed + zeroizing; cleared + /// on app close, network switch, and manual lock. + session: RwLock<HashMap<SecretScope, SessionEntry>>, + /// Network used for BIP-32/derivation by consumers (carried for + /// signer construction; not used by decryption itself). + network: Network, +} + +/// Minimal prompt-copy metadata for a secret that may be password-protected — +/// an HD wallet (mirrored from the wallet-meta sidecar) or an identity whose +/// keys are opted-in Tier-2 (seeded from the loaded `QualifiedIdentity` alias +/// and the DET-side `IdentityMetaView` hint at hydration). The chokepoint uses +/// it to build an informative [`SecretPromptRequest`] without reaching back +/// into the wallet backend. +/// +/// Display-only: it NEVER decides whether to prompt (the vault scheme does, in +/// [`SecretAccess::scope_has_passphrase`]). A missing entry degrades to a +/// generic label, never an error. +#[derive(Clone, Debug, Default)] +pub struct PromptMeta { + /// User-visible label — wallet name, or identity DPNS name / truncated id, + /// if any. + pub alias: Option<String>, + /// User-set password hint, if any. + pub password_hint: Option<String>, +} + +/// An identity object password VERIFIED against an existing protected key of +/// the identity. Produced by +/// [`SecretAccess::verify_identity_object_password`] and consumed by +/// [`SecretAccess::seal_new_identity_key_with_password`], so the add-key flow +/// can enforce the protected-identity precondition BEFORE the irreversible +/// on-chain broadcast and seal the new key AFTER it — with a single prompt. +/// Wraps a [`SecretString`], so the plaintext zeroizes on drop. +pub struct VerifiedIdentityPassword(SecretString); + +impl std::fmt::Debug for VerifiedIdentityPassword { + /// Redacts the wrapped password (M-PUBLIC-DEBUG, M-DONT-LEAK-TYPES). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("VerifiedIdentityPassword").finish() + } +} + +impl SecretAccess { + /// Build a chokepoint over `secret_store`, prompting through `prompt`. + /// + /// Prompt-copy metadata is seeded via [`SecretAccess::set_wallet_meta`] + /// / [`SecretAccess::set_single_key_index`]; absent metadata degrades + /// to a generic label, never an error. + pub fn new( + secret_store: Arc<SecretStore>, + prompt: Arc<dyn SecretPrompt>, + network: Network, + ) -> Self { + Self { + inner: Arc::new(SecretAccessInner { + secret_store, + wallet_meta: RwLock::new(BTreeMap::new()), + single_key_index: RwLock::new(BTreeMap::new()), + identity_prompt_index: RwLock::new(BTreeMap::new()), + prompt, + session: RwLock::new(HashMap::new()), + network, + }), + } + } + + /// The network this chokepoint derives for. + pub fn network(&self) -> Network { + self.inner.network + } + + /// Replace the HD prompt-copy metadata map. Used at hydration time so + /// prompts can show the wallet name and password hint. Poison-safe: a + /// poisoned lock is recovered (matching `forget`/`forget_all`) so a panicked + /// reader can never freeze prompt-copy metadata for the rest of the session. + pub fn set_wallet_meta(&self, meta: BTreeMap<WalletSeedHash, PromptMeta>) { + let mut guard = self + .inner + .wallet_meta + .write() + .unwrap_or_else(|poison| poison.into_inner()); + *guard = meta; + } + + /// Replace the single-key prompt-copy index. Used at hydration time and + /// after an import so prompts can show the key nickname and hint, and + /// so the unprotected fast-path can skip the prompt. Poison-safe: a poisoned + /// lock is recovered so the index can self-heal after a panicked reader. + pub fn set_single_key_index(&self, index: BTreeMap<String, ImportedKey>) { + let mut guard = self + .inner + .single_key_index + .write() + .unwrap_or_else(|poison| poison.into_inner()); + *guard = index; + } + + /// Replace the identity prompt-copy index. Used at hydration time and + /// after an opt-in migration so the sign-time prompt for a protected + /// identity shows its label and password hint. Display-only — never + /// gates whether a prompt fires (the vault scheme does). Poison-safe: a + /// poisoned lock is recovered so the index can self-heal after a panicked + /// reader. + pub fn set_identity_prompt_index(&self, index: BTreeMap<[u8; 32], PromptMeta>) { + let mut guard = self + .inner + .identity_prompt_index + .write() + .unwrap_or_else(|poison| poison.into_inner()); + *guard = index; + } + + /// Run `f` with the plaintext secret for `scope`, obtaining it + /// just-in-time. + /// + /// Resolution: session cache (honoring TTL) → unprotected fast-path → + /// prompt + decrypt. On a wrong passphrase the chokepoint re-asks with + /// [`SecretPromptRetry::WrongPassphrase`] without closing the modal; a + /// dismissed prompt resolves [`TaskError::SecretPromptCancelled`]. + /// + /// `f` receives the plaintext by borrow and MUST NOT copy it out — the + /// type system enforces this (no `Clone`, no `Deref`). The plaintext + /// zeroizes when this call returns. + pub async fn with_secret<R>( + &self, + scope: &SecretScope, + f: impl FnOnce(SecretPlaintext<'_>) -> Result<R, TaskError>, + ) -> Result<R, TaskError> { + self.with_secret_session(scope, async |session| f(session.plaintext())) + .await + } + + /// Run `f` with one decrypted secret held for the whole closure, so a + /// multi-step operation (sign N inputs, derive then sign) prompts at + /// most once. The held secret zeroizes when the closure returns. + /// + /// Semantics otherwise match [`SecretAccess::with_secret`]. + pub async fn with_secret_session<R>( + &self, + scope: &SecretScope, + f: impl AsyncFnOnce(&SecretSession<'_>) -> Result<R, TaskError>, + ) -> Result<R, TaskError> { + // 1. Session-cache hit (opt-in, TTL-honored). Copy the entry into an + // op-scoped `Zeroizing` buffer and release the lock BEFORE the + // closure runs: it may `.await` and re-enter the cache for another + // scope, so holding the lock across it could deadlock. + { + let now = Instant::now(); + let mut needs_evict = false; + let held = { + let guard = read_recover(&self.inner.session); + match guard.get(scope) { + Some(entry) if entry.is_expired(now) => { + needs_evict = true; + None + } + Some(entry) => Some(entry.plaintext.as_ref().to_op_copy()), + None => None, + } + }; + if let Some(plaintext) = held { + let session = SecretSession { + plaintext: &plaintext, + }; + return f(&session).await; + } + if needs_evict { + let mut guard = write_recover(&self.inner.session); + // Re-check expiry under the write lock to avoid racing a + // concurrent refresh, then drop (zeroize) the entry. + if guard.get(scope).is_some_and(|e| e.is_expired(now)) { + guard.remove(scope); + } + } + } + + // 2. Unprotected fast-path: decrypt with no passphrase, no prompt. + // Nothing to remember — there is no toggle on a no-prompt path, + // and a re-resolve is a cheap vault read. + if !self.scope_has_passphrase(scope)? { + let plaintext = self.decrypt_jit(scope, None)?; + let session = SecretSession { + plaintext: &plaintext, + }; + return f(&session).await; + } + + // 3. Prompt → decrypt → run. Re-ask on a wrong passphrase. + let mut retry: Option<SecretPromptRetry> = None; + loop { + let request = self.build_request(scope, retry); + let reply = self + .inner + .prompt + .request(request) + .await + .map_err(|_cancelled| self.cancel_error())?; + + match self.decrypt_jit(scope, Some(&reply.passphrase)) { + Ok(plaintext) => { + // Cache a copy; the original is still needed for this op's + // session borrow below. + self.maybe_remember(scope, plaintext.to_op_copy(), reply.remember); + let session = SecretSession { + plaintext: &plaintext, + }; + return f(&session).await; + } + Err(e) if is_wrong_passphrase(&e) => { + retry = Some(SecretPromptRetry::WrongPassphrase); + continue; + } + Err(other) => return Err(other), + } + } + } + + /// Promote a decrypted secret to the session cache without running an + /// operation. Used by the explicit unlock gesture when the user opts + /// in to "keep unlocked". `RememberPolicy::None` is a no-op. + pub fn remember_session( + &self, + scope: &SecretScope, + plaintext: SecretPlaintext<'_>, + policy: RememberPolicy, + ) { + // Copy the borrowed plaintext into an owned `Plaintext` exactly once, + // then hand ownership to the cache (moved into the box, not re-copied). + let owned = match plaintext { + SecretPlaintext::HdSeed(s) => Plaintext::HdSeed(Zeroizing::new(**s)), + SecretPlaintext::SingleKey(k) => Plaintext::SingleKey(Zeroizing::new(**k)), + SecretPlaintext::IdentityKey(k) => Plaintext::IdentityKey(Zeroizing::new(**k)), + }; + self.maybe_remember(scope, owned, policy); + } + + /// Decrypt an HD-seed envelope with an explicitly-supplied passphrase and + /// promote the result into the session cache — **without prompting**. + /// + /// This is the unlock-gesture bridge: the UI has just verified the + /// passphrase (via [`WalletSeed::open`](crate::model::wallet::WalletSeed::open)), + /// so the seed is re-decrypted here through the same chokepoint decrypt + /// path every signing op uses, then cached so the rest of the session does + /// not re-prompt. `passphrase` is `None` for unprotected wallets (the + /// envelope decrypts verbatim). The plaintext is borrowed only to seed the + /// cache and zeroizes on return. + /// + /// The lazy legacy→steady-state re-wrap happens inside [`Self::decrypt_jit`]: + /// a protected seed re-wraps to **Tier-2 under the same password** (protection + /// KEPT, never downgraded to a raw secret), an unprotected one to the raw + /// label. So there is nothing for the unlock callsite to "finalize" — the + /// wallet's `uses_password` stays accurate (`true` for a protected wallet). + pub fn promote_hd_seed_with_passphrase( + &self, + seed_hash: &WalletSeedHash, + passphrase: Option<&SecretString>, + policy: RememberPolicy, + ) -> Result<(), TaskError> { + let scope = SecretScope::HdSeed { + seed_hash: *seed_hash, + }; + let plaintext = self.decrypt_jit(&scope, passphrase)?; + self.maybe_remember(&scope, plaintext, policy); + Ok(()) + } + + /// Seal a NEW identity key Tier-2 under the identity's EXISTING object + /// password. A protected identity must never acquire a keyless + /// key, so when a key is added to such an identity it is sealed here rather + /// than written raw. + /// + /// Prompts for the password and VERIFIES it by unsealing `verify` (an + /// existing `Protected` key of the same identity) — so the whole identity + /// stays under ONE password, with the standard wrong-password re-ask — then + /// seals `new_key` at its label under that same password. Headless + /// (`NullSecretPrompt`) yields [`TaskError::SecretPromptUnavailable`] and + /// nothing is written (fail closed). + /// + /// This is the verify-then-seal composition for callers that run both + /// halves together. The add-key flow instead calls + /// [`Self::verify_identity_object_password`] BEFORE its on-chain broadcast + /// and [`Self::seal_new_identity_key_with_password`] AFTER, so a headless or + /// wrong-password attempt fails closed before any state transition is sent + /// (O-2) — the same single prompt, split across the broadcast. + pub async fn seal_new_identity_key( + &self, + identity_id: [u8; 32], + verify: &SecretScope, + new_target: &PrivateKeyTarget, + new_key_id: KeyID, + new_key: &[u8; 32], + ) -> Result<(), TaskError> { + let password = self.verify_identity_object_password(verify).await?; + self.seal_new_identity_key_with_password( + identity_id, + new_target, + new_key_id, + new_key, + &password, + ) + } + + /// Prompt for the identity's object password and VERIFY it by unsealing + /// `verify` (an existing `Protected` key of the same identity), returning + /// the verified password for a later + /// [`Self::seal_new_identity_key_with_password`]. + /// + /// Split out of [`Self::seal_new_identity_key`] so the add-key flow can + /// enforce the protected-identity precondition BEFORE its irreversible + /// on-chain broadcast and seal the new key AFTER, without a second prompt. + /// Headless ([`NullSecretPrompt`](crate::wallet_backend::secret_prompt::NullSecretPrompt)) + /// yields [`TaskError::SecretPromptUnavailable`] (fail closed); a wrong + /// password re-asks. The verification plaintext is dropped (zeroized) + /// immediately; the returned password zeroizes on drop. + pub async fn verify_identity_object_password( + &self, + verify: &SecretScope, + ) -> Result<VerifiedIdentityPassword, TaskError> { + let mut retry: Option<SecretPromptRetry> = None; + loop { + let request = self.build_request(verify, retry); + let reply = self + .inner + .prompt + .request(request) + .await + .map_err(|_cancelled| self.cancel_error())?; + + // Verify the typed password against an existing protected key so the + // new key is later sealed under the SAME password as the rest. The + // verification plaintext is dropped (zeroized) immediately. + match self.decrypt_jit(verify, Some(&reply.passphrase)) { + Ok(_verified) => return Ok(VerifiedIdentityPassword(reply.passphrase)), + Err(e) if is_wrong_passphrase(&e) => { + retry = Some(SecretPromptRetry::WrongPassphrase); + continue; + } + Err(other) => return Err(other), + } + } + } + + /// Seal a NEW identity key Tier-2 under an ALREADY-VERIFIED identity object + /// password — the back half of [`Self::seal_new_identity_key`]. + /// No prompt and no re-verify: `password` came from a successful + /// [`Self::verify_identity_object_password`], so this only writes the sealed + /// key. The add-key flow calls this AFTER its on-chain broadcast, having + /// verified the password up front, so the new key never lands keyless. + pub fn seal_new_identity_key_with_password( + &self, + identity_id: [u8; 32], + new_target: &PrivateKeyTarget, + new_key_id: KeyID, + new_key: &[u8; 32], + password: &VerifiedIdentityPassword, + ) -> Result<(), TaskError> { + let scope_id = SecretWalletId::from(identity_id); + let label = SecretScope::identity_key_label(new_target, new_key_id); + self.seam() + .put_secret_protected( + &scope_id, + &label, + &SecretBytes::from_slice(new_key), + &password.0, + ) + .map_err(identity_flavored) + } + + /// Forget the session-cached secret for `scope`, zeroizing it. + /// Idempotent. Poison-safe: a poisoned lock is recovered so a panicked + /// reader can never strand a plaintext in the cache. + pub fn forget(&self, scope: &SecretScope) { + let mut guard = self + .inner + .session + .write() + .unwrap_or_else(|poison| poison.into_inner()); + guard.remove(scope); + } + + /// Forget every session-cached secret, zeroizing all of them. Called on + /// network switch and teardown. Poison-safe. + pub fn forget_all(&self) { + let mut guard = self + .inner + .session + .write() + .unwrap_or_else(|poison| poison.into_inner()); + guard.clear(); + } + + /// `true` when `scope` is currently held in the session cache and not + /// expired. Test/diagnostic helper — does not extend the TTL. + #[cfg(test)] + pub(crate) fn is_session_cached(&self, scope: &SecretScope) -> bool { + self.session_cache_hit(scope) + } + + /// `true` when [`Self::with_secret`] could resolve `scope` without ever + /// prompting — either the plaintext is already in the session cache or the + /// secret is unprotected (decrypts with no passphrase). + /// + /// Lets a non-interactive caller (the background identity sweep) decide up + /// front whether to attempt a derivation or skip the wallet, so it never + /// triggers a passphrase modal. A `false` here is conservative: the resolve + /// would prompt, so the caller should skip. + pub fn can_resolve_without_prompt(&self, scope: &SecretScope) -> bool { + self.session_cache_hit(scope) || !self.scope_has_passphrase(scope).unwrap_or(true) + } + + /// Whether `scope`'s plaintext is in the session cache and not expired. + fn session_cache_hit(&self, scope: &SecretScope) -> bool { + let now = Instant::now(); + self.inner + .session + .read() + .map(|g| g.get(scope).is_some_and(|e| !e.is_expired(now))) + .unwrap_or(false) + } + + /// Insert into the session cache iff `policy` requests it; expiry stamped + /// for `For(duration)`. + /// + /// Takes the plaintext **by value** and moves it straight into the boxed + /// cache entry, so the secret is copied exactly once — at the call boundary + /// — rather than copied to build the argument and copied again to box it. A + /// caller that must keep the plaintext after caching (mid-operation) passes + /// a [`Plaintext::to_op_copy`]; a caller that is done with it passes + /// ownership. + fn maybe_remember(&self, scope: &SecretScope, plaintext: Plaintext, policy: RememberPolicy) { + let now = Instant::now(); + let expires_at = match policy { + RememberPolicy::None => return, + RememberPolicy::UntilAppClose => None, + // On the (unreachable today) overflow of `now + duration`, expire + // immediately rather than risk over-retaining the secret — `None` + // here would mean "never expires". + RememberPolicy::For(duration) => Some(now.checked_add(duration).unwrap_or(now)), + }; + write_recover(&self.inner.session).insert( + scope.clone(), + SessionEntry { + plaintext: Box::new(plaintext), + expires_at, + }, + ); + } + + /// The typed error for a dismissed/absent prompt. A genuine user cancel + /// on the interactive host is [`TaskError::SecretPromptCancelled`]; a + /// cancel from a non-interactive host + /// ([`NullSecretPrompt`](crate::wallet_backend::secret_prompt::NullSecretPrompt)) + /// means there was no window to ask in, surfaced as + /// [`TaskError::SecretPromptUnavailable`]. + fn cancel_error(&self) -> TaskError { + if self.inner.prompt.is_interactive() { + TaskError::SecretPromptCancelled + } else { + TaskError::SecretPromptUnavailable + } + } + + /// Whether `scope`'s stored secret is passphrase-protected. Drives the + /// unprotected fast-path. + /// + /// Seam-first: a secret already migrated to its raw label has no + /// passphrase (the user password no longer gates it). Only a not-yet- + /// migrated legacy entry can still be protected. Identity keys are always + /// unprotected (prompt-free → headless/MCP signing works). + fn scope_has_passphrase(&self, scope: &SecretScope) -> Result<bool, TaskError> { + match scope { + SecretScope::HdSeed { seed_hash } => { + let view = WalletSeedView::new(&self.inner.secret_store); + match view.scheme(seed_hash)? { + // Tier-2: the seed is sealed under its own object password. + SecretScheme::Protected => Ok(true), + // Tier-1 raw: unprotected — no passphrase. + SecretScheme::Unprotected => Ok(false), + // Nothing at the raw label yet ⇒ the legacy envelope's + // `uses_password` is the source of truth until first unlock + // migrates it to the raw label. + SecretScheme::Absent => { + let envelope = view.get(seed_hash)?.ok_or(TaskError::SecretSeamMissing)?; + Ok(envelope.uses_password) + } + } + } + SecretScope::SingleKey { address } => { + let label = label_for_address(address); + match self.seam().scheme(&single_key_namespace_id(), &label)? { + // Tier-2 protected (re-wrapped) ⇒ needs the object password. + SecretScheme::Protected => Ok(true), + SecretScheme::Absent => Err(TaskError::ImportedKeyNotFound), + // Unprotected at the vault: either a migrated raw-32 key + // (no passphrase) or a not-yet-migrated legacy `SingleKeyEntry` + // blob whose `has_passphrase` flag decides. + SecretScheme::Unprotected => { + if self.single_key_raw(address)?.is_some() { + return Ok(false); + } + if let Ok(index) = self.inner.single_key_index.read() + && let Some(meta) = index.get(address) + { + return Ok(meta.has_passphrase); + } + Ok(self.load_single_key_entry(address)?.has_passphrase) + } + } + } + // Identity keys default to keyless (Tier-1 raw) and resolve + // prompt-free so headless/MCP signing keeps working. A user may + // OPT IN per identity to seal them Tier-2; the vault scheme is the + // single source of truth for whether to prompt — no parallel flag. + SecretScope::IdentityKey { + identity_id, + target, + key_id, + } => { + let label = SecretScope::identity_key_label(target, *key_id); + match self + .seam() + .scheme(&SecretWalletId::from(*identity_id), &label)? + { + // Tier-2 protected ⇒ needs the identity's object password. + SecretScheme::Protected => Ok(true), + // Tier-1 raw ⇒ keyless default, prompt-free. + SecretScheme::Unprotected => Ok(false), + // Absent ⇒ the stored identity references a key whose bytes + // are gone. Loud, never a silent prompt-free miss. + SecretScheme::Absent => Err(TaskError::IdentityKeyMissing), + } + } + } + } + + /// Decrypt the stored secret for `scope` with `passphrase` + /// (`None` for unprotected scopes). The only place the vault is read + /// for plaintext. Returns the kind-tagged owned plaintext. + /// + /// Seam-first for all three classes: the raw label wins; the retained + /// legacy reader is the migration fallback for HD seeds and single keys. + fn decrypt_jit( + &self, + scope: &SecretScope, + passphrase: Option<&SecretString>, + ) -> Result<Plaintext, TaskError> { + match scope { + SecretScope::HdSeed { seed_hash } => { + let view = WalletSeedView::new(&self.inner.secret_store); + match view.scheme(seed_hash)? { + // Tier-1 raw — unprotected, no password. + SecretScheme::Unprotected => { + let seed = view + .get_raw(seed_hash)? + .ok_or(TaskError::SecretSeamMissing)?; + Ok(Plaintext::HdSeed(seed)) + } + // Tier-2 — unseal with this seed's own object password. + SecretScheme::Protected => { + let pw = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; + let seed = view + .get_protected(seed_hash, pw)? + .ok_or(TaskError::SecretSeamMissing)?; + // GC a legacy `envelope.v1` orphaned by a crash + // or delete-failure between the migration's + // `set_protected` and `delete`. The Absent branch (the + // only other deleter) is never re-entered once the seed + // is `Protected`, so the stale AES-GCM ciphertext — which + // still decrypts under the seed's OLD password — would + // otherwise survive forever. Idempotent + best-effort. + if let Err(e) = view.delete(seed_hash) { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?e, + "Best-effort GC of a stale legacy seed envelope failed", + ); + } + Ok(Plaintext::HdSeed(seed)) + } + // Legacy AES-GCM envelope: decode-only reader, then LAZY + // re-wrap to the steady-state form and drop the legacy + // envelope. A protected seed re-wraps to Tier-2 under the + // SAME user password (protection KEPT, not downgraded to + // raw); an unprotected one goes to the raw label. An absent + // envelope ⇒ the secret is gone (loud, never a silent miss). + // Crash-safe: the re-store (upsert) precedes the delete, and + // the scheme probe prefers the new label, so a crash between + // leaves both forms and the next read takes the new one. + SecretScheme::Absent => { + let envelope = view.get(seed_hash)?.ok_or(TaskError::SecretSeamMissing)?; + let seed = decrypt_hd_seed(&envelope, passphrase)?; + if envelope.uses_password { + let pw = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; + view.set_protected(seed_hash, &seed, pw)?; + } else { + view.set_raw(seed_hash, &seed)?; + } + // Best-effort GC of the legacy envelope, matching the + // Protected branch above: the new value is already + // written (upsert) and the scheme probe prefers it on the + // next read, so a transient delete failure must not fail a + // successful unlock. A stale envelope is cleaned up later. + if let Err(e) = view.delete(seed_hash) { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?e, + "Best-effort GC of the legacy envelope deferred after migration", + ); + } + Ok(Plaintext::HdSeed(seed)) + } + } + } + SecretScope::SingleKey { address } => { + let label = label_for_address(address); + match self.seam().scheme(&single_key_namespace_id(), &label)? { + // Tier-2 — unseal with this key's own object password. + SecretScheme::Protected => { + let pw = passphrase.ok_or(TaskError::SingleKeyPassphraseIncorrect)?; + let raw = self + .seam() + .get_secret_protected(&single_key_namespace_id(), &label, pw)? + .ok_or(TaskError::ImportedKeyNotFound)?; + let key: [u8; SINGLE_KEY_LEN] = + raw.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = raw.expose_secret().len(), + "Tier-2 single key has wrong length", + ); + TaskError::SecretDecryptFailed + })?; + Ok(Plaintext::SingleKey(Zeroizing::new(key))) + } + SecretScheme::Absent => Err(TaskError::ImportedKeyNotFound), + SecretScheme::Unprotected => { + // A migrated raw-32 key wins prompt-free. + if let Some(raw) = self.single_key_raw(address)? { + return Ok(Plaintext::SingleKey(raw)); + } + // Legacy `SingleKeyEntry` (decode-only reader). A + // protected entry was just decrypted with the user's + // passphrase — LAZY re-wrap it to Tier-2 under the SAME + // password (the upsert replaces the AES-GCM framing), + // KEEPING protection. Idempotent. + let entry = self.load_single_key_entry(address)?; + let raw = entry.decrypt(passphrase.map(|p| p.expose_secret()))?; + if entry.has_passphrase { + let pw = passphrase.ok_or(TaskError::SingleKeyPassphraseIncorrect)?; + self.migrate_single_key_to_tier2(address, &raw, pw); + } + Ok(Plaintext::SingleKey(raw)) + } + } + } + SecretScope::IdentityKey { + identity_id, + target, + key_id, + } => { + let scope_id = SecretWalletId::from(*identity_id); + let label = SecretScope::identity_key_label(target, *key_id); + match self.seam().scheme(&scope_id, &label)? { + // Tier-2 — unseal with this identity's object password + // (opted-in). Symmetric to the single-key Protected arm. + SecretScheme::Protected => { + let pw = passphrase.ok_or(TaskError::IdentityKeyPassphraseIncorrect)?; + let raw = self + .seam() + .get_secret_protected(&scope_id, &label, pw)? + .ok_or(TaskError::IdentityKeyMissing)?; + let key = identity_key_from_bytes(raw.expose_secret())?; + Ok(Plaintext::IdentityKey(Zeroizing::new(key))) + } + // Tier-1 raw — keyless default, no password. + SecretScheme::Unprotected => { + let raw = self + .seam() + .get_secret(&scope_id, &label)? + .ok_or(TaskError::IdentityKeyMissing)?; + let key = identity_key_from_bytes(raw.expose_secret())?; + Ok(Plaintext::IdentityKey(Zeroizing::new(key))) + } + SecretScheme::Absent => Err(TaskError::IdentityKeyMissing), + } + } + } + } + + /// Borrow the secret store as a [`SecretSeam`]. + fn seam(&self) -> SecretSeam<'_> { + SecretSeam::new(&self.inner.secret_store) + } + + /// LAZY-re-wrap a just-decrypted protected single key to a Tier-2 envelope + /// under the same label and object `password` (the upsert replaces the + /// legacy AES-GCM framing), KEEPING protection. Best-effort: a vault-write + /// failure is logged and the key keeps working via the legacy reader. + /// + /// `has_passphrase` is deliberately NOT flipped — the secret stays protected, + /// so the in-memory index and the persisted flag remain accurate (the next + /// resolve still prompts for the object password). + fn migrate_single_key_to_tier2( + &self, + address: &str, + raw: &[u8; SINGLE_KEY_LEN], + password: &SecretString, + ) { + let label = label_for_address(address); + if let Err(e) = self.seam().put_secret_protected( + &single_key_namespace_id(), + &label, + &platform_wallet_storage::secrets::SecretBytes::from_slice(raw), + password, + ) { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?e, + "Single-key lazy Tier-2 re-wrap deferred (vault write failed)", + ); + } + } + + /// Read the raw 32-byte single-key secret for `address` if the entry has + /// already been migrated to its raw label, else `None`. A legacy + /// `SingleKeyEntry`-framed value (length != 32) is left for the legacy + /// reader and reported as `None` here. + fn single_key_raw( + &self, + address: &str, + ) -> Result<Option<Zeroizing<[u8; SINGLE_KEY_LEN]>>, TaskError> { + let label = label_for_address(address); + let Some(payload) = self.seam().get_secret(&single_key_namespace_id(), &label)? else { + return Ok(None); + }; + match <[u8; SINGLE_KEY_LEN]>::try_from(payload.expose_secret()) { + Ok(raw) => Ok(Some(Zeroizing::new(raw))), + // Not 32 bytes ⇒ a legacy framed entry, not yet migrated. + Err(_) => Ok(None), + } + } + + /// Load and decode the stored single-key entry for `address`. + fn load_single_key_entry(&self, address: &str) -> Result<SingleKeyEntry, TaskError> { + let label = label_for_address(address); + let payload = self + .inner + .secret_store + .get(&single_key_namespace_id(), &label) + .map_err(|source| TaskError::SecretStore { + source: Box::new(source), + })? + .ok_or(TaskError::ImportedKeyNotFound)?; + SingleKeyEntry::decode(payload.expose_secret()) + } + + /// Build a prompt request for `scope`, filling display copy from the + /// in-memory metadata where available. + fn build_request( + &self, + scope: &SecretScope, + retry: Option<SecretPromptRetry>, + ) -> SecretPromptRequest { + let (label, hint) = match scope { + SecretScope::HdSeed { seed_hash } => { + let meta = self + .inner + .wallet_meta + .read() + .ok() + .and_then(|g| g.get(seed_hash).cloned()) + .unwrap_or_default(); + let label = meta.alias.unwrap_or_else(|| "your wallet".to_string()); + (label, meta.password_hint) + } + SecretScope::SingleKey { address } => { + let meta = self + .inner + .single_key_index + .read() + .ok() + .and_then(|g| g.get(address).cloned()); + let label = meta + .as_ref() + .and_then(|m| m.alias.clone()) + .unwrap_or_else(|| address.clone()); + let hint = meta.and_then(|m| m.passphrase_hint); + (label, hint) + } + // Opted-in (Tier-2) identity keys DO prompt; read the display copy + // from the identity prompt-index (alias + password hint). A missing + // entry degrades to a generic label, never an error. + SecretScope::IdentityKey { identity_id, .. } => { + let meta = self + .inner + .identity_prompt_index + .read() + .ok() + .and_then(|g| g.get(identity_id).cloned()); + let label = meta + .as_ref() + .and_then(|m| m.alias.clone()) + .unwrap_or_else(|| "this identity".to_string()); + let hint = meta.and_then(|m| m.password_hint); + (label, hint) + } + }; + let mut request = SecretPromptRequest::new(scope.clone(), label).with_hint(hint); + if let Some(reason) = retry { + request = request.retrying(reason); + } + request + } +} + +/// Decrypt the HD seed envelope. `uses_password = false` means +/// `encrypted_seed` holds the raw 64 bytes verbatim (no passphrase needed); +/// otherwise Argon2id-derive the AES-GCM key from `passphrase` and decrypt. +fn decrypt_hd_seed( + envelope: &StoredSeedEnvelope, + passphrase: Option<&SecretString>, +) -> Result<Zeroizing<[u8; HD_SEED_LEN]>, TaskError> { + if !envelope.uses_password { + let raw: [u8; HD_SEED_LEN] = + envelope.encrypted_seed.as_slice().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = envelope.encrypted_seed.len(), + "Unprotected HD seed envelope has wrong length", + ); + TaskError::SecretDecryptFailed + })?; + return Ok(Zeroizing::new(raw)); + } + + let passphrase = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; + let plaintext = decrypt_message( + &envelope.encrypted_seed, + &envelope.salt, + &envelope.nonce, + passphrase.expose_secret(), + "secret_access::decrypt_hd_seed", + ) + .map_err(|e| match e { + DecryptError::WrongPassword => TaskError::HdPassphraseIncorrect, + DecryptError::Malformed => TaskError::SecretDecryptFailed, + })?; + let seed: [u8; HD_SEED_LEN] = plaintext.as_slice().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = plaintext.len(), + "Decrypted HD seed is not 64 bytes", + ); + TaskError::SecretDecryptFailed + })?; + Ok(Zeroizing::new(seed)) +} + +/// Convert raw vault bytes into a 32-byte identity private key, mapping a +/// wrong-length blob to the typed [`TaskError::IdentityKeyMalformed`] (vault +/// corruption / truncated write) rather than a panic or a generic decrypt +/// error. Shared by the Tier-1 and Tier-2 identity-key decrypt arms. +pub(crate) fn identity_key_from_bytes(bytes: &[u8]) -> Result<[u8; SINGLE_KEY_LEN], TaskError> { + bytes.try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::secret_access", + blob_len = bytes.len(), + "Stored identity key has wrong length", + ); + TaskError::IdentityKeyMalformed + }) +} + +/// Whether `e` is the "wrong passphrase" condition that the re-ask loop +/// catches and re-prompts on (rather than aborting). +fn is_wrong_passphrase(e: &TaskError) -> bool { + match e { + TaskError::SingleKeyPassphraseIncorrect + | TaskError::HdPassphraseIncorrect + | TaskError::IdentityKeyPassphraseIncorrect => true, + // A Tier-2 unseal that rejected the object password surfaces through the + // seam as `WrongPassword`; the re-ask loop catches it and re-prompts + // rather than aborting (same UX as the legacy AES-GCM wrong-pass path). + TaskError::SecretSeam { source } => matches!(**source, SecretStoreError::WrongPassword), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use crate::model::wallet::encryption::encrypt_message; + use crate::wallet_backend::secret_prompt::NullSecretPrompt; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::single_key::{SingleKeyView, open_secret_store}; + + /// A sentinel passphrase + seed/key used by confinement tests. If any + /// of these byte sequences appears in an error, log, or Debug output, + /// the chokepoint leaked a secret. + const SENTINEL_PASSPHRASE: &str = "correct-horse-battery-staple-SENTINEL"; + const SENTINEL_SEED: [u8; HD_SEED_LEN] = [0x5A; HD_SEED_LEN]; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + /// Write a protected HD seed envelope under `seed_hash`, encrypting + /// `seed` with `passphrase`. + fn store_protected_hd( + store: &Arc<SecretStore>, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + passphrase: &str, + ) { + let crate::model::wallet::encryption::EncryptedEnvelope { + ciphertext: encrypted_seed, + salt, + nonce, + } = encrypt_message(seed, passphrase).expect("encrypt seed"); + let envelope = StoredSeedEnvelope { + encrypted_seed, + salt, + nonce, + password_hint: Some("granny's birthday".into()), + uses_password: true, + xpub_encoded: vec![0xCD; 78], + }; + WalletSeedView::new(store) + .set(seed_hash, &envelope) + .expect("store envelope"); + } + + /// Write an unprotected HD seed envelope (raw 64 bytes, no password). + fn store_unprotected_hd(store: &Arc<SecretStore>, seed_hash: &WalletSeedHash, seed: &[u8; 64]) { + let envelope = StoredSeedEnvelope { + encrypted_seed: seed.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: vec![0x22; 78], + }; + WalletSeedView::new(store) + .set(seed_hash, &envelope) + .expect("store envelope"); + } + + fn access(store: Arc<SecretStore>, prompt: Arc<dyn SecretPrompt>) -> SecretAccess { + SecretAccess::new(store, prompt, Network::Testnet) + } + + #[test] + fn hd_seed_wrong_length_nonce_returns_typed_error_not_panic() { + // A protected envelope whose nonce is not 12 bytes is a corrupt + // at-rest blob: `decrypt_hd_seed` must surface a typed error rather + // than panic inside `Nonce::from_slice` (which would poison the + // long-lived secret-store mutex). + let envelope = StoredSeedEnvelope { + encrypted_seed: vec![0u8; 80], + salt: vec![0u8; 16], + nonce: vec![0u8; 5], // wrong length on purpose + password_hint: None, + uses_password: true, + xpub_encoded: vec![0u8; 78], + }; + let passphrase = SecretString::new(SENTINEL_PASSPHRASE); + match decrypt_hd_seed(&envelope, Some(&passphrase)) { + Err(TaskError::SecretDecryptFailed) => {} + other => panic!("expected SecretDecryptFailed, got {other:?}"), + } + } + + // --- HD seed scope ---------------------------------------------------- + + #[tokio::test] + async fn cache_miss_prompts_decrypts_and_borrows_seed() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x01; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store, prompt.clone()); + + let scope = SecretScope::HdSeed { seed_hash }; + let matched = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_hd_seed().copied() == Some(SENTINEL_SEED)) + }) + .await + .expect("with_secret"); + assert!(matched, "closure saw the decrypted seed"); + assert_eq!(prompt.ask_count(), 1, "exactly one prompt on cache miss"); + // None policy ⇒ nothing cached. + assert!(!sa.is_session_cached(&scope)); + } + + #[tokio::test] + async fn session_hit_does_not_prompt() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x02; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + // The prompt is scripted with exactly ONE answer. The first op + // remembers for the session; the second op must hit the cache — + // if it prompted, `TestPrompt` would panic on the empty script, + // failing the test for the right reason. + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::remember( + SENTINEL_PASSPHRASE, + RememberPolicy::UntilAppClose, + )])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + assert!(sa.is_session_cached(&scope), "promoted to session cache"); + + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!( + prompt.ask_count(), + 1, + "session hit reused the cache, no re-prompt" + ); + } + + #[tokio::test] + async fn none_policy_does_not_cache() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x03; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store, prompt); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + assert!(!sa.is_session_cached(&scope), "None ⇒ no caching"); + } + + #[tokio::test] + async fn cancel_aborts_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x04; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::Cancel])); + let sa = access(store, prompt); + let scope = SecretScope::HdSeed { seed_hash }; + + let mut ran = false; + let err = sa + .with_secret(&scope, |_pt| { + ran = true; + Ok(()) + }) + .await + .expect_err("cancel aborts"); + assert!(matches!(err, TaskError::SecretPromptCancelled)); + assert!(!ran, "closure never ran on cancel"); + assert!(!sa.is_session_cached(&scope), "nothing cached on cancel"); + } + + #[tokio::test] + async fn null_prompt_on_protected_scope_yields_unavailable() { + // Headless host: a passphrase-protected scope has no window to ask + // in, so the chokepoint surfaces the typed "unavailable" error + // rather than a misleading "you cancelled". + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x0C; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let sa = access(store, Arc::new(NullSecretPrompt)); + let scope = SecretScope::HdSeed { seed_hash }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("no interactive prompt"); + assert!(matches!(err, TaskError::SecretPromptUnavailable)); + } + + #[tokio::test] + async fn null_prompt_unprotected_scope_still_resolves() { + // The headless host must not block no-password wallets: unprotected + // scopes decrypt with no passphrase and never reach the prompt. + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x0D; 32]; + store_unprotected_hd(&store, &seed_hash, &SENTINEL_SEED); + + let sa = access(store, Arc::new(NullSecretPrompt)); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("unprotected resolves headless"); + } + + #[tokio::test] + async fn unprotected_scope_does_not_prompt() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x05; 32]; + store_unprotected_hd(&store, &seed_hash, &SENTINEL_SEED); + + // never() would panic if asked — proves no prompt fired. + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 0, "unprotected ⇒ no prompt"); + } + + #[tokio::test] + async fn can_resolve_without_prompt_tracks_protection_and_cache() { + // The background identity sweep keys off this: an unprotected wallet or + // a session-unlocked protected wallet resolves without a prompt; a + // locked protected wallet does not, so the sweep skips it. + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + + let unprotected: WalletSeedHash = [0x10; 32]; + store_unprotected_hd(&store, &unprotected, &SENTINEL_SEED); + let protected: WalletSeedHash = [0x11; 32]; + store_protected_hd(&store, &protected, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + // The prompt is never consulted — `can_resolve_without_prompt` must + // decide purely from at-rest protection and the session cache. + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + + assert!( + sa.can_resolve_without_prompt(&SecretScope::HdSeed { + seed_hash: unprotected + }), + "unprotected scope resolves with no prompt" + ); + let protected_scope = SecretScope::HdSeed { + seed_hash: protected, + }; + assert!( + !sa.can_resolve_without_prompt(&protected_scope), + "locked protected scope would prompt" + ); + + // Once the seed is session-cached (the user unlocked it), it resolves + // without a prompt. + sa.remember_session( + &protected_scope, + SecretPlaintext::HdSeed(&Zeroizing::new(SENTINEL_SEED)), + RememberPolicy::UntilAppClose, + ); + assert!( + sa.can_resolve_without_prompt(&protected_scope), + "session-unlocked protected scope resolves with no prompt" + ); + assert_eq!(prompt.ask_count(), 0, "decision never prompts"); + } + + #[tokio::test] + async fn wrong_passphrase_reasks_then_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x06; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("wrong-pass"), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 2, "one wrong + one right"); + let second = &prompt.requests()[1]; + assert_eq!( + second.retry_reason, + Some(SecretPromptRetry::WrongPassphrase), + "re-ask carries the wrong-passphrase reason", + ); + } + + #[tokio::test] + async fn ttl_expiry_reprompts() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x07; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::remember(SENTINEL_PASSPHRASE, RememberPolicy::For(Duration::ZERO)), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + // For(ZERO) is already expired: not cached, and the next call + // re-prompts rather than hitting the cache. + assert!( + !sa.is_session_cached(&scope), + "zero TTL is immediately expired" + ); + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + assert_eq!(prompt.ask_count(), 2, "expired entry forces a re-prompt"); + } + + #[tokio::test] + async fn forget_clears_session_cache() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x08; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::remember( + SENTINEL_PASSPHRASE, + RememberPolicy::UntilAppClose, + )])); + let sa = access(store, prompt); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + assert!(sa.is_session_cached(&scope)); + sa.forget(&scope); + assert!(!sa.is_session_cached(&scope)); + } + + #[tokio::test] + async fn remember_session_promotes_and_forget_all_clears() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + // The unlock gesture promotes a verified seed without running an + // operation. A never-prompt double proves no prompt is involved. + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::HdSeed { + seed_hash: [0x09; 32], + }; + + // None is a no-op. + sa.remember_session( + &scope, + SecretPlaintext::HdSeed(&Zeroizing::new(SENTINEL_SEED)), + RememberPolicy::None, + ); + assert!(!sa.is_session_cached(&scope), "None ⇒ not cached"); + + sa.remember_session( + &scope, + SecretPlaintext::HdSeed(&Zeroizing::new(SENTINEL_SEED)), + RememberPolicy::UntilAppClose, + ); + assert!(sa.is_session_cached(&scope), "promoted to session cache"); + + sa.forget_all(); + assert!( + !sa.is_session_cached(&scope), + "forget_all clears everything" + ); + } + + // --- single-key scope ------------------------------------------------- + + fn import_protected_key(store: &Arc<SecretStore>, passphrase: &str) -> String { + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + let view = SingleKeyView::from_views(store, &index, Network::Testnet, None); + let imported = view + .import_wif_with_passphrase( + &known_testnet_wif(), + Some("My Key".into()), + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(zeroize::Zeroizing::new(passphrase.to_string())), + hint: Some("the usual".into()), + }, + ) + .expect("import"); + imported.address + } + + fn known_testnet_wif() -> String { + use dash_sdk::dpp::dashcore::PrivateKey; + use dash_sdk::dpp::dashcore::secp256k1::SecretKey; + let mut bytes = [0u8; 32]; + bytes[31] = 1; + let sk = SecretKey::from_byte_array(&bytes).unwrap(); + PrivateKey::new(sk, Network::Testnet).to_wif() + } + + /// Write a legacy DET AES-GCM `SingleKeyEntry` straight to the vault Tier-1 — + /// the pre-Tier-2 protected-import shape. Fresh imports now seal Tier-2 at + /// import time, so this is how the legacy→Tier-2 lazy migration path stays + /// covered. + fn write_legacy_protected_key(store: &Arc<SecretStore>, passphrase: &str) -> String { + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; + + let priv_key = PrivateKey::from_wif(&known_testnet_wif()).expect("wif"); + let raw: Zeroizing<[u8; 32]> = + Zeroizing::new(priv_key.inner[..].try_into().expect("32 bytes")); + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); + let pub_bytes = pub_key.inner.serialize().to_vec(); + let entry = + SingleKeyEntry::protected(&raw, passphrase, Some("the usual".into()), pub_bytes) + .expect("build legacy protected entry"); + let payload = entry.encode().expect("encode legacy entry"); + store + .set( + &single_key_namespace_id(), + &label_for_address(&address), + &SecretBytes::from_slice(&payload), + ) + .expect("write legacy vault entry"); + address + } + + #[tokio::test] + async fn single_key_cache_miss_prompts_and_decrypts() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let address = import_protected_key(&store, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope = SecretScope::SingleKey { + address: address.clone(), + }; + let len = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_single_key().map(|k| k.len()).unwrap_or(0)) + }) + .await + .unwrap(); + assert_eq!(len, 32, "decrypted single-key is 32 bytes"); + assert_eq!(prompt.ask_count(), 1); + } + + #[tokio::test] + async fn single_key_wrong_passphrase_reasks() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let address = import_protected_key(&store, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("bad-passphrase"), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope = SecretScope::SingleKey { address }; + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + assert_eq!(prompt.ask_count(), 2); + } + + /// TS-LAZY-03 (Tier-2) — a *legacy* protected single key lazy RE-WRAPS + /// through the chokepoint, KEEPING protection: the first `with_secret` + /// decrypts with the passphrase AND re-stores a Tier-2 object-password + /// envelope (not a raw secret); a second `with_secret` therefore still + /// requires the password. Starts from a legacy AES-GCM entry so the + /// migration path is genuinely exercised (fresh imports already seal Tier-2). + #[tokio::test] + async fn ts_lazy_03_protected_single_key_rewraps_to_tier2_via_chokepoint() { + use dash_sdk::dpp::dashcore::PrivateKey; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let address = write_legacy_protected_key(&store, SENTINEL_PASSPHRASE); + let expected: [u8; 32] = PrivateKey::from_wif(&known_testnet_wif()).unwrap().inner[..] + .try_into() + .unwrap(); + + // First resolve: one passphrase, re-wraps to Tier-2. + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope = SecretScope::SingleKey { + address: address.clone(), + }; + let first = sa + .with_secret(&scope, |pt| Ok(pt.expose_single_key().copied())) + .await + .unwrap(); + assert_eq!(first, Some(expected)); + assert_eq!(prompt.ask_count(), 1); + + // The vault now holds a Tier-2 envelope (kept protected) — a password- + // free read fails, and the password read returns the 32 key bytes. + let label = label_for_address(&address); + assert_eq!( + SecretSeam::new(&store) + .scheme(&single_key_namespace_id(), &label) + .unwrap(), + SecretScheme::Protected, + "the single key must re-wrap to Tier-2, never downgrade to raw" + ); + assert!( + store.get(&single_key_namespace_id(), &label).is_err(), + "a password-free read of a protected single key must fail" + ); + let pw = SecretString::new(SENTINEL_PASSPHRASE); + let unsealed = store + .get_secret(&single_key_namespace_id(), &label, Some(&pw)) + .unwrap() + .unwrap(); + assert_eq!(unsealed.expose_secret(), &expected[..]); + + // Second resolve still requires the object password (Tier-2, not raw). + let prompt2 = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa2 = access(Arc::clone(&store), prompt2.clone()); + let second = sa2 + .with_secret(&scope, |pt| Ok(pt.expose_single_key().copied())) + .await + .expect("resolve with the password"); + assert_eq!(second, Some(expected)); + assert_eq!(prompt2.ask_count(), 1, "protected single key prompts again"); + } + + /// TS-T2-SK-ISO — PER-SECRET isolation for imported single keys: two Tier-2 + /// keys under DIFFERENT passwords. A's password cannot open B (the negative + /// crypto property), and remembering A never satisfies B (scope-keyed cache). + #[tokio::test] + async fn ts_t2_sk_iso_per_secret_passwords_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let addr_a = "single-key-address-A".to_string(); + let addr_b = "single-key-address-B".to_string(); + let key_a = [0xA7u8; 32]; + let key_b = [0xB8u8; 32]; + let pw_a = SecretString::new("single-key-A-pwpwpwpw"); + let pw_b = SecretString::new("single-key-B-pwpwpwpw"); + let seam = SecretSeam::new(&store); + seam.put_secret_protected( + &single_key_namespace_id(), + &label_for_address(&addr_a), + &SecretBytes::from_slice(&key_a), + &pw_a, + ) + .unwrap(); + seam.put_secret_protected( + &single_key_namespace_id(), + &label_for_address(&addr_b), + &SecretBytes::from_slice(&key_b), + &pw_b, + ) + .unwrap(); + + // Negative crypto property: A's password is REJECTED by B's envelope. + match seam.get_secret_protected( + &single_key_namespace_id(), + &label_for_address(&addr_b), + &pw_a, + ) { + Err(TaskError::SecretSeam { source }) + if matches!(*source, SecretStoreError::WrongPassword) => {} + other => panic!("A's password must be rejected by B, got {other:?}"), + } + + // Scope-keyed cache: remembering A does not satisfy B — B still prompts. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::remember("single-key-A-pwpwpwpw", RememberPolicy::UntilAppClose), + ScriptedAnswer::remember("single-key-B-pwpwpwpw", RememberPolicy::UntilAppClose), + ])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope_a = SecretScope::SingleKey { + address: addr_a.clone(), + }; + let scope_b = SecretScope::SingleKey { + address: addr_b.clone(), + }; + + sa.with_secret(&scope_a, |pt| { + assert_eq!(pt.expose_single_key().copied(), Some(key_a)); + Ok(()) + }) + .await + .unwrap(); + assert!(sa.is_session_cached(&scope_a)); + assert!( + !sa.is_session_cached(&scope_b), + "A's unlock must not cache B" + ); + + sa.with_secret(&scope_b, |pt| { + assert_eq!(pt.expose_single_key().copied(), Some(key_b)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 2, "B prompted independently of A"); + } + + // --- secret confinement ----------------------------------------------- + + #[tokio::test] + async fn sentinel_never_appears_in_error_or_debug() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x0A; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + // Drive a wrong-passphrase-then-cancel so an error surfaces. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ScriptedAnswer::Cancel, + ])); + let sa = access(store, prompt); + let scope = SecretScope::HdSeed { seed_hash }; + + // First: success path returns the decrypted seed by borrow, but we + // must not be able to surface it. Build an error deliberately. + let err = sa + .with_secret::<()>(&scope, |_pt| Err(TaskError::SecretDecryptFailed)) + .await + .expect_err("closure returned an error"); + + let display = err.to_string(); + let debug = format!("{err:?}"); + let sentinel_seed_hex = hex::encode(SENTINEL_SEED); + for (label, haystack) in [("display", &display), ("debug", &debug)] { + assert!( + !haystack.contains(SENTINEL_PASSPHRASE), + "{label} leaked the sentinel passphrase: {haystack}" + ); + assert!( + !haystack.contains(&sentinel_seed_hex), + "{label} leaked the sentinel seed bytes: {haystack}" + ); + } + + // Second op cancels — the cancellation error must also be clean. + let cancel = sa + .with_secret::<()>(&scope, |_pt| Ok(())) + .await + .expect_err("cancel"); + let cdisplay = cancel.to_string(); + let cdebug = format!("{cancel:?}"); + assert!(!cdisplay.contains(SENTINEL_PASSPHRASE)); + assert!(!cdebug.contains(SENTINEL_PASSPHRASE)); + assert!(!cdisplay.contains(&sentinel_seed_hex)); + assert!(!cdebug.contains(&sentinel_seed_hex)); + } + + /// Cross-scope re-entrancy: a `with_secret_session` for scope A whose + /// closure `.await`s another secret access for scope B must resolve both + /// — the chokepoint releases the session-cache lock BEFORE running (and + /// awaiting in) the closure (see step 1 of `with_secret_session`), so an + /// inner call that re-takes the lock for a different scope cannot deadlock. + /// This guards that documented lock-release-before-await property against a + /// future cross-scope deadlock regression. + #[tokio::test] + async fn nested_cross_scope_access_resolves_both() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_a: WalletSeedHash = [0xA1; 32]; + let seed_b: WalletSeedHash = [0xB2; 32]; + let seed_b_bytes = [0x77u8; 64]; + store_protected_hd(&store, &seed_a, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + store_protected_hd(&store, &seed_b, &seed_b_bytes, SENTINEL_PASSPHRASE); + + // Both scopes remember for the session: scope B is promoted first so + // the inner call hits the cache (re-taking the read lock) while the + // outer scope-A session is live. Scope A's first access also remembers. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::remember(SENTINEL_PASSPHRASE, RememberPolicy::UntilAppClose), + ScriptedAnswer::remember(SENTINEL_PASSPHRASE, RememberPolicy::UntilAppClose), + ])); + let sa = access(store, prompt.clone()); + let scope_a = SecretScope::HdSeed { seed_hash: seed_a }; + let scope_b = SecretScope::HdSeed { seed_hash: seed_b }; + + // Seed the cache for B so the nested call is a pure cache hit. + sa.with_secret(&scope_b, |_pt| Ok(())).await.unwrap(); + assert!(sa.is_session_cached(&scope_b)); + + let sa_inner = sa.clone(); + let both = sa + .with_secret_session(&scope_a, async move |session| { + let outer_ok = session.plaintext().expose_hd_seed().copied() == Some(SENTINEL_SEED); + // Re-enter the chokepoint for scope B from inside scope A's + // live session. If the outer call still held the cache lock, + // this would deadlock. + let inner_ok = sa_inner + .with_secret(&scope_b, |pt| { + Ok(pt.expose_hd_seed().copied() == Some(seed_b_bytes)) + }) + .await?; + Ok(outer_ok && inner_ok) + }) + .await + .expect("nested access must resolve, not deadlock"); + + assert!(both, "both the outer and the nested inner secret resolved"); + assert_eq!( + prompt.ask_count(), + 2, + "one prompt for B (seeding) + one for A; the nested B hit the cache" + ); + } + + #[tokio::test] + async fn with_secret_session_holds_one_secret_across_steps() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x0B; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + // Borrow the held secret three times (simulating three signs). + let count = sa + .with_secret_session(&scope, async |session| { + let mut matches = 0; + for _ in 0..3 { + if session.plaintext().expose_hd_seed().copied() == Some(SENTINEL_SEED) { + matches += 1; + } + } + Ok(matches) + }) + .await + .unwrap(); + assert_eq!(count, 3, "held secret borrowed N times"); + assert_eq!(prompt.ask_count(), 1, "one prompt for the whole operation"); + } + + // --- identity-key scope (raw seam, prompt-free) ----------------------- + + use crate::model::qualified_identity::PrivateKeyTarget; + use platform_wallet_storage::secrets::{SecretBytes, WalletId as SecretWalletId}; + + /// Store a raw identity key in the vault under the seam label, the way the + /// migration does. + fn store_identity_key( + store: &Arc<SecretStore>, + identity_id: [u8; 32], + target: &PrivateKeyTarget, + key_id: u32, + key: &[u8; 32], + ) { + let label = SecretScope::identity_key_label(target, key_id); + SecretSeam::new(store) + .put_secret( + &SecretWalletId::from(identity_id), + &label, + &SecretBytes::from_slice(key), + ) + .expect("store identity key"); + } + + /// TS-FAST-01 — an identity-key scope resolves prompt-free under a + /// never-prompt host (the unprotected fast-path), returns the exact 32 + /// bytes, and never asks. Proves headless/MCP identity signing works. + #[tokio::test] + async fn ts_fast_01_identity_key_resolves_prompt_free() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x33u8; 32]; + let key = [0xC7u8; 32]; + store_identity_key( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 7, + &key, + ); + + // never() panics if asked — proves no prompt fires. + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 7, + }; + + let matched = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("identity key resolves prompt-free"); + assert!(matched, "closure saw the raw identity key"); + assert_eq!(prompt.ask_count(), 0, "identity key never prompts"); + assert!( + sa.can_resolve_without_prompt(&scope), + "identity key is always resolvable without a prompt" + ); + } + + /// TS-MISS-01/02 — an HD seed present in NEITHER raw nor legacy form + /// surfaces the loud typed `SecretSeamMissing` (never a silent `Ok(None)` + /// that would drop a key on the floor), distinct from `WalletNotFound`. + #[tokio::test] + async fn ts_miss_01_hd_seed_in_neither_form_is_secret_seam_missing() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::HdSeed { + seed_hash: [0x7Du8; 32], + }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("seed gone"); + assert!( + matches!(err, TaskError::SecretSeamMissing), + "expected SecretSeamMissing, got {err:?}" + ); + } + + /// Seal a raw identity key Tier-2 under `password`, the way the opt-in + /// migration does (in-place upsert at the SAME label as the Tier-1 value). + fn store_identity_key_protected( + store: &Arc<SecretStore>, + identity_id: [u8; 32], + target: &PrivateKeyTarget, + key_id: u32, + key: &[u8; 32], + password: &str, + ) { + let label = SecretScope::identity_key_label(target, key_id); + SecretSeam::new(store) + .put_secret_protected( + &SecretWalletId::from(identity_id), + &label, + &SecretBytes::from_slice(key), + &SecretString::new(password), + ) + .expect("seal identity key tier-2"); + } + + /// Opt-in seal: a Tier-2 identity key reports `Protected` + /// (scheme-as-flag), a password-free read fails, the chokepoint prompts + /// exactly once, decrypts the exact 32 bytes, and `can_resolve_without_prompt` + /// is false (the background sweep skips a locked protected identity). + #[tokio::test] + async fn ts_t2_ik_01_protected_identity_key_prompts_and_decrypts() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x51u8; 32]; + let key = [0xD4u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 4, + &key, + SENTINEL_PASSPHRASE, + ); + + // Scheme-as-flag: Protected, and a password-free read fails. + let label = SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 4); + assert_eq!( + SecretSeam::new(&store) + .scheme(&SecretWalletId::from(identity_id), &label) + .unwrap(), + SecretScheme::Protected, + "opt-in seals the identity key Tier-2" + ); + assert!( + store + .get(&SecretWalletId::from(identity_id), &label) + .is_err(), + "a password-free read of a protected identity key must fail" + ); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 4, + }; + assert!( + !sa.can_resolve_without_prompt(&scope), + "a locked protected identity key would prompt — the sweep must skip it" + ); + let matched = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("protected identity key resolves with the password"); + assert!(matched, "closure saw the unsealed identity key"); + assert_eq!(prompt.ask_count(), 1, "exactly one prompt"); + } + + /// A Tier-2 identity key re-asks on a wrong password (no oracle) and then + /// succeeds — the same re-ask UX as protected seeds and single keys. + #[tokio::test] + async fn ts_t2_ik_02_protected_identity_key_wrong_password_reasks() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x52u8; 32]; + let key = [0xE5u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnVoterIdentity, + 2, + &key, + SENTINEL_PASSPHRASE, + ); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("not-the-password"), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnVoterIdentity, + key_id: 2, + }; + let matched = sa + .with_secret(&scope, |pt| { + Ok(pt.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("retry succeeds"); + assert!(matched); + assert_eq!(prompt.ask_count(), 2, "one wrong-pass re-ask, then success"); + } + + /// Headless (NullSecretPrompt): an OPTED-IN identity key has no window to + /// ask in, so the chokepoint surfaces the typed `SecretPromptUnavailable` + /// — the accepted trade-off. A non-opted-in identity key (default keyless) + /// still resolves headless (covered by TS-FAST-01). + #[tokio::test] + async fn ts_t2_ik_03_headless_protected_identity_key_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x53u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0xF6u8; 32], + SENTINEL_PASSPHRASE, + ); + + let sa = access(store, Arc::new(NullSecretPrompt)); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("no interactive prompt headless"); + assert!( + matches!(err, TaskError::SecretPromptUnavailable), + "expected SecretPromptUnavailable, got {err:?}" + ); + } + + /// TS-T2-IK-ISO — PER-IDENTITY password isolation. Two identities sealed + /// under DIFFERENT passwords: A's password is rejected by B's envelope + /// (the negative crypto property), and remembering A never satisfies B + /// (scope-keyed cache). + #[tokio::test] + async fn ts_t2_ik_iso_per_identity_passwords_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let id_a = [0xA1u8; 32]; + let id_b = [0xB2u8; 32]; + let key_a = [0x1Au8; 32]; + let key_b = [0x2Bu8; 32]; + store_identity_key_protected( + &store, + id_a, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &key_a, + "identity-A-passwordpw", + ); + store_identity_key_protected( + &store, + id_b, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &key_b, + "identity-B-passwordpw", + ); + + // Negative crypto property: A's password is REJECTED by B's envelope. + let label = SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0); + match SecretSeam::new(&store).get_secret_protected( + &SecretWalletId::from(id_b), + &label, + &SecretString::new("identity-A-passwordpw"), + ) { + Err(TaskError::SecretSeam { source }) + if matches!(*source, SecretStoreError::WrongPassword) => {} + other => panic!("A's password must be rejected by B, got {other:?}"), + } + + // Scope-keyed cache: remembering A does not satisfy B — B still prompts. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::remember("identity-A-passwordpw", RememberPolicy::UntilAppClose), + ScriptedAnswer::remember("identity-B-passwordpw", RememberPolicy::UntilAppClose), + ])); + let sa = access(Arc::clone(&store), prompt.clone()); + let scope_a = SecretScope::IdentityKey { + identity_id: id_a, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + let scope_b = SecretScope::IdentityKey { + identity_id: id_b, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + + sa.with_secret(&scope_a, |pt| { + assert_eq!(pt.expose_identity_key().copied(), Some(key_a)); + Ok(()) + }) + .await + .unwrap(); + assert!(sa.is_session_cached(&scope_a)); + assert!( + !sa.is_session_cached(&scope_b), + "A's unlock must not cache B" + ); + + sa.with_secret(&scope_b, |pt| { + assert_eq!(pt.expose_identity_key().copied(), Some(key_b)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 2, "B prompted independently of A"); + } + + /// The sign-time prompt for a protected identity carries the alias and + /// password hint from the identity prompt-index (display-only). An empty + /// index degrades to a generic label, never an error. + #[tokio::test] + async fn protected_identity_key_prompt_uses_identity_prompt_index() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x54u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 1, + &[0x77u8; 32], + SENTINEL_PASSPHRASE, + ); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store, prompt.clone()); + sa.set_identity_prompt_index(BTreeMap::from([( + identity_id, + PromptMeta { + alias: Some("alice.dash".to_string()), + password_hint: Some("the usual".to_string()), + }, + )])); + let scope = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 1, + }; + sa.with_secret(&scope, |_pt| Ok(())).await.unwrap(); + let req = &prompt.requests()[0]; + assert_eq!(req.display_label, "alice.dash", "prompt shows the alias"); + assert_eq!(req.hint.as_deref(), Some("the usual"), "prompt shows hint"); + } + + /// A NEW key added to a protected identity is sealed + /// Tier-2 under the identity's verified password — never written keyless. + /// After the seal the new key reports `Protected`, a password-free read + /// fails, and it unseals to the exact bytes under the same password. + #[tokio::test] + async fn seal_new_identity_key_seals_tier2_under_verified_password() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x61u8; 32]; + // An existing protected key of the identity (the verify anchor). + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x10u8; 32], + SENTINEL_PASSPHRASE, + ); + let new_key = [0x20u8; 32]; + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(Arc::clone(&store), prompt.clone()); + let verify = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + sa.seal_new_identity_key( + identity_id, + &verify, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5, + &new_key, + ) + .await + .expect("seal new key under the verified password"); + assert_eq!(prompt.ask_count(), 1, "one prompt to verify + seal"); + + let new_label = + SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5); + let seam = SecretSeam::new(&store); + assert_eq!( + seam.scheme(&SecretWalletId::from(identity_id), &new_label) + .unwrap(), + SecretScheme::Protected, + "the new key is sealed Tier-2, never keyless", + ); + assert!( + store + .get(&SecretWalletId::from(identity_id), &new_label) + .is_err(), + "a password-free read of the new key must fail", + ); + let unsealed = seam + .get_secret_protected( + &SecretWalletId::from(identity_id), + &new_label, + &SecretString::new(SENTINEL_PASSPHRASE), + ) + .unwrap() + .unwrap(); + assert_eq!(unsealed.expose_secret(), &new_key[..]); + } + + /// Headless: sealing a new key onto a protected identity fails closed + /// (`SecretPromptUnavailable`) and writes NOTHING — no keyless key lands. + #[tokio::test] + async fn seal_new_identity_key_headless_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x62u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x11u8; 32], + SENTINEL_PASSPHRASE, + ); + + let sa = access(Arc::clone(&store), Arc::new(NullSecretPrompt)); + let verify = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + let err = sa + .seal_new_identity_key( + identity_id, + &verify, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5, + &[0x20u8; 32], + ) + .await + .expect_err("headless cannot seal"); + assert!( + matches!(err, TaskError::SecretPromptUnavailable), + "expected SecretPromptUnavailable, got {err:?}" + ); + // Nothing was written for the new key — no keyless leak. + let new_label = + SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5); + assert_eq!( + SecretSeam::new(&store) + .scheme(&SecretWalletId::from(identity_id), &new_label) + .unwrap(), + SecretScheme::Absent, + "a failed headless seal must leave no key at all", + ); + } + + /// A wrong password re-asks (verifying against the existing protected key), + /// then seals the new key on the correct password. + #[tokio::test] + async fn seal_new_identity_key_wrong_password_reasks() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x63u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x12u8; 32], + SENTINEL_PASSPHRASE, + ); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("not-the-password"), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(Arc::clone(&store), prompt.clone()); + let verify = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + sa.seal_new_identity_key( + identity_id, + &verify, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5, + &[0x20u8; 32], + ) + .await + .expect("retry then seal"); + assert_eq!(prompt.ask_count(), 2, "one wrong-pass re-ask, then success"); + assert_eq!( + SecretSeam::new(&store) + .scheme( + &SecretWalletId::from(identity_id), + &SecretScope::identity_key_label( + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5 + ) + ) + .unwrap(), + SecretScheme::Protected, + ); + } + + /// O-2: the add-key flow verifies the password UP FRONT + /// ([`SecretAccess::verify_identity_object_password`]) and seals AFTER its + /// broadcast ([`SecretAccess::seal_new_identity_key_with_password`]). The + /// split prompts EXACTLY ONCE total and seals the new key Tier-2 — the same + /// outcome as the combined `seal_new_identity_key`, with the verify and seal + /// halves usable around an intervening on-chain broadcast. + #[tokio::test] + async fn verify_up_front_then_seal_after_broadcast_one_prompt() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x64u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x13u8; 32], + SENTINEL_PASSPHRASE, + ); + let new_key = [0x21u8; 32]; + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(Arc::clone(&store), prompt.clone()); + let verify = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + + // Front half: the precondition the add-key flow runs BEFORE broadcast. + let password = sa + .verify_identity_object_password(&verify) + .await + .expect("verify the object password up front"); + assert_eq!(prompt.ask_count(), 1, "one prompt at the precondition"); + + // (broadcast would happen here) — back half: seal AFTER, no re-prompt. + sa.seal_new_identity_key_with_password( + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 5, + &new_key, + &password, + ) + .expect("seal the new key with the verified password"); + assert_eq!(prompt.ask_count(), 1, "sealing did not prompt again"); + + let new_label = + SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5); + let seam = SecretSeam::new(&store); + assert_eq!( + seam.scheme(&SecretWalletId::from(identity_id), &new_label) + .unwrap(), + SecretScheme::Protected, + "the new key is sealed Tier-2, never keyless", + ); + let unsealed = seam + .get_secret_protected( + &SecretWalletId::from(identity_id), + &new_label, + &SecretString::new(SENTINEL_PASSPHRASE), + ) + .unwrap() + .unwrap(); + assert_eq!(unsealed.expose_secret(), &new_key[..]); + } + + /// O-2 fail-closed: headless verification of a protected identity's + /// password yields `SecretPromptUnavailable` and writes NOTHING. Because the + /// add-key flow runs this BEFORE its broadcast, a headless add never reaches + /// the on-chain state transition — no on-chain/local divergence. + #[tokio::test] + async fn verify_identity_object_password_headless_fails_closed_before_seal() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let identity_id = [0x65u8; 32]; + store_identity_key_protected( + &store, + identity_id, + &PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + &[0x14u8; 32], + SENTINEL_PASSPHRASE, + ); + + let sa = access(Arc::clone(&store), Arc::new(NullSecretPrompt)); + let verify = SecretScope::IdentityKey { + identity_id, + target: PrivateKeyTarget::PrivateKeyOnMainIdentity, + key_id: 0, + }; + let err = sa + .verify_identity_object_password(&verify) + .await + .expect_err("headless cannot verify"); + assert!( + matches!(err, TaskError::SecretPromptUnavailable), + "expected SecretPromptUnavailable, got {err:?}" + ); + // The precondition failed, so the seal half never runs: no key written. + let new_label = + SecretScope::identity_key_label(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 5); + assert_eq!( + SecretSeam::new(&store) + .scheme(&SecretWalletId::from(identity_id), &new_label) + .unwrap(), + SecretScheme::Absent, + "a failed precondition must leave no key at all", + ); + } + + /// A missing identity key surfaces the loud typed `IdentityKeyMissing`, + /// never a silent miss. + #[tokio::test] + async fn identity_key_missing_is_loud() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::IdentityKey { + identity_id: [0x44u8; 32], + target: PrivateKeyTarget::PrivateKeyOnVoterIdentity, + key_id: 1, + }; + let err = sa + .with_secret(&scope, |_pt| Ok(())) + .await + .expect_err("missing identity key"); + assert!( + matches!(err, TaskError::IdentityKeyMissing), + "expected IdentityKeyMissing, got {err:?}" + ); + } + + /// TS-LEGACY-01 — with only a legacy unprotected envelope present (no raw + /// `seed.raw.v1`), the seam-first reader falls through to the retained + /// legacy decoder and recovers the exact seed, prompt-free. + #[tokio::test] + async fn ts_legacy_01_hd_legacy_envelope_served_when_raw_absent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x4E; 32]; + store_unprotected_hd(&store, &seed_hash, &SENTINEL_SEED); + + let prompt = Arc::new(TestPrompt::never()); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("legacy envelope served via fallback"); + assert_eq!(prompt.ask_count(), 0, "unprotected legacy ⇒ no prompt"); + } + + /// Seam-first precedence: when BOTH a raw `seed.raw.v1` and a legacy + /// envelope exist (the legal mid-migration state, TS-CRASH-01 read half), + /// the raw value wins and the legacy is not consulted. + #[tokio::test] + async fn raw_seed_wins_over_legacy_when_both_present() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x5E; 32]; + // Legacy holds one seed; raw holds a DIFFERENT one — proving which won. + let legacy_seed = [0x11u8; 64]; + store_unprotected_hd(&store, &seed_hash, &legacy_seed); + let raw_seed = [0x99u8; 64]; + WalletSeedView::new(&store) + .set_raw(&seed_hash, &raw_seed) + .unwrap(); + + let sa = access(store, Arc::new(TestPrompt::never())); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!( + pt.expose_hd_seed().copied(), + Some(raw_seed), + "raw seam value must win over the legacy envelope" + ); + Ok(()) + }) + .await + .expect("raw wins"); + } + + // --- Tier-2 per-secret object-password adoption ----------------------- + + /// TS-T2-01 — lazy re-wrap KEEPS protection. A protected legacy AES-GCM + /// envelope, on first unlock, migrates to a Tier-2 object-password envelope + /// at the raw label (NOT downgraded to a password-free raw secret), the + /// legacy envelope is dropped, and the seed reads back only with its + /// password. + #[tokio::test] + async fn ts_t2_01_protected_seed_rewraps_to_tier2_on_first_unlock() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x71; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SENTINEL_PASSPHRASE); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); + let sa = access(store.clone(), prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("first unlock"); + assert_eq!(prompt.ask_count(), 1); + + let view = WalletSeedView::new(&store); + // Steady state is Tier-2 protected, NOT raw. + assert_eq!(view.scheme(&seed_hash).unwrap(), SecretScheme::Protected); + // Legacy envelope dropped. + assert!( + view.get(&seed_hash).unwrap().is_none(), + "legacy envelope removed after re-wrap" + ); + // Reads back only WITH the object password ... + let pw = SecretString::new(SENTINEL_PASSPHRASE); + assert_eq!( + view.get_protected(&seed_hash, &pw).unwrap().map(|z| *z), + Some(SENTINEL_SEED) + ); + // ... and NOT without it (a raw read sees a protected blob). + assert!( + view.get_raw(&seed_hash).is_err(), + "raw read of a protected seed must fail, never strip protection" + ); + } + + /// TS-T2-02 — a Tier-2 seed re-asks on a wrong object password (upstream + /// `WrongPassword` ⇒ re-prompt, not abort) and then succeeds. + #[tokio::test] + async fn ts_t2_02_tier2_seed_wrong_password_reasks_then_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x72; 32]; + let right = SecretString::new(SENTINEL_PASSPHRASE); + WalletSeedView::new(&store) + .set_protected(&seed_hash, &SENTINEL_SEED, &right) + .expect("seal seed as Tier-2"); + assert_eq!( + WalletSeedView::new(&store).scheme(&seed_hash).unwrap(), + SecretScheme::Protected + ); + + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("not-the-password"), + ScriptedAnswer::once(SENTINEL_PASSPHRASE), + ])); + let sa = access(store, prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("retry succeeds"); + assert_eq!(prompt.ask_count(), 2, "one wrong-pass re-ask, then success"); + } + + /// TS-T2-03 — PER-SECRET password isolation. Two seeds protected under + /// DIFFERENT passwords: unlocking A (and remembering it) does NOT satisfy + /// B — B still prompts for its OWN password, each decrypts only with its + /// own, and A's remembered entry never unlocks B. + #[tokio::test] + async fn ts_t2_03_per_secret_passwords_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let hash_a: WalletSeedHash = [0xAA; 32]; + let hash_b: WalletSeedHash = [0xBB; 32]; + let seed_a = [0xA1u8; 64]; + let seed_b = [0xB2u8; 64]; + let pw_a = SecretString::new("password-A-aaaaaaaaaa"); + let pw_b = SecretString::new("password-B-bbbbbbbbbb"); + let view = WalletSeedView::new(&store); + view.set_protected(&hash_a, &seed_a, &pw_a).unwrap(); + view.set_protected(&hash_b, &seed_b, &pw_b).unwrap(); + + // Negative crypto property: A's password CANNOT open B's envelope. + // Upstream binds the AEAD AAD to wallet_id‖label and derives a fresh + // per-object key, so B's envelope rejects A's password with a tag + // failure (`WrongPassword`) rather than yielding A's — or any — bytes. + match view.get_protected(&hash_b, &pw_a) { + Err(TaskError::SecretSeam { source }) + if matches!(*source, SecretStoreError::WrongPassword) => {} + other => panic!("A's password must be REJECTED by B's envelope, got {other:?}"), + } + + // Scripted in access order: A remembers, then B. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::remember("password-A-aaaaaaaaaa", RememberPolicy::UntilAppClose), + ScriptedAnswer::remember("password-B-bbbbbbbbbb", RememberPolicy::UntilAppClose), + ])); + let sa = access(store, prompt.clone()); + let scope_a = SecretScope::HdSeed { seed_hash: hash_a }; + let scope_b = SecretScope::HdSeed { seed_hash: hash_b }; + + sa.with_secret(&scope_a, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(seed_a)); + Ok(()) + }) + .await + .unwrap(); + assert!(sa.is_session_cached(&scope_a)); + assert!( + !sa.is_session_cached(&scope_b), + "A's unlock must not cache B" + ); + + // B STILL prompts (A's cache entry does not satisfy B) and decrypts to B. + sa.with_secret(&scope_b, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(seed_b)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 2, "B prompted independently of A"); + + // A still resolves from its own cache entry — no third prompt. + sa.with_secret(&scope_a, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(seed_a)); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(prompt.ask_count(), 2, "A served from cache, no re-prompt"); + } +} diff --git a/src/wallet_backend/secret_prompt.rs b/src/wallet_backend/secret_prompt.rs new file mode 100644 index 000000000..5ddd730bf --- /dev/null +++ b/src/wallet_backend/secret_prompt.rs @@ -0,0 +1,410 @@ +//! The UI↔async boundary for just-in-time secret access. +//! +//! This module is the *only* seam the presentation layer implements to +//! participate in JIT secret resolution. It carries no egui types and no +//! upstream `key_wallet`/`platform_wallet` types — the lone exception is +//! [`SecretString`], a `platform_wallet_storage` zeroizing wrapper the +//! project already depends on (M-PLATFORM-WALLET-FIRST-PARTY) and reuses +//! deliberately rather than rolling a new secret type. +//! +//! Flow: a backend operation that needs plaintext builds a +//! [`SecretPromptRequest`] (scope + display copy, **no secret**) and awaits +//! [`SecretPrompt::request`]. The host (egui in the app, a test double in +//! tests) collects the user's passphrase and answers with a +//! [`SecretPromptReply`] (passphrase as `SecretString` + a +//! [`RememberPolicy`]). Dismissing the prompt resolves as +//! [`SecretPromptCancelled`]. +//! +//! The reply carries the *passphrase* — a key-derivation input — never a +//! derived seed/key. The backend decrypts just-in-time inside +//! [`SecretAccess`](crate::wallet_backend::SecretAccess), so the channel +//! never transports key material and cancellation is a clean sender drop. + +use std::time::Duration; + +use async_trait::async_trait; +use dash_sdk::dpp::identity::KeyID; +use platform_wallet_storage::secrets::SecretString; + +use crate::model::qualified_identity::PrivateKeyTarget; +use crate::model::wallet::WalletSeedHash; + +/// Which secret an operation needs. DET-opaque: carries no upstream type +/// and no plaintext, only opaque-but-copyable handles (CLAUDE.md rule 6) +/// suitable for prompt copy. `Eq + Hash` so it keys the caches in +/// [`SecretAccess`](crate::wallet_backend::SecretAccess). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum SecretScope { + /// HD wallet seed, identified by DET's `SHA256(seed)` hash. + HdSeed { + /// 32-byte DET seed hash; reused directly as the upstream + /// `WalletId` scope in the secret vault. + seed_hash: WalletSeedHash, + }, + /// Imported single key, identified by its P2PKH address. + SingleKey { + /// Base58 P2PKH address — the stable per-key identifier. + address: String, + }, + /// An identity private key stored raw in the vault, resolved per-use + /// (the `InVault` placeholder). Unprotected — resolves prompt-free, so + /// headless/MCP identity signing keeps working. + IdentityKey { + /// 32-byte identity id (`Identifier::to_buffer()`), the vault scope. + identity_id: [u8; 32], + /// Which associated identity the key belongs to. + target: PrivateKeyTarget, + /// The key's `KeyID` within the identity. + key_id: KeyID, + }, +} + +impl SecretScope { + /// The vault label for an identity-key scope: + /// `identity_key_priv.<target_tag>.<key_id>`. The target is a stable + /// single-char tag so the label stays inside the upstream allowlist + /// `^[A-Za-z0-9._-]{1,64}$`. + pub fn identity_key_label(target: &PrivateKeyTarget, key_id: KeyID) -> String { + format!("identity_key_priv.{}.{key_id}", target_tag(target)) + } +} + +/// Stable one-char tag for a [`PrivateKeyTarget`] used in vault labels. +fn target_tag(target: &PrivateKeyTarget) -> char { + match target { + PrivateKeyTarget::PrivateKeyOnMainIdentity => 'm', + PrivateKeyTarget::PrivateKeyOnVoterIdentity => 'v', + PrivateKeyTarget::PrivateKeyOnOperatorIdentity => 'o', + } +} + +/// How long a decrypted secret may be remembered after the operation that +/// obtained it completes. +/// +/// The default is [`RememberPolicy::None`] — operation-scoped residency, +/// the secret zeroizes when the operation ends. A "remember" toggle in the +/// prompt promotes the secret to the session cache. +/// +/// The GUI wires only `None` and `UntilAppClose`; [`RememberPolicy::For`] +/// is data-model-only for now (the session cache honors its TTL, but no UI +/// surface emits it yet). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum RememberPolicy { + /// Do not cache. The secret lives only for the operation that + /// obtained it. This is the default residency. + #[default] + None, + /// Cache for the rest of the process. Cleared on app close, network + /// switch, and manual lock. + UntilAppClose, + /// Cache until the given duration elapses from the moment of insert. + /// The session cache stamps an expiry and evicts on access past it. + For(Duration), +} + +/// Why the prompt is being re-shown. `None` on the first ask; set when the +/// chokepoint re-asks after a wrong passphrase so the host renders the +/// inline error without closing the modal. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SecretPromptRetry { + /// The previous passphrase did not decrypt the stored secret. + WrongPassphrase, +} + +/// A request for the user to supply a passphrase. Built by the backend, +/// answered by the host. Carries **no secret** — only the scope and the +/// copy the host needs to render an informative modal. +/// +/// `#[non_exhaustive]` so future prompt-copy fields can be added without a +/// breaking change to every construction site. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct SecretPromptRequest { + /// Which secret is being requested. + pub scope: SecretScope, + /// Human label for the modal body — wallet alias, key nickname, or + /// address. Already user-appropriate; the host shows it verbatim. + pub display_label: String, + /// Optional user-set hint (HD password hint or single-key hint). + pub hint: Option<String>, + /// Set on a re-ask after a wrong passphrase. `None` on first ask. + pub retry_reason: Option<SecretPromptRetry>, +} + +impl SecretPromptRequest { + /// Build a first-ask request for `scope` with the given display copy. + pub fn new(scope: SecretScope, display_label: impl Into<String>) -> Self { + Self { + scope, + display_label: display_label.into(), + hint: None, + retry_reason: None, + } + } + + /// Attach a user-set hint shown next to the passphrase field. + pub fn with_hint(mut self, hint: Option<String>) -> Self { + self.hint = hint; + self + } + + /// Mark this request as a re-ask after a wrong passphrase. + pub fn retrying(mut self, reason: SecretPromptRetry) -> Self { + self.retry_reason = Some(reason); + self + } +} + +/// The user's answer to a [`SecretPromptRequest`]. +/// +/// `passphrase` is a [`SecretString`] (zeroizing) — a key-derivation +/// *input*, never a derived key. `remember` carries the session-toggle +/// choice; default [`RememberPolicy::None`]. +#[derive(Debug)] +pub struct SecretPromptReply { + /// The passphrase the user typed. Decrypted just-in-time by the + /// chokepoint and dropped (zeroized) immediately after. + pub passphrase: SecretString, + /// How long, if at all, to remember the decrypted secret. + pub remember: RememberPolicy, +} + +impl SecretPromptReply { + /// Build a reply with the given passphrase and remember policy. + pub fn new(passphrase: SecretString, remember: RememberPolicy) -> Self { + Self { + passphrase, + remember, + } + } +} + +/// The user dismissed the prompt, or no interactive host was available. +/// Carries no data — there is nothing to report beyond "no passphrase". +#[derive(Debug, thiserror::Error)] +#[error("the passphrase prompt was cancelled")] +pub struct SecretPromptCancelled; + +/// The UI↔async seam: ask the user for the passphrase for a scope. +/// +/// Implementations MUST NOT block the caller's executor; the egui host +/// enqueues the request, repaints, and awaits a one-shot reply. A test +/// double answers synchronously from a script. Resolving with +/// [`SecretPromptCancelled`] aborts the operation cleanly. +#[async_trait] +pub trait SecretPrompt: Send + Sync { + /// Ask the user for the passphrase for `request.scope`. Resolves when + /// the user submits (`Ok`) or dismisses (`Err`). + async fn request( + &self, + request: SecretPromptRequest, + ) -> Result<SecretPromptReply, SecretPromptCancelled>; + + /// Whether this host can actually ask a human. `true` for the egui host; + /// `false` for [`NullSecretPrompt`] (headless MCP / CLI). The chokepoint + /// uses this to distinguish a genuine user cancel from "no prompt exists + /// here", surfacing the right typed error for each. + fn is_interactive(&self) -> bool { + true + } +} + +/// The [`SecretPrompt`] for non-interactive hosts (MCP server, CLI). +/// +/// There is no window to ask for a passphrase, so every request resolves as +/// [`SecretPromptCancelled`] — the chokepoint maps that to a typed +/// [`TaskError::SecretPromptUnavailable`](crate::backend_task::error::TaskError::SecretPromptUnavailable) +/// for the caller. Per the Q-HEADLESS security ruling there is **no** +/// environment-variable or CLI-flag passphrase fallback: a passphrase- +/// protected secret simply cannot be unlocked headless. Unprotected scopes +/// never reach the prompt (the chokepoint's fast-path decrypts them with no +/// passphrase), so this host does not block read-only or no-password flows. +#[derive(Debug, Default, Clone, Copy)] +pub struct NullSecretPrompt; + +#[async_trait] +impl SecretPrompt for NullSecretPrompt { + async fn request( + &self, + _request: SecretPromptRequest, + ) -> Result<SecretPromptReply, SecretPromptCancelled> { + Err(SecretPromptCancelled) + } + + fn is_interactive(&self) -> bool { + false + } +} + +#[cfg(test)] +pub(crate) mod test_support { + //! A scripted [`SecretPrompt`] double for chokepoint unit tests. + + use std::collections::VecDeque; + use std::sync::Mutex; + + use super::*; + + /// One scripted answer the [`TestPrompt`] hands back on the next call. + pub(crate) enum ScriptedAnswer { + /// Reply with this passphrase and remember policy. + Reply { + /// Passphrase to hand back (plaintext for the script only). + passphrase: String, + /// Remember policy to attach to the reply. + remember: RememberPolicy, + }, + /// Dismiss the prompt (resolves [`SecretPromptCancelled`]). + Cancel, + } + + impl ScriptedAnswer { + /// Convenience: an operation-scoped reply (no caching). + pub(crate) fn once(passphrase: &str) -> Self { + Self::Reply { + passphrase: passphrase.to_string(), + remember: RememberPolicy::None, + } + } + + /// Convenience: a reply promoted to the session cache. + pub(crate) fn remember(passphrase: &str, remember: RememberPolicy) -> Self { + Self::Reply { + passphrase: passphrase.to_string(), + remember, + } + } + } + + /// Scripted [`SecretPrompt`] for unit tests. Pops answers FIFO and + /// records every request it received so tests can assert prompt copy, + /// retry reasons, and — crucially — that no prompt fired at all. + pub(crate) struct TestPrompt { + answers: Mutex<VecDeque<ScriptedAnswer>>, + seen: Mutex<Vec<SecretPromptRequest>>, + } + + impl TestPrompt { + /// Build a prompt that will hand back `answers` in order. + pub(crate) fn new(answers: impl IntoIterator<Item = ScriptedAnswer>) -> Self { + Self { + answers: Mutex::new(answers.into_iter().collect()), + seen: Mutex::new(Vec::new()), + } + } + + /// A prompt that fails the test if it is ever asked. Used to prove + /// a session-cache hit or unprotected scope never prompts. + pub(crate) fn never() -> Self { + Self::new(std::iter::empty()) + } + + /// How many times the prompt was asked. + pub(crate) fn ask_count(&self) -> usize { + self.seen.lock().unwrap().len() + } + + /// Snapshot of the requests the prompt received, in order. + pub(crate) fn requests(&self) -> Vec<SecretPromptRequest> { + self.seen.lock().unwrap().clone() + } + } + + #[async_trait] + impl SecretPrompt for TestPrompt { + async fn request( + &self, + request: SecretPromptRequest, + ) -> Result<SecretPromptReply, SecretPromptCancelled> { + self.seen.lock().unwrap().push(request); + let answer = self + .answers + .lock() + .unwrap() + .pop_front() + .expect("TestPrompt asked more times than it was scripted for"); + match answer { + ScriptedAnswer::Reply { + passphrase, + remember, + } => Ok(SecretPromptReply::new( + SecretString::new(passphrase), + remember, + )), + ScriptedAnswer::Cancel => Err(SecretPromptCancelled), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{ScriptedAnswer, TestPrompt}; + use super::*; + + fn hd_scope() -> SecretScope { + SecretScope::HdSeed { + seed_hash: [0x11; 32], + } + } + + #[test] + fn remember_policy_defaults_to_none() { + assert_eq!(RememberPolicy::default(), RememberPolicy::None); + } + + #[test] + fn request_builder_starts_without_retry() { + let req = SecretPromptRequest::new(hd_scope(), "My Wallet").with_hint(Some("hint".into())); + assert!(req.retry_reason.is_none()); + assert_eq!(req.display_label, "My Wallet"); + assert_eq!(req.hint.as_deref(), Some("hint")); + } + + #[test] + fn request_builder_marks_retry() { + let req = SecretPromptRequest::new(hd_scope(), "My Wallet") + .retrying(SecretPromptRetry::WrongPassphrase); + assert_eq!(req.retry_reason, Some(SecretPromptRetry::WrongPassphrase)); + } + + #[tokio::test] + async fn test_prompt_hands_back_scripted_reply() { + let prompt = TestPrompt::new([ScriptedAnswer::once("hunter2")]); + let reply = prompt + .request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + .expect("scripted reply"); + assert_eq!(reply.passphrase.expose_secret(), "hunter2"); + assert_eq!(reply.remember, RememberPolicy::None); + assert_eq!(prompt.ask_count(), 1); + } + + #[tokio::test] + async fn test_prompt_cancel_resolves_cancelled() { + let prompt = TestPrompt::new([ScriptedAnswer::Cancel]); + let err = prompt + .request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + .expect_err("cancel resolves Err"); + // Carries no data — the Display is generic and secret-free. + assert_eq!(err.to_string(), "the passphrase prompt was cancelled"); + } + + #[test] + fn cancelled_display_carries_no_secret() { + let dbg = format!("{:?}", SecretPromptCancelled); + assert_eq!(dbg, "SecretPromptCancelled"); + } + + #[tokio::test] + async fn null_prompt_always_cancels_and_is_not_interactive() { + let prompt = NullSecretPrompt; + assert!(!prompt.is_interactive()); + let err = prompt + .request(SecretPromptRequest::new(hd_scope(), "My Wallet")) + .await + .expect_err("null prompt cancels"); + assert_eq!(err.to_string(), "the passphrase prompt was cancelled"); + } +} diff --git a/src/wallet_backend/secret_seam.rs b/src/wallet_backend/secret_seam.rs new file mode 100644 index 000000000..140a1c385 --- /dev/null +++ b/src/wallet_backend/secret_seam.rs @@ -0,0 +1,470 @@ +//! The single chokepoint for storing/loading raw wallet secret bytes. +//! +//! All three secret classes (HD seed, imported single key, identity private +//! key) route their RAW bytes through this one seam into the upstream +//! [`SecretStore`] vault. No DET-side serialization wraps the secret: a +//! [`SecretBytes`] is written verbatim and read back verbatim. +//! +//! Per-secret encryption is wired here through the upstream Tier-2 envelope: +//! the `*_protected` methods seal/unseal a secret under its OWN object password +//! (Argon2id + XChaCha20-Poly1305) before it reaches the backend, while the +//! plain `*_secret` methods stay Tier-1 (unprotected — vault-file perms only). +//! [`SecretSeam::scheme`] reports which tier a stored secret uses without the +//! password. This is the single coherent encrypt/decrypt path. +//! +//! The seam is **prompt-free**: it never builds a passphrase request. It +//! receives the password its caller ([`SecretAccess`]) obtained just-in-time +//! and passes it straight to the upstream envelope. The only remaining legacy +//! decrypt (for not-yet-migrated AES-GCM secrets) lives in the legacy reader. +//! +//! [`SecretAccess`]: crate::wallet_backend::secret_access::SecretAccess +//! +//! No-serialization invariant: secrets are passed as [`SecretBytes`], which +//! deliberately has no `Serialize`/`Encode` (verified upstream). Any struct +//! embedding a `SecretBytes` therefore cannot derive those traits — the +//! compiler enforces the rule. The seam never constructs an intermediate +//! serializable wrapper around the secret. +//! +//! TS-INV-01 — the invariant guard, enforced by the compiler. A newtype that +//! tries to derive `serde::Serialize` over a [`SecretBytes`] does NOT compile, +//! because `SecretBytes: !Serialize`. If a future upstream adds `Serialize` to +//! `SecretBytes`, this doctest starts compiling and the failing test flags that +//! the invariant has silently weakened. +//! +//! ```compile_fail +//! use platform_wallet_storage::secrets::SecretBytes; +//! #[derive(serde::Serialize)] +//! struct Leaky(SecretBytes); +//! ``` +//! +//! Serializing a `SecretBytes` directly is likewise rejected: +//! +//! ```compile_fail +//! use platform_wallet_storage::secrets::SecretBytes; +//! let secret = SecretBytes::from_slice(&[0u8; 32]); +//! let _ = serde_json::to_string(&secret).unwrap(); +//! ``` + +use std::sync::Arc; + +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, +}; + +use crate::backend_task::error::TaskError; + +/// At-rest protection scheme of the value stored under a `(scope, label)`, +/// detected without the object password via a `get(None)` probe. +/// +/// This is the seam's single source of truth for "does this secret need a +/// password?" — it reads the scheme from the upstream envelope, so a secret +/// that was lazily re-wrapped to Tier-2 is reported `Protected` even though no +/// DET-side metadata says so. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretScheme { + /// No value stored under `(scope, label)`. + Absent, + /// Stored unprotected (Tier-1 raw / unprotected envelope) — readable with + /// no password. + Unprotected, + /// Stored under a Tier-2 object password (Argon2id + XChaCha20-Poly1305) — + /// readable only via [`SecretSeam::get_secret_protected`]. + Protected, +} + +/// The single doorway through which raw wallet secret bytes enter and leave +/// the vault. Cheap to construct — callers build one per operation over the +/// shared [`SecretStore`] handle. +/// +/// Two at-rest tiers, selected by the caller per secret (never per wallet): +/// the `*_secret` methods are **Tier-1** (unprotected — vault-file perms only), +/// the `*_secret_protected` methods are **Tier-2** (sealed under that secret's +/// own object password before it reaches the backend). The seam is the one +/// coherent encrypt/decrypt path; it stays **prompt-free** — it receives the +/// password its caller (`SecretAccess`) obtained just-in-time, it never prompts. +pub struct SecretSeam<'a> { + secret_store: &'a Arc<SecretStore>, +} + +impl<'a> SecretSeam<'a> { + /// Borrow the shared [`SecretStore`] as the raw-secret seam. + pub fn new(secret_store: &'a Arc<SecretStore>) -> Self { + Self { secret_store } + } + + /// Store `secret` **unprotected** (Tier-1) under `(scope, label)`, + /// overwriting any prior value. Idempotent — the upstream `set` upserts. + /// + /// Tier-1 is intentionally password-free: confidentiality rests on the + /// vault file's owner-only permissions. Use [`Self::put_secret_protected`] + /// for a secret that carries its own object password. + pub fn put_secret( + &self, + scope: &SecretWalletId, + label: &str, + secret: &SecretBytes, + ) -> Result<(), TaskError> { + self.secret_store.set(scope, label, secret).map_err(map_err) + } + + /// Store `secret` **protected** (Tier-2) under `(scope, label)`, sealed with + /// `password` (Argon2id + XChaCha20-Poly1305) before it reaches the backend, + /// overwriting any prior value. Idempotent upsert. The password belongs to + /// THIS secret only — never a shared/per-wallet password. + pub fn put_secret_protected( + &self, + scope: &SecretWalletId, + label: &str, + secret: &SecretBytes, + password: &SecretString, + ) -> Result<(), TaskError> { + self.secret_store + .set_secret(scope, label, secret, Some(password)) + .map_err(map_err) + } + + /// Load the **unprotected** bytes stored under `(scope, label)`, or + /// `Ok(None)` if nothing is stored there. No prompt. + /// + /// A Tier-2 (protected) value under this label is reported as + /// [`SecretStoreError::NeedsPassword`] by the backend (mapped through + /// [`TaskError::SecretSeam`]); callers that may face either tier should + /// branch on [`Self::scheme`] first. + pub fn get_secret( + &self, + scope: &SecretWalletId, + label: &str, + ) -> Result<Option<SecretBytes>, TaskError> { + self.secret_store.get(scope, label).map_err(map_err) + } + + /// Load the **protected** (Tier-2) bytes stored under `(scope, label)`, + /// unsealing with `password`, or `Ok(None)` if nothing is stored there. + /// + /// A wrong password surfaces as [`SecretStoreError::WrongPassword`]; an + /// unprotected value read through this path surfaces as + /// [`SecretStoreError::ExpectedProtectedButUnsealed`] (a refused downgrade) + /// — both via [`TaskError::SecretSeam`]. + pub fn get_secret_protected( + &self, + scope: &SecretWalletId, + label: &str, + password: &SecretString, + ) -> Result<Option<SecretBytes>, TaskError> { + self.secret_store + .get_secret(scope, label, Some(password)) + .map_err(map_err) + } + + /// Detect the at-rest [`SecretScheme`] under `(scope, label)` without the + /// object password, via a `get(None)` probe. `NeedsPassword` ⇒ + /// [`SecretScheme::Protected`]; a present value ⇒ + /// [`SecretScheme::Unprotected`]; absent ⇒ [`SecretScheme::Absent`]. + pub fn scheme(&self, scope: &SecretWalletId, label: &str) -> Result<SecretScheme, TaskError> { + match self.secret_store.get(scope, label) { + Ok(Some(_)) => Ok(SecretScheme::Unprotected), + Ok(None) => Ok(SecretScheme::Absent), + Err(SecretStoreError::NeedsPassword) => Ok(SecretScheme::Protected), + Err(source) => Err(map_err(source)), + } + } + + /// Idempotent delete of `(scope, label)`. A missing entry is `Ok(())`. + /// Delete is metadata-free — there is no secret to (de)crypt. + pub fn delete_secret(&self, scope: &SecretWalletId, label: &str) -> Result<(), TaskError> { + // `delete` returns `Result<bool, _>` (true = existed, false = absent); + // DET treats delete as idempotent, so the bool is discarded. + self.secret_store + .delete(scope, label) + .map(|_| ()) + .map_err(map_err) + } +} + +fn map_err(source: SecretStoreError) -> TaskError { + TaskError::SecretSeam { + source: Box::new(source), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::open_secret_store; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + /// TS-RT-01 — HD seed raw round-trip. A known 64-byte seed stored under + /// `seed.raw.v1` comes back byte-for-byte. A missing label and a foreign + /// scope both return `Ok(None)` (scope/label partition). + #[test] + fn ts_rt_01_hd_seed_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x11u8; 32]); + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(3).wrapping_add(7); + } + + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&seed)) + .expect("put"); + let got = seam + .get_secret(&scope, "seed.raw.v1") + .expect("get") + .expect("present"); + assert_eq!( + got.expose_secret(), + &seed[..], + "round-tripped seed must equal the exact 64 input bytes" + ); + + // Missing label and foreign scope both miss. + assert!( + seam.get_secret(&scope, "single_key_priv.x") + .expect("get missing label") + .is_none() + ); + let other = SecretWalletId::from([0x22u8; 32]); + assert!( + seam.get_secret(&other, "seed.raw.v1") + .expect("get foreign scope") + .is_none(), + "a different scope must not see the seed" + ); + } + + /// TS-RT-02 — single-key raw round-trip. The stored value is exactly 32 + /// bytes (raw, NOT a `SingleKeyEntry` envelope). A foreign scope misses. + #[test] + fn ts_rt_02_single_key_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = crate::wallet_backend::single_key::single_key_namespace_id(); + let key = [0xABu8; 32]; + let label = "single_key_priv.yTestAddress"; + + seam.put_secret(&scope, label, &SecretBytes::from_slice(&key)) + .expect("put"); + let got = seam + .get_secret(&scope, label) + .expect("get") + .expect("present"); + assert_eq!(got.expose_secret(), &key[..]); + assert_eq!( + got.expose_secret().len(), + 32, + "raw single key is exactly 32 bytes, not a versioned envelope" + ); + + let other = SecretWalletId::from([0u8; 32]); + assert!(seam.get_secret(&other, label).expect("foreign").is_none()); + } + + /// TS-RT-03 — identity-key raw round-trip. Two `(target, key_id)` labels + /// under one identity scope do not collide; two identities (distinct + /// scopes) with the same `key_id` do not collide. + #[test] + fn ts_rt_03_identity_key_raw_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let identity_a = SecretWalletId::from([0xA1u8; 32]); + let identity_b = SecretWalletId::from([0xB2u8; 32]); + let key0 = [0x01u8; 32]; + let key1 = [0x02u8; 32]; + + seam.put_secret( + &identity_a, + "identity_key_priv.0.0", + &SecretBytes::from_slice(&key0), + ) + .unwrap(); + seam.put_secret( + &identity_a, + "identity_key_priv.0.1", + &SecretBytes::from_slice(&key1), + ) + .unwrap(); + // Same key_id (0) under a different identity scope — distinct value. + let key_other = [0x99u8; 32]; + seam.put_secret( + &identity_b, + "identity_key_priv.0.0", + &SecretBytes::from_slice(&key_other), + ) + .unwrap(); + + assert_eq!( + seam.get_secret(&identity_a, "identity_key_priv.0.0") + .unwrap() + .unwrap() + .expose_secret(), + &key0[..] + ); + assert_eq!( + seam.get_secret(&identity_a, "identity_key_priv.0.1") + .unwrap() + .unwrap() + .expose_secret(), + &key1[..], + "distinct (target,key_id) labels under one identity do not collide" + ); + assert_eq!( + seam.get_secret(&identity_b, "identity_key_priv.0.0") + .unwrap() + .unwrap() + .expose_secret(), + &key_other[..], + "same key_id under a different identity scope does not collide" + ); + } + + /// TS-INV-02 — the seam accepts/returns `SecretBytes`, never a serde + /// struct. The compiler is the assertion: this round-trips a `SecretBytes` + /// through the real signatures with no intermediate serializable wrapper. + #[test] + fn ts_inv_02_seam_uses_secret_bytes_not_serde_struct() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x33u8; 32]); + let secret: SecretBytes = SecretBytes::from_slice(&[0x42u8; 32]); + seam.put_secret(&scope, "seed.raw.v1", &secret).unwrap(); + let _back: Option<SecretBytes> = seam.get_secret(&scope, "seed.raw.v1").unwrap(); + } + + /// Idempotent delete — removing an absent entry succeeds, and a delete + /// after `put_secret` clears the value. + #[test] + fn delete_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x44u8; 32]); + seam.delete_secret(&scope, "seed.raw.v1").expect("absent"); + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&[1u8; 64])) + .unwrap(); + seam.delete_secret(&scope, "seed.raw.v1").expect("first"); + seam.delete_secret(&scope, "seed.raw.v1").expect("second"); + assert!(seam.get_secret(&scope, "seed.raw.v1").unwrap().is_none()); + } + + /// TS-NOLEAK-01 — the on-disk vault file holds the raw secret in neither + /// hex nor decimal-array form. The in-memory `get_secret` return is + /// legitimately plaintext by design — this asserts the persisted file, not + /// the return value. + /// + /// Scope note: this proves **non-literal-plaintext**, NOT + /// confidentiality. The secret here is stored Tier-1 in a `file_unprotected` + /// (keyless) vault, which upstream documents as "obfuscation, not + /// confidentiality" — the key derives from an empty passphrase under a public + /// salt, so anyone who can read the file can re-derive it. Real at-rest + /// confidentiality is a property of **Tier-2** object-password secrets only + /// (see `open_secret_store`'s doc). A green TS-NOLEAK-01 must not be read as + /// "Tier-1 secrets are confidential at rest." + #[test] + fn ts_noleak_01_on_disk_vault_does_not_contain_raw_secret() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.pwsvault"); + let store = Arc::new(open_secret_store(&path).expect("open vault")); + let seam = SecretSeam::new(&store); + let scope = SecretWalletId::from([0x55u8; 32]); + let secret = crate::wallet_backend::leak_test_support::distinctive_secret_64(); + seam.put_secret(&scope, "seed.raw.v1", &SecretBytes::from_slice(&secret)) + .unwrap(); + drop(store); + + let on_disk = std::fs::read(&path).expect("read vault file"); + let rendered = String::from_utf8_lossy(&on_disk); + crate::wallet_backend::leak_test_support::assert_no_leak_bytes( + &rendered, + &secret, + "seam on-disk vault", + ); + } + + /// TS-INV-03 — source-text audit over the changed secret-path modules: no + /// `#[derive(...Serialize...)]` / `Encode` struct may name a plaintext-key + /// field. Catches the bare-`Vec<u8>`/`[u8; 32]` plaintext bypass the + /// `compile_fail` doctests (which only catch an embedded `SecretBytes`) + /// cannot. The module list must track the blast-radius table — a stale list + /// silently shrinks the surface, itself a finding. + #[test] + fn ts_inv_03_no_serializable_struct_embeds_a_plaintext_field() { + // Files under the secret-path blast radius. + const MODULES: &[&str] = &[ + "src/wallet_backend/secret_seam.rs", + "src/wallet_backend/secret_access.rs", + "src/wallet_backend/identity_key_store.rs", + "src/wallet_backend/wallet_seed_store.rs", + "src/wallet_backend/single_key.rs", + "src/wallet_backend/single_key_entry.rs", + "src/model/qualified_identity/encrypted_key_storage.rs", + "src/model/qualified_identity/identity_meta.rs", + "src/model/wallet/meta.rs", + "src/model/single_key.rs", + "src/model/wallet/seed_envelope.rs", + ]; + // Field-shape needles that name plaintext key/seed material by type. + // (`ciphertext`/`encrypted_seed`/`encrypted_private_key` are NOT here — + // they hold AES-GCM ciphertext or migration-reader bytes, not plaintext; + // the no-serialization invariant is about embedding live plaintext.) + const PLAINTEXT_NEEDLES: &[&str] = + &["SecretBytes", "Zeroizing<[u8", ": [u8; 32]", ": [u8; 64]"]; + + let manifest = env!("CARGO_MANIFEST_DIR"); + let mut offenders = Vec::new(); + for rel in MODULES { + let path = std::path::Path::new(manifest).join(rel); + let src = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("audit must read {rel}: {e} (stale module list?)")); + + // Track whether the most recent derive line opted into a serializer. + let mut in_serializable_struct = false; + let mut brace_depth_at_struct: Option<usize> = None; + let mut depth = 0usize; + for line in src.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("#[derive(") + && (trimmed.contains("Serialize") || trimmed.contains("Encode")) + { + in_serializable_struct = true; + brace_depth_at_struct = None; + continue; + } + if in_serializable_struct && brace_depth_at_struct.is_none() { + // The struct opener for the pending derive. + if line.contains('{') { + brace_depth_at_struct = Some(depth); + } + } + // Inside the serializable struct body, look for plaintext fields. + if in_serializable_struct + && brace_depth_at_struct.is_some() + && PLAINTEXT_NEEDLES.iter().any(|n| line.contains(n)) + { + offenders.push(format!("{rel}: {}", line.trim())); + } + depth += line.matches('{').count(); + depth = depth.saturating_sub(line.matches('}').count()); + if let Some(start) = brace_depth_at_struct + && depth <= start + && line.contains('}') + { + in_serializable_struct = false; + brace_depth_at_struct = None; + } + } + } + assert!( + offenders.is_empty(), + "a Serialize/Encode struct names a plaintext-key field (no-serialization invariant): {offenders:#?}", + ); + } +} diff --git a/src/wallet_backend/shielded.rs b/src/wallet_backend/shielded.rs new file mode 100644 index 000000000..5d593c7f8 --- /dev/null +++ b/src/wallet_backend/shielded.rs @@ -0,0 +1,332 @@ +//! Shielded-pool (Orchard) operations on [`WalletBackend`]. +//! +//! `platform-wallet` is always built with the `shielded` Cargo feature in +//! DET (added in Phase A). No DET-level opt-out exists, so these methods +//! are unconditionally available. The fund-moving ops and reads consumed by +//! `run_shielded_task` / `ensure_shielded_bound` are wired (Phase D); the +//! activity/notes list reads remain `#[allow(dead_code)]` until the Phase-F +//! upstream-coordinator read path lands. + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; + +use super::{ + DetPlatformSigner, DetSigner, PlatformPathIndex, WalletBackend, map_shielded_op_error, +}; + +impl WalletBackend { + /// Resolve the network-scoped shielded coordinator. + /// + /// Returns `ShieldedNotConfigured` when `configure_shielded` was not called + /// during backend construction (should never happen in practice — it is + /// called unconditionally in `WalletBackend::new`). + async fn shielded_coordinator_arc( + &self, + ) -> Result< + std::sync::Arc<platform_wallet::wallet::shielded::NetworkShieldedCoordinator>, + TaskError, + > { + self.inner + .pwm + .shielded_coordinator() + .await + .ok_or(TaskError::ShieldedNotConfigured) + } + + /// Idempotently bind Orchard ZIP-32 keys for `seed_hash` to the shielded + /// coordinator. A no-op when the wallet is already bound; one call needed + /// per wallet per process lifetime. + /// + /// Called from `bootstrap_wallet_addresses_jit` (Phase C-bind) inside the + /// existing `with_secret_session` scope; also callable directly for the + /// MCP headless path. + pub(crate) async fn ensure_shielded_bound( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8], + ) -> Result<(), TaskError> { + let wallet = self.resolve_wallet(seed_hash).await?; + if wallet.is_shielded_bound().await { + return Ok(()); + } + let coordinator = self.shielded_coordinator_arc().await?; + wallet + .bind_shielded(seed, &[0], &coordinator) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + }) + } + + /// Bind Orchard keys for `seed_hash` by resolving its HD seed just-in-time + /// through the [`SecretAccess`](super::SecretAccess) chokepoint, then delegating to + /// [`Self::ensure_shielded_bound`]. + /// + /// The headless sibling of the Phase-C-bind call in + /// `bootstrap_wallet_addresses_jit`: an unprotected wallet resolves + /// prompt-free via the no-passphrase fast-path; a protected one needs its + /// seed already promoted to the session cache. Idempotent — exits + /// immediately when the wallet is already bound. The Phase-G `shielded_init` + /// MCP tool uses this so an agent can bind a wallet without a GUI prompt. + #[cfg(any(feature = "mcp", feature = "cli"))] + pub(crate) async fn ensure_shielded_bound_jit( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<(), TaskError> { + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + self.ensure_shielded_bound(seed_hash, seed).await + }) + .await + } + + /// Warm the Orchard proving key so the first shielded spend does not block. + /// + /// The Halo2 proving key takes ~30 s to build on first use; this primes the + /// process-global cache ahead of time. Runs on a blocking thread so the + /// async runtime keeps serving other work during the build, and is + /// idempotent — a second call returns immediately. Returns whether the key + /// is ready afterwards (a `spawn_blocking` panic leaves it unbuilt → `false`). + /// The Phase-G `shielded_init` MCP tool calls this during headless setup. + #[cfg(any(feature = "mcp", feature = "cli"))] + pub(crate) async fn warm_shielded_prover(&self) -> bool { + tokio::task::spawn_blocking(|| { + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + prover.warm_up(); + prover.is_ready() + }) + .await + .unwrap_or(false) + } + + /// Fund the shielded pool from a Core asset lock through the upstream + /// orchestrator pipeline. + /// + /// Mirrors `fund_platform_address` exactly: the `asset_lock_signer` is a + /// `DetSigner` borrowed from the held JIT session, and the upstream method + /// owns the full IS→CL fallback + `consume_asset_lock` path. A single + /// `(recipient, None)` entry passes the whole lock value (minus the flat + /// shielded fee) to `recipient`. + /// + /// The Orchard prover is created internally via + /// `CachedOrchardProver::new()` — callers do not supply a prover. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn shield_from_asset_lock( + &self, + seed_hash: &WalletSeedHash, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + recipient: dash_sdk::dpp::address_funds::OrchardAddress, + dummy_outputs: usize, + settings: Option<dash_sdk::platform::transition::put_settings::PutSettings>, + ) -> Result<(), TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let asset_lock_signer = + DetSigner::from_held(session.plaintext(), self.inner.network); + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_fund_from_asset_lock( + &coordinator, + funding, + vec![(recipient, None)], + &asset_lock_signer, + &prover, + None, + dummy_outputs, + settings, + // User-facing funding: wait indefinitely for the + // ChainLock (the broadcast lock is pending, never failed). + None, + ) + .await + .map_err(map_shielded_op_error) + }) + .await + } + + /// Shield platform-address credits (Type 15) into the Orchard pool. + /// + /// The `signer` authorises the per-address `AddressWitness`; it is a + /// `DetPlatformSigner` built from the held JIT seed and `path_index`. + /// Build `path_index` via `PlatformPathIndex::from_wallet` before calling — + /// the same pattern as `fund_platform_address`. + /// + /// The Orchard prover is created internally via + /// `CachedOrchardProver::new()` — callers do not supply a prover. + pub(crate) async fn shield_from_balance( + &self, + seed_hash: &WalletSeedHash, + path_index: &PlatformPathIndex, + shielded_account: u32, + payment_account: u32, + amount: u64, + ) -> Result<(), TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let signer = DetPlatformSigner::from_held(seed, self.inner.network, path_index); + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_shield_from_account( + &coordinator, + shielded_account, + payment_account, + amount, + &signer, + &prover, + ) + .await + .map_err(map_shielded_op_error) + }) + .await + } + + /// Shielded → shielded transfer from `account`'s notes to `recipient`. + /// + /// No seed scope needed — the Orchard ASK is already resident in the + /// wallet's bound `shielded_keys` slot from `ensure_shielded_bound`. + /// + /// The Orchard prover is created internally via + /// `CachedOrchardProver::new()` — callers do not supply a prover. + pub(crate) async fn shielded_transfer( + &self, + seed_hash: &WalletSeedHash, + account: u32, + recipient_raw_43: &[u8; 43], + amount: u64, + memo: [u8; 36], + ) -> Result<(), TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_transfer_to( + &coordinator, + account, + recipient_raw_43, + amount, + memo, + &prover, + ) + .await + .map_err(map_shielded_op_error) + } + + /// Unshield from `account`'s notes to a transparent platform address + /// (bech32m `"dash1…"` / `"tdash1…"` string). + /// + /// No seed scope needed — keys are already bound. + /// + /// The Orchard prover is created internally via + /// `CachedOrchardProver::new()` — callers do not supply a prover. + pub(crate) async fn shielded_unshield( + &self, + seed_hash: &WalletSeedHash, + account: u32, + to_platform_addr_bech32m: &str, + amount: u64, + ) -> Result<(), TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_unshield_to( + &coordinator, + account, + to_platform_addr_bech32m, + amount, + &prover, + ) + .await + .map_err(map_shielded_op_error) + } + + /// Withdraw from `account`'s notes to a Core L1 address (Base58Check). + /// + /// No seed scope needed — keys are already bound. + /// + /// The Orchard prover is created internally via + /// `CachedOrchardProver::new()` — callers do not supply a prover. + pub(crate) async fn shielded_withdraw( + &self, + seed_hash: &WalletSeedHash, + account: u32, + to_core_address: &str, + amount: u64, + core_fee_per_byte: u32, + ) -> Result<(), TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_withdraw_to( + &coordinator, + account, + to_core_address, + amount, + core_fee_per_byte, + &prover, + ) + .await + .map_err(map_shielded_op_error) + } + + /// Per-account unspent shielded balance for `seed_hash`'s wallet. + /// + /// Returns an empty map when the wallet is not bound or has no shielded + /// balance. This is the push-snapshot producer (Phase E): the result is + /// written into `AppContext::shielded_balances` by `on_shielded_sync_completed`. + pub(crate) async fn shielded_balances( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<std::collections::BTreeMap<u32, u64>, TaskError> { + let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + wallet + .shielded_balances(&coordinator) + .await + .map_err(|e| TaskError::WalletBackend { + source: Box::new(e), + }) + } + + /// The default Orchard payment address for `account` on `seed_hash`'s wallet + /// (raw 43-byte representation). Returns `None` if the wallet is not bound + /// or `account` is not registered. + pub async fn shielded_default_address( + &self, + seed_hash: &WalletSeedHash, + account: u32, + ) -> Result<Option<[u8; 43]>, TaskError> { + let wallet = self.resolve_wallet(seed_hash).await?; + Ok(wallet.shielded_default_address(account).await) + } + + /// Force an immediate shielded sync pass (network-wide across every bound + /// wallet on the coordinator). + /// + /// `sync_now` fires `on_shielded_sync_completed` synchronously before it + /// returns, so the [`EventBridge`](super::EventBridge) has already written the post-sync + /// per-wallet balances into `AppContext::shielded_balances` (Phase E) by the + /// time this resolves — a subsequent `shielded_balance_credits` read sees + /// the fresh figure. The 60-second background loop is the normal driver; + /// this is the explicit-refresh primitive for the backend-e2e lifecycle test + /// and the Phase-G `shielded_sync` MCP tool. A no-op when shielded support + /// was never configured (empty coordinator → empty pass). + pub async fn sync_shielded_now(&self, force: bool) { + self.inner.pwm.shielded_sync_arc().sync_now(force).await; + } +} diff --git a/src/wallet_backend/sidecar.rs b/src/wallet_backend/sidecar.rs new file mode 100644 index 000000000..8f5efe999 --- /dev/null +++ b/src/wallet_backend/sidecar.rs @@ -0,0 +1,272 @@ +//! Generic network-scoped, base58-keyed sidecar view over [`DetKv`]. +//! +//! The wallet-metadata, identity-metadata, and auth-pubkey-cache sidecars share +//! one shape: a k/v store keyed by `<network><infix><base58(32-byte id)>`, read +//! best-effort (a corrupt blob degrades to absent rather than blocking the UI), +//! and written through a domain-specific [`TaskError`] envelope. +//! [`SidecarView`] captures that shape once; each domain is a thin typed wrapper +//! choosing the value type, key infix, k/v scope, and error mapper. +//! +//! Domains whose blobs need a legacy-format fallback (e.g. `WalletMeta`) +//! override [`SidecarValue::read`]; the common case uses the plain typed read. + +use std::marker::PhantomData; +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::base58; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::backend_task::error::TaskError; +use crate::wallet_backend::kv::{KvAdapterError, network_prefix}; +use crate::wallet_backend::{DetKv, DetScope}; + +/// A 32-byte sidecar identifier — a wallet seed hash or an identity id. Both are +/// `[u8; 32]` and are keyed identically as base58. +pub(crate) type SidecarId = [u8; 32]; + +/// Build the canonical sidecar key `<network><infix><base58(id)>`. The single +/// definition of the sidecar key shape. +pub(crate) fn sidecar_key(network: Network, infix: &str, id: &SidecarId) -> String { + format!( + "{}{infix}{}", + network_prefix(network), + base58::encode_slice(id) + ) +} + +/// Extract the 32-byte id from a key starting with `prefix`. `None` when the +/// suffix is not 32 bytes of base58 — catches both prefix mismatches and +/// corrupt keys. +fn parse_id(key: &str, prefix: &str) -> Option<SidecarId> { + let rest = key.strip_prefix(prefix)?; + base58::decode(rest).ok()?.try_into().ok() +} + +/// Which k/v partition a sidecar's entries live in. +#[derive(Clone, Copy)] +pub(crate) enum SidecarScope { + /// Cross-network global partition; entries survive wallet deletion and are + /// enumerable via [`SidecarView::list`]. + Global, + /// Scoped to the wallet whose seed hash is the entry id, so entries cascade + /// when that wallet is removed. Not cross-wallet enumerable. + WalletById, +} + +impl SidecarScope { + /// Resolve the concrete [`DetScope`] for `id` under this partition. + fn det_scope(self, id: &SidecarId) -> DetScope<'_> { + match self { + SidecarScope::Global => DetScope::Global, + SidecarScope::WalletById => DetScope::Wallet(id), + } + } +} + +/// A value stored in a [`SidecarView`]. The read strategy is part of the value +/// type so a domain needing a legacy-format fallback can override it while the +/// common case uses a plain typed read. +pub(crate) trait SidecarValue: Serialize + DeserializeOwned + Sized { + /// Read one blob by fully-qualified `key`. Default: a plain typed `get`. + /// Override to add a legacy-format fallback and one-shot migration re-store. + fn read(kv: &DetKv, scope: DetScope<'_>, key: &str) -> Result<Option<Self>, KvAdapterError> { + kv.get::<Self>(scope, key) + } +} + +/// A network-scoped, base58-keyed sidecar over [`DetKv`]. Cheap to construct +/// (borrow only), so wrappers build one per operation. +pub(crate) struct SidecarView<'a, V> { + kv: &'a Arc<DetKv>, + /// Colon-wrapped namespace, e.g. `:wallet_meta:`. The full key is + /// `<network><infix><base58(id)>`. + infix: &'static str, + scope: SidecarScope, + /// Maps a store failure to the domain's user-facing [`TaskError`] envelope. + map_err: fn(KvAdapterError) -> TaskError, + _marker: PhantomData<fn() -> V>, +} + +impl<'a, V: SidecarValue> SidecarView<'a, V> { + /// Build a view over `kv` for the `infix` namespace, `scope` partition, and + /// `map_err` envelope. + pub(crate) fn new( + kv: &'a Arc<DetKv>, + infix: &'static str, + scope: SidecarScope, + map_err: fn(KvAdapterError) -> TaskError, + ) -> Self { + Self { + kv, + infix, + scope, + map_err, + _marker: PhantomData, + } + } + + /// Fetch the value for `id`, degrading to `None` on a missing or corrupt + /// blob (logged). The sidecar read never fails the caller. + pub(crate) fn get(&self, network: Network, id: &SidecarId) -> Option<V> { + let key = sidecar_key(network, self.infix, id); + match V::read(self.kv, self.scope.det_scope(id), &key) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + target: "wallet_backend::sidecar", + sidecar = self.infix, + key = %key, + error = ?e, + "Failed to read sidecar entry; treating as absent", + ); + None + } + } + } + + /// Upsert the value for `id`. Re-writing the same value is an idempotent + /// overwrite (DetKv upserts by key). + pub(crate) fn set(&self, network: Network, id: &SidecarId, value: &V) -> Result<(), TaskError> { + let key = sidecar_key(network, self.infix, id); + self.kv + .put(self.scope.det_scope(id), &key, value) + .map_err(self.map_err) + } + + /// Delete the value for `id`. Idempotent — a missing key returns `Ok(())`. + pub(crate) fn delete(&self, network: Network, id: &SidecarId) -> Result<(), TaskError> { + let key = sidecar_key(network, self.infix, id); + self.kv + .delete(self.scope.det_scope(id), &key) + .map_err(self.map_err) + } + + /// All `(id, value)` pairs persisted for `network`. + /// + /// A key with a non-base58 id suffix, or a blob that fails to read, is + /// logged and skipped so one corrupt row cannot poison the listing. Only + /// meaningful for [`SidecarScope::Global`]; a `WalletById` sidecar has no + /// cross-wallet listing and returns an empty vec. + pub(crate) fn list(&self, network: Network) -> Vec<(SidecarId, V)> { + if !matches!(self.scope, SidecarScope::Global) { + return Vec::new(); + } + let prefix = format!("{}{}", network_prefix(network), self.infix); + let keys = match self.kv.list(DetScope::Global, Some(&prefix)) { + Ok(k) => k, + Err(e) => { + tracing::warn!( + target: "wallet_backend::sidecar", + sidecar = self.infix, + network = ?network, + error = ?e, + "Failed to list sidecar keys; returning empty list", + ); + return Vec::new(); + } + }; + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + let Some(id) = parse_id(&key, &prefix) else { + tracing::warn!( + target: "wallet_backend::sidecar", + sidecar = self.infix, + key = %key, + "Skipping sidecar key with non-base58 id suffix", + ); + continue; + }; + match V::read(self.kv, DetScope::Global, &key) { + Ok(Some(value)) => out.push((id, value)), + Ok(None) => {} + Err(e) => { + tracing::warn!( + target: "wallet_backend::sidecar", + sidecar = self.infix, + key = %key, + error = ?e, + "Skipping unreadable sidecar blob", + ); + } + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::kv_test_support::InMemoryKv; + + const INFIX: &str = ":blob:"; + + #[derive(Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + struct Blob(u32); + impl SidecarValue for Blob {} + + fn mapper(e: KvAdapterError) -> TaskError { + TaskError::AvatarCacheStorage { + source: Box::new(e), + } + } + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + /// SIDECAR-001 — a `Global` view round-trips set→get, enumerates via `list`, + /// partitions by network, and deletes idempotently. + #[test] + fn global_view_round_trips_and_lists() { + let kv = kv(); + let view = SidecarView::<Blob>::new(&kv, INFIX, SidecarScope::Global, mapper); + let a = [0x11; 32]; + let b = [0x22; 32]; + view.set(Network::Testnet, &a, &Blob(1)).unwrap(); + view.set(Network::Testnet, &b, &Blob(2)).unwrap(); + view.set(Network::Mainnet, &a, &Blob(9)).unwrap(); + + assert_eq!(view.get(Network::Testnet, &a), Some(Blob(1))); + let mut listed = view.list(Network::Testnet); + listed.sort_by_key(|(id, _)| *id); + assert_eq!(listed, vec![(a, Blob(1)), (b, Blob(2))]); + // Other network is partitioned off. + assert_eq!(view.list(Network::Mainnet), vec![(a, Blob(9))]); + + view.delete(Network::Testnet, &a).unwrap(); + view.delete(Network::Testnet, &a).unwrap(); + assert_eq!(view.get(Network::Testnet, &a), None); + } + + /// SIDECAR-002 — a `WalletById` view stores under the wallet scope and is + /// retrievable by id, but has no cross-wallet listing (`list` is empty). + #[test] + fn wallet_scoped_view_has_no_cross_wallet_list() { + let kv = kv(); + let view = SidecarView::<Blob>::new(&kv, INFIX, SidecarScope::WalletById, mapper); + let id = [0x33; 32]; + view.set(Network::Testnet, &id, &Blob(7)).unwrap(); + assert_eq!(view.get(Network::Testnet, &id), Some(Blob(7))); + assert!(view.list(Network::Testnet).is_empty()); + } + + /// SIDECAR-003 — a key whose id suffix is not base58 is skipped, so one + /// corrupt row cannot poison the listing. + #[test] + fn list_skips_non_base58_suffix() { + let kv = kv(); + let view = SidecarView::<Blob>::new(&kv, INFIX, SidecarScope::Global, mapper); + let id = [0x44; 32]; + view.set(Network::Testnet, &id, &Blob(5)).unwrap(); + kv.put( + DetScope::Global, + &format!("testnet{INFIX}!!!not-base58!!!"), + &Blob(0), + ) + .unwrap(); + assert_eq!(view.list(Network::Testnet), vec![(id, Blob(5))]); + } +} diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs new file mode 100644 index 000000000..87742b78c --- /dev/null +++ b/src/wallet_backend/single_key.rs @@ -0,0 +1,1916 @@ +//! Single-key (imported WIF) view backed by the upstream `SecretStore`. +//! +//! Each imported private key lives in the encrypted secret vault under the +//! label `single_key_priv.<base58_addr>`, scoped to a fixed per-backend +//! `WalletId` (built from [`SINGLE_KEY_NAMESPACE_BYTES`] via +//! [`single_key_namespace_id`]). The separator is a dot because the upstream +//! label allowlist is `^[A-Za-z0-9._-]{1,64}$` and rejects colons. +//! +//! `SingleKeyView` is the only doorway DET code uses to import, list, +//! forget, or sign with imported keys. WIF parsing goes through +//! `dash_sdk::dpp::dashcore::PrivateKey::from_wif` — DET does not +//! re-implement WIF. + +use std::sync::Arc; + +#[cfg(test)] +use dash_sdk::dpp::dashcore::secp256k1::Message; +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; +#[cfg(test)] +use dash_sdk::dpp::dashcore::secp256k1::ecdsa::Signature; +use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, +}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::single_key::ImportedKey; +use crate::model::wallet::single_key::{ + ClosedSingleKey, OpenSingleKey, SingleKeyData, SingleKeyHash, SingleKeyWallet, +}; +use crate::wallet_backend::kv::network_prefix; +use crate::wallet_backend::poison::{read_recover, write_recover}; +use crate::wallet_backend::secret_seam::{SecretScheme, SecretSeam}; +use crate::wallet_backend::single_key_entry::SingleKeyEntry; +use crate::wallet_backend::{DetKv, DetScope}; + +/// Minimum length (in characters) for a per-key passphrase. Re-exported +/// from the model so the rule has a single home; both this backend and +/// the import/restore dialogs share the same value. +pub use crate::model::wallet::passphrase::MIN_SINGLE_KEY_PASSPHRASE_LEN; + +/// Fixed per-backend namespace id for single-key entries. +/// +/// Single-key wallets are not HD wallets, so they share a namespace +/// instead of each carrying a derived `WalletId`. The bytes are the +/// SHA-256 of the ASCII string `"det-single-key-namespace"`, picked so +/// the namespace is recognisable in a hex dump without colliding with +/// any plausible upstream-derived id. +const SINGLE_KEY_NAMESPACE_BYTES: [u8; 32] = [ + 0x7a, 0x3c, 0x99, 0x88, 0xc2, 0x4a, 0x55, 0x6e, 0xf1, 0xa0, 0x06, 0xb4, 0x2d, 0x77, 0x90, 0x1e, + 0x4b, 0x2c, 0x68, 0xff, 0x33, 0xd1, 0x84, 0x09, 0xab, 0x57, 0xee, 0x10, 0x6c, 0x95, 0x71, 0x42, +]; + +/// Label prefix used to namespace single-key private-key entries inside +/// the secret store. Dot is required: the upstream label allowlist is +/// `^[A-Za-z0-9._-]{1,64}$`, so the original `single_key_priv:` design +/// is rewritten to `single_key_priv.` here. +pub const SINGLE_KEY_PRIV_LABEL_PREFIX: &str = "single_key_priv."; + +/// Colon-separated namespace shared across networks. The full key is +/// `<network>:single_key_meta:<address>`. The DET-side k/v sidecar holds +/// the enumerable list of imported-key metadata so the cold-boot path can +/// reconstruct the in-memory index without enumerating the (non-enumerable) +/// secret store. Mirrors the [`WalletMetaView`](super::WalletMetaView) +/// shape (T-W-00) — same network-prefix convention. +pub(crate) const SINGLE_KEY_META_INFIX: &str = ":single_key_meta:"; + +/// Build the canonical sidecar key for `(network, address)`. +pub(crate) fn meta_key_for(network: Network, address: &str) -> String { + format!( + "{}{SINGLE_KEY_META_INFIX}{address}", + network_prefix(network) + ) +} + +/// Build the cross-network prefix used to enumerate imported keys for +/// `network`. +fn meta_prefix_for(network: Network) -> String { + format!("{}{SINGLE_KEY_META_INFIX}", network_prefix(network)) +} + +/// Build the secret-store label for an imported key at `address`. +pub(crate) fn label_for_address(address: &str) -> String { + format!("{SINGLE_KEY_PRIV_LABEL_PREFIX}{address}") +} + +/// The fixed `WalletId` namespace scope for single-key entries. +pub(crate) fn single_key_namespace_id() -> SecretWalletId { + SecretWalletId::from(SINGLE_KEY_NAMESPACE_BYTES) +} + +/// Borrowed view exposing the imported-key operations of a +/// [`WalletBackend`](super::WalletBackend). Constructed via +/// [`WalletBackend::single_key`](super::WalletBackend::single_key). +pub struct SingleKeyView<'a> { + pub(crate) secret_store: &'a Arc<SecretStore>, + pub(crate) index: &'a std::sync::RwLock<std::collections::BTreeMap<String, ImportedKey>>, + pub(crate) network: Network, + /// Enumerable cross-network sidecar holding the imported-key + /// metadata blobs. `None` ⇒ a transient view that does not persist + /// (used by `WalletBackend::new` before the app k/v is wired and by + /// the unit tests below). + pub(crate) app_kv: Option<&'a Arc<DetKv>>, +} + +/// Optional passphrase choice supplied by the import dialog. Kept as a +/// small struct so the import API has one parameter for "no passphrase +/// / passphrase + hint" rather than two parallel `Option`s. +/// +/// `Debug` is hand-written to redact the passphrase: deriving it would +/// dump the plaintext into logs or panic backtraces. +#[derive(Clone, Default)] +pub struct ImportPassphrase { + /// User-supplied passphrase, kept in [`Zeroizing`] so it wipes on + /// drop. Empty / `None` ⇒ no passphrase. + pub passphrase: Option<Zeroizing<String>>, + /// Optional hint shown next to the unlock prompt. + pub hint: Option<String>, +} + +impl std::fmt::Debug for ImportPassphrase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportPassphrase") + .field( + "passphrase", + &self + .passphrase + .as_ref() + .map(|p| format!("[redacted; len {}]", p.len())), + ) + .field("hint", &self.hint) + .finish() + } +} + +impl<'a> SingleKeyView<'a> { + /// Borrow the moving parts of a [`SingleKeyView`] without going + /// through [`WalletBackend::single_key`]. Kept `pub` so benches and + /// downstream tooling can build the view from owned `Arc`s. + pub fn from_views( + secret_store: &'a Arc<SecretStore>, + index: &'a std::sync::RwLock<std::collections::BTreeMap<String, ImportedKey>>, + network: Network, + app_kv: Option<&'a Arc<DetKv>>, + ) -> Self { + Self { + secret_store, + index, + network, + app_kv, + } + } + + /// Parse a WIF-encoded private key, store its raw secret bytes in the + /// encrypted vault under `single_key_priv.<address>`, persist the + /// derived [`ImportedKey`] metadata to the enumerable k/v sidecar, + /// and refresh the in-memory index. Idempotent on the same WIF — + /// re-import overwrites the alias and refreshes the stored bytes. + /// + /// Equivalent to + /// [`Self::import_wif_with_passphrase`] with `ImportPassphrase::default()`. + pub fn import_wif(&self, wif: &str, alias: Option<String>) -> Result<ImportedKey, TaskError> { + self.import_wif_with_passphrase(wif, alias, ImportPassphrase::default()) + } + + /// Per-key passphrase import — same as [`Self::import_wif`], plus an optional + /// per-key passphrase. When `passphrase.passphrase` is `Some(p)` and + /// non-empty the raw key bytes are AES-GCM encrypted under `p` + /// before being written to the vault; the metadata sidecar records + /// `has_passphrase = true` so the unlock UI can prompt later. An + /// empty / `None` passphrase falls back to the legacy + /// unprotected-but-vault-encrypted shape. + pub fn import_wif_with_passphrase( + &self, + wif: &str, + alias: Option<String>, + passphrase: ImportPassphrase, + ) -> Result<ImportedKey, TaskError> { + let priv_key = PrivateKey::from_wif(wif).map_err(|source| TaskError::InvalidWif { + source: Box::new(source), + })?; + // The cold-boot rebuild reconstructs the key in compressed form + // (`PrivateKey::from_byte_array` always sets `compressed = true`), so + // an uncompressed import would change its address after a restart. + // Reject it here to keep the displayed address stable. + if !priv_key.compressed { + return Err(TaskError::UncompressedWifUnsupported); + } + let secp = Secp256k1::new(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&secp), + }; + let address = Address::p2pkh(&pub_key, self.network); + let address_str = address.to_string(); + + // Extracted WIF bytes wrapped in `Zeroizing` so the stack copy wipes + // on drop instead of lingering after the entry is built. + let raw: Zeroizing<[u8; 32]> = Zeroizing::new( + priv_key.inner[..] + .try_into() + .map_err(|_| TaskError::SingleKeyCryptoFailure)?, + ); + + let pub_bytes = pub_key.inner.serialize().to_vec(); + let label = label_for_address(&address_str); + + // Both tiers route through the secret seam under the same label — no + // DET-side `SingleKeyEntry` framing for new imports. An unprotected key + // is stored as RAW 32 bytes (Tier-1); a protected key is sealed Tier-2 + // under the user's passphrase (Argon2id + XChaCha20-Poly1305) at import + // time, so the storage chokepoint is a single shape from import onward + // with no lazy first-unlock migration. The locked-render pubkey lives in + // the `ImportedKey` sidecar either way. + let (has_passphrase, passphrase_hint) = + match passphrase.passphrase.as_ref().map(|p| p.as_str()) { + Some(p) if !p.is_empty() => { + if p.chars().count() < MIN_SINGLE_KEY_PASSPHRASE_LEN { + return Err(TaskError::SingleKeyPassphraseTooShort { + min: MIN_SINGLE_KEY_PASSPHRASE_LEN as u32, + }); + } + let pw = SecretString::new(p); + SecretSeam::new(self.secret_store).put_secret_protected( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*raw), + &pw, + )?; + (true, passphrase.hint.clone()) + } + _ => { + SecretSeam::new(self.secret_store).put_secret( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*raw), + )?; + (false, None) + } + }; + + let imported = ImportedKey { + address: address_str.clone(), + alias, + network: self.network, + has_passphrase, + passphrase_hint, + public_key_bytes: pub_bytes, + }; + + if let Some(kv) = self.app_kv { + let key = meta_key_for(self.network, &address_str); + kv.put(DetScope::Global, &key, &imported) + .map_err(|source| TaskError::SingleKeyMetaStorage { + source: Box::new(source), + })?; + } + + write_recover(self.index).insert(address_str, imported.clone()); + Ok(imported) + } + + /// Persist a new alias for the imported key at `address` to the + /// modern sidecar and refresh the in-memory index. This is the + /// single source of truth for single-key renames — it mirrors the + /// HD-wallet rename path through `WalletMetaView::set`, so the new + /// name survives a cold boot without touching the legacy + /// `single_key_wallet` table. An empty `alias` clears the nickname. + /// + /// No-op success when the view has no sidecar wired (transient + /// construction path) — the in-memory index is still updated so the + /// rename is visible in-session. + pub fn set_alias(&self, address: &str, alias: Option<String>) -> Result<(), TaskError> { + let mut idx = write_recover(self.index); + let entry = idx.get_mut(address).ok_or(TaskError::ImportedKeyNotFound)?; + entry.alias = alias; + let updated = entry.clone(); + drop(idx); + + if let Some(kv) = self.app_kv { + let key = meta_key_for(self.network, address); + kv.put(DetScope::Global, &key, &updated).map_err(|source| { + TaskError::SingleKeyMetaStorage { + source: Box::new(source), + } + })?; + } + Ok(()) + } + + /// Confirm that `passphrase` unlocks the protected imported key at + /// `address`, without retaining the decrypted key anywhere. The plaintext + /// is decrypted just-in-time into a [`Zeroizing`] binding that is dropped + /// (and wiped) before this returns — so a successful "Unlock" gesture + /// leaves no plaintext in the long-lived session map. + /// + /// Returns [`TaskError::SingleKeyPassphraseIncorrect`] on a wrong + /// passphrase (the same generic signal as the restore path — no oracle). + /// For an unprotected entry the passphrase is irrelevant. A not-yet-migrated + /// legacy protected entry that just unlocked is RE-WRAPPED to a Tier-2 + /// object-password envelope under the same password (protection KEPT; + /// `has_passphrase` stays true) — so there is no downgrade to surface and no + /// notice to show. An already-Tier-2 entry is verified by unsealing and + /// needs no re-wrap. + pub fn verify_passphrase(&self, address: &str, passphrase: &str) -> Result<(), TaskError> { + let label = label_for_address(address); + match SecretSeam::new(self.secret_store).scheme(&single_key_namespace_id(), &label)? { + // Already Tier-2: verify by unsealing with the supplied password. A + // wrong password maps to the generic incorrect signal (no oracle); a + // correct one confirms without re-parking plaintext. No re-wrap. + SecretScheme::Protected => { + let pw = SecretString::new(passphrase); + SecretSeam::new(self.secret_store) + .get_secret_protected(&single_key_namespace_id(), &label, &pw) + // A wrong password maps to the generic incorrect signal (no + // oracle), matched structurally on the seam's typed source. + .map_err(|e| match &e { + TaskError::SecretSeam { source } + if matches!(**source, SecretStoreError::WrongPassword) => + { + TaskError::SingleKeyPassphraseIncorrect + } + _ => e, + })? + .ok_or(TaskError::ImportedKeyNotFound)?; + Ok(()) + } + SecretScheme::Absent => Err(TaskError::ImportedKeyNotFound), + // Legacy `SingleKeyEntry` (or a migrated raw-32 key): decode, decrypt + // to verify, then lazily re-wrap a protected entry to Tier-2 under the + // SAME password. An unprotected entry ignores the passphrase. + SecretScheme::Unprotected => { + let payload = SecretSeam::new(self.secret_store) + .get_secret(&single_key_namespace_id(), &label)? + .ok_or(TaskError::ImportedKeyNotFound)?; + let entry = SingleKeyEntry::decode(payload.expose_secret())?; + // Decrypt to verify, then drop immediately — the binding is wiped + // on drop, so the plaintext never crosses back out of this method. + let verified: Zeroizing<[u8; 32]> = entry.decrypt(Some(passphrase))?; + if entry.has_passphrase { + let pw = SecretString::new(passphrase); + SecretSeam::new(self.secret_store).put_secret_protected( + &single_key_namespace_id(), + &label, + &SecretBytes::from_slice(&*verified), + &pw, + )?; + } + Ok(()) + } + } + } + + /// List every imported key tracked by this backend, sorted by + /// address. Reads the in-memory index only — does not touch the + /// secret vault. + pub fn list(&self) -> Vec<ImportedKey> { + read_recover(self.index).values().cloned().collect() + } + + /// Forget the imported key at `address`: drop its index entry, delete + /// its secret-store row, and remove the k/v sidecar entry. Idempotent + /// — absent addresses are an `Ok(())`. + pub fn forget(&self, address: &str) -> Result<(), TaskError> { + let label = label_for_address(address); + SecretSeam::new(self.secret_store).delete_secret(&single_key_namespace_id(), &label)?; + if let Some(kv) = self.app_kv { + let key = meta_key_for(self.network, address); + kv.delete(DetScope::Global, &key).map_err(|source| { + TaskError::SingleKeyMetaStorage { + source: Box::new(source), + } + })?; + } + write_recover(self.index).remove(address); + Ok(()) + } + + /// Enumerate every imported-key metadata blob persisted for the view's + /// network from the k/v sidecar. Returns an empty vector when the + /// view has no sidecar wired (transient construction path), when no + /// entries exist, or when listing fails (logged). + pub(crate) fn list_persisted(&self) -> Vec<ImportedKey> { + let Some(kv) = self.app_kv else { + return Vec::new(); + }; + let prefix = meta_prefix_for(self.network); + let keys = match kv.list(DetScope::Global, Some(&prefix)) { + Ok(k) => k, + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + network = ?self.network, + error = ?e, + "Failed to list single-key sidecar entries; treating as empty", + ); + return Vec::new(); + } + }; + let mut out = Vec::with_capacity(keys.len()); + for key in keys { + match self.read_imported_key(kv, &key) { + Ok(Some(meta)) => out.push(meta), + Ok(None) => {} + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + key = %key, + error = ?e, + "Skipping unreadable single-key sidecar blob", + ); + } + } + } + out + } + + /// Read one `ImportedKey` sidecar blob with a dual-format fallback. Tries + /// the current shape first; on a decode failure (an old blob lacks the + /// appended `public_key_bytes`) falls back to the legacy [`ImportedKeyV1`] + /// shape and RE-STORES it in the current shape — so an imported key created + /// before that field still appears in the picker instead of vanishing. + /// Mirrors the `WalletMeta` dual-format reader. + fn read_imported_key( + &self, + kv: &Arc<DetKv>, + key: &str, + ) -> Result<Option<ImportedKey>, crate::wallet_backend::KvAdapterError> { + use crate::wallet_backend::KvAdapterError; + match kv.get::<ImportedKey>(DetScope::Global, key) { + Ok(opt) => return Ok(opt), + Err(KvAdapterError::Decode(_)) => {} + Err(e) => return Err(e), + } + let Some(v1) = kv.get::<crate::model::single_key::ImportedKeyV1>(DetScope::Global, key)? + else { + return Ok(None); + }; + let migrated: ImportedKey = v1.into(); + if let Err(e) = kv.put(DetScope::Global, key, &migrated) { + tracing::warn!( + target = "wallet_backend::single_key", + key = %key, + error = ?e, + "Could not re-store migrated single-key sidecar; will retry next read", + ); + } + Ok(Some(migrated)) + } + + /// Reconstruct DET-side [`SingleKeyWallet`] rows from the k/v sidecar + /// plus the encrypted secret vault. Used by the cold-boot hydration + /// path that replaces the legacy `db.get_single_key_wallets` read. + /// + /// Per-entry failures (missing vault row, malformed key bytes, + /// address parse error) are logged and skipped so a single corrupt + /// sidecar entry cannot prevent the wallet picker from listing the + /// survivors. Balances and UTXOs start at zero / empty — the + /// single-key SPV refresh path is stubbed + /// ([`TaskError::SingleKeyWalletsUnsupported`]), so this matches the + /// pre-refresh state the legacy reader produced on launch. + pub fn hydrate_wallets(&self) -> Vec<(SingleKeyHash, SingleKeyWallet)> { + let metas = self.list_persisted(); + let mut out = Vec::with_capacity(metas.len()); + for meta in metas { + match self.rebuild_wallet(&meta) { + Ok(Some(wallet)) => { + out.push((wallet.key_hash, wallet)); + } + Ok(None) => {} + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + error = ?e, + "Failed to rebuild single-key wallet from sidecar; skipping", + ); + } + } + } + out + } + + /// Rebuild the single display [`SingleKeyWallet`] for one imported key, + /// reading the same vault + metadata the cold-boot + /// [`Self::hydrate_wallets`] path uses. Routes through the shared + /// [`Self::rebuild_wallet`], so the in-memory shape an import produces is + /// byte-for-byte identical to the one a restart reconstructs: a + /// passphrase-protected key comes back **closed** (no plaintext in the + /// long-lived map), an unprotected key comes back open. + /// + /// `Ok(None)` mirrors `rebuild_wallet`'s skip-and-log contract (missing + /// vault row, unparseable bytes) — the caller decides how to surface a + /// freshly-imported key that could not be rebuilt. + pub fn rebuild_display_wallet( + &self, + meta: &ImportedKey, + ) -> Result<Option<SingleKeyWallet>, TaskError> { + self.rebuild_wallet(meta) + } + + /// Seed the in-memory index from the k/v sidecar. Idempotent: re-runs + /// overwrite existing in-memory entries with the persisted view, so a + /// cold-boot hydration cannot lose entries created in the same + /// process before the backend was wired (mirrors the HD-wallet + /// `entry().or_insert` pattern in + /// [`hydrate_context_wallets`](super::WalletBackend::hydrate_context_wallets)). + pub fn rehydrate_index(&self) -> Result<(), TaskError> { + let metas = self.list_persisted(); + if metas.is_empty() { + return Ok(()); + } + let mut idx = write_recover(self.index); + for meta in metas { + idx.entry(meta.address.clone()).or_insert(meta); + } + Ok(()) + } + + fn rebuild_wallet(&self, meta: &ImportedKey) -> Result<Option<SingleKeyWallet>, TaskError> { + let label = label_for_address(&meta.address); + // A key re-wrapped to a Tier-2 object-password envelope (keep-protection, + // on the first unlock) reads back as Protected without the password. + // Reconstruct it CLOSED from the public sidecar — the password is + // intentionally unavailable at cold boot, so the secret is never read + // here. Without the scheme probe a plain `get` would surface + // `NeedsPassword` and the key would vanish from the picker. + if matches!( + SecretSeam::new(self.secret_store).scheme(&single_key_namespace_id(), &label)?, + SecretScheme::Protected + ) { + return Ok(self.rebuild_closed_tier2_wallet(meta)); + } + let secret = match SecretSeam::new(self.secret_store) + .get_secret(&single_key_namespace_id(), &label)? + { + Some(s) => s, + None => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + "Single-key sidecar entry has no matching vault secret; skipping", + ); + return Ok(None); + } + }; + + let entry = match SingleKeyEntry::decode(secret.expose_secret()) { + Ok(e) => e, + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + error = ?e, + "Single-key vault entry could not be decoded; skipping", + ); + return Ok(None); + } + }; + + // For passphrase-protected entries the rebuilt wallet is closed + // (no plaintext) — the picker still needs to render it so the + // user can unlock it on demand. Open entries get rebuilt with + // their raw bytes the way the legacy path did. + if entry.has_passphrase { + // Closed: derive identity from public material only. + return Ok(self.rebuild_closed_passphrase_wallet(meta, &entry)); + } + + let key_bytes = match entry.decrypt(None) { + Ok(b) => b, + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + error = ?e, + "Single-key entry plaintext recovery failed; skipping", + ); + return Ok(None); + } + }; + + let priv_key = match PrivateKey::from_byte_array(&key_bytes, meta.network) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + error = %e, + "Single-key vault bytes are not a valid private key; skipping", + ); + return Ok(None); + } + }; + let secp = Secp256k1::new(); + let public_key = priv_key.public_key(&secp); + let address = Address::p2pkh(&public_key, meta.network); + + let key_hash = ClosedSingleKey::compute_key_hash(&key_bytes); + let closed = ClosedSingleKey { + key_hash, + encrypted_private_key: key_bytes.to_vec(), + salt: Vec::new(), + nonce: Vec::new(), + }; + let private_key_data = SingleKeyData::Open(OpenSingleKey { + private_key: *key_bytes, + key_info: closed, + }); + + Ok(Some(SingleKeyWallet { + private_key_data, + uses_password: false, + public_key, + address, + alias: meta.alias.clone(), + key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: std::collections::HashMap::new(), + core_wallet_name: None, + })) + } + + /// Build a [`SingleKeyWallet`] for a passphrase-protected entry + /// without touching the plaintext. The rebuilt wallet is closed + /// (`uses_password = true`); the picker can still render the alias, + /// address, and key_hash. Stores the ciphertext/salt/nonce on the + /// closed payload so downstream "is this the same key" checks + /// remain comparable. Returns `None` (skip and log) when the + /// entry's stored public-key bytes are missing or unparseable — + /// without them the rebuilt wallet would lack a usable + /// [`PublicKey`]. + fn rebuild_closed_passphrase_wallet( + &self, + meta: &ImportedKey, + entry: &SingleKeyEntry, + ) -> Option<SingleKeyWallet> { + let (address, public_key) = parse_locked_address_and_pubkey(meta, &entry.public_key_bytes)?; + + // `compute_key_hash` is defined over the plaintext private key; + // locked entries don't have it here, so the handle is SHA-256 of the + // ciphertext under a domain-separation tag. The tag keeps this handle + // in a different space from the plaintext `compute_key_hash`, so a + // locked entry and an open one can never collide on the same BTreeMap + // key. Two locked entries with the same plaintext but distinct salts + // still hash apart — fine, the handle is only a per-entry map key. + const LOCKED_HANDLE_DOMAIN: &[u8] = b"det-single-key-locked-handle-v1"; + let key_hash = locked_key_handle(LOCKED_HANDLE_DOMAIN, &entry.ciphertext); + let closed = ClosedSingleKey { + key_hash, + encrypted_private_key: entry.ciphertext.clone(), + salt: entry.salt.clone(), + nonce: entry.nonce.clone(), + }; + Some(SingleKeyWallet { + private_key_data: SingleKeyData::Closed(closed), + uses_password: true, + public_key, + address, + alias: meta.alias.clone(), + key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: std::collections::HashMap::new(), + core_wallet_name: None, + }) + } + + /// Build a closed [`SingleKeyWallet`] for a key whose secret is sealed in a + /// Tier-2 object-password envelope — the steady-state shape after the first + /// unlock. The ciphertext is unreachable without the password at cold boot, + /// so the public material comes from the `ImportedKey` sidecar + /// (`public_key_bytes` + `address`) and the per-entry handle is derived from + /// the public key bytes. Returns `None` (skip + log) when the sidecar's + /// public material is missing or unparseable. + fn rebuild_closed_tier2_wallet(&self, meta: &ImportedKey) -> Option<SingleKeyWallet> { + let (address, public_key) = parse_locked_address_and_pubkey(meta, &meta.public_key_bytes)?; + + // No ciphertext is reachable for a Tier-2 entry without the password, so + // the per-entry handle is domain-separated over the public key bytes + // (which uniquely identify the key) instead of the ciphertext. + const LOCKED_TIER2_HANDLE_DOMAIN: &[u8] = b"det-single-key-locked-tier2-handle-v1"; + let key_hash = locked_key_handle(LOCKED_TIER2_HANDLE_DOMAIN, &meta.public_key_bytes); + let closed = ClosedSingleKey { + key_hash, + encrypted_private_key: Vec::new(), + salt: Vec::new(), + nonce: Vec::new(), + }; + Some(SingleKeyWallet { + private_key_data: SingleKeyData::Closed(closed), + uses_password: true, + public_key, + address, + alias: meta.alias.clone(), + key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: std::collections::HashMap::new(), + core_wallet_name: None, + }) + } +} + +/// Parse the stored address (network-checked) and the compressed public key +/// from `public_key_bytes` for a locked single-key render. `None` (skip + log) +/// when the address or the public key is missing or unparseable — without both +/// the rebuilt wallet would lack a usable address / [`PublicKey`]. Shared by the +/// legacy-AES-GCM and Tier-2 closed-render paths so they apply one policy. +fn parse_locked_address_and_pubkey( + meta: &ImportedKey, + public_key_bytes: &[u8], +) -> Option<(Address, PublicKey)> { + use std::str::FromStr; + let address = match Address::from_str(&meta.address) { + Ok(a) => match a.require_network(meta.network) { + Ok(a) => a, + Err(_) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + network = ?meta.network, + "Locked single-key entry address does not match expected network; skipping", + ); + return None; + } + }, + Err(_) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + "Locked single-key entry address is not parseable; skipping", + ); + return None; + } + }; + + if public_key_bytes.is_empty() { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + "Locked single-key entry has no stored public key; skipping (re-import to refresh)", + ); + return None; + } + let inner = match dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_slice(public_key_bytes) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + target = "wallet_backend::single_key", + address = %meta.address, + error = %e, + "Locked single-key entry public-key bytes are unparseable; skipping", + ); + return None; + } + }; + Some(( + address, + PublicKey { + compressed: true, + inner, + }, + )) +} + +/// SHA-256 of `domain || material`, used as a stable per-entry BTreeMap handle +/// for a locked single-key wallet. The domain tag keeps locked handles in a +/// different space from the plaintext `compute_key_hash`, so a locked entry and +/// an open one can never collide. +fn locked_key_handle(domain: &[u8], material: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(material); + let out = hasher.finalize(); + let mut h = [0u8; 32]; + h.copy_from_slice(&out); + h +} + +/// Open or create the file-backed secret store at `path`. The parent +/// directory is created if missing; on Unix the vault file inherits its +/// initial mode from upstream's writer (the encrypted-file backend +/// refuses pre-existing modes looser than `0600`, so the secret-at-rest +/// floor is enforced at open time — see `SecretStoreError::InsecurePermissions`). +/// +/// The vault file itself is opened **keyless** ([`SecretStore::file_unprotected`]). +/// Upstream documents this verbatim as **"obfuscation, not confidentiality"**: the +/// vault key derives from an empty passphrase under a public salt, so anyone who +/// can READ the vault file can re-derive it and recover every **Tier-1** +/// (unprotected) secret. Tier-1 at-rest protection is therefore **owner-only file +/// permissions ALONE** — it covers no-password seeds, raw imported keys, and +/// identity keys (prompt-free by design for headless signing). +/// +/// Real at-rest **confidentiality** comes only from **Tier-2** *object* passwords: +/// each protected secret is sealed under its own password (Argon2id + XChaCha20) +/// via [`SecretStore::set_secret`] / read back with [`SecretStore::get_secret`] +/// BEFORE it reaches the backend, so a full vault-file compromise cannot reveal a +/// protected secret. (Upstream's [`SecretStore::file`] now rejects a blank +/// passphrase; `file_unprotected` is the explicit keyless door it documents for +/// exactly this per-secret-password model.) This Tier-1-is-obfuscation-only +/// residual is an accepted, documented risk — see the ADR under +/// `docs/ai-design/2026-06-19-secret-storage-seam/`. Hosts that can hold +/// a real key may instead use [`SecretStore::os`] (OS keyring) or a vault +/// passphrase via `EncryptedFileStore::rekey`. +pub fn open_secret_store(path: &std::path::Path) -> Result<SecretStore, SecretStoreError> { + prepare_vault_dir(path)?; + SecretStore::file_unprotected(path) +} + +/// Open the file-backed secret store at `path` unlocked by `passphrase`. +/// +/// The funds-safe, non-destructive recovery door for a **legacy vault** an +/// older build wrote with a real passphrase: the keyless +/// [`open_secret_store`] fails such a vault with +/// [`SecretStoreError::WrongPassphrase`], and the GUI boot seam falls through +/// to this with the user-supplied passphrase. It opens the SAME vault in +/// place — it never deletes, recreates, or rekeys it, so wallet seeds are +/// never at risk. Parent-directory creation and owner-only permissions match +/// [`open_secret_store`] exactly. +/// +/// A blank passphrase is rejected upstream +/// ([`SecretStoreError::BlankPassphrase`]); the deliberately keyless door is +/// [`open_secret_store`]. +pub fn open_secret_store_with_passphrase( + path: &std::path::Path, + passphrase: SecretString, +) -> Result<SecretStore, SecretStoreError> { + prepare_vault_dir(path)?; + SecretStore::file(path, passphrase) +} + +/// Create the vault's parent directory and lock it to owner-only. +/// +/// The upstream file backend refuses to open a vault whose parent dir is +/// group/other-writable (a rename-swap threat), so the secrets dir is forced +/// to `0700` before the vault is opened or created. +fn prepare_vault_dir(path: &std::path::Path) -> Result<(), SecretStoreError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|_| SecretStoreError::MalformedVault)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| SecretStoreError::MalformedVault)?; + } + } + Ok(()) +} + +/// Test-only signing helpers. Production single-key signing goes through the +/// JIT chokepoint ([`WalletBackend::sign_single_key`](super::WalletBackend::sign_single_key)), +/// which resolves the plaintext via [`SecretAccess`](crate::wallet_backend::SecretAccess) +/// and signs through [`DetSigner`](crate::wallet_backend::det_signer); these +/// direct-from-view paths exist only to exercise the unprotected key lookup in +/// unit tests. +#[cfg(test)] +impl<'a> SingleKeyView<'a> { + /// Read the raw private-key bytes for an **unprotected** imported key. + /// + /// A protected key (Tier-2-sealed or a legacy passphrase-protected + /// `SingleKeyEntry`) is never unlocked here — the direct call returns + /// [`TaskError::SingleKeyPassphraseRequired`], mirroring the production + /// chokepoint's typed signal. + fn raw_key_bytes(&self, address: &str) -> Result<Zeroizing<[u8; 32]>, TaskError> { + let label = label_for_address(address); + // A Tier-2-sealed key cannot be read without the passphrase — surface the + // typed "passphrase required" signal (the chokepoint is the unlock path), + // mirroring the legacy protected `SingleKeyEntry` case below. + if matches!( + SecretSeam::new(self.secret_store).scheme(&single_key_namespace_id(), &label)?, + SecretScheme::Protected + ) { + return Err(TaskError::SingleKeyPassphraseRequired { + addr: address.to_string(), + }); + } + let payload = SecretSeam::new(self.secret_store) + .get_secret(&single_key_namespace_id(), &label)? + .ok_or(TaskError::ImportedKeyNotFound)?; + let entry = SingleKeyEntry::decode(payload.expose_secret())?; + if entry.has_passphrase { + return Err(TaskError::SingleKeyPassphraseRequired { + addr: address.to_string(), + }); + } + // `decrypt` returns the key wrapped in `Zeroizing`, so it wipes on + // drop instead of lingering on the stack after the sign. + entry.decrypt(None) + } + + /// Sign a 32-byte message hash with the **unprotected** imported key + /// registered at `address`. Pure ECDSA on secp256k1; no BIP-32 + /// derivation is touched (TC-SK-008). A protected key returns + /// [`TaskError::SingleKeyPassphraseRequired`]. + fn sign_with(&self, address: &str, msg: &[u8; 32]) -> Result<Signature, TaskError> { + let bytes = self.raw_key_bytes(address)?; + sign_message_with_raw_key(&bytes, msg) + } +} + +/// Sign a 32-byte digest with raw secp256k1 private-key bytes. Test-only — +/// the production JIT path signs inline through +/// [`DetSigner`](crate::wallet_backend::det_signer::DetSigner). +#[cfg(test)] +fn sign_message_with_raw_key(bytes: &[u8; 32], msg: &[u8; 32]) -> Result<Signature, TaskError> { + let sk = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_byte_array(bytes) + .map_err(|_| TaskError::ImportedKeyNotFound)?; + let message = Message::from_digest(*msg); + Ok(Secp256k1::new().sign_ecdsa(&message, &sk)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The hand-written `ImportPassphrase` `Debug` must redact the + /// passphrase so it can never leak through `{:?}` into logs or panic + /// backtraces. + #[test] + fn import_passphrase_debug_redacts_secret() { + let pp = "correct-horse-battery-staple"; + let imp = ImportPassphrase { + passphrase: Some(Zeroizing::new(pp.to_string())), + hint: Some("the usual".into()), + }; + let dbg = format!("{imp:?}"); + assert!(!dbg.contains(pp), "debug leaked the passphrase: {dbg}"); + assert!( + dbg.contains("[redacted"), + "debug must mark redaction: {dbg}" + ); + // Non-secret hint stays visible for diagnostics. + assert!(dbg.contains("the usual")); + } + + /// A vault written WITH a real passphrase: the keyless boot open fails + /// `WrongPassphrase`, but the passphrase-accepting open recovers the SAME + /// vault and reads back the identical entry — proving the recovery door is + /// non-destructive (no delete/recreate/rekey of the seed vault). + #[test] + fn legacy_passphrase_vault_round_trips_without_destroying_data() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets").join("det-secrets.pwsvault"); + let scope = single_key_namespace_id(); + let label = "single_key_priv.roundtrip"; + let secret = [7u8; 32]; + + // Seed a passphrase-protected vault, then release its exclusive lock. + { + let store = open_secret_store_with_passphrase(&path, SecretString::new("legacy-pass")) + .expect("create passphrase vault"); + store + .set(&scope, label, &SecretBytes::from_slice(&secret)) + .expect("write entry"); + } + + // Boot opens keyless → the empty-passphrase verify-token fails. + let err = open_secret_store(&path).expect_err("keyless open must fail a passphrase vault"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "expected WrongPassphrase, got {err:?}" + ); + + // The correct passphrase re-opens the same vault, data intact. + let store = open_secret_store_with_passphrase(&path, SecretString::new("legacy-pass")) + .expect("re-open with passphrase"); + let got = store + .get(&scope, label) + .expect("read entry") + .expect("entry present"); + assert_eq!(got.expose_secret(), &secret, "recovered bytes must match"); + } + + /// The deliberately keyless door must reject opening with a passphrase only + /// when the vault was sealed with one — and a freshly keyless vault must + /// still open keyless (regression guard for the recovery refactor). + #[test] + fn keyless_vault_still_opens_keyless_after_refactor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets").join("det-secrets.pwsvault"); + let scope = single_key_namespace_id(); + { + let store = open_secret_store(&path).expect("create keyless vault"); + store + .set(&scope, "k", &SecretBytes::from_slice(&[1u8; 32])) + .expect("write"); + } + // Re-open keyless — the default boot path is unchanged. + let store = open_secret_store(&path).expect("re-open keyless"); + assert!(store.get(&scope, "k").expect("read").is_some()); + } + + fn fresh_view( + dir: &std::path::Path, + network: Network, + ) -> ( + Arc<SecretStore>, + std::sync::RwLock<std::collections::BTreeMap<String, ImportedKey>>, + Network, + ) { + let path = dir.join("secrets.pwsvault"); + let store = Arc::new(open_secret_store(&path).expect("open vault")); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + (store, index, network) + } + + fn known_wif() -> &'static str { + // Testnet WIF with deterministic, all-zero-except-last-byte key + // bytes. Generated locally with `PrivateKey::new(SecretKey::from_byte_array(&[0;31].chain(&[1])).unwrap(), Testnet).to_wif()` + // and pinned here to keep tests offline + reproducible. + "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN8rFTv2sfUK" + } + + /// TC-SK-003: importing a WIF writes exactly one entry whose label + /// matches `^single_key_priv\.[1-9A-HJ-NP-Za-km-z]{26,35}$` and is + /// scoped to the per-backend single-key `WalletId` namespace. + #[test] + fn tc_sk_003_label_format_and_namespace_scope() { + let dir = tempfile::tempdir().expect("tempdir"); + let (store, index, network) = fresh_view(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + + let imported = view + .import_wif(known_wif(), Some("primary".to_string())) + .expect("import"); + + // The label uses the dotted prefix (upstream allowlist rejects ':'). + let label = label_for_address(&imported.address); + assert!( + label.starts_with(SINGLE_KEY_PRIV_LABEL_PREFIX), + "label {label} should start with {SINGLE_KEY_PRIV_LABEL_PREFIX}" + ); + let addr_part = &label[SINGLE_KEY_PRIV_LABEL_PREFIX.len()..]; + let len_ok = (26..=35).contains(&addr_part.len()); + let charset_ok = addr_part + .bytes() + .all(|b| matches!(b, b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z')); + assert!( + len_ok && charset_ok, + "address part {addr_part} should match base58 26-35 chars" + ); + + // Round-trip via the namespace WalletId proves the entry is + // scoped where the spec requires it — read with a different id + // and the entry is invisible. + let got = store + .get(&single_key_namespace_id(), &label) + .expect("get scoped") + .expect("present under namespace"); + assert!(!got.expose_secret().is_empty()); + + let other_id = SecretWalletId::from([0u8; 32]); + let absent = store.get(&other_id, &label).expect("get other"); + assert!( + absent.is_none(), + "entry must not be visible under a different WalletId scope" + ); + + // Exactly one entry tracked. + assert_eq!(view.list().len(), 1); + } + + /// TC-SK-008: `sign_with` looks the imported key up by its + /// `single_key_priv.<addr>` label and signs locally with the + /// recovered bytes. No BIP-32 derivation is touched — the only + /// secret material reaches the signer through the secret-store + /// lookup path. + #[test] + fn tc_sk_008_sign_uses_secret_store_path_not_bip32() { + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + + let dir = tempfile::tempdir().expect("tempdir"); + let (store, index, network) = fresh_view(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + let imported = view.import_wif(known_wif(), None).expect("import"); + + let msg = [0x42u8; 32]; + let sig = view + .sign_with(&imported.address, &msg) + .expect("sign with imported key"); + + // Verify with the public key derived from the same WIF — proves + // the signer hit the imported-key path (not some other key the + // backend might fall back to) without referencing BIP-32 at all. + let priv_key = PrivateKey::from_wif(known_wif()).expect("wif"); + let secp = Secp256k1::new(); + let pk = priv_key.inner.public_key(&secp); + secp.verify_ecdsa(&Message::from_digest(msg), &sig, &pk) + .expect("signature verifies against WIF-derived pubkey"); + + // Forgetting the key removes both the index entry and the + // secret store row, so a second sign attempt surfaces the + // typed "not found" variant. + view.forget(&imported.address).expect("forget"); + assert!(view.list().is_empty()); + let err = view + .sign_with(&imported.address, &msg) + .expect_err("post-forget sign must fail"); + assert!( + matches!(err, TaskError::ImportedKeyNotFound), + "expected ImportedKeyNotFound, got {err:?}" + ); + } + + /// Invalid WIF surfaces the typed `InvalidWif` variant rather than + /// the storage diagnostic. No secret-store write happens — the index + /// stays empty. + #[test] + fn invalid_wif_rejected_without_secret_store_write() { + let dir = tempfile::tempdir().expect("tempdir"); + let (store, index, network) = fresh_view(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + let err = view + .import_wif("not-a-valid-wif", None) + .expect_err("invalid wif"); + assert!( + matches!(err, TaskError::InvalidWif { .. }), + "expected InvalidWif, got {err:?}" + ); + assert!(view.list().is_empty()); + } + + /// Build an uncompressed-format WIF for the same key bytes `known_wif` + /// encodes, so the reject test exercises the compression flag rather + /// than a different key. + fn uncompressed_wif() -> String { + let mut compressed = PrivateKey::from_wif(known_wif()).expect("parse known wif"); + compressed.compressed = false; + compressed.to_wif() + } + + /// F10 — an uncompressed-format WIF is rejected at import with the typed + /// `UncompressedWifUnsupported` variant; no vault or index write happens. + #[test] + fn f10_uncompressed_wif_rejected_at_import() { + let dir = tempfile::tempdir().expect("tempdir"); + let (store, index, network) = fresh_view(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: None, + }; + let err = view + .import_wif(&uncompressed_wif(), None) + .expect_err("uncompressed wif must be rejected"); + assert!( + matches!(err, TaskError::UncompressedWifUnsupported), + "expected UncompressedWifUnsupported, got {err:?}" + ); + assert!(view.list().is_empty(), "no entry should be created"); + } + + /// F10 — round-trip: a compressed WIF import → persist → cold-boot + /// rebuild yields the SAME address the import derived. Locks the + /// no-divergence guarantee the uncompressed reject protects. + #[test] + fn f10_compressed_import_rebuild_preserves_address() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let imported = view.import_wif(known_wif(), None).expect("import"); + + // Simulate a fresh process: drop the in-memory index, rebuild. + index.write().unwrap().clear(); + let rebuilt = view.hydrate_wallets(); + assert_eq!(rebuilt.len(), 1); + assert_eq!( + rebuilt[0].1.address.to_string(), + imported.address, + "rebuilt address must match the import-time address" + ); + } + + /// In-memory `KvStore` adapter shared by the sidecar tests below. + /// The single-key sidecar is global-only, so the fake asserts every + /// call lands in [`ObjectId::Global`] and stores under a flat map. + #[derive(Default)] + struct InMemoryKv { + global: std::sync::Mutex<std::collections::BTreeMap<String, Vec<u8>>>, + } + + impl platform_wallet_storage::KvStore for InMemoryKv { + fn get( + &self, + scope: &platform_wallet_storage::ObjectId, + key: &str, + ) -> Result<Option<Vec<u8>>, platform_wallet_storage::KvError> { + assert_eq!( + scope, + &platform_wallet_storage::ObjectId::Global, + "single-key sidecar uses global scope" + ); + Ok(self.global.lock().unwrap().get(key).cloned()) + } + fn put( + &self, + scope: &platform_wallet_storage::ObjectId, + key: &str, + value: &[u8], + ) -> Result<(), platform_wallet_storage::KvError> { + assert_eq!( + scope, + &platform_wallet_storage::ObjectId::Global, + "single-key sidecar uses global scope" + ); + self.global + .lock() + .unwrap() + .insert(key.to_string(), value.to_vec()); + Ok(()) + } + fn delete( + &self, + scope: &platform_wallet_storage::ObjectId, + key: &str, + ) -> Result<(), platform_wallet_storage::KvError> { + assert_eq!( + scope, + &platform_wallet_storage::ObjectId::Global, + "single-key sidecar uses global scope" + ); + self.global.lock().unwrap().remove(key); + Ok(()) + } + fn list_keys( + &self, + scope: &platform_wallet_storage::ObjectId, + prefix: Option<&str>, + ) -> Result<Vec<String>, platform_wallet_storage::KvError> { + assert_eq!( + scope, + &platform_wallet_storage::ObjectId::Global, + "single-key sidecar uses global scope" + ); + let g = self.global.lock().unwrap(); + let it = g + .keys() + .filter(|k| prefix.is_none_or(|p| k.starts_with(p))) + .cloned(); + Ok(it.collect()) + } + } + + /// Test fixture bundling the moving parts a [`SingleKeyView`] needs + /// when wired against a fake `KvStore`. Returned as a struct to + /// keep the constructor tuple-light (clippy `type_complexity`). + struct ViewFixture { + store: Arc<SecretStore>, + index: std::sync::RwLock<std::collections::BTreeMap<String, ImportedKey>>, + kv: Arc<DetKv>, + network: Network, + } + + fn fresh_view_with_kv(dir: &std::path::Path, network: Network) -> ViewFixture { + let path = dir.join("secrets.pwsvault"); + let store = Arc::new(open_secret_store(&path).expect("open vault")); + let index = std::sync::RwLock::new(std::collections::BTreeMap::new()); + let kv = Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))); + ViewFixture { + store, + index, + kv, + network, + } + } + + /// TC-W-01b-A — `import_wif` writes a sidecar entry under the + /// canonical `<network>:single_key_meta:<addr>` key, listable by the + /// cross-network prefix. + #[test] + fn tc_w_01b_a_import_writes_sidecar_entry() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + let imported = view + .import_wif(known_wif(), Some("primary".into())) + .expect("import"); + let prefix = meta_prefix_for(network); + let keys = kv.list(DetScope::Global, Some(&prefix)).expect("list"); + assert_eq!(keys.len(), 1, "exactly one sidecar entry"); + assert_eq!(keys[0], meta_key_for(network, &imported.address)); + + let stored: ImportedKey = kv + .get(DetScope::Global, &keys[0]) + .expect("get") + .expect("entry present"); + assert_eq!(stored, imported); + } + + /// TC-W-01b-B — `forget` deletes the sidecar entry idempotently. A + /// re-call on an already-forgotten address remains `Ok(())` and does + /// not resurrect anything in the listing. + #[test] + fn tc_w_01b_b_forget_drops_sidecar_entry_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + let imported = view.import_wif(known_wif(), None).expect("import"); + view.forget(&imported.address).expect("forget"); + view.forget(&imported.address).expect("forget twice"); + + let prefix = meta_prefix_for(network); + let keys = kv.list(DetScope::Global, Some(&prefix)).expect("list"); + assert!(keys.is_empty(), "sidecar must be empty after forget"); + assert!(view.list().is_empty()); + } + + /// TC-W-01b-C — cold-boot hydration rebuilds a [`SingleKeyWallet`] + /// from `(sidecar, secret-store)` with the alias preserved, the + /// derived address matching the secret bytes, and the wallet opened + /// in-process (no per-wallet password — vault scope is the gate). + #[test] + fn tc_w_01b_c_hydrate_round_trip_rebuilds_wallet() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let imported = view + .import_wif(known_wif(), Some("savings".into())) + .expect("import"); + + // Drop the in-memory index to simulate a fresh process. + index.write().unwrap().clear(); + let rebuilt = view.hydrate_wallets(); + assert_eq!(rebuilt.len(), 1); + let (_, wallet) = &rebuilt[0]; + assert_eq!(wallet.address.to_string(), imported.address); + assert_eq!(wallet.alias.as_deref(), Some("savings")); + assert!(wallet.is_open(), "rehydrated wallet must be open"); + assert!( + !wallet.uses_password, + "vault-scoped (no per-wallet password)" + ); + assert_eq!(wallet.confirmed_balance, 0); + assert!(wallet.utxos.is_empty()); + + // Re-seeding the index from the sidecar is idempotent — the + // entry appears once and matches the original. + view.rehydrate_index().expect("rehydrate"); + let listed = view.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0], imported); + } + + /// TC-W-01b-D — a sidecar entry whose vault row is missing is + /// skipped (logged) so a single corrupt pair cannot block the + /// picker. The hydration vector still yields healthy entries. + #[test] + fn tc_w_01b_d_orphan_sidecar_entry_skipped() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + // Healthy entry. + view.import_wif(known_wif(), None).expect("import"); + + // Orphan sidecar entry — sidecar row written, no vault row. + let orphan = ImportedKey { + address: "yNotARealAddress".into(), + alias: Some("ghost".into()), + network, + has_passphrase: false, + passphrase_hint: None, + public_key_bytes: Vec::new(), + }; + kv.put( + DetScope::Global, + &meta_key_for(network, &orphan.address), + &orphan, + ) + .expect("put orphan"); + + let rebuilt = view.hydrate_wallets(); + assert_eq!( + rebuilt.len(), + 1, + "orphan must be skipped, healthy entry preserved" + ); + } + + /// Importing with a passphrase encrypts the in-vault + /// payload (so a vault dump does not yield the raw key) and the + /// sidecar records `has_passphrase = true` with the user's hint. A fresh + /// protected import seals Tier-2 at import time, so the vault row reads back + /// as [`SecretScheme::Protected`] (a password-free read fails). + /// + /// JIT model: there is no unlock cache to prime at import, so a direct + /// `sign_with` on the protected key returns the typed + /// `SingleKeyPassphraseRequired` — protected signing flows through the + /// chokepoint instead (see `sec_002_protected_sign_via_chokepoint`). + #[test] + fn sec_002_import_with_passphrase_encrypts_payload() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + let imported = view + .import_wif_with_passphrase( + known_wif(), + Some("secure".into()), + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(Zeroizing::new("correcthorsebattery".into())), + hint: Some("xkcd 936".into()), + }, + ) + .expect("import"); + assert!(imported.has_passphrase); + assert_eq!(imported.passphrase_hint.as_deref(), Some("xkcd 936")); + + // The vault row is sealed Tier-2 at import — a password-free read fails + // (NeedsPassword), so the at-rest value is never the plaintext key. + let label = label_for_address(&imported.address); + assert_eq!( + SecretSeam::new(&store) + .scheme(&single_key_namespace_id(), &label) + .expect("scheme"), + SecretScheme::Protected, + "a protected import must seal Tier-2 at import time", + ); + assert!( + store.get(&single_key_namespace_id(), &label).is_err(), + "a password-free read of a Tier-2 single key must fail", + ); + + // No cache prime: a direct view sign on the protected key reports + // that a passphrase is required (the chokepoint is the unlock path). + let err = view + .sign_with(&imported.address, &[0x42u8; 32]) + .expect_err("protected key has no cache to sign from"); + assert!(matches!(err, TaskError::SingleKeyPassphraseRequired { .. })); + } + + /// Regression for the cold-boot disappearance of a Tier-2-protected single + /// key: after the first unlock re-wraps the key to a Tier-2 object-password + /// envelope (keep-protection), the cold-boot rebuild must still list it + /// CLOSED instead of skipping it. Before the scheme-first branch, + /// `rebuild_wallet` did a plain `get`, which surfaced `NeedsPassword` and the + /// key vanished from the picker on every launch. + #[test] + fn tier2_protected_single_key_rebuilds_closed_and_is_listed() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + let passphrase = "correct-horse-battery-staple"; + let imported = view + .import_wif_with_passphrase( + known_wif(), + Some("savings".into()), + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(Zeroizing::new(passphrase.into())), + hint: Some("xkcd 936".into()), + }, + ) + .expect("import"); + let address = imported.address.clone(); + + // First unlock re-wraps the legacy AES-GCM entry to a Tier-2 envelope + // under the same password. + view.verify_passphrase(&address, passphrase) + .expect("verify + re-wrap"); + let label = label_for_address(&address); + assert_eq!( + SecretSeam::new(&store) + .scheme(&single_key_namespace_id(), &label) + .expect("scheme"), + SecretScheme::Protected, + "key must read back as Tier-2 protected without a password", + ); + + // Cold-boot rebuild returns Ok(Some(closed)) — never an Err that skips. + let rebuilt = view + .rebuild_display_wallet(&imported) + .expect("no error from a protected single key") + .expect("protected key must rebuild closed, not be skipped"); + assert!( + matches!(rebuilt.private_key_data, SingleKeyData::Closed(_)), + "a Tier-2 key must rebuild as a closed wallet", + ); + assert!(rebuilt.uses_password); + assert_eq!(rebuilt.address.to_string(), address); + + // And the full cold-boot enumeration lists it too. + let listed = view.hydrate_wallets(); + assert!( + listed.iter().any(|(_, w)| w.address.to_string() == address), + "the Tier-2 single key must appear in the cold-boot listing", + ); + } + + /// JIT-adapted protected sign — a protected imported key is signed through + /// the chokepoint. A direct view sign reports `SingleKeyPassphraseRequired`; + /// then `SecretAccess::with_secret` prompts, re-asks on a wrong passphrase, + /// decrypts just-in-time on the right one, and signs. The signature + /// verifies against the WIF-derived public key. + #[tokio::test] + async fn sec_002_protected_sign_via_chokepoint() { + use crate::wallet_backend::secret_access::SecretAccess; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let imported = view + .import_wif_with_passphrase( + known_wif(), + None, + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(Zeroizing::new("opensesame".into())), + hint: None, + }, + ) + .expect("import"); + + // Re-seed the index from the sidecar (cold-boot analogue): no + // plaintext is cached anywhere. + view.rehydrate_index().expect("rehydrate"); + + // Direct view sign on the protected key → typed "passphrase required". + let err = view + .sign_with(&imported.address, &[0u8; 32]) + .expect_err("locked sign must surface PassphraseRequired"); + match err { + TaskError::SingleKeyPassphraseRequired { addr } => { + assert_eq!(addr, imported.address); + } + other => panic!("expected SingleKeyPassphraseRequired, got {other:?}"), + } + + // Chokepoint path: one wrong passphrase (re-ask) then the right one. + let prompt = Arc::new(TestPrompt::new([ + ScriptedAnswer::once("wrong-one"), + ScriptedAnswer::once("opensesame"), + ])); + let sa = SecretAccess::new(Arc::clone(&store), prompt.clone(), network); + sa.set_single_key_index(index.read().unwrap().clone()); + let scope = SecretScope::SingleKey { + address: imported.address.clone(), + }; + let msg = [0u8; 32]; + let sig = sa + .with_secret(&scope, |pt| { + let bytes = pt + .expose_single_key() + .ok_or(TaskError::ImportedKeyNotFound)?; + sign_message_with_raw_key(bytes, &msg) + }) + .await + .expect("chokepoint signs after the right passphrase"); + + let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); + let secp = Secp256k1::new(); + let pk = priv_key.inner.public_key(&secp); + secp.verify_ecdsa(&Message::from_digest(msg), &sig, &pk) + .expect("chokepoint signature verifies"); + assert_eq!(prompt.ask_count(), 2, "one wrong + one right passphrase"); + } + + /// A passphrase shorter than the configured minimum is + /// rejected at import time with the typed + /// `SingleKeyPassphraseTooShort` variant; no vault write occurs. + #[test] + fn sec_002_short_passphrase_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let err = view + .import_wif_with_passphrase( + known_wif(), + None, + crate::wallet_backend::single_key::ImportPassphrase { + passphrase: Some(Zeroizing::new("short".into())), + hint: None, + }, + ) + .expect_err("short passphrase rejected"); + match err { + TaskError::SingleKeyPassphraseTooShort { min } => { + assert_eq!(min, super::MIN_SINGLE_KEY_PASSPHRASE_LEN as u32); + } + other => panic!("expected SingleKeyPassphraseTooShort, got {other:?}"), + } + assert!(view.list().is_empty(), "no entry should be created"); + } + + /// #192 — renaming an imported single key persists the new alias to + /// the modern sidecar (not the legacy DB), so the rename survives a + /// cold-boot rehydration. Mirrors the HD-wallet rename path. + #[test] + fn set_alias_persists_to_sidecar_and_survives_rehydrate() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + let imported = view + .import_wif(known_wif(), Some("old name".into())) + .expect("import"); + + view.set_alias(&imported.address, Some("new name".into())) + .expect("rename"); + + // In-memory index reflects the new alias immediately. + assert_eq!( + view.list()[0].alias.as_deref(), + Some("new name"), + "rename must update the in-memory index" + ); + + // Cold-boot analogue: drop the index and rehydrate from the + // sidecar — the persisted alias must be the new one. + index.write().unwrap().clear(); + view.rehydrate_index().expect("rehydrate"); + assert_eq!( + view.list()[0].alias.as_deref(), + Some("new name"), + "renamed alias must survive a cold-boot rehydration" + ); + } + + /// Renaming an address that was never imported surfaces the typed + /// `ImportedKeyNotFound` rather than silently creating a ghost entry. + #[test] + fn set_alias_unknown_address_is_typed_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let err = view + .set_alias("yNeverImported", Some("x".into())) + .expect_err("unknown address must fail"); + assert!(matches!(err, TaskError::ImportedKeyNotFound), "got {err:?}"); + } + + /// Legacy 32-byte raw vault payloads (pre per-key-passphrase) + /// still decode as `has_passphrase = false`, so a user who + /// upgrades from a previous tag never loses their imported keys. + #[test] + fn sec_002_legacy_raw_payload_still_signs() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + // Pretend a pre-per-key-passphrase install wrote a raw 32-byte + // payload under the canonical label, with a matching sidecar entry. + let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); + let pub_key = PublicKey { + compressed: priv_key.compressed, + inner: priv_key.inner.public_key(&Secp256k1::new()), + }; + let address = Address::p2pkh(&pub_key, network).to_string(); + let label = label_for_address(&address); + let raw = SecretBytes::from_slice(&priv_key.inner[..]); + store + .set(&single_key_namespace_id(), &label, &raw) + .expect("write legacy bytes"); + let meta = ImportedKey { + address: address.clone(), + alias: None, + network, + has_passphrase: false, + passphrase_hint: None, + public_key_bytes: Vec::new(), + }; + kv.put(DetScope::Global, &meta_key_for(network, &address), &meta) + .expect("seed sidecar"); + view.rehydrate_index().expect("rehydrate"); + + // No passphrase needed, signing works. + view.sign_with(&address, &[0x11u8; 32]) + .expect("legacy sign without passphrase"); + } + + /// TS-RT-02 / TS-EAGER-02 (import half) — an unprotected import writes the + /// RAW 32 bytes under the canonical label (no `SingleKeyEntry` framing), + /// the sidecar carries the public key for locked render, and the key signs. + #[test] + fn unprotected_import_writes_raw_32_bytes_not_framed() { + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + let imported = view + .import_wif(known_wif(), Some("raw".into())) + .expect("import"); + assert!(!imported.has_passphrase); + assert!( + !imported.public_key_bytes.is_empty(), + "sidecar carries the locked-render public key" + ); + + // Vault payload is exactly the raw 32 bytes — no version-tag framing. + let label = label_for_address(&imported.address); + let raw = store + .get(&single_key_namespace_id(), &label) + .expect("get") + .expect("present"); + assert_eq!( + raw.expose_secret().len(), + 32, + "raw, not a versioned envelope" + ); + let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); + assert_eq!(raw.expose_secret(), &priv_key.inner[..]); + + // Signs with no passphrase. + view.sign_with(&imported.address, &[0x42u8; 32]) + .expect("raw key signs"); + } + + /// Dual-format sidecar upgrade — an OLD `ImportedKey` sidecar blob written WITHOUT the + /// appended `public_key_bytes` (the pre-this-PR 5-field shape) is read back + /// through the view's dual-format fallback: it does NOT vanish from the + /// picker, its fields are preserved, and it is re-stored in the new shape. + #[test] + fn old_imported_key_blob_decodes_and_restores() { + use crate::model::single_key::ImportedKeyV1; + + let dir = tempfile::tempdir().expect("tempdir"); + let ViewFixture { + store, + index, + kv, + network, + } = fresh_view_with_kv(dir.path(), Network::Testnet); + let view = SingleKeyView { + secret_store: &store, + index: &index, + network, + app_kv: Some(&kv), + }; + + // Write the OLD 5-field shape directly, the way the base branch did. + let address = "yTestImportedAddr".to_string(); + let key = meta_key_for(network, &address); + let v1 = ImportedKeyV1 { + address: address.clone(), + alias: Some("legacy key".into()), + network, + has_passphrase: true, + passphrase_hint: Some("the usual".into()), + }; + kv.put(DetScope::Global, &key, &v1).expect("write old blob"); + + // The view lists it (dual-format fallback) — not skipped. + let listed = view.list_persisted(); + assert_eq!(listed.len(), 1, "old key must not vanish from the picker"); + let got = &listed[0]; + assert_eq!(got.address, address); + assert_eq!(got.alias.as_deref(), Some("legacy key")); + assert!(got.has_passphrase); + assert_eq!(got.passphrase_hint.as_deref(), Some("the usual")); + assert!( + got.public_key_bytes.is_empty(), + "no stored pubkey pre-migration" + ); + + // It was re-stored in the new shape: a direct new-shape decode succeeds. + let direct: Option<ImportedKey> = kv + .get(DetScope::Global, &key) + .expect("direct new-shape read"); + assert_eq!(direct.expect("present").address, address); + } +} diff --git a/src/wallet_backend/single_key_entry.rs b/src/wallet_backend/single_key_entry.rs new file mode 100644 index 000000000..280c0f0ac --- /dev/null +++ b/src/wallet_backend/single_key_entry.rs @@ -0,0 +1,339 @@ +//! Per-key passphrase envelope for imported single-key WIFs. +//! +//! The upstream `SecretStore` row at `single_key_priv.<addr>` used to +//! hold the raw 32-byte secret. With per-key passphrases, the row +//! instead holds a [`SingleKeyEntry`] payload framed as +//! `[version_byte || bincode-encoded SingleKeyEntry]`. The version byte +//! discriminates from the legacy 32-byte raw form: a raw legacy entry +//! is exactly 32 bytes; the framed form is always longer, so the +//! reader falls back to "treat as legacy raw key" when the input is +//! 32 bytes and otherwise decodes the version-tagged shape. +//! +//! When `has_passphrase` is `false`, `ciphertext` carries the raw 32 +//! private-key bytes verbatim (matches the legacy in-vault layout, +//! sandwiched in a struct so future per-entry metadata has a place to +//! land). Salt and nonce are empty. When `has_passphrase` is `true`, +//! `ciphertext` is the AES-GCM ciphertext produced by +//! [`crate::model::wallet::encryption::encrypt_message`] over the raw +//! 32 private-key bytes, with `salt` and `nonce` carrying the Argon2id +//! salt and the GCM nonce respectively. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::wallet_backend::versioned_bincode::{decode_tagged_or, encode_tagged}; + +/// Current on-disk version tag for [`SingleKeyEntry`]. +pub const SINGLE_KEY_ENTRY_VERSION: u8 = 1; + +/// Length of a raw (un-versioned) legacy entry — the bare 32 private +/// key bytes that pre-per-key-passphrase code wrote. +pub const LEGACY_RAW_KEY_LEN: usize = 32; + +/// On-disk shape of a single imported private key. See module docs. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SingleKeyEntry { + /// `true` iff `ciphertext` is the AES-GCM encryption of the raw 32 + /// private-key bytes under a user-supplied passphrase. `false` + /// means `ciphertext` holds the raw 32 bytes verbatim. + pub has_passphrase: bool, + /// Argon2id salt used by the AES-GCM key derivation. Empty when + /// `has_passphrase = false`. + pub salt: Vec<u8>, + /// GCM nonce used during encryption. Empty when + /// `has_passphrase = false`. + pub nonce: Vec<u8>, + /// Either the raw 32 private-key bytes (no passphrase) or the + /// AES-GCM ciphertext over the same bytes (passphrase). + pub ciphertext: Vec<u8>, + /// Optional user-supplied hint shown next to the passphrase + /// prompt. Stored next to the row so a re-launched app can still + /// surface it without keeping any in-memory state. + pub passphrase_hint: Option<String>, + /// Compressed SEC1-encoded public key derived from the underlying + /// private key. Stored so the cold-boot picker can rebuild the + /// passphrase-locked entry's `SingleKeyWallet` without unlocking + /// it. Empty for legacy entries that pre-date this field; the + /// caller falls back to deriving from plaintext when unlocked. + #[serde(default)] + pub public_key_bytes: Vec<u8>, +} + +impl std::fmt::Debug for SingleKeyEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SingleKeyEntry") + .field("has_passphrase", &self.has_passphrase) + .field("ciphertext", &"[redacted]") + .field("salt_len", &self.salt.len()) + .field("nonce_len", &self.nonce.len()) + .field("passphrase_hint", &self.passphrase_hint) + .field("public_key_bytes_len", &self.public_key_bytes.len()) + .finish() + } +} + +impl SingleKeyEntry { + /// Build a passphrase-less entry that just holds the raw 32 key + /// bytes. Mirrors the legacy in-vault shape but inside the + /// versioned struct so all entries (with or without passphrase) + /// share the same code path. + pub fn unprotected(raw_key: [u8; 32]) -> Self { + Self { + has_passphrase: false, + salt: Vec::new(), + nonce: Vec::new(), + ciphertext: raw_key.to_vec(), + passphrase_hint: None, + public_key_bytes: Vec::new(), + } + } + + /// Build a passphrase-protected entry over `raw_key` with + /// `passphrase`. Uses the same AES-GCM + Argon2id helper the HD + /// wallet seed-password path uses, so a single dependency floor + /// covers both code paths. `public_key_bytes` carries the + /// compressed SEC1 pubkey so the cold-boot picker can rebuild the + /// locked entry's wallet without unlocking it. + pub fn protected( + raw_key: &[u8; 32], + passphrase: &str, + hint: Option<String>, + public_key_bytes: Vec<u8>, + ) -> Result<Self, TaskError> { + let envelope = crate::model::wallet::encryption::encrypt_message(raw_key, passphrase) + .map_err(|detail| { + tracing::warn!( + target = "wallet_backend::single_key_entry", + ?detail, + "Failed to encrypt single-key entry with user passphrase", + ); + TaskError::SingleKeyCryptoFailure + })?; + Ok(Self { + has_passphrase: true, + salt: envelope.salt, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext, + passphrase_hint: hint, + public_key_bytes, + }) + } + + /// Recover the raw 32 private-key bytes. For passphrase-protected + /// entries the caller must supply the passphrase; for unprotected + /// entries it is ignored. + /// + /// Returned wrapped in [`Zeroizing`]: the key bytes zeroize when + /// the caller drops the binding, so a copy never lingers on the stack after + /// crossing this boundary. + pub fn decrypt(&self, passphrase: Option<&str>) -> Result<Zeroizing<[u8; 32]>, TaskError> { + if !self.has_passphrase { + let raw: [u8; 32] = self.ciphertext.as_slice().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::single_key_entry", + blob_len = self.ciphertext.len(), + "Unprotected single-key entry has wrong raw-key length", + ); + TaskError::SingleKeyCryptoFailure + })?; + return Ok(Zeroizing::new(raw)); + } + let passphrase = match passphrase { + Some(p) if !p.is_empty() => p, + _ => { + // Caller should have routed via SingleKeyPassphraseRequired; + // surfacing an Incorrect here is the safer default since + // we cannot proceed. + return Err(TaskError::SingleKeyPassphraseIncorrect); + } + }; + let plaintext = crate::model::wallet::encryption::decrypt_message( + &self.ciphertext, + &self.salt, + &self.nonce, + passphrase, + "single_key_entry::decrypt", + ) + .map_err(|e| match e { + crate::model::wallet::encryption::DecryptError::WrongPassword => { + TaskError::SingleKeyPassphraseIncorrect + } + crate::model::wallet::encryption::DecryptError::Malformed => { + TaskError::SingleKeyCryptoFailure + } + })?; + let raw: [u8; 32] = plaintext.as_slice().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::single_key_entry", + blob_len = plaintext.len(), + "Decrypted single-key entry is not 32 bytes", + ); + TaskError::SingleKeyCryptoFailure + })?; + Ok(Zeroizing::new(raw)) + } + + /// Encode for the upstream vault: `[version || bincode(self)]`. + pub fn encode(&self) -> Result<Vec<u8>, TaskError> { + encode_tagged(SINGLE_KEY_ENTRY_VERSION, self).map_err(|detail| { + tracing::warn!( + target = "wallet_backend::single_key_entry", + error = %detail, + "bincode encode failed for single-key entry", + ); + TaskError::SingleKeyCryptoFailure + }) + } + + /// Decode from the upstream vault. Accepts: + /// * exactly 32 bytes — a legacy raw-private-key entry + /// (treated as `has_passphrase = false`), + /// * leading `SINGLE_KEY_ENTRY_VERSION` byte + bincode body — + /// the current shape. + pub fn decode(bytes: &[u8]) -> Result<Self, TaskError> { + if bytes.len() == LEGACY_RAW_KEY_LEN { + let mut raw = [0u8; LEGACY_RAW_KEY_LEN]; + raw.copy_from_slice(bytes); + return Ok(Self::unprotected(raw)); + } + // Not a 32-byte legacy raw key: it must be the version-tagged shape. + // Anything else (empty, unknown tag, or corrupt body) is a decode + // failure — the fallback here has no further legacy form to try. + decode_tagged_or(bytes, SINGLE_KEY_ENTRY_VERSION, |bytes| { + if let Some((&tag, _)) = bytes.split_first() + && tag != SINGLE_KEY_ENTRY_VERSION + { + tracing::warn!( + target = "wallet_backend::single_key_entry", + ?tag, + "Unknown single-key entry version tag", + ); + } + Err(TaskError::SingleKeyCryptoFailure) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unprotected_round_trip_preserves_key_bytes() { + let raw = [0x77u8; 32]; + let entry = SingleKeyEntry::unprotected(raw); + let bytes = entry.encode().expect("encode"); + let decoded = SingleKeyEntry::decode(&bytes).expect("decode"); + assert!(!decoded.has_passphrase); + assert_eq!(*decoded.decrypt(None).expect("plaintext"), raw); + } + + #[test] + fn protected_round_trip_requires_correct_passphrase() { + let raw = [0x11u8; 32]; + let entry = SingleKeyEntry::protected(&raw, "correct horse battery", None, Vec::new()) + .expect("encrypt"); + let bytes = entry.encode().expect("encode"); + let decoded = SingleKeyEntry::decode(&bytes).expect("decode"); + assert!(decoded.has_passphrase); + assert_eq!( + *decoded + .decrypt(Some("correct horse battery")) + .expect("plaintext"), + raw + ); + // Wrong passphrase surfaces the typed Incorrect variant. + match decoded.decrypt(Some("wrong")) { + Err(TaskError::SingleKeyPassphraseIncorrect) => {} + other => panic!("expected SingleKeyPassphraseIncorrect, got {other:?}"), + } + // Missing passphrase also surfaces Incorrect (caller is + // expected to route via SingleKeyPassphraseRequired before this). + match decoded.decrypt(None) { + Err(TaskError::SingleKeyPassphraseIncorrect) => {} + other => panic!("expected SingleKeyPassphraseIncorrect, got {other:?}"), + } + } + + #[test] + fn legacy_32_byte_blob_decodes_as_unprotected() { + let raw = [0xABu8; 32]; + let decoded = SingleKeyEntry::decode(&raw).expect("decode legacy"); + assert!(!decoded.has_passphrase); + assert!(decoded.salt.is_empty()); + assert!(decoded.nonce.is_empty()); + assert_eq!(decoded.ciphertext, raw.to_vec()); + } + + #[test] + fn debug_output_does_not_expose_private_bytes() { + let raw = [0x42u8; 32]; + let entry = SingleKeyEntry::unprotected(raw); + let dbg = format!("{entry:?}"); + // The raw key byte 0x42 must not appear verbatim in the debug string. + assert!( + !dbg.contains("42, 42, 42"), + "debug output leaked private bytes: {dbg}" + ); + assert!( + dbg.contains("[redacted]"), + "expected [redacted] in debug output: {dbg}" + ); + } + + #[test] + fn wrong_length_nonce_returns_typed_error_not_panic() { + // A corrupt at-rest entry whose nonce is not 12 bytes must surface a + // typed error rather than panic inside `Nonce::from_slice` (which + // would poison the long-lived secret-store mutex). + let entry = SingleKeyEntry { + has_passphrase: true, + salt: vec![0u8; 16], + nonce: vec![0u8; 7], // wrong length on purpose + ciphertext: vec![0u8; 48], + passphrase_hint: None, + public_key_bytes: Vec::new(), + }; + match entry.decrypt(Some("any-passphrase")) { + Err(TaskError::SingleKeyCryptoFailure) => {} + other => panic!("expected SingleKeyCryptoFailure, got {other:?}"), + } + } + + #[test] + fn wrong_length_unprotected_blob_returns_typed_error_not_panic() { + // An unprotected entry whose raw blob is not 32 bytes must also be a + // typed error, not a panic. + let entry = SingleKeyEntry { + has_passphrase: false, + salt: Vec::new(), + nonce: Vec::new(), + ciphertext: vec![0u8; 31], // one byte short + passphrase_hint: None, + public_key_bytes: Vec::new(), + }; + match entry.decrypt(None) { + Err(TaskError::SingleKeyCryptoFailure) => {} + other => panic!("expected SingleKeyCryptoFailure, got {other:?}"), + } + } + + #[test] + fn passphrase_hint_round_trips() { + let raw = [0x22u8; 32]; + let entry = SingleKeyEntry::protected( + &raw, + "pp", + Some("nickname of childhood pet".into()), + Vec::new(), + ) + .expect("encrypt"); + let bytes = entry.encode().expect("encode"); + let decoded = SingleKeyEntry::decode(&bytes).expect("decode"); + assert_eq!( + decoded.passphrase_hint.as_deref(), + Some("nickname of childhood pet") + ); + } +} diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs new file mode 100644 index 000000000..2cf45be2b --- /dev/null +++ b/src/wallet_backend/snapshot.rs @@ -0,0 +1,1316 @@ +//! Display-only wallet snapshot model. +//! +//! `WalletSnapshot` drives the wallets screen (balance, transaction list, +//! UTXO list) without ever blocking the egui frame thread. It is fed by the +//! [`EventBridge`](super::EventBridge) off upstream `platform-wallet` events +//! and read synchronously, lock-free, infallibly by the UI via +//! [`AppContext::wallet_backend()`](crate::context::AppContext::wallet_backend). +//! +//! # FUND-SAFETY MANDATE — DISPLAY-ONLY +//! +//! The snapshot exists ONLY to render the UI. Coin selection and transaction +//! construction MUST go through +//! [`WalletBackend::send_payment`](super::WalletBackend::send_payment) / +//! [`WalletBackend::create_asset_lock_proof`](super::WalletBackend::create_asset_lock_proof), +//! which use the upstream-authoritative live UTXO set at send time. No code +//! path may select spendable inputs from a `WalletSnapshot` +//! (`backend-architecture.md` §A04 reviewer gate). +//! +//! # Why transactions are accumulated, not recomputed +//! +//! DET does not enable upstream's `keep-finalized-transactions` Cargo feature, +//! so once a transaction is chain-locked upstream drops its record from the +//! in-memory wallet. The full history is therefore *event-sourced*: +//! `WalletEvent::{TransactionDetected, BlockProcessed}` carry the records and +//! the upstream persister stores them durably. The snapshot store accumulates +//! these records (the surviving piece of the deleted `reconcile_spv_wallets` +//! `TransactionRecord` → `WalletTransaction` mapping). Balance and UTXOs, by +//! contrast, are read straight off the live wallet — and always from the *same* +//! read, so the headline balance and the per-address breakdown a snapshot +//! publishes are one consistent generation (see [`SnapshotStore::recompute`]). + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +use arc_swap::ArcSwap; +use dash_sdk::dpp::dashcore::{Address, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ + OutputRole, TransactionRecord, +}; +use dash_sdk::dpp::key_wallet::transaction_checking::TransactionContext; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::PlatformWallet; + +use crate::model::dashpay::DetectedIncomingOutput; +use crate::model::wallet::{TransactionStatus, WalletSeedHash, WalletTransaction}; + +/// Upstream `WalletId` (`SHA256(root_xpub || root_chain_code)`), distinct from +/// DET's `WalletSeedHash`. Mirrors the alias in [`super`]. +type WalletId = [u8; 32]; + +/// Confirmed / unconfirmed / total balance in duffs. DET-shaped — no upstream +/// `WalletBalance` / `WalletCoreBalance` crosses the seam +/// (rust-best-practices M-DONT-LEAK-TYPES). +/// +/// `total` is the headline figure and counts immature coinbase and locked +/// (CoinJoin) funds that coin selection cannot touch. `spendable()` is the +/// subset the upstream `CoinSelector` actually draws from. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DetWalletBalance { + pub confirmed: u64, + pub unconfirmed: u64, + pub total: u64, +} + +impl DetWalletBalance { + /// Funds coin selection can spend right now: confirmed plus unconfirmed. + /// Excludes the immature and locked duffs that `total` counts but the + /// upstream `CoinSelector` rejects. Reserve a "Max" send against this, not + /// `total`, or the send over-shoots the selectable set and fails with + /// insufficient funds. + pub fn spendable(&self) -> u64 { + self.confirmed.saturating_add(self.unconfirmed) + } +} + +/// One unspent output. DET-shaped — no upstream `Utxo` crosses the seam. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetUtxo { + pub outpoint: OutPoint, + pub value: u64, + pub script_pubkey: ScriptBuf, + pub address: Address, +} + +/// Per-wallet display snapshot. Cheap to clone-share via the enclosing `Arc`. +#[derive(Debug, Clone, Default)] +pub struct WalletSnapshot { + pub balance: DetWalletBalance, + pub transactions: Vec<WalletTransaction>, + pub utxos: Vec<DetUtxo>, + /// UTXO-derived per-address balances, summed across this wallet's UTXOs. + /// Feeds the account-summary view. + pub address_balances: BTreeMap<Address, u64>, + /// The BIP-44 external (receive) addresses SPV actually watches, as strings. + /// The Receive flow renders this set, so it can only ever show, copy, or QR + /// an address inside the watched gap-limit window — never a legacy DET-side + /// index past it. Lock-free read on the UI hot path. + pub monitored_receive_addresses: Vec<String>, + /// Authoritative derivation path for every address the upstream wallet has + /// generated, across all accounts and pools. Lets the account-summary view + /// categorize funded addresses that DET's own `watched_addresses` + /// bookkeeping has not indexed yet, so no funded address is dropped from the + /// per-category tab totals. + pub address_paths: BTreeMap<Address, DerivationPath>, +} + +/// Chain-derived parts of a published snapshot — everything except the +/// event-sourced transaction history, which [`SnapshotStore::publish`] +/// assembles from the tx log. Bundling these keeps `publish` under the +/// argument limit and makes the carry-forward-on-contention path explicit. +struct SnapshotState { + balance: DetWalletBalance, + utxos: Vec<DetUtxo>, + address_balances: BTreeMap<Address, u64>, + monitored_receive_addresses: Vec<String>, + address_paths: BTreeMap<Address, DerivationPath>, +} + +/// Map a finalized-or-pending upstream `TransactionContext` to DET's richer +/// `TransactionStatus`. Upstream now distinguishes InstantSend and chain-lock, +/// so this supersedes the old height-only `from_height` heuristic. +fn status_from_context(context: &TransactionContext) -> TransactionStatus { + match context { + TransactionContext::Mempool => TransactionStatus::Unconfirmed, + TransactionContext::InstantSend(_) => TransactionStatus::InstantSendLocked, + TransactionContext::InBlock(_) => TransactionStatus::Confirmed, + TransactionContext::InChainLockedBlock(_) => TransactionStatus::ChainLocked, + } +} + +/// The single `TransactionRecord` → `WalletTransaction` mapping. Relocated +/// verbatim-in-spirit from the deleted `reconcile_spv_wallets`; this is the +/// only place that conversion lives now (`backend-architecture.md`). +pub(super) fn map_transaction_record(record: &TransactionRecord) -> WalletTransaction { + let block_info = record.block_info(); + WalletTransaction { + txid: record.txid, + transaction: record.transaction.clone(), + timestamp: block_info.map(|bi| bi.timestamp() as u64).unwrap_or(0), + height: record.height(), + block_hash: block_info.map(|bi| bi.block_hash()), + net_amount: record.net_amount, + fee: record.fee, + label: Some(record.label.clone()).filter(|s| !s.is_empty()), + // Per-wallet history — every record involves our addresses. + is_ours: true, + status: status_from_context(&record.context), + } +} + +/// The `(transaction, [(outpoint, txout, address)])` payload the asset-lock and +/// identity-funding screens wait on, matching the +/// `CoreItem::ReceivedAvailableUTXOTransaction` contract. +pub(super) type ReceivedUtxoTransaction = (Transaction, Vec<(OutPoint, TxOut, Address)>); + +/// Extract the wallet-owned outputs of a freshly-seen transaction as the +/// payload the asset-lock and identity-funding screens wait on +/// (`CoreItem::ReceivedAvailableUTXOTransaction`). +/// +/// Only outputs that pay into this wallet (`OutputRole::Received` or +/// `OutputRole::Change`) with a decodable address are included — these are the +/// funding UTXOs a waiting screen matches against its QR funding address. +/// `Sent`/`Unspendable` outputs and address-less scripts are skipped. +/// +/// Returns `None` when the transaction has no wallet-owned outputs (e.g. a +/// pure outgoing payment), so the bridge emits the event only when there is a +/// received UTXO for a screen to advance on. +pub(super) fn received_outputs_for_record( + record: &TransactionRecord, +) -> Option<ReceivedUtxoTransaction> { + let tx = &record.transaction; + let mut owned = Vec::new(); + for out in &record.output_details { + if !matches!(out.role, OutputRole::Received | OutputRole::Change) { + continue; + } + let Some(address) = out.address.clone() else { + continue; + }; + let Some(txout) = tx.output.get(out.index as usize) else { + continue; + }; + let outpoint = OutPoint { + txid: record.txid, + vout: out.index, + }; + owned.push((outpoint, txout.clone(), address)); + } + + if owned.is_empty() { + None + } else { + Some((tx.clone(), owned)) + } +} + +/// Extract every received output of a freshly-seen transaction as a +/// [`DetectedIncomingOutput`] candidate for incoming DashPay +/// contact-payment detection. +/// +/// Unlike [`received_outputs_for_record`], which exists to advance funding +/// screens, this carries the `(txid, address, value)` the detector needs to +/// resolve `address → (contact, index)` and record the payment. Only +/// `OutputRole::Received` outputs with a decodable address are candidates — +/// `Change` is excluded because contact payments always land on a freshly +/// derived receiving address, never on our own change. The detector applies +/// the authoritative DashPay-address match downstream; this is the cheap +/// pre-filter that keeps the event hot path free of any owner/KV lookup. +pub(super) fn incoming_payment_candidates( + record: &TransactionRecord, +) -> Vec<DetectedIncomingOutput> { + let txid = record.txid.to_string(); + record + .output_details + .iter() + .filter(|out| matches!(out.role, OutputRole::Received)) + .filter_map(|out| { + out.address.as_ref().map(|address| DetectedIncomingOutput { + txid: txid.clone(), + vout: out.index, + address: address.to_string(), + amount_duffs: out.value, + }) + }) + .collect() +} + +/// The wallet's BIP-44 external (receive) addresses — the SPV-watched gap-limit +/// window — read from the managed wallet info. +/// +/// Reads the standard BIP-44 account's external pool the same way the upstream +/// `account_address_pools_blocking` accessor does. The caller derefs the +/// non-blocking `try_state()` guard to its `core_wallet`, so the event callback +/// never blocks. +fn external_addresses_from_info( + info: &dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, +) -> Vec<String> { + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + + let standard = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + info.accounts + .all_accounts() + .into_iter() + .find(|a| a.managed_account_type().to_account_type() == standard) + .map(|account| { + account + .managed_account_type() + .address_pools() + .into_iter() + .filter(|pool| pool.is_external()) + .flat_map(|pool| pool.all_addresses()) + .map(|addr| addr.to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// The derivation path of every address the wallet has generated, across all +/// accounts and every pool (external and internal). +/// +/// Read straight off each pool's [`AddressInfo`](dash_sdk::dpp::key_wallet::managed_account::address_pool::AddressInfo) +/// (`address → path`), which the account-summary view uses to categorize funded +/// addresses DET's own `watched_addresses` bookkeeping has not indexed yet. +fn address_paths_from_info( + info: &dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, +) -> BTreeMap<Address, DerivationPath> { + let mut paths = BTreeMap::new(); + for account in info.accounts.all_accounts() { + for pool in account.managed_account_type().address_pools() { + for entry in pool.addresses.values() { + paths.insert(entry.address.clone(), entry.path.clone()); + } + } + } + paths +} + +/// Build the carry-forward chain state used when `try_state()` is contended: +/// the **entire** prior snapshot — balance included — so the published snapshot +/// stays internally consistent (header total and per-address breakdown from one +/// generation). Refreshing balance alone here while carrying the breakdown +/// stale is exactly the split-source divergence [`SnapshotStore::recompute`] +/// avoids. +fn carried_forward_state(prior: &WalletSnapshot) -> SnapshotState { + SnapshotState { + balance: prior.balance, + utxos: prior.utxos.clone(), + address_balances: prior.address_balances.clone(), + monitored_receive_addresses: prior.monitored_receive_addresses.clone(), + address_paths: prior.address_paths.clone(), + } +} + +/// Shared store of per-wallet display snapshots plus the event-sourced +/// transaction accumulator. Held by both [`WalletBackend`](super::WalletBackend) +/// (for the read accessors) and the [`EventBridge`](super::EventBridge) (for +/// the recompute-on-event push). +/// +/// Wallet handles are wired in at registration (after construction — the +/// bridge that this store backs is built first), so a pre-registration read +/// yields an empty snapshot, which the UI renders as the existing "syncing" +/// state, not a zero-balance bug. +pub(super) struct SnapshotStore { + /// DET-keyed published snapshots. Lock-free read on the UI hot path. + snapshots: ArcSwap<HashMap<WalletSeedHash, Arc<WalletSnapshot>>>, + /// Event-sourced transaction history, keyed by upstream `WalletId` then + /// `Txid`. A `BTreeMap` per wallet so re-seen records (mempool → block → + /// chainlock) upsert in place and iteration is deterministic. + tx_log: Mutex<HashMap<WalletId, BTreeMap<Txid, WalletTransaction>>>, + /// Per-wallet registration: upstream `WalletId` → (DET `WalletSeedHash`, + /// cheap shared `PlatformWallet` handle). The handle gives lock-free + /// balance (`balance()`) and non-blocking UTXO (`try_state()`) reads, so + /// the event callback never blocks and never touches an async lock. + registered: Mutex<HashMap<WalletId, RegisteredWallet>>, +} + +struct RegisteredWallet { + seed_hash: WalletSeedHash, + wallet: Arc<PlatformWallet>, +} + +impl std::fmt::Debug for SnapshotStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SnapshotStore").finish_non_exhaustive() + } +} + +impl SnapshotStore { + pub(super) fn new() -> Self { + Self { + snapshots: ArcSwap::from_pointee(HashMap::new()), + tx_log: Mutex::new(HashMap::new()), + registered: Mutex::new(HashMap::new()), + } + } + + /// Record a wallet's `WalletId` ⇄ (`WalletSeedHash`, handle) association + /// at registration so events (keyed by `WalletId`) can recompute the + /// DET-keyed snapshot off the lock-free balance + non-blocking UTXO read. + pub(super) fn register_wallet( + &self, + seed_hash: WalletSeedHash, + wallet_id: WalletId, + wallet: Arc<PlatformWallet>, + ) { + if let Ok(mut map) = self.registered.lock() { + map.insert(wallet_id, RegisteredWallet { seed_hash, wallet }); + } + } + + /// Drop every trace of a forgotten wallet: its published snapshot, its + /// `WalletId`-keyed registration, and its event-sourced transaction log. + /// Without this a removed wallet's balance and history keep being read and + /// re-published on the next `EventBridge` recompute. + pub(super) fn forget_wallet(&self, seed_hash: &WalletSeedHash, wallet_id: &WalletId) { + self.snapshots.rcu(|current| { + let mut next = HashMap::clone(current); + next.remove(seed_hash); + next + }); + if let Ok(mut map) = self.registered.lock() { + map.remove(wallet_id); + } + if let Ok(mut log) = self.tx_log.lock() { + log.remove(wallet_id); + } + } + + /// Resolve an upstream `WalletId` to DET's `WalletSeedHash`, if the wallet + /// is registered. The shielded sync-completed event is keyed by `WalletId` + /// (network-scoped, upstream-native), but DET's balance snapshot is keyed by + /// `WalletSeedHash`, so the `EventBridge` maps through this registry. + pub(super) fn seed_hash_for(&self, wallet_id: &WalletId) -> Option<WalletSeedHash> { + self.registered + .lock() + .ok() + .and_then(|map| map.get(wallet_id).map(|r| r.seed_hash)) + } + + /// Read a wallet's published snapshot. Lock-free, infallible. An absent + /// entry (pre-first-sync) yields the default empty snapshot, which the UI + /// renders as "syncing". + pub(super) fn snapshot(&self, seed_hash: &WalletSeedHash) -> Arc<WalletSnapshot> { + self.snapshots + .load() + .get(seed_hash) + .cloned() + .unwrap_or_default() + } + + /// Whether a snapshot has been published for the wallet yet. `false` + /// before the first `EventBridge` recompute ⇒ the UI shows "syncing". + pub(super) fn has_snapshot(&self, seed_hash: &WalletSeedHash) -> bool { + self.snapshots.load().contains_key(seed_hash) + } + + /// Merge freshly-seen transaction records into the per-wallet log. + /// Upsert-keyed by `Txid` so a record re-seen at a higher confirmation + /// tier replaces the lower one. + pub(super) fn accumulate_transactions<'a, I>(&self, wallet_id: &WalletId, records: I) + where + I: IntoIterator<Item = &'a TransactionRecord>, + { + let Ok(mut log) = self.tx_log.lock() else { + return; + }; + let per_wallet = log.entry(*wallet_id).or_default(); + for record in records { + per_wallet.insert(record.txid, map_transaction_record(record)); + } + } + + /// Upgrade a previously-accumulated record's status to + /// `InstantSendLocked`. + /// + /// `WalletEvent::TransactionInstantLocked` carries only a `txid`, not a + /// full `TransactionRecord` (upstream has already recorded the tx + /// off-chain via `TransactionDetected`), so this mutates the tracked + /// entry in place rather than going through + /// [`Self::accumulate_transactions`]. A no-op if the txid isn't tracked + /// yet, or if it already carries a stronger status — a block + /// confirmation may race the InstantLock notification, and this must + /// never regress `Confirmed`/`ChainLocked` back to `InstantSendLocked`. + pub(super) fn mark_instant_locked(&self, wallet_id: &WalletId, txid: Txid) { + let Ok(mut log) = self.tx_log.lock() else { + return; + }; + if let Some(tx) = log.get_mut(wallet_id).and_then(|m| m.get_mut(&txid)) + && tx.status < TransactionStatus::InstantSendLocked + { + tx.status = TransactionStatus::InstantSendLocked; + } + } + + /// Read a single tracked record's current status. Test-only seam for + /// asserting the `EventBridge` → `SnapshotStore` upgrade path without a + /// full registered-wallet publish. + #[cfg(test)] + pub(super) fn transaction_status( + &self, + wallet_id: &WalletId, + txid: &Txid, + ) -> Option<TransactionStatus> { + self.tx_log.lock().ok().and_then(|log| { + log.get(wallet_id) + .and_then(|m| m.get(txid)) + .map(|tx| tx.status) + }) + } + + /// Recompute and atomically publish the snapshot for one wallet, off the + /// non-blocking UTXO state plus the event-sourced tx log. Called by the + /// `EventBridge` after it has accumulated the event's records. + /// + /// Never blocks and never awaits: `try_state()` is a non-blocking try-lock + /// that yields `None` under contention, in which case the *entire* prior + /// snapshot is carried forward and a subsequent event recomputes once the + /// lock is free. + /// + /// # Balance / breakdown consistency + /// + /// The headline balance and the UTXO-derived per-address breakdown are read + /// from the **same** `try_state()` guard — `state.balance()` (the stored + /// [`WalletCoreBalance`], refreshed alongside every UTXO mutation under the + /// wallet-manager write lock) and `state.utxos()` reflect one and the same + /// generation of wallet state. So the wallet-header total + /// ([`WalletSnapshot::balance`]`.total`) and the per-account tab breakdown + /// (summed from [`WalletSnapshot::address_balances`]) are always computed + /// from the same underlying data — a fresh balance is never spliced onto a + /// stale breakdown. This deliberately does *not* read the lock-free + /// `wallet.balance()` atomics: those are maintained by a separate event + /// handler that can lag the state under its own map contention, which is + /// exactly the split-source divergence this method avoids. On contention we + /// carry the whole prior (already-consistent) snapshot forward rather than + /// refreshing balance alone. + pub(super) fn recompute(&self, wallet_id: &WalletId) { + let (seed_hash, wallet) = { + let Ok(map) = self.registered.lock() else { + return; + }; + match map.get(wallet_id) { + Some(r) => (r.seed_hash, Arc::clone(&r.wallet)), + // Event for a wallet not registered DET-side — its snapshot + // is built once registration records the handle and a later + // event fires. + None => return, + } + }; + + // Non-blocking read. Balance and UTXO breakdown come from the same + // guard (see the consistency note above). On contention, carry the + // entire prior snapshot forward — balance included — so every field of + // the published snapshot reflects one consistency point. + let state = match wallet.try_state() { + Some(state) => { + let core_balance = state.balance(); + let balance = DetWalletBalance { + confirmed: core_balance.confirmed(), + unconfirmed: core_balance.unconfirmed(), + total: core_balance.total(), + }; + let mut utxos = Vec::new(); + let mut address_balances: BTreeMap<Address, u64> = BTreeMap::new(); + for u in state.utxos() { + *address_balances.entry(u.address.clone()).or_insert(0) += u.txout.value; + utxos.push(DetUtxo { + outpoint: u.outpoint, + value: u.txout.value, + script_pubkey: u.txout.script_pubkey.clone(), + address: u.address.clone(), + }); + } + SnapshotState { + balance, + utxos, + address_balances, + monitored_receive_addresses: external_addresses_from_info(&state.core_wallet), + address_paths: address_paths_from_info(&state.core_wallet), + } + } + // Accepted, self-healing edge case: if this is the very first + // recompute for the wallet and it loses the try-lock, there is no + // prior snapshot, so `snapshot()` returns the all-zero default and + // `publish` still marks the wallet as having a snapshot. A wallet + // with genuine prior funds (app restart on an existing seed) can + // then render as "0 DASH, synced" for a single event cycle instead + // of "syncing". The next event wins the lock and publishes the real + // balance, correcting it. + None => carried_forward_state(&self.snapshot(&seed_hash)), + }; + + self.publish(&seed_hash, wallet_id, state); + } + + /// Assemble the event-sourced tx history with the freshly-read + /// balance/UTXO state and atomically publish the snapshot. Split from + /// [`Self::recompute`] so the publish + tx-log assembly is unit-testable + /// without a live `PlatformWallet`. + fn publish(&self, seed_hash: &WalletSeedHash, wallet_id: &WalletId, state: SnapshotState) { + let transactions: Vec<WalletTransaction> = self + .tx_log + .lock() + .ok() + .and_then(|log| log.get(wallet_id).map(|m| m.values().cloned().collect())) + .unwrap_or_default(); + + let snapshot = Arc::new(WalletSnapshot { + balance: state.balance, + transactions, + utxos: state.utxos, + address_balances: state.address_balances, + monitored_receive_addresses: state.monitored_receive_addresses, + address_paths: state.address_paths, + }); + + self.snapshots.rcu(|current| { + let mut next = (**current).clone(); + next.insert(*seed_hash, snapshot.clone()); + next + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::dashcore::hashes::Hash; + use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dpp::dashcore::{BlockHash, Network, PublicKey, Transaction, TxOut}; + use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; + use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ + OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use dash_sdk::dpp::key_wallet::transaction_checking::BlockInfo; + use dash_sdk::dpp::key_wallet::transaction_checking::transaction_router::TransactionType; + + fn seed(n: u8) -> WalletSeedHash { + [n; 32] + } + fn wid(n: u8) -> WalletId { + [n; 32] + } + + /// A transaction whose txid is distinct per `n` (the lock_time perturbs + /// the hash) so re-`new`'d records key apart in the log. + fn tx_with(n: u8) -> Transaction { + Transaction { + version: 1, + lock_time: n as u32, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + fn record(n: u8, net: i64) -> TransactionRecord { + TransactionRecord::new( + tx_with(n), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + net, + ) + } + + /// Publish a snapshot directly off the tx-log via the same seam + /// `recompute` uses for its final step — exercises tx accumulation + + /// publish without needing a live `PlatformWallet`. + fn publish_tx_only(store: &SnapshotStore, seed: WalletSeedHash, wid: WalletId) { + store.publish( + &seed, + &wid, + SnapshotState { + balance: DetWalletBalance::default(), + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: Vec::new(), + address_paths: BTreeMap::new(), + }, + ); + } + + #[test] + fn empty_store_yields_default_snapshot() { + let store = SnapshotStore::new(); + let snap = store.snapshot(&seed(1)); + assert_eq!(snap.balance, DetWalletBalance::default()); + assert!(snap.transactions.is_empty()); + assert!(snap.utxos.is_empty()); + // Pre-sync: no watched receive set is published yet. + assert!(snap.monitored_receive_addresses.is_empty()); + } + + /// FUNDS-SAFETY (display list): the Receive list is sourced from the + /// snapshot's `monitored_receive_addresses` — the SPV-watched set published + /// off the event-bridge recompute. Publishing a watched set makes it the + /// only set the read accessor returns, so the rendered list ⊆ watched set by + /// construction (it shows nothing else). Pins the round-trip the UI relies + /// on: the Receive list can never surface an unwatched index with Copy + QR. + #[test] + fn published_monitored_set_is_the_only_receive_list_source() { + let store = SnapshotStore::new(); + let watched = vec!["yWatched1".to_string(), "yWatched2".to_string()]; + + store.publish( + &seed(9), + &wid(9), + SnapshotState { + balance: DetWalletBalance::default(), + utxos: Vec::new(), + address_balances: BTreeMap::new(), + monitored_receive_addresses: watched.clone(), + address_paths: BTreeMap::new(), + }, + ); + + let snap = store.snapshot(&seed(9)); + assert_eq!( + snap.monitored_receive_addresses, watched, + "the read accessor returns exactly the published watched set" + ); + // Every address the Receive list would render comes from this set — + // there is no other source, so the rendered set ⊆ watched set. + for addr in &snap.monitored_receive_addresses { + assert!(watched.contains(addr)); + } + } + + /// The `external_addresses_from_info` filter — the real seam the + /// recompute uses — extracts exactly the standard BIP-44 account's external + /// (receive) pool. Exercised against a real upstream `ManagedWalletInfo`, not + /// a hand-injected vec: this is what publishes the watched receive set the + /// Receive list renders. + #[test] + fn external_addresses_from_info_returns_the_standard_external_pool() { + use dash_sdk::dpp::dashcore::Address; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + let wallet = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("upstream wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let external = external_addresses_from_info(&info); + + // The standard BIP-44 external pool is bootstrapped to the gap limit, so + // the filter must return a non-empty set of decodable testnet addresses. + assert!( + !external.is_empty(), + "the external pool must yield the bootstrapped receive addresses" + ); + for addr in &external { + let parsed = addr + .parse::<Address<_>>() + .expect("a decodable address") + .require_network(network); + assert!(parsed.is_ok(), "every address is on the active network"); + } + } + + /// `address_paths_from_info` must surface a derivation path for every + /// generated address — the account-summary view categorizes funded + /// addresses through this map, so a missing entry silently drops funds from + /// the per-category totals. Every external (receive) address must appear. + #[test] + fn address_paths_from_info_covers_the_generated_addresses() { + use dash_sdk::dpp::dashcore::Address; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + let wallet = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("upstream wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let paths = address_paths_from_info(&info); + assert!( + !paths.is_empty(), + "generated addresses must carry derivation paths" + ); + + for addr in external_addresses_from_info(&info) { + let parsed = addr + .parse::<Address<_>>() + .expect("a decodable address") + .require_network(network) + .expect("address on the active network"); + assert!( + paths.contains_key(&parsed), + "external receive address {addr} must have a derivation path" + ); + } + } + + #[test] + fn spendable_excludes_immature_and_locked_held_in_total() { + // `total` carries immature coinbase + locked CoinJoin funds (here the + // 700 gap above confirmed+unconfirmed) that the CoinSelector rejects. + // `spendable()` must report only confirmed + unconfirmed. + let balance = DetWalletBalance { + confirmed: 500, + unconfirmed: 300, + total: 1_500, + }; + assert_eq!(balance.spendable(), 800); + assert!(balance.spendable() < balance.total); + } + + /// Crosses the `send_screen` "Max" seam: the Max a Core send reserves must + /// come from the *spendable* set, never `total`. When the wallet holds + /// immature/locked funds (total > spendable), feeding `total` to the Max + /// math over-shoots what coin selection can spend, so the broadcast fails + /// with insufficient funds. + #[test] + fn core_max_reserves_against_spendable_not_total() { + use crate::model::fee_estimation::core_max_send_amount_duffs; + + // 800 spendable, 700 immature/locked riding in `total`. + let balance = DetWalletBalance { + confirmed: 500, + unconfirmed: 300, + total: 1_500, + }; + + let max = core_max_send_amount_duffs(balance.spendable(), 1, 1) + .expect("spendable covers the fee"); + + // Max may never exceed what coin selection can actually spend. + assert!( + max <= balance.spendable(), + "Max {max} over-reserves against spendable {}", + balance.spendable() + ); + + // Reserving against `total` would let Max exceed the spendable set — + // the exact over-shoot this fix kills. + let buggy_max = core_max_send_amount_duffs(balance.total, 1, 1) + .expect("total trivially covers the fee"); + assert!( + buggy_max > balance.spendable(), + "the total-based Max should over-reserve, proving the seam matters" + ); + } + + #[test] + fn accumulate_then_publish_surfaces_tx_history() { + let store = SnapshotStore::new(); + store.accumulate_transactions(&wid(7), [&record(1, 500), &record(2, -200)]); + publish_tx_only(&store, seed(7), wid(7)); + let snap = store.snapshot(&seed(7)); + assert_eq!(snap.transactions.len(), 2); + assert_eq!(snap.balance, DetWalletBalance::default()); + } + + #[test] + fn reseen_txid_upserts_in_place() { + let store = SnapshotStore::new(); + store.accumulate_transactions(&wid(3), [&record(1, 100)]); + let mut confirmed = record(1, 100); + confirmed.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 10, + BlockHash::from_byte_array([0u8; 32]), + 123, + )); + store.accumulate_transactions(&wid(3), [&confirmed]); + publish_tx_only(&store, seed(3), wid(3)); + let snap = store.snapshot(&seed(3)); + assert_eq!(snap.transactions.len(), 1); + assert_eq!(snap.transactions[0].status, TransactionStatus::ChainLocked); + } + + /// The bug this fix kills: an InstantLock notification (which carries + /// only a `txid`, not a `TransactionRecord`) must still upgrade the + /// already-tracked mempool record's status, not leave it `Unconfirmed` + /// forever. + #[test] + fn mark_instant_locked_upgrades_a_pending_record() { + let store = SnapshotStore::new(); + let rec = record(1, 500); + let txid = rec.txid; + store.accumulate_transactions(&wid(4), [&rec]); + + store.mark_instant_locked(&wid(4), txid); + publish_tx_only(&store, seed(4), wid(4)); + + let snap = store.snapshot(&seed(4)); + assert_eq!(snap.transactions.len(), 1); + assert_eq!( + snap.transactions[0].status, + TransactionStatus::InstantSendLocked + ); + } + + /// A block confirmation may race the InstantLock notification. If the + /// stronger status already landed first, the InstantLock must not + /// regress it back to `InstantSendLocked`. + #[test] + fn mark_instant_locked_does_not_downgrade_a_stronger_status() { + let store = SnapshotStore::new(); + let mut rec = record(2, 300); + rec.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 10, + BlockHash::from_byte_array([0u8; 32]), + 123, + )); + let txid = rec.txid; + store.accumulate_transactions(&wid(5), [&rec]); + + store.mark_instant_locked(&wid(5), txid); + publish_tx_only(&store, seed(5), wid(5)); + + let snap = store.snapshot(&seed(5)); + assert_eq!(snap.transactions[0].status, TransactionStatus::ChainLocked); + } + + /// An InstantLock for a wallet/txid combination the log hasn't seen yet + /// (e.g. events arriving out of order) must not panic and must not + /// fabricate a phantom entry. + #[test] + fn mark_instant_locked_for_untracked_txid_is_a_noop() { + let store = SnapshotStore::new(); + let rec = record(1, 1); + store.accumulate_transactions(&wid(6), [&rec]); + + store.mark_instant_locked(&wid(9), Txid::all_zeros()); + publish_tx_only(&store, seed(6), wid(6)); + + assert_eq!(store.snapshot(&seed(6)).transactions.len(), 1); + } + + #[test] + fn recompute_for_unregistered_wallet_is_a_noop() { + let store = SnapshotStore::new(); + store.accumulate_transactions(&wid(9), [&record(1, 1)]); + // No registered handle → recompute returns early, nothing published. + store.recompute(&wid(9)); + assert!(store.snapshot(&seed(9)).transactions.is_empty()); + } + + /// A published snapshot whose header total equals the sum of its + /// per-address breakdown — the consistency invariant the wallets screen + /// relies on (`core_balance_duffs` reads `.balance.total`; the Core tab sums + /// `.address_balances`). + fn consistent_snapshot() -> WalletSnapshot { + let a = addr(11); + let b = addr(12); + let mut address_balances = BTreeMap::new(); + address_balances.insert(a.clone(), 1_000u64); + address_balances.insert(b.clone(), 5_000u64); + let mut address_paths = BTreeMap::new(); + address_paths.insert(a.clone(), DerivationPath::from(Vec::new())); + address_paths.insert(b.clone(), DerivationPath::from(Vec::new())); + WalletSnapshot { + balance: DetWalletBalance { + confirmed: 6_000, + unconfirmed: 0, + total: 6_000, + }, + transactions: Vec::new(), + utxos: vec![DetUtxo { + outpoint: OutPoint::null(), + value: 1_000, + script_pubkey: a.script_pubkey(), + address: a, + }], + address_balances, + monitored_receive_addresses: vec!["yWatched".to_string()], + address_paths, + } + } + + /// QA-001: on `try_state()` contention the recompute carries the **entire** + /// prior snapshot forward — balance included — so the header total and the + /// per-address breakdown never split across generations. Refreshing balance + /// alone here (the old behavior) spliced a fresh total onto a stale + /// breakdown; this pins the whole-snapshot carry so that regression cannot + /// return. (The live lock race itself is only reachable through the + /// network-backed backend-e2e harness; this exercises the exact state the + /// contention branch publishes.) + #[test] + fn contention_carries_whole_prior_snapshot_keeping_total_and_breakdown_consistent() { + let prior = consistent_snapshot(); + let breakdown_sum: u64 = prior.address_balances.values().sum(); + assert_eq!( + prior.balance.total, breakdown_sum, + "precondition: the prior snapshot is itself consistent" + ); + + let carried = carried_forward_state(&prior); + + // Balance is carried, never freshly re-read: it still matches the + // breakdown it was published with. + assert_eq!( + carried.balance, prior.balance, + "the header total is carried from the prior generation, not refreshed alone" + ); + let carried_sum: u64 = carried.address_balances.values().sum(); + assert_eq!( + carried.balance.total, carried_sum, + "header total and per-address breakdown stay from one generation under contention" + ); + + // Every other field is carried verbatim too. + assert_eq!(carried.address_balances, prior.address_balances); + assert_eq!(carried.utxos, prior.utxos); + assert_eq!( + carried.monitored_receive_addresses, + prior.monitored_receive_addresses + ); + assert_eq!(carried.address_paths, prior.address_paths); + } + + /// The same invariant proven through the store's publish/read round-trip: + /// publishing the contention carry-forward of a consistent snapshot leaves + /// the readable snapshot consistent (total == breakdown sum) and unchanged. + #[test] + fn published_contention_carry_forward_stays_consistent() { + let store = SnapshotStore::new(); + store.publish( + &seed(11), + &wid(11), + carried_forward_state(&consistent_snapshot()), + ); + + // A contention recompute republishes the carry-forward of what's stored. + let prior = store.snapshot(&seed(11)); + store.publish(&seed(11), &wid(11), carried_forward_state(&prior)); + + let snap = store.snapshot(&seed(11)); + let breakdown_sum: u64 = snap.address_balances.values().sum(); + assert_eq!( + snap.balance.total, breakdown_sum, + "header total still equals the per-address breakdown after carry-forward" + ); + assert_eq!(snap.balance, prior.balance); + assert_eq!(snap.address_balances, prior.address_balances); + } + + /// A distinct testnet p2pkh address keyed off `n` (derived from a valid + /// secret key so the pubkey is a real curve point). + fn addr(n: u8) -> Address { + let mut sk_bytes = [1u8; 32]; + sk_bytes[31] = n.max(1); + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&sk_bytes).unwrap(); + let pubkey = PublicKey::new(sk.public_key(&secp)); + Address::p2pkh(&pubkey, Network::Testnet) + } + + /// Build a record whose transaction carries `outputs` (value, owning + /// address) and matching `OutputDetail`s with the given roles. Each + /// output's `script_pubkey` is the address's own script so the converter's + /// outpoint→address mapping is faithful. + fn record_with_outputs(n: u8, outputs: &[(u64, Address, OutputRole)]) -> TransactionRecord { + let mut tx = tx_with(n); + let mut details = Vec::new(); + for (index, (value, address, role)) in outputs.iter().enumerate() { + tx.output.push(TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }); + details.push(OutputDetail { + index: index as u32, + role: *role, + address: Some(address.clone()), + value: *value, + }); + } + TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + details, + 0, + ) + } + + #[test] + fn received_output_surfaces_as_funding_utxo() { + let funding = addr(1); + let rec = record_with_outputs(1, &[(100_000, funding.clone(), OutputRole::Received)]); + + let (tx, owned) = + received_outputs_for_record(&rec).expect("a received output yields a payload"); + + assert_eq!(tx.txid(), rec.txid); + assert_eq!(owned.len(), 1); + let (outpoint, txout, address) = &owned[0]; + assert_eq!(*address, funding); + assert_eq!(outpoint.txid, rec.txid); + assert_eq!(outpoint.vout, 0); + assert_eq!(txout.value, 100_000); + assert_eq!(txout.script_pubkey, funding.script_pubkey()); + } + + #[test] + fn change_outputs_are_included_sent_outputs_are_not() { + let change = addr(2); + let counterparty = addr(3); + let rec = record_with_outputs( + 2, + &[ + (5_000, change.clone(), OutputRole::Change), + (9_000, counterparty, OutputRole::Sent), + ], + ); + + let (_, owned) = + received_outputs_for_record(&rec).expect("a change output yields a payload"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].2, change); + } + + #[test] + fn pure_outgoing_transaction_yields_no_payload() { + let counterparty = addr(4); + let rec = record_with_outputs(3, &[(7_000, counterparty, OutputRole::Sent)]); + assert!(received_outputs_for_record(&rec).is_none()); + } + + #[test] + fn incoming_candidates_surface_received_outputs() { + let recv = addr(5); + let rec = record_with_outputs(5, &[(42_000, recv.clone(), OutputRole::Received)]); + + let candidates = incoming_payment_candidates(&rec); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].address, recv.to_string()); + assert_eq!(candidates[0].amount_duffs, 42_000); + assert_eq!(candidates[0].txid, rec.txid.to_string()); + assert_eq!(candidates[0].vout, 0); + } + + #[test] + fn two_received_outputs_carry_distinct_vouts() { + // One transaction paying two different contact addresses must yield two + // candidates whose vouts differ — otherwise downstream keying by + // (txid, vout) collapses them and one payment is lost. + let recv_a = addr(5); + let recv_b = addr(6); + let rec = record_with_outputs( + 7, + &[ + (11_000, recv_a.clone(), OutputRole::Received), + (22_000, recv_b.clone(), OutputRole::Received), + ], + ); + + let candidates = incoming_payment_candidates(&rec); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].vout, 0); + assert_eq!(candidates[1].vout, 1); + assert_ne!(candidates[0].vout, candidates[1].vout); + assert_eq!(candidates[0].amount_duffs, 11_000); + assert_eq!(candidates[1].amount_duffs, 22_000); + } + + #[test] + fn incoming_candidates_exclude_change_and_sent() { + let recv = addr(6); + let change = addr(7); + let counterparty = addr(8); + let rec = record_with_outputs( + 6, + &[ + (10_000, recv.clone(), OutputRole::Received), + (3_000, change, OutputRole::Change), + (9_000, counterparty, OutputRole::Sent), + ], + ); + + let candidates = incoming_payment_candidates(&rec); + + // Only the Received output is a contact-payment candidate; change + // lands on our own address and sent leaves the wallet. + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].address, recv.to_string()); + } + + #[test] + fn pure_outgoing_transaction_yields_no_incoming_candidates() { + let counterparty = addr(9); + let rec = record_with_outputs(9, &[(7_000, counterparty, OutputRole::Sent)]); + assert!(incoming_payment_candidates(&rec).is_empty()); + } + + #[test] + fn status_mapping_covers_every_context() { + assert_eq!( + status_from_context(&TransactionContext::Mempool), + TransactionStatus::Unconfirmed + ); + let bi = BlockInfo::new(1, BlockHash::from_byte_array([0u8; 32]), 1); + assert_eq!( + status_from_context(&TransactionContext::InBlock(bi)), + TransactionStatus::Confirmed + ); + assert_eq!( + status_from_context(&TransactionContext::InChainLockedBlock(bi)), + TransactionStatus::ChainLocked + ); + } + + /// A BIP-44 external path `m/44'/1'/acct'/0/0` on testnet. + fn bip44_account_path(acct: u32) -> DerivationPath { + use dash_sdk::dpp::key_wallet::bip32::ChildNumber; + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: acct }, + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ]) + } + + /// QA-003: the wallets-screen header total and the Core-tab breakdown must + /// reconcile through the **real** read accessors — not hand-fed literals + /// compared to themselves. + /// + /// Publishes a realistic snapshot through the same `publish` seam + /// `recompute` uses. Its `balance.total` is the exact field + /// `WalletBackend::wallet_balance().total` returns and the header renders; + /// its `address_balances` / `address_paths` are the exact accessors the Core + /// tabs feed to `collect_account_summaries`. Crucially the snapshot includes + /// a funded address the generated-path set has NOT indexed, and `total` + /// counts it — so the assertion "Σ per-account summaries == header total" + /// only holds if `collect_account_summaries` surfaces that stray balance + /// (pass 2) rather than dropping it. That is the header-vs-tab agreement the + /// original tautological test claimed but never checked. + #[test] + fn header_total_reconciles_with_core_tab_breakdown_through_real_accessors() { + use crate::model::wallet::DerivationPathReference; + use crate::ui::state::account_summary::{AccountCategory, collect_account_summaries}; + + // account #0: two receive addresses (3.5 DASH); account #1: one (0.5). + let a0 = addr(20); + let a0b = addr(21); + let a1 = addr(22); + // A funded address outside the generated-path window (0.07 DASH). + let stray = addr(23); + + let mut address_balances = BTreeMap::new(); + address_balances.insert(a0.clone(), 250_000_000u64); + address_balances.insert(a0b.clone(), 100_000_000u64); + address_balances.insert(a1.clone(), 50_000_000u64); + address_balances.insert(stray, 7_000_000u64); + // `address_balances` is summed from `state.utxos()` (all UTXOs), so this + // total mirrors what `state.balance().total()` reports at the same + // generation — the QA-001 consistency the header relies on. + let total: u64 = address_balances.values().sum(); + + // Only the generated addresses carry paths (the stray one does not). + let mut address_paths = BTreeMap::new(); + address_paths.insert(a0, bip44_account_path(0)); + address_paths.insert(a0b, bip44_account_path(0)); + address_paths.insert(a1, bip44_account_path(1)); + + let store = SnapshotStore::new(); + store.publish( + &seed(20), + &wid(20), + SnapshotState { + balance: DetWalletBalance { + confirmed: total, + unconfirmed: 0, + total, + }, + utxos: Vec::new(), + address_balances: address_balances.clone(), + monitored_receive_addresses: Vec::new(), + address_paths: address_paths.clone(), + }, + ); + + let snap = store.snapshot(&seed(20)); + // The exact value `WalletBackend::wallet_balance().total` returns. + let header_total = snap.balance.total; + assert_eq!(header_total, 407_000_000); + + // The exact accessors the Core tabs read. + let summaries = collect_account_summaries( + Network::Testnet, + &snap.address_balances, + &snap.address_paths, + ); + let core_tab_sum: u64 = summaries.iter().map(|s| s.confirmed_balance).sum(); + assert_eq!( + core_tab_sum, header_total, + "every funded address is reconciled into the header total — none dropped" + ); + + // Per-account distinctness (the original test's intent, now proven + // against the real header total rather than a hand-fed literal). + let acct0 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44 && s.index == Some(0)) + .expect("account #0 summary"); + assert_eq!(acct0.confirmed_balance, 350_000_000); + let acct1 = summaries + .iter() + .find(|s| s.category == AccountCategory::Bip44 && s.index == Some(1)) + .expect("account #1 summary"); + assert_eq!(acct1.confirmed_balance, 50_000_000); + // The stray funded address is surfaced (pass 2), never dropped. + let other = summaries + .iter() + .find(|s| s.category == AccountCategory::Other(DerivationPathReference::Unknown)) + .expect("stray funded address surfaced as Other"); + assert_eq!(other.confirmed_balance, 7_000_000); + } + + /// QA-006 pin: a real upstream wallet's generated addresses must never + /// categorize as `PlatformPayment` today — upstream `all_accounts()` + /// (the sole source for `address_paths_from_info`) excludes the + /// platform-payment pool. `build_account_tabs`'s dedup guard is dormant only + /// while this holds; if a future dependency bump folds that pool into + /// `all_accounts()`, a `PlatformPayment` summary appears here and this test + /// fails loudly, flagging that the dedup path is now live. + #[test] + fn generated_paths_never_categorize_as_platform_payment_today() { + use crate::ui::state::account_summary::{AccountCategory, collect_account_summaries}; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x42u8; 64]; + let network = Network::Testnet; + let wallet = + UpstreamWallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::Default) + .expect("upstream wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let address_paths = address_paths_from_info(&info); + assert!( + !address_paths.is_empty(), + "the wallet must have generated addresses to make this assertion meaningful" + ); + + let summaries = collect_account_summaries(network, &BTreeMap::new(), &address_paths); + assert!( + !summaries + .iter() + .any(|s| s.category == AccountCategory::PlatformPayment), + "platform-payment addresses must not reach the generated-path set today; \ + if this fails, upstream all_accounts() now includes them and build_account_tabs \ + must rely on its dedup guard" + ); + } +} diff --git a/src/wallet_backend/token_balance.rs b/src/wallet_backend/token_balance.rs new file mode 100644 index 000000000..634d0efb0 --- /dev/null +++ b/src/wallet_backend/token_balance.rs @@ -0,0 +1,263 @@ +//! Per-`(identity, token)` balance view (T6 seam), read live from upstream. +//! +//! [`UpstreamTokenBalances`] is the doorway DET code uses to read the raw +//! `u64` Platform token balance for an `(identity, token)` pair. Upstream's +//! [`IdentitySyncManager`](platform_wallet::manager::identity_sync::IdentitySyncManager) +//! owns the authoritative balances; DET no longer keeps a `det:token_balance` +//! cache of its own. +//! +//! ## Read path +//! +//! The upstream readers (`all_state` / `state_for_identity`) are async, but +//! DET reads token balances from the synchronous egui frame. So the backend +//! refreshes a lock-free DET-typed snapshot off the UI thread (see +//! [`WalletBackend::refresh_token_balances`](super::WalletBackend::refresh_token_balances)) +//! and the view reads that snapshot infallibly via [`ArcSwap`]. +//! +//! ## Syncing vs zero +//! +//! An identity that has never completed a sync pass (absent from upstream +//! state, or `last_sync_unix == 0`) is reported as **not present** — [`get`] +//! returns `None` and [`list`] omits it. The UI renders this as the +//! "balance unknown / Check" state, never as a misleading `0`. Only a synced +//! identity contributes its `(token, balance)` rows. +//! +//! ## Type boundary +//! +//! Upstream `IdentityTokenSyncState` / `IdentityTokenSyncInfo` / `TokenAmount` +//! are converted to DET / SDK domain types ([`Identifier`], `u64`) at the +//! publish boundary (rust-best-practices M-DONT-LEAK-TYPES). No upstream +//! manager type crosses the seam. +//! +//! [`get`]: UpstreamTokenBalances::get +//! [`list`]: UpstreamTokenBalances::list + +use std::collections::BTreeMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use dash_sdk::platform::Identifier; + +/// One synced identity's token balances. Presence in +/// [`TokenBalanceSnapshot::identities`] means the identity has completed at +/// least one sync pass; an unsynced identity is simply absent. +type IdentityBalances = BTreeMap<Identifier, u64>; + +/// DET-typed, lock-free token-balance snapshot. Maps each *synced* identity +/// to its per-token balances. Cheap to clone-share via the enclosing `Arc`. +#[derive(Debug, Clone, Default)] +pub struct TokenBalanceSnapshot { + /// Synced identities → (token → balance). An identity absent here has + /// not completed a sync pass yet and must be reported as "syncing". + identities: BTreeMap<Identifier, IdentityBalances>, +} + +impl TokenBalanceSnapshot { + /// Balance for one `(identity, token)` pair. `None` when the identity is + /// not yet synced *or* the token is not among its synced balances. + fn get(&self, identity_id: &Identifier, token_id: &Identifier) -> Option<u64> { + self.identities.get(identity_id)?.get(token_id).copied() + } + + /// Every `(identity, token, balance)` triple across synced identities. + fn list(&self) -> Vec<(Identifier, Identifier, u64)> { + self.identities + .iter() + .flat_map(|(identity_id, tokens)| { + tokens + .iter() + .map(move |(token_id, balance)| (*identity_id, *token_id, *balance)) + }) + .collect() + } +} + +/// Lock-free store of the published [`TokenBalanceSnapshot`]. Held by +/// [`WalletBackend`](super::WalletBackend); the refresh path publishes into +/// it off the UI thread and the view reads from it on the frame thread. +#[derive(Debug, Default)] +pub(super) struct TokenBalanceStore { + snapshot: ArcSwap<TokenBalanceSnapshot>, +} + +impl TokenBalanceStore { + pub(super) fn new() -> Self { + Self { + snapshot: ArcSwap::from_pointee(TokenBalanceSnapshot::default()), + } + } + + /// Read the current snapshot. Lock-free, infallible. + pub(super) fn load(&self) -> Arc<TokenBalanceSnapshot> { + self.snapshot.load_full() + } + + /// Atomically publish a freshly-built snapshot. Called by the refresh + /// path after it has read upstream state and converted it to DET types. + pub(super) fn publish(&self, snapshot: TokenBalanceSnapshot) { + self.snapshot.store(Arc::new(snapshot)); + } + + /// Assemble a [`TokenBalanceSnapshot`] from already-converted, per-synced + /// identity balances. Identities are passed in only when synced — the + /// caller drops unsynced ones at the upstream boundary. + pub(super) fn snapshot_from( + synced: impl IntoIterator<Item = (Identifier, IdentityBalances)>, + ) -> TokenBalanceSnapshot { + TokenBalanceSnapshot { + identities: synced.into_iter().collect(), + } + } + + /// Surgically set one `(identity, token)` balance in the published + /// snapshot, leaving every other entry intact. Used to reflect a + /// proof-derived post-transaction balance immediately, before the next + /// upstream sync pass confirms it. The identity becomes present + /// (no longer "syncing") for that token. + pub(super) fn apply(&self, identity_id: Identifier, token_id: Identifier, balance: u64) { + self.snapshot.rcu(|current| { + let mut next = (**current).clone(); + next.identities + .entry(identity_id) + .or_default() + .insert(token_id, balance); + next + }); + } +} + +/// Read the raw per-`(identity, token)` token balance from the lock-free +/// upstream-fed snapshot. Registry decoration (alias / config / order list) +/// stays with the caller; this view owns only the balance slot. +pub struct UpstreamTokenBalances { + snapshot: Arc<TokenBalanceSnapshot>, +} + +impl UpstreamTokenBalances { + pub(super) fn new(store: &TokenBalanceStore) -> Self { + Self { + snapshot: store.load(), + } + } + + /// Balance for one `(identity, token)` pair, or `None` when the identity + /// is not yet synced or the token has no synced balance. + pub fn get(&self, identity_id: &Identifier, token_id: &Identifier) -> Option<u64> { + self.snapshot.get(identity_id, token_id) + } + + /// Every `(identity, token, balance)` triple across synced identities. + pub fn list(&self) -> Vec<(Identifier, Identifier, u64)> { + self.snapshot.list() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn store_with( + synced: impl IntoIterator<Item = (Identifier, IdentityBalances)>, + ) -> TokenBalanceStore { + let store = TokenBalanceStore::new(); + store.publish(TokenBalanceStore::snapshot_from(synced)); + store + } + + /// TB1: a synced identity's balance is readable through the view. + #[test] + fn synced_balance_is_readable() { + let store = store_with([(id(1), BTreeMap::from([(id(2), 5_000)]))]); + let view = UpstreamTokenBalances::new(&store); + assert_eq!(view.get(&id(1), &id(2)), Some(5_000)); + } + + /// TB2: an unsynced identity reads as `None` (syncing), never `0`. + #[test] + fn unsynced_identity_reads_none() { + let store = store_with([(id(1), BTreeMap::from([(id(2), 5_000)]))]); + let view = UpstreamTokenBalances::new(&store); + // id(9) never synced — absent from the snapshot. + assert_eq!(view.get(&id(9), &id(2)), None); + } + + /// TB3: a synced identity that lacks a balance for a specific token reads + /// as `None`, not `0` — distinguishing "not fetched" from "zero". + #[test] + fn synced_identity_missing_token_reads_none() { + let store = store_with([(id(1), BTreeMap::from([(id(2), 5_000)]))]); + let view = UpstreamTokenBalances::new(&store); + assert_eq!(view.get(&id(1), &id(7)), None); + } + + /// TB4: a synced identity with an explicit zero balance reads as + /// `Some(0)` — a real, fetched zero is distinct from "syncing". + #[test] + fn explicit_zero_balance_reads_some_zero() { + let store = store_with([(id(1), BTreeMap::from([(id(2), 0)]))]); + let view = UpstreamTokenBalances::new(&store); + assert_eq!(view.get(&id(1), &id(2)), Some(0)); + } + + /// TB5: `list` returns every synced triple and omits unsynced identities. + #[test] + fn list_returns_synced_triples_only() { + let store = store_with([ + (id(1), BTreeMap::from([(id(2), 10), (id(3), 20)])), + (id(4), BTreeMap::from([(id(5), 30)])), + ]); + let view = UpstreamTokenBalances::new(&store); + let mut got = view.list(); + got.sort_by_key(|(_, _, bal)| *bal); + assert_eq!( + got, + vec![(id(1), id(2), 10), (id(1), id(3), 20), (id(4), id(5), 30)] + ); + } + + /// TB6: a freshly-constructed store yields an empty snapshot — every + /// read is "syncing" until the first publish. + #[test] + fn empty_store_yields_no_balances() { + let store = TokenBalanceStore::new(); + let view = UpstreamTokenBalances::new(&store); + assert_eq!(view.get(&id(1), &id(2)), None); + assert!(view.list().is_empty()); + } + + /// TB8: `apply` sets one balance without disturbing other entries, and + /// makes a previously-unsynced identity present for that token. + #[test] + fn apply_sets_one_balance_in_place() { + let store = store_with([(id(1), BTreeMap::from([(id(2), 10)]))]); + // Update an existing pair and add a brand-new (unsynced) identity. + store.apply(id(1), id(2), 42); + store.apply(id(8), id(9), 7); + let view = UpstreamTokenBalances::new(&store); + assert_eq!(view.get(&id(1), &id(2)), Some(42)); + assert_eq!(view.get(&id(8), &id(9)), Some(7)); + } + + /// TB7: the view captures the snapshot at construction; a later publish + /// is not seen by an already-built view (callers build one per read). + #[test] + fn view_is_a_point_in_time_read() { + let store = TokenBalanceStore::new(); + let view = UpstreamTokenBalances::new(&store); + store.publish(TokenBalanceStore::snapshot_from([( + id(1), + BTreeMap::from([(id(2), 99)]), + )])); + // The pre-publish view still sees nothing. + assert_eq!(view.get(&id(1), &id(2)), None); + // A freshly-built view sees the new snapshot. + assert_eq!( + UpstreamTokenBalances::new(&store).get(&id(1), &id(2)), + Some(99) + ); + } +} diff --git a/src/wallet_backend/versioned_bincode.rs b/src/wallet_backend/versioned_bincode.rs new file mode 100644 index 000000000..5375393e4 --- /dev/null +++ b/src/wallet_backend/versioned_bincode.rs @@ -0,0 +1,92 @@ +//! Shared `[version_byte || bincode(body)]` framing for the in-vault payloads +//! that carry it: the HD-seed [`StoredSeedEnvelope`] and the imported +//! [`SingleKeyEntry`]. Encoding prepends a leading version tag; decoding reads +//! it back and, when the tag or body does not match, defers to a per-format +//! fallback closure (a bare-bincode legacy value, a raw fixed-length blob, or a +//! typed error) so each payload keeps its own legacy-shape handling. +//! +//! [`StoredSeedEnvelope`]: crate::model::wallet::seed_envelope::StoredSeedEnvelope +//! [`SingleKeyEntry`]: crate::wallet_backend::single_key_entry::SingleKeyEntry + +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Encode `body` as `[version || bincode(body)]`. +pub(crate) fn encode_tagged<T: Serialize>( + version: u8, + body: &T, +) -> Result<Vec<u8>, bincode::error::EncodeError> { + let body = bincode::serde::encode_to_vec(body, bincode::config::standard())?; + let mut out = Vec::with_capacity(body.len() + 1); + out.push(version); + out.extend_from_slice(&body); + Ok(out) +} + +/// Decode a `[version || bincode(T)]` payload. When `bytes` begins with +/// `version` and the remainder decodes cleanly as `T`, that value is returned; +/// otherwise the call defers to `fallback` with the full `bytes` (the legacy / +/// untagged path — which may itself decode a bare-bincode value, interpret a +/// raw blob, or return a typed error). +pub(crate) fn decode_tagged_or<T, E>( + bytes: &[u8], + version: u8, + fallback: impl FnOnce(&[u8]) -> Result<T, E>, +) -> Result<T, E> +where + T: DeserializeOwned, +{ + if let Some((&tag, rest)) = bytes.split_first() + && tag == version + && let Ok((decoded, _)) = + bincode::serde::decode_from_slice::<T, _>(rest, bincode::config::standard()) + { + return Ok(decoded); + } + fallback(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + struct Sample { + a: u32, + b: String, + } + + const VERSION: u8 = 7; + + #[test] + fn tagged_round_trip() { + let s = Sample { + a: 42, + b: "hi".into(), + }; + let bytes = encode_tagged(VERSION, &s).expect("encode"); + assert_eq!(bytes[0], VERSION, "payload starts with the version tag"); + let got: Sample = + decode_tagged_or(&bytes, VERSION, |_| Err::<Sample, ()>(())).expect("decode"); + assert_eq!(got, s); + } + + #[test] + fn wrong_tag_defers_to_fallback() { + let s = Sample { + a: 1, + b: "x".into(), + }; + let bytes = encode_tagged(VERSION, &s).expect("encode"); + // Decode with a different expected version — the tag mismatches, so the + // fallback runs and receives the full byte slice. + let got: Result<Sample, u8> = decode_tagged_or(&bytes, VERSION + 1, |b| Err(b[0])); + assert_eq!(got, Err(VERSION), "fallback saw the untouched leading byte"); + } + + #[test] + fn empty_input_defers_to_fallback() { + let got: Result<Sample, &str> = decode_tagged_or(&[], VERSION, |_| Err("empty")); + assert_eq!(got, Err("empty")); + } +} diff --git a/src/wallet_backend/wallet_meta.rs b/src/wallet_backend/wallet_meta.rs new file mode 100644 index 000000000..5ae1ee631 --- /dev/null +++ b/src/wallet_backend/wallet_meta.rs @@ -0,0 +1,331 @@ +//! DET-side wallet-metadata view (T-W-00). +//! +//! [`WalletMetaView`] is the only doorway DET code uses to read or +//! write [`WalletMeta`] (alias / `is_main` / `core_wallet_name`) for +//! HD wallets. The view borrows a shared [`DetKv`] handle pointing at +//! `det-app.sqlite` and serialises every entry under a colon-prefixed, +//! network-scoped key: +//! +//! ```text +//! <network>:wallet_meta:<seed_hash_base58> +//! ``` +//! +//! Network-prefixed keys + the global (`None`) wallet scope mirror the +//! C3 `det:settings:v1` pattern: the cross-network `det-app.sqlite` +//! file is the right store (one file, one schema, easy backup), and +//! per-wallet scope (`Some(&WalletId)`) cannot be used because the +//! upstream `WalletId` does not exist until a wallet is registered +//! with `PlatformWalletManager`. The seed hash is the stable +//! DET-level identifier and fits the key naturally. +//! +//! All accessors are infallible at the read path: a missing key +//! returns `None`, a corrupted blob (schema mismatch / truncated / +//! decode failure) is logged and treated as absent so the wallet +//! picker degrades gracefully rather than blocking the UI. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::meta::{WalletMeta, WalletMetaV1}; +use crate::wallet_backend::DetKv; +use crate::wallet_backend::kv::{KvAdapterError, map_kv_storage_error}; +#[cfg(test)] +use crate::wallet_backend::sidecar::sidecar_key; +use crate::wallet_backend::sidecar::{SidecarScope, SidecarValue, SidecarView}; + +/// Colon-separated namespace shared across networks. The full key is +/// `<network>:wallet_meta:<seed_hash_base58>`. +pub(crate) const KEY_INFIX: &str = ":wallet_meta:"; + +/// Build the canonical k/v key for a wallet's metadata blob. The generic view +/// builds keys itself; this mirror exists for key-shape tests. +#[cfg(test)] +pub(crate) fn key_for(network: Network, seed_hash: &WalletSeedHash) -> String { + sidecar_key(network, KEY_INFIX, seed_hash) +} + +impl SidecarValue for WalletMeta { + /// Read a single wallet-meta blob with a dual-format fallback. Tries the + /// current 6-field [`WalletMeta`] shape first; on a decode failure (an old + /// 4-field blob runs out of bytes for the appended fields) falls back to the + /// legacy [`WalletMetaV1`] shape and RE-STORES it in the current shape + /// (one-shot migration). `Ok(None)` when the key is absent. + fn read( + kv: &DetKv, + scope: crate::wallet_backend::DetScope<'_>, + key: &str, + ) -> Result<Option<Self>, KvAdapterError> { + // New shape first. The DetKv schema-version mismatch is a hard error + // (propagate); only a bincode *decode* failure means "try legacy". + match kv.get::<WalletMeta>(scope, key) { + Ok(opt) => return Ok(opt), + Err(KvAdapterError::Decode(_)) => {} + Err(e) => return Err(e), + } + // Legacy 4-field shape. A success here is an old blob: migrate it. + let Some(v1) = kv.get::<WalletMetaV1>(scope, key)? else { + return Ok(None); + }; + let migrated: WalletMeta = v1.into(); + if let Err(e) = kv.put(scope, key, &migrated) { + // Re-store is best-effort: the in-memory value is correct this + // session; the next read retries the migration. + tracing::warn!( + target: "wallet_backend::wallet_meta", + key = %key, + error = ?e, + "Could not re-store migrated wallet meta; will retry next read", + ); + } + Ok(Some(migrated)) + } +} + +/// Typed wallet-metadata sidecar (T-W-00). A thin wrapper over the generic +/// [`SidecarView`]: metadata (`alias` / `is_main` / `core_wallet_name` / xpub / +/// password fields) is `Global`-scoped because the picker renders it before any +/// wallet is registered with `PlatformWalletManager` — so per-wallet scope is +/// unavailable and the seed hash is the stable DET-level key. Reads degrade to +/// `None`/skip on a corrupt blob (with a legacy-format fallback, see +/// [`SidecarValue::read`]) so the picker never blocks. +pub struct WalletMetaView<'a>(SidecarView<'a, WalletMeta>); + +impl<'a> WalletMetaView<'a> { + /// Borrow a [`DetKv`] handle as a typed wallet-metadata view. Kept + /// `pub` so benches and downstream tooling can build the view + /// without going through [`WalletBackend::wallet_meta`]. + pub fn new(kv: &'a Arc<DetKv>) -> Self { + Self(SidecarView::new( + kv, + KEY_INFIX, + SidecarScope::Global, + map_kv_error_to_task_error, + )) + } + + /// All `(seed_hash, meta)` pairs persisted for `network`. A single corrupt + /// row is logged and skipped so the wallet listing degrades to "name + /// unknown" rather than refusing to open the app. + pub fn list(&self, network: Network) -> Vec<(WalletSeedHash, WalletMeta)> { + self.0.list(network) + } + + /// Fetch the metadata for a single wallet. `None` when the key is + /// absent or the blob fails to decode (logged). + pub fn get(&self, network: Network, seed_hash: &WalletSeedHash) -> Option<WalletMeta> { + self.0.get(network, seed_hash) + } + + /// Upsert the metadata for a single wallet. Re-writing the same value is an + /// idempotent overwrite (DetKv upserts by key). + pub fn set( + &self, + network: Network, + seed_hash: &WalletSeedHash, + meta: &WalletMeta, + ) -> Result<(), TaskError> { + self.0.set(network, seed_hash, meta) + } + + /// Delete the metadata for a single wallet. Idempotent — a + /// missing key returns `Ok(())`. + pub fn delete(&self, network: Network, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + self.0.delete(network, seed_hash) + } +} + +/// Wallet-meta adapter errors all funnel into the dedicated +/// [`TaskError::KvSidecarStorage`] envelope so the banner copy +/// matches the surface ("wallet details") rather than the more +/// generic upstream wallet-storage one. +fn map_kv_error_to_task_error(e: KvAdapterError) -> TaskError { + map_kv_storage_error(e, |source| TaskError::KvSidecarStorage { + sidecar: "wallet_meta", + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use dash_sdk::dpp::dashcore::base58; + + use crate::wallet_backend::DetScope; + use crate::wallet_backend::kv_test_support::InMemoryKv; + + fn kv() -> Arc<DetKv> { + Arc::new(DetKv::from_store(Arc::new(InMemoryKv::default()))) + } + + fn meta(alias: &str, is_main: bool, core: Option<&str>) -> WalletMeta { + WalletMeta { + alias: alias.into(), + is_main, + core_wallet_name: core.map(str::to_string), + xpub_encoded: Vec::new(), + uses_password: false, + password_hint: None, + } + } + + /// W-META-VIEW-001 (TC-W-001 storage half) — a written meta + /// round-trips through `get` and shows up in `list` for the same + /// network. + #[test] + fn set_then_get_round_trips() { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x11; 32]; + let m = meta("paycheque", true, Some("local-dashd")); + view.set(Network::Testnet, &seed, &m).expect("set"); + assert_eq!(view.get(Network::Testnet, &seed), Some(m.clone())); + let listed = view.list(Network::Testnet); + assert_eq!(listed, vec![(seed, m)]); + } + + /// W-META-VIEW-002 (TC-W-008) — set overwrites; renaming via the + /// view is a single upsert and the new alias surfaces on the next + /// read. + #[test] + fn set_overwrites_existing_entry() { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x22; 32]; + view.set(Network::Mainnet, &seed, &meta("old", false, None)) + .expect("first set"); + view.set(Network::Mainnet, &seed, &meta("new", true, None)) + .expect("second set"); + assert_eq!( + view.get(Network::Mainnet, &seed), + Some(meta("new", true, None)) + ); + } + + /// W-META-VIEW-003 — `list` does not leak entries from other + /// networks (the `<network>:` prefix is the partition). Mirrors + /// the per-network isolation contract from `kv.rs::list`. + #[test] + fn list_partitions_by_network() { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let a: WalletSeedHash = [0x33; 32]; + let b: WalletSeedHash = [0x44; 32]; + view.set(Network::Testnet, &a, &meta("on testnet", false, None)) + .unwrap(); + view.set(Network::Mainnet, &b, &meta("on mainnet", true, None)) + .unwrap(); + let testnet = view.list(Network::Testnet); + let mainnet = view.list(Network::Mainnet); + assert_eq!(testnet, vec![(a, meta("on testnet", false, None))]); + assert_eq!(mainnet, vec![(b, meta("on mainnet", true, None))]); + } + + /// W-META-VIEW-004 — `delete` is idempotent (matches the + /// underlying `DetKv::delete` contract). + #[test] + fn delete_is_idempotent() { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x55; 32]; + view.delete(Network::Testnet, &seed).expect("delete absent"); + view.set(Network::Testnet, &seed, &meta("x", false, None)) + .unwrap(); + view.delete(Network::Testnet, &seed).expect("first delete"); + view.delete(Network::Testnet, &seed).expect("second delete"); + assert_eq!(view.get(Network::Testnet, &seed), None); + } + + /// W-META-VIEW-005 — `get` on a missing key returns `None` rather + /// than erroring; this is the listing path's graceful-degradation + /// contract. + #[test] + fn get_missing_returns_none() { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x66; 32]; + assert_eq!(view.get(Network::Devnet, &seed), None); + } + + /// W-META-VIEW-006 — a corrupt key in the store (non-base58 + /// suffix) is skipped silently rather than blocking the listing. + #[test] + fn list_skips_unparseable_keys() { + let kv = kv(); + let store = kv.clone(); + let view = WalletMetaView::new(&kv); + // Plant a valid entry plus a garbage entry that shares the + // network prefix but has a non-base58 suffix. + let seed: WalletSeedHash = [0x77; 32]; + view.set(Network::Testnet, &seed, &meta("ok", false, None)) + .unwrap(); + store + .put( + DetScope::Global, + &format!("testnet{KEY_INFIX}!!!not-base58!!!"), + &meta("garbage", false, None), + ) + .unwrap(); + let listed = view.list(Network::Testnet); + assert_eq!(listed, vec![(seed, meta("ok", false, None))]); + } + + /// W-META-VIEW-007 — the canonical key shape uses base58 encoding + /// for the 32-byte seed hash. Locks the shape so a future change + /// (hex, etc.) needs an explicit migration. + #[test] + fn key_for_uses_base58_seed_hash() { + let seed: WalletSeedHash = [0xAB; 32]; + let key = key_for(Network::Mainnet, &seed); + assert!(key.starts_with("mainnet:wallet_meta:")); + let suffix = key.trim_start_matches("mainnet:wallet_meta:"); + let decoded = base58::decode(suffix).expect("base58 decodes"); + assert_eq!(decoded.as_slice(), seed.as_slice()); + } + + /// Dual-format legacy-blob upgrade — an OLD 4-field blob, written exactly + /// as the base branch did (`kv.put::<WalletMetaV1>`), is read back through + /// the view: its `alias`/`is_main`/`core_wallet_name`/`xpub` are preserved + /// (NOT silently lost to a `Vec<u8>` type-confusion), the new fields default, + /// and the entry is RE-STORED in the new 6-field shape (a subsequent + /// `get::<WalletMeta>` succeeds directly). Covers a 1-char alias (the + /// leading-byte-collision case a version-tag dispatch would mis-route). + /// Makes the `WalletMetaV1` legacy path live + tested end-to-end. + #[test] + fn old_wallet_meta_blob_decodes_preserves_fields_and_restores() { + for alias in ["paycheque", "a", "ab"] { + let kv = kv(); + let view = WalletMetaView::new(&kv); + let seed: WalletSeedHash = [0x5A; 32]; + let key = key_for(Network::Testnet, &seed); + + // Write the OLD shape directly, the way the base branch did. + let v1 = WalletMetaV1 { + alias: alias.into(), + is_main: true, + core_wallet_name: Some("local-dashd".into()), + xpub_encoded: vec![0x22; 78], + }; + kv.put(DetScope::Global, &key, &v1).expect("write old blob"); + + // The view reads it (dual-format fallback), preserving every field. + let got = view.get(Network::Testnet, &seed).expect("old blob decodes"); + assert_eq!(got.alias, alias, "alias preserved"); + assert!(got.is_main, "is_main preserved"); + assert_eq!(got.core_wallet_name.as_deref(), Some("local-dashd")); + assert_eq!(got.xpub_encoded, vec![0x22; 78]); + assert!(!got.uses_password, "new field defaults false"); + assert!(got.password_hint.is_none()); + + // It was re-stored in the new shape: a direct new-shape decode now + // succeeds (no more legacy fallback needed). + let direct: Option<WalletMeta> = kv + .get(DetScope::Global, &key) + .expect("direct new-shape read"); + assert_eq!(direct.expect("present").alias, alias); + } + } +} diff --git a/src/wallet_backend/wallet_seed_store.rs b/src/wallet_backend/wallet_seed_store.rs new file mode 100644 index 000000000..1da5ffb51 --- /dev/null +++ b/src/wallet_backend/wallet_seed_store.rs @@ -0,0 +1,477 @@ +//! Seed-storage view over the upstream [`SecretStore`]. +//! +//! The active write path stores each HD wallet's RAW 64-byte BIP-39 seed +//! through the raw secret seam at one label per wallet: +//! +//! ```text +//! service: WalletId(seed_hash) +//! label: "seed.raw.v1" +//! value: SecretBytes(raw seed) // Tier-1 unprotected, OR +//! a Tier-2 object-password envelope // Argon2id + XChaCha20-Poly1305 +//! ``` +//! +//! An unprotected seed is written Tier-1 via [`WalletSeedView::set_raw`]; a +//! password-protected seed is sealed Tier-2 under its OWN object password via +//! [`WalletSeedView::set_protected`]. The non-secret metadata (`uses_password`, +//! hint, master xpub) lives in `WalletMeta`, not next to the seed. +//! +//! The legacy `envelope.v1` row — a bincode-encoded [`StoredSeedEnvelope`] +//! whose ciphertext was DET's own AES-GCM envelope — is retained DECODE-ONLY as +//! a migration reader ([`WalletSeedView::get`] / +//! [`WalletSeedView::legacy_envelope_get`]). Every production write goes +//! through the raw/`set_protected` seam; a legacy envelope is rewritten to the +//! raw label on the first load/unlock and then deleted. +//! +//! The `WalletSeedHash` is reused directly as the upstream `WalletId` +//! (both are `[u8; 32]`). +//! +//! All accessors funnel storage errors into the dedicated +//! [`TaskError::WalletSeedStorage`] envelope so banner copy can speak +//! about "your wallet" rather than the more generic "imported keys" +//! wording on [`TaskError::SecretStore`]. + +use std::sync::Arc; + +use platform_wallet_storage::secrets::{ + SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, +}; +use zeroize::Zeroizing; + +use crate::backend_task::error::TaskError; +use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::seed_envelope::{STORED_SEED_ENVELOPE_VERSION, StoredSeedEnvelope}; +use crate::wallet_backend::secret_access::SEED_RAW_LABEL; +use crate::wallet_backend::secret_seam::{SecretScheme, SecretSeam}; +use crate::wallet_backend::versioned_bincode::{decode_tagged_or, encode_tagged}; + +/// Label under which the bincode-encoded envelope is stored. Versioned +/// so a future shape change (e.g. an additional field that breaks +/// decoding) bumps the label rather than reinterpreting existing rows. +pub(crate) const ENVELOPE_LABEL: &str = "envelope.v1"; + +/// Leading payload byte that tags the on-disk shape. Pre-v1 entries +/// (written by the same `set` path before this tag was introduced) start +/// with bincode's length prefix for `encrypted_seed` (a varint) and +/// almost never collide with [`STORED_SEED_ENVELOPE_VERSION`] for any +/// realistic envelope shape — the version-byte test below covers the +/// boundary case and the reader falls back to legacy decoding when the +/// tag prefix doesn't match. +fn encode_with_version(envelope: &StoredSeedEnvelope) -> Result<Vec<u8>, TaskError> { + encode_tagged(STORED_SEED_ENVELOPE_VERSION, envelope).map_err(|_| { + TaskError::WalletSeedStorage { + source: Box::new(SecretStoreError::MalformedVault), + } + }) +} + +/// Decode the on-disk shape, accepting both the leading-version-byte +/// form (current) and a legacy bare-bincode form. A future schema bump +/// matches on the leading byte to dispatch to the right decoder. +fn decode_with_version(bytes: &[u8]) -> Result<StoredSeedEnvelope, TaskError> { + let malformed = || TaskError::WalletSeedStorage { + source: Box::new(SecretStoreError::MalformedVault), + }; + decode_tagged_or(bytes, STORED_SEED_ENVELOPE_VERSION, |bytes| { + // Legacy entry: bare bincode of the envelope with no leading + // version byte. Treated as v1. + bincode::serde::decode_from_slice::<StoredSeedEnvelope, _>( + bytes, + bincode::config::standard(), + ) + .map(|(decoded, _)| decoded) + .map_err(|_| malformed()) + }) +} + +/// View borrowing the shared upstream [`SecretStore`] handle. Cheap to +/// construct — callers may build one per operation. +pub struct WalletSeedView<'a> { + pub(crate) secret_store: &'a Arc<SecretStore>, +} + +impl<'a> WalletSeedView<'a> { + /// Borrow a [`SecretStore`] as a typed seed-envelope view. Kept + /// `pub` so benches and downstream tooling can build the view + /// without going through [`WalletBackend::wallet_seeds`]. + pub fn new(secret_store: &'a Arc<SecretStore>) -> Self { + Self { secret_store } + } + + /// Fetch the stored envelope for `seed_hash`, or `None` if nothing + /// is stored under that hash. A row that fails to decode as a + /// `StoredSeedEnvelope` is surfaced as + /// [`TaskError::WalletSeedStorage`] — the vault returned bytes, + /// but DET cannot make sense of them. + pub fn get(&self, seed_hash: &WalletSeedHash) -> Result<Option<StoredSeedEnvelope>, TaskError> { + let Some(bytes) = self + .secret_store + .get(&scope_for(seed_hash), ENVELOPE_LABEL) + .map_err(map_err)? + else { + return Ok(None); + }; + Ok(Some(decode_with_version(bytes.expose_secret())?)) + } + + /// Store `envelope` under `seed_hash`, overwriting any prior value. + /// Idempotent — upstream `SecretStore::set` upserts. + pub fn set( + &self, + seed_hash: &WalletSeedHash, + envelope: &StoredSeedEnvelope, + ) -> Result<(), TaskError> { + let bytes = encode_with_version(envelope)?; + let value = SecretBytes::from_slice(&bytes); + self.secret_store + .set(&scope_for(seed_hash), ENVELOPE_LABEL, &value) + .map_err(map_err) + } + + /// Forget the envelope at `seed_hash`. Idempotent — a missing + /// entry returns `Ok(())`. + pub fn delete(&self, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + // `delete` now returns `Result<bool, _>` (true = existed, false = absent). + // Delete is idempotent here, so we discard the bool. + self.secret_store + .delete(&scope_for(seed_hash), ENVELOPE_LABEL) + .map(|_| ()) + .map_err(map_err) + } + + /// Retained decode-only legacy reader: read the `envelope.v1` row. Alias + /// for [`Self::get`] under the migration-reader name — the loader and the + /// chokepoint reach for it explicitly when the raw seed is absent. + pub fn legacy_envelope_get( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Option<StoredSeedEnvelope>, TaskError> { + self.get(seed_hash) + } + + /// Store the RAW 64-byte BIP-39 seed under `seed.raw.v1` via the seam. + /// No DET-side encryption — the seam writes the bytes verbatim. The + /// non-secret metadata (`uses_password`, hint, xpub) lives in `WalletMeta`. + pub fn set_raw(&self, seed_hash: &WalletSeedHash, seed: &[u8; 64]) -> Result<(), TaskError> { + SecretSeam::new(self.secret_store).put_secret( + &scope_for(seed_hash), + SEED_RAW_LABEL, + &SecretBytes::from_slice(seed), + ) + } + + /// Read the RAW 64-byte seed under `seed.raw.v1`, or `None` if it has not + /// been migrated to the raw label yet. + pub fn get_raw( + &self, + seed_hash: &WalletSeedHash, + ) -> Result<Option<Zeroizing<[u8; 64]>>, TaskError> { + let Some(bytes) = + SecretSeam::new(self.secret_store).get_secret(&scope_for(seed_hash), SEED_RAW_LABEL)? + else { + return Ok(None); + }; + let seed: [u8; 64] = bytes.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::wallet_seed_store", + blob_len = bytes.expose_secret().len(), + "Raw seam seed has wrong length", + ); + map_err(SecretStoreError::MalformedVault) + })?; + Ok(Some(Zeroizing::new(seed))) + } + + /// Idempotent delete of the raw `seed.raw.v1` row. + pub fn delete_raw(&self, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { + SecretSeam::new(self.secret_store).delete_secret(&scope_for(seed_hash), SEED_RAW_LABEL) + } + + /// At-rest [`SecretScheme`] of the `seed.raw.v1` row — `Protected` once the + /// seed is Tier-2 sealed, `Unprotected` for a raw seed, `Absent` when only + /// the legacy `envelope.v1` (or nothing) is present. No password needed. + pub fn scheme(&self, seed_hash: &WalletSeedHash) -> Result<SecretScheme, TaskError> { + SecretSeam::new(self.secret_store).scheme(&scope_for(seed_hash), SEED_RAW_LABEL) + } + + /// Store the 64-byte seed under `seed.raw.v1` **Tier-2 protected**, sealed + /// with this seed's own object `password` (Argon2id + XChaCha20-Poly1305). + /// Replaces any raw/legacy value at the same label (upsert). + pub fn set_protected( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + password: &SecretString, + ) -> Result<(), TaskError> { + SecretSeam::new(self.secret_store).put_secret_protected( + &scope_for(seed_hash), + SEED_RAW_LABEL, + &SecretBytes::from_slice(seed), + password, + ) + } + + /// Read the Tier-2-protected 64-byte seed under `seed.raw.v1`, unsealing + /// with `password`, or `None` if nothing is stored there. A wrong password + /// surfaces as [`SecretStoreError::WrongPassword`] (via the seam). + pub fn get_protected( + &self, + seed_hash: &WalletSeedHash, + password: &SecretString, + ) -> Result<Option<Zeroizing<[u8; 64]>>, TaskError> { + let Some(bytes) = SecretSeam::new(self.secret_store).get_secret_protected( + &scope_for(seed_hash), + SEED_RAW_LABEL, + password, + )? + else { + return Ok(None); + }; + let seed: [u8; 64] = bytes.expose_secret().try_into().map_err(|_| { + tracing::warn!( + target = "wallet_backend::wallet_seed_store", + blob_len = bytes.expose_secret().len(), + "Tier-2 seam seed has wrong length", + ); + map_err(SecretStoreError::MalformedVault) + })?; + Ok(Some(Zeroizing::new(seed))) + } +} + +/// Reuse the 32-byte `WalletSeedHash` as the upstream `WalletId` +/// scope. Both types are `[u8; 32]` newtypes — see +/// `packages/rs-platform-wallet-storage/src/secrets/validate.rs`. +fn scope_for(seed_hash: &WalletSeedHash) -> SecretWalletId { + SecretWalletId::from(*seed_hash) +} + +fn map_err(source: SecretStoreError) -> TaskError { + TaskError::WalletSeedStorage { + source: Box::new(source), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::single_key::open_secret_store; + + fn fresh_store(dir: &std::path::Path) -> Arc<SecretStore> { + let path = dir.join("secrets.pwsvault"); + Arc::new(open_secret_store(&path).expect("open vault")) + } + + fn sample_password_envelope() -> StoredSeedEnvelope { + StoredSeedEnvelope { + encrypted_seed: vec![0xAB; 80], + salt: vec![0x01; 16], + nonce: vec![0x02; 12], + password_hint: Some("granny's birthday".into()), + uses_password: true, + xpub_encoded: vec![0xCD; 78], + } + } + + fn sample_non_password_envelope() -> StoredSeedEnvelope { + StoredSeedEnvelope { + encrypted_seed: vec![0x11; 64], + salt: Vec::new(), + nonce: Vec::new(), + password_hint: None, + uses_password: false, + xpub_encoded: vec![0x22; 78], + } + } + + /// TC-W-001 (storage half) — a written envelope round-trips + /// through `get` and the decoded struct equals the input field by + /// field. Guards the bincode codec and the label binding. + #[test] + fn tc_w_001_envelope_round_trips_through_view() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x11; 32]; + let envelope = sample_password_envelope(); + view.set(&seed_hash, &envelope).expect("set"); + let got = view.get(&seed_hash).expect("get").expect("entry present"); + assert_eq!(got, envelope); + } + + /// TC-W-002 — re-running `set` with the same envelope is + /// idempotent: the second pass overwrites with the same bytes + /// and `get` still returns the same struct. + #[test] + fn tc_w_002_seed_migration_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x22; 32]; + let envelope = sample_password_envelope(); + view.set(&seed_hash, &envelope).expect("first"); + view.set(&seed_hash, &envelope).expect("second"); + let got = view.get(&seed_hash).expect("get").expect("present"); + assert_eq!(got, envelope); + } + + /// A `uses_password = true` envelope round-trips with all + /// password-related fields preserved (salt / nonce / hint). The + /// migration step uses this path; if a future field is dropped + /// here the unlock UI would lose information. + #[test] + fn password_protected_envelope_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x33; 32]; + let envelope = sample_password_envelope(); + view.set(&seed_hash, &envelope).expect("set"); + let got = view.get(&seed_hash).expect("get").expect("present"); + assert!(got.uses_password); + assert_eq!(got.salt, envelope.salt); + assert_eq!(got.nonce, envelope.nonce); + assert_eq!(got.password_hint.as_deref(), Some("granny's birthday")); + assert_eq!(got.xpub_encoded, envelope.xpub_encoded); + } + + /// A `uses_password = false` envelope round-trips with empty + /// salt/nonce and the `false` flag preserved. The xpub still has + /// to come back so the picker keeps working. + #[test] + fn non_password_envelope_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x44; 32]; + let envelope = sample_non_password_envelope(); + view.set(&seed_hash, &envelope).expect("set"); + let got = view.get(&seed_hash).expect("get").expect("present"); + assert!(!got.uses_password); + assert!(got.salt.is_empty()); + assert!(got.nonce.is_empty()); + assert_eq!(got.xpub_encoded, envelope.xpub_encoded); + } + + /// `get` on a missing hash returns `None`, not an error. + #[test] + fn get_missing_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x55; 32]; + assert!(view.get(&seed_hash).unwrap().is_none()); + } + + /// `delete` is idempotent — removing an absent entry succeeds, and + /// a subsequent delete after `set` also succeeds. + #[test] + fn delete_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x66; 32]; + view.delete(&seed_hash).expect("delete absent"); + view.set(&seed_hash, &sample_non_password_envelope()) + .unwrap(); + view.delete(&seed_hash).expect("first delete"); + view.delete(&seed_hash).expect("second delete"); + assert!(view.get(&seed_hash).unwrap().is_none()); + } + + /// A freshly-written envelope's on-disk payload starts + /// with [`STORED_SEED_ENVELOPE_VERSION`], and a legacy bare-bincode + /// payload (written without the leading version byte) still decodes + /// cleanly. Locks the framing the reader needs to keep accepting + /// pre-tag entries. + #[test] + fn sec_005_version_byte_round_trip_and_legacy_fallback() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0x99; 32]; + let envelope = sample_password_envelope(); + view.set(&seed_hash, &envelope).expect("set"); + + // The raw on-disk bytes start with the version tag — the + // storage layer prepends a byte that bincode did not produce. + let raw = store + .get(&scope_for(&seed_hash), ENVELOPE_LABEL) + .expect("get") + .expect("present"); + assert_eq!( + raw.expose_secret()[0], + STORED_SEED_ENVELOPE_VERSION, + "stored payload must start with the current version tag", + ); + // The trailing bytes round-trip through bincode unchanged. + let (re_decoded, _): (StoredSeedEnvelope, _) = bincode::serde::decode_from_slice( + &raw.expose_secret()[1..], + bincode::config::standard(), + ) + .expect("trailing bytes decode as bincode envelope"); + assert_eq!(re_decoded, envelope); + + // Legacy fallback — a value written without the version byte + // (bare bincode) still decodes. + let legacy_bytes = + bincode::serde::encode_to_vec(&envelope, bincode::config::standard()).unwrap(); + let legacy = SecretBytes::from_slice(&legacy_bytes); + let other_hash: WalletSeedHash = [0xAA; 32]; + store + .set(&scope_for(&other_hash), ENVELOPE_LABEL, &legacy) + .unwrap(); + let got = view.get(&other_hash).expect("get").expect("present"); + assert_eq!(got, envelope, "legacy untagged payload still decodes"); + } + + /// Distinct seed hashes live in distinct scopes. The `WalletId` + /// partition is the only thing keeping wallets apart, so a + /// cross-wallet read must surface `None` rather than another + /// wallet's envelope. + #[test] + fn scope_partitions_by_wallet_id() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let a: WalletSeedHash = [0x77; 32]; + let b: WalletSeedHash = [0x88; 32]; + let envelope_a = sample_password_envelope(); + let mut envelope_b = sample_non_password_envelope(); + envelope_b.xpub_encoded = vec![0xEE; 78]; + + view.set(&a, &envelope_a).unwrap(); + view.set(&b, &envelope_b).unwrap(); + + assert_eq!(view.get(&a).unwrap().unwrap(), envelope_a); + assert_eq!(view.get(&b).unwrap().unwrap(), envelope_b); + } + + /// The raw seam path round-trips the exact 64-byte seed and is independent + /// of the legacy `envelope.v1` row (distinct labels). `get_raw` on a hash + /// with only a legacy envelope returns `None`. + #[test] + fn raw_seed_round_trips_independent_of_legacy() { + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let view = WalletSeedView::new(&store); + let seed_hash: WalletSeedHash = [0xB1; 32]; + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(9).wrapping_add(1); + } + + view.set_raw(&seed_hash, &seed).expect("set_raw"); + assert_eq!(*view.get_raw(&seed_hash).unwrap().unwrap(), seed); + // The legacy reader sees nothing under this hash. + assert!(view.legacy_envelope_get(&seed_hash).unwrap().is_none()); + + view.delete_raw(&seed_hash).expect("delete_raw"); + assert!(view.get_raw(&seed_hash).unwrap().is_none()); + + // A legacy-only hash returns None from the raw reader. + let legacy_only: WalletSeedHash = [0xB2; 32]; + view.set(&legacy_only, &sample_non_password_envelope()) + .unwrap(); + assert!(view.get_raw(&legacy_only).unwrap().is_none()); + } +} diff --git a/tests/backend-e2e/README.md b/tests/backend-e2e/README.md index d36d8fee5..de7c81807 100644 --- a/tests/backend-e2e/README.md +++ b/tests/backend-e2e/README.md @@ -11,6 +11,13 @@ application at runtime. All tests are marked `#[ignore]` to prevent them from running during normal `cargo test`. They require network access and a funded wallet. +The harness routes all SDK-deep work — backend tasks (`run_task`) and direct +`dash_sdk::platform::Fetch` calls in test bodies (`run_on_large_stack`) alike — +through a dedicated runtime with a 32 MB thread stack +(`framework/task_runner.rs`), so deep SDK proof verification no longer requires +`RUST_MIN_STACK` to be set by the caller. Exporting it anyway is harmless +belt-and-suspenders for any code path outside that runtime. + ```bash # Run all backend E2E tests cargo test --test backend-e2e --all-features -- --ignored --nocapture @@ -253,19 +260,20 @@ Add `mod my_new_test;` to `main.rs`. The test binary is defined by the ### SPV UTXO spendability timing After broadcasting a transaction, the change output is not immediately spendable. -The SPV WalletManager reports **total balance** (including unconfirmed) but only -includes confirmed/InstantSend-locked UTXOs in its spendable set. This means: +The upstream `platform-wallet` engine drives chain sync and pushes wallet state +through the `EventBridge` into a per-wallet display snapshot. The snapshot's +`total` includes unconfirmed funds, while `confirmed` reflects the +confirmed/InstantSend-locked set actually usable for spending. This means: -- `total_balance_duffs()` may show funds, but `build_unsigned_payment_tx()` fails - with "Insufficient funds" because `account.utxos` is empty. -- `confirmed_balance_duffs()` reflects actually spendable funds. +- `snapshot_balance().total` may show funds, but a send fails with + "Insufficient funds" while the change output is still unconfirmed. +- `snapshot_balance().confirmed` reflects actually spendable funds. The framework mitigates this with: -- **`wait_for_spendable_balance()`** -- polls `Wallet::spv_confirmed_balance()` - (which returns `None` until SPV has synced balance data, avoiding false - positives from the `max_balance()` fallback) and triggers - `reconcile_spv_wallets()` on each iteration. +- **`wait_for_spendable_balance()`** -- polls + `AppContext::snapshot_balance().confirmed` (the EventBridge-pushed snapshot), + waiting until confirmed funds reach the target. - **Post-send wait** -- after funding a test wallet, `create_funded_test_wallet()` waits for the full funded amount to become spendable, then waits for the framework wallet's change output to settle before returning. diff --git a/tests/backend-e2e/core_tasks.rs b/tests/backend-e2e/core_tasks.rs index 2b5c192ba..aae345a46 100644 --- a/tests/backend-e2e/core_tasks.rs +++ b/tests/backend-e2e/core_tasks.rs @@ -1,4 +1,4 @@ -//! Backend E2E tests for CoreTask variants (TC-001 to TC-011). +//! Backend E2E tests for CoreTask variants (TC-001 to TC-012). use crate::framework::fixtures; use crate::framework::harness::ctx; @@ -7,6 +7,7 @@ use dash_evo_tool::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymen use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::model::wallet::single_key::SingleKeyWallet; use std::sync::{Arc, RwLock}; +use std::time::Duration; // TC-001: RefreshWalletInfo — Core only #[ignore] @@ -35,7 +36,9 @@ async fn test_tc001_refresh_wallet_info_core_only() { other => panic!("Expected RefreshedWallet, got: {:?}", other), } - let balance = wallet.read().expect("wallet lock").total_balance_duffs(); + let balance = app_context + .snapshot_balance(&ctx.framework_wallet_hash) + .total; assert!(balance > 0, "Framework wallet balance should be > 0"); } @@ -68,10 +71,10 @@ async fn test_tc002_refresh_wallet_info_core_and_platform() { // TC-003: RefreshSingleKeyWalletInfo // -// Single-key wallets require Dash Core (RPC) for UTXO discovery — SPV tracks -// HD wallet-derived addresses only. The backend now returns a typed -// `OperationRequiresDashCore` error in SPV mode; the test asserts that -// mode-specific outcome rather than an unconditional success. +// Single-key wallets are intentionally unsupported this release (see +// single-key-mock.md, Decision #7): every single-key task arm returns the typed +// `SingleKeyWalletsUnsupported`. The test asserts that typed outcome rather than +// an unconditional success. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn test_tc003_refresh_single_key_wallet_info() { @@ -98,32 +101,17 @@ async fn test_tc003_refresh_single_key_wallet_info() { let task = BackendTask::CoreTask(CoreTask::RefreshSingleKeyWalletInfo(skw_arc.clone())); let result = run_task(app_context, task).await; - // Single-key wallets require Dash Core for UTXO discovery. In SPV mode - // the backend returns a typed `OperationRequiresDashCore` error; in RPC - // mode the refresh should succeed. - match app_context.core_backend_mode() { - dash_evo_tool::spv::CoreBackendMode::Spv => { - let err = result - .expect_err("RefreshSingleKeyWalletInfo must fail in SPV mode with a typed error"); - assert!( - matches!( - err, - dash_evo_tool::backend_task::error::TaskError::OperationRequiresDashCore { .. } - ), - "Expected OperationRequiresDashCore in SPV mode, got: {:?}", - err - ); - } - dash_evo_tool::spv::CoreBackendMode::Rpc => { - let result = result.expect("RefreshSingleKeyWalletInfo should succeed in RPC mode"); - match result { - BackendTaskSuccessResult::RefreshedWallet { .. } => {} - other => panic!("Expected RefreshedWallet, got: {:?}", other), - } - // Balance may be 0 for a fresh key — just verify the read succeeds - let _balance = skw_arc.read().expect("skw lock").total_balance_duffs(); - } - } + // Single-key wallets are unsupported in this version; the backend + // returns the typed `SingleKeyWalletsUnsupported` error. + let err = result.expect_err("RefreshSingleKeyWalletInfo must fail with a typed error"); + assert!( + matches!( + err, + dash_evo_tool::backend_task::error::TaskError::SingleKeyWalletsUnsupported + ), + "Expected SingleKeyWalletsUnsupported, got: {:?}", + err + ); } // TC-004: CreateRegistrationAssetLock @@ -204,57 +192,19 @@ async fn test_tc005_create_top_up_asset_lock() { } } -// TC-006: RecoverAssetLocks -// -// In SPV mode the backend returns an empty `RecoveredAssetLocks { 0, 0 }` -// result because asset lock finality is delivered via InstantLock / ChainLock -// events — no explicit recovery pass is needed. In RPC mode the Core wallet -// is scanned for untracked asset lock transactions. -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn test_tc006_recover_asset_locks() { - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let wallet = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets - .get(&ctx.framework_wallet_hash) - .expect("framework wallet must exist") - .clone() - }; - - let task = BackendTask::CoreTask(CoreTask::RecoverAssetLocks(wallet.clone())); - let result = run_task(app_context, task) - .await - .expect("RecoverAssetLocks should succeed"); - - match result { - BackendTaskSuccessResult::RecoveredAssetLocks { - recovered_count, - total_amount, - } => { - // 0 recoveries is valid if no asset locks exist - tracing::info!( - "RecoverAssetLocks: count={}, total={} duffs", - recovered_count, - total_amount - ); - } - other => panic!("Expected RecoveredAssetLocks, got: {:?}", other), - } -} +// TC-006: RecoverAssetLocks — REMOVED (Decision #8: hard-removed; upstream +// AssetLockManager tracks asset locks continuously, no explicit recovery pass) // TC-007: GetBestChainLock — REMOVED (Core RPC-specific, not available in SPV mode) // TC-008: GetBestChainLocks — REMOVED (Core RPC-specific, not available in SPV mode) // TC-009: SendSingleKeyWalletPayment // -// Broadcast now routes through `AppContext::broadcast_raw_transaction`, so a -// single-key send can reach the network in both RPC and SPV modes. UTXO -// discovery still requires Dash Core; in SPV mode the test verifies that -// `RefreshSingleKeyWalletInfo` returns `OperationRequiresDashCore` and stops -// before attempting the send (no spendable UTXOs available). +// Single-key wallets are intentionally unsupported this release (see +// single-key-mock.md, Decision #7): every single-key task arm returns the typed +// `SingleKeyWalletsUnsupported`. The test verifies that +// `RefreshSingleKeyWalletInfo` returns `SingleKeyWalletsUnsupported` and stops; +// the single-key send flow is unreachable until single-key wallets are reinstated. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn test_tc009_send_single_key_wallet_payment() { @@ -277,62 +227,30 @@ async fn test_tc009_send_single_key_wallet_payment() { ) .expect("Failed to create SingleKeyWallet"); - let skw_address = skw.address.to_string(); let skw_arc = Arc::new(RwLock::new(skw)); - // Fund the single-key wallet from the framework wallet - let framework_wallet = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets - .get(&ctx.framework_wallet_hash) - .expect("framework wallet must exist") - .clone() - }; - - run_task( - app_context, - BackendTask::CoreTask(CoreTask::SendWalletPayment { - wallet: framework_wallet, - request: WalletPaymentRequest { - recipients: vec![PaymentRecipient { - address: skw_address.clone(), - amount_duffs: 500_000, - }], - subtract_fee_from_amount: false, - memo: Some("TC-009 single key funding".to_string()), - override_fee: None, - }, - }), - ) - .await - .expect("Funding single-key wallet should succeed"); - - // Wait for the transaction to propagate, then refresh UTXOs. - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - - // Backend E2E runs against SPV only (see tests/backend-e2e/README.md), and - // single-key wallets depend on Core RPC for UTXO refresh. The refresh task - // therefore returns `OperationRequiresDashCore` — we verify the typed error - // and stop; the send step is unreachable without refreshed UTXOs. + // Single-key wallets are unsupported this release: the refresh + // arm returns the typed `SingleKeyWalletsUnsupported` regardless of network + // mode. We verify the typed error and stop; the send step is unreachable + // until single-key wallets are reinstated. let refresh_result = run_task( app_context, BackendTask::CoreTask(CoreTask::RefreshSingleKeyWalletInfo(skw_arc.clone())), ) .await; - let err = refresh_result - .expect_err("RefreshSingleKeyWalletInfo must fail in SPV mode with a typed error"); + let err = refresh_result.expect_err("RefreshSingleKeyWalletInfo must fail with a typed error"); assert!( matches!( err, - dash_evo_tool::backend_task::error::TaskError::OperationRequiresDashCore { .. } + dash_evo_tool::backend_task::error::TaskError::SingleKeyWalletsUnsupported ), - "Expected OperationRequiresDashCore in SPV mode, got: {:?}", + "Expected SingleKeyWalletsUnsupported, got: {:?}", err ); tracing::info!( - "TC-009: single-key wallet flow is not supported in SPV mode; \ - verified typed OperationRequiresDashCore error and skipping send step." + "TC-009: single-key wallets are unsupported this release; \ + verified typed SingleKeyWalletsUnsupported error and skipping send step." ); // ---------------------------------------------------------------------- @@ -387,8 +305,6 @@ async fn test_tc009_send_single_key_wallet_payment() { // address: recipient_address, // amount_duffs: 1_000, // }], - // subtract_fee_from_amount: true, - // memo: Some("TC-009 send back".to_string()), // override_fee: None, // }, // }), @@ -436,8 +352,6 @@ async fn test_tc011_send_wallet_payment_invalid_address() { address: "not-a-valid-address!!!".to_string(), amount_duffs: 1_000, }], - subtract_fee_from_amount: false, - memo: None, override_fee: None, }, }); @@ -453,3 +367,77 @@ async fn test_tc011_send_wallet_payment_invalid_address() { let err = result.unwrap_err(); tracing::info!("TC-011: got expected error variant: {:?}", err); } + +// TC-012: CreateRegistrationAssetLock — freshly-registered wallet, added +// *after* SPV is already connected and synced, as opposed to TC-004's +// framework wallet (registered before the SPV client's first sync pass — +// see `BackendTestContext::init` in `framework/harness.rs`). Isolates +// whether a wallet's coin-selection can pick an incorrectly-spendable UTXO +// regardless of when it was registered, or whether that is specific to +// wallets present from the client's initial (potentially long-lived, +// history-accumulating) sync. See +// docs/ai-design/2026-07-07-asset-lock-finality-retest/retest-findings.md +// for the investigation this isolates. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_tc012_create_registration_asset_lock_late_added_wallet() { + let ctx = ctx().await; + let app_context = &ctx.app_context; + + // A brand-new wallet has no chain history predating its own funding + // transaction below — there is no window in which a real spend of one + // of its outputs could have happened without this test observing it. + let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(2_000_000).await; + + // `create_funded_test_wallet` only waits for `DetWalletBalance::spendable()` + // (confirmed + unconfirmed — a UI-display heuristic, see + // `wallet_backend/snapshot.rs`'s FUND-SAFETY MANDATE doc comment). The + // real asset-lock coin-selector requires strictly confirmed or IS-locked + // funds, so a plain unconfirmed mempool deposit satisfies `spendable()` + // immediately while still yielding "No UTXOs available for selection" in + // the asset-lock builder — a harness/product balance-classification + // mismatch, not the question this test isolates. Wait for `.confirmed` + // specifically (IS-lock or block confirmation) before attempting the + // asset lock, so a genuine coin-selection defect isn't confounded with + // this test-harness timing gap. Budgeted for a full Dash block (~2.5min + // average) since InstantSend lock arrival isn't guaranteed within any + // fixed window in practice — observed anywhere from a few seconds to a + // 180s window with zero IS-lock activity at all during this + // investigation. + let confirm_deadline = std::time::Instant::now() + Duration::from_secs(420); + loop { + let confirmed = app_context.snapshot_balance(&seed_hash).confirmed; + if confirmed >= 2_000_000 { + break; + } + assert!( + std::time::Instant::now() < confirm_deadline, + "TC-012: funding tx for the late-added wallet did not reach confirmed \ + status (IS-lock or block) within 420s — confirmed={confirmed}, target=2_000_000" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Use identity index 98 to avoid collision with TC-004 (index 99) and + // the shared fixtures (index 0). Credits match TC-004 (100_000_000 + // credits == 100_000 duffs) for a direct comparison. + let task = BackendTask::CoreTask(CoreTask::CreateRegistrationAssetLock( + wallet_arc.clone(), + 100_000_000, + 98, + )); + + let result = run_task(app_context, task) + .await + .expect("CreateRegistrationAssetLock should succeed for a freshly-added, funded wallet"); + + match result { + BackendTaskSuccessResult::Message(msg) => { + tracing::info!("TC-012: asset lock broadcast message: {}", msg); + } + other => panic!( + "TC-012: expected Message from CreateRegistrationAssetLock, got: {:?}", + other + ), + } +} diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index 5315fdad3..867d164ea 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -1,5 +1,13 @@ //! DashPayTask backend E2E tests (TC-031 to TC-044). //! +//! DEFERRED: this module is currently disabled (commented out in +//! `tests/backend-e2e/main.rs`). The dashpay backend depends on upstream +//! `platform-wallet` dashpay support that is still incomplete; the completion +//! lands in `dashpay/platform#3841` ("fix(platform-wallet)!: complete dashpay", +//! shumkov, branch `feat/dashpay-m1-sync-correctness`). Re-enable the `mod +//! dashpay_tasks;` declaration once that PR merges and the DET platform-wallet +//! dep is bumped. +//! //! Tests run serially via `--test-threads=1`. TC-037 through TC-042 form a //! sequential contact flow merged into a single lifecycle test: //! send request -> load requests -> accept -> register addresses -> update info. @@ -15,6 +23,7 @@ use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::Qua use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; /// TC-031: LoadProfile — identity with no profile @@ -193,6 +202,98 @@ async fn tc_034_load_contacts_empty() { } } +/// TC-046: offline contacts read serves the DET contact-profile cache. +/// +/// `LoadContactsOffline` must resolve without a network round-trip, reading +/// relationships from rehydrated state and display profiles from the DET +/// contact-profile cache. Here a profile is seeded directly into the cache and +/// the offline read is expected to surface a non-empty contact list (the +/// fixture pair has an established contact) without erroring. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn tc_046_load_contacts_offline_serves_cache() { + use dash_evo_tool::wallet_backend::CachedContactProfile; + + let ctx = harness::ctx().await; + let pair = fixtures::shared_dashpay_pair().await; + + let contact_id = pair.identity_b.identity.id(); + + // Seed a cached profile for the contact so the offline read can paint it. + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend wired"); + backend + .contact_profile_cache() + .put( + &contact_id, + &CachedContactProfile { + username: Some("cached-name.dash".to_string()), + display_name: Some("Cached Name".to_string()), + avatar_url: Some("https://example.com/cached.png".to_string()), + bio: None, + account_reference: Some(3), + }, + ) + .expect("seed cached profile"); + + // Ensure A has a contact relationship with B so the offline read has a row + // to serve — without this the assertions below could pass vacuously on an + // empty list. A sent contact request surfaces as a `pending` contact, which + // is enough for the cache-merge path under test; the step is idempotent + // (it tolerates an already-established relationship from a prior run). + step_send_contact_request(ctx, pair).await; + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactsOffline { + identity: pair.identity_a.clone(), + })); + let result = run_task(&ctx.app_context, task) + .await + .expect("TC-046: offline read should not fail"); + + let contacts = match result { + BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => contacts, + other => panic!("TC-046: expected DashPayContactsWithInfo, got: {:?}", other), + }; + tracing::info!( + "TC-046: offline read returned {} contacts without network", + contacts.len() + ); + + // The offline read MUST serve a non-empty list — an empty result means the + // cache was not consulted (or the relationship was not rehydrated), which + // is the bug this test guards against. + assert!( + !contacts.is_empty(), + "TC-046: offline read must return the established contact, got an empty list" + ); + + // The seeded contact must be present and carry the cached display fields — + // proof the offline read served the DET contact-profile cache, not the + // network (no SDK round-trip happens in `LoadContactsOffline`). + let c = contacts + .iter() + .find(|c| c.identity_id == contact_id) + .unwrap_or_else(|| { + panic!("TC-046: the established contact B must appear in the offline read") + }); + assert_eq!( + c.username.as_deref(), + Some("cached-name.dash"), + "TC-046: cached username must surface in the offline read (served from cache, not network)" + ); + assert_eq!( + c.avatar_url.as_deref(), + Some("https://example.com/cached.png"), + "TC-046: cached avatar URL must surface in the offline read" + ); + assert_eq!( + c.account_reference, 3, + "TC-046: cached account reference must surface in the offline read (the \"Account #N\" badge)" + ); +} + /// TC-035: LoadContactRequests — empty #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] @@ -255,7 +356,7 @@ async fn step_send_contact_request( ) { tracing::info!("=== Step 1: SendContactRequest (A -> B) ==="); - let (signing_key, _signing_key_bytes) = &pair.signing_key_a; + let signing_key = &pair.signing_key_a; // TODO: DPNS propagation delay on username resolution // Expected: SendContactRequest resolves the DPNS name immediately after @@ -407,6 +508,77 @@ async fn step_register_dashpay_addresses( } } +/// Exercise the real seed-bearing contact receiving-account registration path +/// against a live (seeded) wallet, idempotently. +/// +/// `tc_045` injects a sidecar mapping and feeds synthetic outputs, so it proves +/// match/record only. This drives `bootstrap_wallet_addresses_jit` — the boot / +/// unlock path that calls `register_contact_receiving_accounts` for every +/// established contact — which derives and registers the hardened DIP-15 +/// receiving account so SPV watches the addresses the contact pays us at. +/// +/// Requires `dashpay_sync` to run first so the upstream wallet-manager's +/// `established_contacts` is populated; without it `established_contact_pairs` +/// returns an empty slice and the step is a silent no-op. Asserts that at least +/// one account was wired after the first pass. The second pass must also succeed +/// without newly wiring additional accounts (idempotency check: `bump_monitor_revision` +/// is skipped on re-registration so no spurious bloom-filter rebuilds occur). +async fn step_register_contact_receiving_accounts( + ctx: &crate::framework::harness::BackendTestContext, + pair: &fixtures::SharedDashPayPair, +) { + tracing::info!("=== Step: register contact receiving accounts (watch faucet) ==="); + + let owner_id = pair.identity_b.identity.id(); + let seed_hash = pair + .identity_b + .dashpay_wallet_seed_hash() + .expect("identity_b must have a DashPay wallet"); + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend wired"); + + // Populate upstream established_contacts via dashpay_sync so + // established_contact_pairs returns the mutual contact with identity_a. + // Without this, the view reads an empty in-memory map and the step no-ops. + backend + .dashpay_sync(&owner_id) + .await + .expect("dashpay_sync must succeed before registration"); + + // First pass: bootstrap should register at least one DashpayReceivingFunds + // account (the mutual contact with identity_a). + ctx.app_context + .bootstrap_wallet_addresses_jit(&pair.wallet_b) + .await; + + let count_after_first = backend.dashpay_receiving_account_count(&seed_hash).await; + assert!( + count_after_first > 0, + "Expected at least one DashpayReceivingFunds account after first registration, got 0. \ + Check that dashpay_sync populated established_contacts before the bootstrap." + ); + tracing::info!( + count = count_after_first, + "First registration: {} DashpayReceivingFunds account(s) wired", + count_after_first + ); + + // Second pass: idempotent re-run must not panic, must not add new accounts + // (same contacts → same keys → len unchanged), and must not bump + // monitor_revision (which would cause a spurious bloom-filter rebuild). + ctx.app_context + .bootstrap_wallet_addresses_jit(&pair.wallet_b) + .await; + + let count_after_second = backend.dashpay_receiving_account_count(&seed_hash).await; + assert_eq!( + count_after_second, count_after_first, + "Re-registration must not add new accounts (idempotent re-run changed the count)" + ); +} + async fn step_update_contact_info( ctx: &crate::framework::harness::BackendTestContext, pair: &fixtures::SharedDashPayPair, @@ -590,10 +762,19 @@ async fn tc_037_dashpay_contact_lifecycle() { step_register_dashpay_addresses(ctx, pair).await; + step_register_contact_receiving_accounts(ctx, pair).await; + step_update_contact_info(ctx, pair).await; } -/// TC-041: LoadPaymentHistory — empty +/// TC-041: LoadPaymentHistory smoke check. +/// +/// Post-D4d the DET-side `dashpay_payments` table is gone — payments are +/// read from the upstream `ManagedIdentity` via +/// [`WalletBackend::dashpay_view`]. This test confirms the +/// `LoadPaymentHistory` backend-task wiring still resolves end-to-end +/// through the adapter; populated-state coverage lives in +/// `tc_037_dashpay_contact_lifecycle` and `tc_044_pay_contact`. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn tc_041_load_payment_history_empty() { @@ -611,7 +792,7 @@ async fn tc_041_load_payment_history_empty() { match result { BackendTaskSuccessResult::DashPayPaymentHistory(history) => { tracing::info!( - "TC-041: LoadPaymentHistory returned {} entries", + "TC-041: LoadPaymentHistory returned {} entries via adapter", history.len() ); } @@ -619,6 +800,162 @@ async fn tc_041_load_payment_history_empty() { } } +/// TC-045: incoming contact-payment detection records each matched output. +/// +/// Drives the [`DashPayTask::DetectIncomingContactPayments`] path the +/// `EventBridge` fires for freshly-seen wallet transactions. Known +/// `(owner, address) -> (contact, index)` mappings are persisted directly via +/// the wallet-backend sidecar (the same write `RegisterDashPayAddresses` +/// performs), then synthetic received outputs are fed to the detector. +/// +/// The single transaction here pays TWO different contacts in two outputs +/// (distinct `vout`s) to prove per-output keying: keying by `tx_id` alone would +/// let the second output overwrite the first and lose a payment. Both must +/// surface in `LoadPaymentHistory` with the correct amount and counterparty, +/// and a re-scan must not double-count either. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn tc_045_detect_incoming_contact_payment() { + use dash_evo_tool::model::dashpay::DetectedIncomingOutput; + + let ctx = harness::ctx().await; + let pair = fixtures::shared_dashpay_pair().await; + + let owner = pair.identity_a.identity.id(); + let contact_0 = pair.identity_b.identity.id(); + // A second distinct counterparty for the second output. Its identity need + // not exist on-platform — the detector keys purely off the sidecar address + // map, and `LoadPaymentHistory` surfaces an unknown counterparty by id. + let contact_1 = Identifier::from([0x5a; 32]); + + // Two deterministic, network-valid receiving addresses (distinct pubkeys) + // standing in for two freshly-derived contact addresses. Derived from fixed + // secret keys so the addresses stay stable across runs while remaining valid + // curve points — secp256k1 now rejects raw bytes that are not on the curve, + // so a hand-written `[0x02; 33]` is no longer a usable public key. + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let derive_pubkey = |seed: [u8; 32]| { + let secret_key = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&seed) + .expect("fixed test secret key is a valid scalar"); + dash_sdk::dpp::dashcore::PublicKey::new(secret_key.public_key(&secp)) + }; + let pubkey_0 = derive_pubkey([0x01; 32]); + let pubkey_1 = derive_pubkey([0x02; 32]); + let address_0 = + dash_sdk::dpp::dashcore::Address::p2pkh(&pubkey_0, ctx.app_context.network()).to_string(); + let address_1 = + dash_sdk::dpp::dashcore::Address::p2pkh(&pubkey_1, ctx.app_context.network()).to_string(); + // One txid, two outputs — re-runs upsert in place per (txid, vout). + let tx_id = format!("detincoming{}", hex::encode(&owner.to_buffer()[..8])); + + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend wired"); + backend + .dashpay_set_address_mapping(&owner, &address_0, &contact_0, 0) + .expect("persist address mapping 0"); + backend + .dashpay_set_address_mapping(&owner, &address_1, &contact_1, 0) + .expect("persist address mapping 1"); + + let outputs = vec![ + DetectedIncomingOutput { + txid: tx_id.clone(), + vout: 0, + address: address_0.clone(), + amount_duffs: 12_345, + }, + DetectedIncomingOutput { + txid: tx_id.clone(), + vout: 1, + address: address_1.clone(), + amount_duffs: 67_890, + }, + ]; + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::DetectIncomingContactPayments { + outputs: outputs.clone(), + })); + let result = run_task(&ctx.app_context, task) + .await + .expect("TC-045: detection should not fail"); + assert!( + matches!( + result, + BackendTaskSuccessResult::Refresh | BackendTaskSuccessResult::None + ), + "TC-045: detection returns Refresh on a match or None on a miss, got: {:?}", + result + ); + + // Both recorded payments must appear in A's history under the same bare + // tx_id — one per contact output, with the right amount + counterparty. + let load_history = || async { + let history = run_task( + &ctx.app_context, + BackendTask::DashPayTask(Box::new(DashPayTask::LoadPaymentHistory { + identity: pair.identity_a.clone(), + })), + ) + .await + .expect("TC-045: LoadPaymentHistory should not fail"); + match history { + BackendTaskSuccessResult::DashPayPaymentHistory(entries) => entries, + other => panic!("TC-045: expected DashPayPaymentHistory, got: {:?}", other), + } + }; + + let c0_short = contact_0.to_string(Encoding::Base58); + let c0_short = &c0_short[..c0_short.len().min(8)]; + let c1_short = contact_1.to_string(Encoding::Base58); + let c1_short = &c1_short[..c1_short.len().min(8)]; + + let entries = load_history().await; + let for_tx: Vec<_> = entries.iter().filter(|(id, ..)| id == &tx_id).collect(); + assert_eq!( + for_tx.len(), + 2, + "TC-045: both contact outputs of one tx must be recorded, got {:?}", + for_tx + ); + // First output: 12_345 to contact_0. + assert!( + for_tx + .iter() + .any(|(_, name, amount, incoming, _)| *amount == 12_345 + && *incoming + && name.contains(c0_short)), + "TC-045: the first output (12345 from contact_0) must be recorded, got {:?}", + for_tx + ); + // Second output: 67_890 to contact_1 — proof the second did not clobber + // the first under a shared tx_id. + assert!( + for_tx + .iter() + .any(|(_, name, amount, incoming, _)| *amount == 67_890 + && *incoming + && name.contains(c1_short)), + "TC-045: the second output (67890 from contact_1) must be recorded distinctly, got {:?}", + for_tx + ); + + // Idempotency: a re-scan of the same outputs must not add duplicates. + let rescan = BackendTask::DashPayTask(Box::new(DashPayTask::DetectIncomingContactPayments { + outputs, + })); + run_task(&ctx.app_context, rescan) + .await + .expect("TC-045: re-scan should not fail"); + let after = load_history().await; + let count_after = after.iter().filter(|(id, ..)| id == &tx_id).count(); + assert_eq!( + count_after, 2, + "TC-045: a re-scan of the same transaction must not double-count per (txid, vout)" + ); +} + /// TC-043: RejectContactRequest (requires third DashPay identity) #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] @@ -629,7 +966,7 @@ async fn tc_043_reject_contact_request() { // Create a third DashPay identity (C) from a fresh funded wallet tracing::info!("TC-043: creating third DashPay identity (C)..."); let (seed_hash_c, wallet_c) = ctx.create_funded_test_wallet(30_000_000).await; - let (qi_c, _key_bytes_c) = + let qi_c = dashpay_helpers::create_dashpay_identity(&ctx.app_context, &wallet_c, seed_hash_c).await; // Register a DPNS name for C so A can send a contact request @@ -698,7 +1035,7 @@ async fn tc_043_reject_contact_request() { // the propagation check above confirms the name is queryable // Actual: occasionally fails with UsernameResolutionFailed because a // different DAPI node serves the query before it has the name - let (signing_key_a, _) = &pair.signing_key_a; + let signing_key_a = &pair.signing_key_a; tracing::info!( "TC-043: sending contact request from A to C ('{}')", username_c @@ -779,7 +1116,7 @@ async fn tc_044_send_contact_request_nonexistent_username() { let ctx = harness::ctx().await; let pair = fixtures::shared_dashpay_pair().await; - let (signing_key, _) = &pair.signing_key_a; + let signing_key = &pair.signing_key_a; let task = BackendTask::DashPayTask(Box::new(DashPayTask::SendContactRequest { identity: pair.identity_a.clone(), diff --git a/tests/backend-e2e/event_bridge_live.rs b/tests/backend-e2e/event_bridge_live.rs new file mode 100644 index 000000000..82a41d606 --- /dev/null +++ b/tests/backend-e2e/event_bridge_live.rs @@ -0,0 +1,35 @@ +//! Live EventBridge validation: a real upstream `SpvRuntime` sync must drive +//! the DET `EventBridge` (`on_progress` / `on_sync_event`) → `ConnectionStatus` +//! → frame-loop `TaskResult::Refresh` → repaint. +//! +//! Network-dependent, so `#[ignore]`. The deterministic mapping logic is +//! covered by unit tests in `src/wallet_backend/event_bridge.rs`; this test +//! exercises the full live path: the shared harness builds and starts a real +//! `WalletBackend`, whose upstream `SpvRuntime` drives the `EventBridge`. + +use crate::framework::harness::ctx; +use dash_evo_tool::model::spv_status::SpvStatus; +use std::time::Duration; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "network-dependent; drives a real WalletBackend sync via the shared harness"] +async fn event_bridge_drives_connection_status_on_live_sync() { + let app_ctx = ctx().await.app_context.clone(); + + // The harness started a real WalletBackend; the upstream SpvRuntime sync + // loop fires EventBridge callbacks, so ConnectionStatus must leave Idle + // and reach Running on completion. + let deadline = std::time::Instant::now() + Duration::from_secs(300); + loop { + let status = app_ctx.connection_status().spv_status(); + if status == SpvStatus::Running { + break; + } + assert!( + std::time::Instant::now() < deadline, + "EventBridge did not drive ConnectionStatus to Running within 5m \ + (last status: {status:?})" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } +} diff --git a/tests/backend-e2e/framework/cleanup.rs b/tests/backend-e2e/framework/cleanup.rs index 49174b040..c443292f4 100644 --- a/tests/backend-e2e/framework/cleanup.rs +++ b/tests/backend-e2e/framework/cleanup.rs @@ -55,8 +55,6 @@ pub async fn cleanup_test_wallets( // Derive a fresh receive address for each sweep to distribute UTXOs // across multiple addresses instead of concentrating on a single one. - // Clone the wallet Arc before dropping the read lock to avoid holding - // it across get_receive_address (which may acquire a write lock). let framework_wallet = { let wallets = app_context.wallets().read().expect("wallets lock"); wallets @@ -64,18 +62,15 @@ pub async fn cleanup_test_wallets( .expect("framework wallet must exist") .clone() }; - let framework_address = get_receive_address(app_context, &framework_wallet); + let framework_address = get_receive_address(app_context, &framework_wallet).await; // Wait briefly for SPV to sync this wallet's balance. let _ = wait::wait_for_spendable_balance(app_context, hash, 1, Duration::from_secs(1)).await; let (spendable, total) = { - let wallet = wallet_arc.read().expect("wallet lock"); - ( - wallet.confirmed_balance_duffs(), - wallet.total_balance_duffs(), - ) + let snap = app_context.snapshot_balance(&hash); + (snap.confirmed, snap.total) }; // Delete wallets with no funds at all — they're fully spent orphans @@ -100,14 +95,22 @@ pub async fn cleanup_test_wallets( continue; } - // Attempt to sweep spendable funds back to framework wallet + // Attempt to sweep spendable funds back to framework wallet. The + // wallet engine cannot subtract the fee from the amount, so reserve + // the estimated L1 fee here and send the remainder. + let utxo_count = app_context.snapshot_utxo_count(&hash); + let Some(sweep_amount) = dash_evo_tool::model::fee_estimation::core_max_send_amount_duffs( + spendable, utxo_count, 1, + ) else { + // Spendable funds do not cover the network fee — skip this run. + continue; + }; + let request = WalletPaymentRequest { recipients: vec![PaymentRecipient { address: framework_address.clone(), - amount_duffs: spendable, + amount_duffs: sweep_amount, }], - subtract_fee_from_amount: true, - memo: Some("E2E cleanup: sweep orphaned wallet".to_string()), override_fee: None, }; @@ -121,7 +124,7 @@ pub async fn cleanup_test_wallets( swept += 1; tracing::info!( "Cleanup: returned {} duffs from orphaned wallet {:?}", - spendable, + sweep_amount, &hash[..4] ); } diff --git a/tests/backend-e2e/framework/dashpay_helpers.rs b/tests/backend-e2e/framework/dashpay_helpers.rs index d30eb1839..ed720258f 100644 --- a/tests/backend-e2e/framework/dashpay_helpers.rs +++ b/tests/backend-e2e/framework/dashpay_helpers.rs @@ -23,15 +23,14 @@ use dash_evo_tool::model::qualified_identity::QualifiedIdentity; /// encryption and decryption keys, so this simply delegates to the standard /// identity registration flow. /// -/// Returns the QualifiedIdentity and the raw master authentication private key -/// bytes captured during registration (before the wallet encrypts them). +/// Returns the registered `QualifiedIdentity`; signing in tests uses the +/// identity's public keys (the private keys are derived just-in-time). pub async fn create_dashpay_identity( app_context: &Arc<AppContext>, wallet_arc: &Arc<RwLock<Wallet>>, wallet_seed_hash: WalletSeedHash, -) -> (QualifiedIdentity, Vec<u8>) { - let (reg_info, master_key_bytes) = - build_identity_registration(app_context, wallet_arc, wallet_seed_hash); +) -> QualifiedIdentity { + let reg_info = build_identity_registration(app_context, wallet_arc, wallet_seed_hash).await; let task = BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)); let result = run_task(app_context, task) @@ -45,7 +44,7 @@ pub async fn create_dashpay_identity( qi.identity.id(), fee ); - (qi, master_key_bytes) + qi } other => panic!( "create_dashpay_identity: expected RegisteredIdentity, got: {:?}", diff --git a/tests/backend-e2e/framework/fixtures.rs b/tests/backend-e2e/framework/fixtures.rs index 2fbb42e3e..2bd4da220 100644 --- a/tests/backend-e2e/framework/fixtures.rs +++ b/tests/backend-e2e/framework/fixtures.rs @@ -39,7 +39,6 @@ pub struct SharedIdentity { pub wallet_arc: Arc<RwLock<Wallet>>, pub wallet_seed_hash: WalletSeedHash, pub signing_key: IdentityPublicKey, - pub signing_key_bytes: Vec<u8>, } /// Get (or initialize) the shared identity fixture. @@ -53,8 +52,8 @@ pub async fn shared_identity() -> &'static SharedIdentity { tracing::info!("SharedIdentity: creating funded test wallet (30M duffs)..."); let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(30_000_000).await; - let (reg_info, master_key_bytes) = - build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash); + let reg_info = + build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash).await; let task = BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)); let result = run_task(&ctx.app_context, task) @@ -83,7 +82,6 @@ pub async fn shared_identity() -> &'static SharedIdentity { wallet_arc, wallet_seed_hash: seed_hash, signing_key, - signing_key_bytes: master_key_bytes, } }) .await @@ -139,8 +137,7 @@ pub async fn shared_token() -> &'static SharedToken { let owner_id = si.qualified_identity.identity.id(); let qualified_contracts = ctx .app_context - .db() - .get_contracts(&ctx.app_context, None, None) + .get_contracts() .expect("SharedToken: failed to query contracts from DB"); let contract = qualified_contracts @@ -188,8 +185,8 @@ pub struct SharedDashPayPair { pub identity_b: dash_evo_tool::model::qualified_identity::QualifiedIdentity, pub username_a: String, pub username_b: String, - pub signing_key_a: (IdentityPublicKey, Vec<u8>), - pub signing_key_b: (IdentityPublicKey, Vec<u8>), + pub signing_key_a: IdentityPublicKey, + pub signing_key_b: IdentityPublicKey, pub wallet_a: Arc<RwLock<Wallet>>, pub wallet_b: Arc<RwLock<Wallet>>, } @@ -207,10 +204,7 @@ pub async fn shared_dashpay_pair() -> &'static SharedDashPayPair { ); // Fund wallets and register identities in parallel (A and B // are independent — no shared state between them). - let ( - (seed_hash_a, wallet_a, qi_a, key_bytes_a), - (seed_hash_b, wallet_b, qi_b, key_bytes_b), - ) = tokio::join!( + let ((seed_hash_a, wallet_a, qi_a), (seed_hash_b, wallet_b, qi_b)) = tokio::join!( create_dashpay_member(&ctx.app_context, ctx), create_dashpay_member(&ctx.app_context, ctx), ); @@ -279,8 +273,8 @@ pub async fn shared_dashpay_pair() -> &'static SharedDashPayPair { tokio::time::sleep(poll_interval).await; } - let signing_key_a = (find_authentication_public_key(&qi_a), key_bytes_a); - let signing_key_b = (find_authentication_public_key(&qi_b), key_bytes_b); + let signing_key_a = find_authentication_public_key(&qi_a); + let signing_key_b = find_authentication_public_key(&qi_b); tracing::info!( "SharedDashPayPair: ready — A={:?} ({}), B={:?} ({})", @@ -341,12 +335,10 @@ async fn create_dashpay_member( WalletSeedHash, Arc<RwLock<Wallet>>, dash_evo_tool::model::qualified_identity::QualifiedIdentity, - Vec<u8>, ) { let (seed_hash, wallet) = ctx.create_funded_test_wallet(30_000_000).await; - let (qi, key_bytes) = - dashpay_helpers::create_dashpay_identity(app_context, &wallet, seed_hash).await; - (seed_hash, wallet, qi, key_bytes) + let qi = dashpay_helpers::create_dashpay_identity(app_context, &wallet, seed_hash).await; + (seed_hash, wallet, qi) } /// Register a DPNS name for a qualified identity. diff --git a/tests/backend-e2e/framework/funding.rs b/tests/backend-e2e/framework/funding.rs index 84ee0e379..a284f6659 100644 --- a/tests/backend-e2e/framework/funding.rs +++ b/tests/backend-e2e/framework/funding.rs @@ -1,10 +1,60 @@ //! Faucet HTTP client and balance verification for test wallets on testnet. +use crate::framework::task_runner::run_task; +use dash_evo_tool::backend_task::wallet::WalletTask; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::context::AppContext; use dash_evo_tool::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::dashcore::Network; use std::sync::Arc; use std::time::Duration; +/// Test-only platform-receive-address helper for the e2e suite. +/// +/// Production resolves the HD seed through the JIT chokepoint; the legacy +/// parked-seed `Wallet::platform_receive_address` was retired. This mirrors +/// the old "reuse-or-generate" behaviour without touching a seed in the test: +/// when `skip_known` is false and a watched platform-payment address already +/// exists, return it (seed-free); otherwise dispatch +/// [`WalletTask::GeneratePlatformReceiveAddress`], which derives the next one +/// through the chokepoint in the backend. +pub async fn derive_platform_receive_address( + app_context: &Arc<AppContext>, + seed_hash: WalletSeedHash, + network: Network, + skip_known: bool, +) -> PlatformAddress { + if !skip_known { + let existing = { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet = wallets.get(&seed_hash).expect("framework wallet missing"); + let guard = wallet.read().expect("wallet read lock"); + guard + .platform_addresses(network) + .into_iter() + .next() + .map(|(_, platform_addr)| platform_addr) + }; + if let Some(platform_addr) = existing { + return platform_addr; + } + } + + let result = run_task( + app_context, + BackendTask::WalletTask(WalletTask::GeneratePlatformReceiveAddress { seed_hash }), + ) + .await + .expect("GeneratePlatformReceiveAddress task failed"); + + let BackendTaskSuccessResult::GeneratedPlatformReceiveAddress { address, .. } = result else { + panic!("unexpected result for GeneratePlatformReceiveAddress: {result:?}"); + }; + PlatformAddress::from_bech32m_string(&address) + .expect("derived platform address is not valid bech32m") +} + #[allow(dead_code)] const FAUCET_BASE_URL: &str = "http://faucet.testnet.networks.dash.org"; const MIN_BALANCE_DUFFS: u64 = 1_000_000_000; // 10 DASH @@ -14,7 +64,7 @@ const MIN_BALANCE_DUFFS: u64 = 1_000_000_000; // 10 DASH /// If the balance is below the threshold, panics with the receive address /// and instructions for the user to fund it manually. pub async fn verify_framework_funded(app_context: &Arc<AppContext>, wallet_hash: WalletSeedHash) { - let (current_balance, address) = get_wallet_balance_and_address(app_context, wallet_hash); + let (current_balance, address) = get_wallet_balance_and_address(app_context, wallet_hash).await; if current_balance >= MIN_BALANCE_DUFFS { tracing::info!( @@ -32,26 +82,18 @@ pub async fn verify_framework_funded(app_context: &Arc<AppContext>, wallet_hash: ); } -/// Get the wallet's current total balance and receive address. -fn get_wallet_balance_and_address( +/// Get the wallet's current total balance and a SPV-watched receive address. +async fn get_wallet_balance_and_address( app_context: &Arc<AppContext>, wallet_hash: WalletSeedHash, ) -> (u64, String) { - let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet_arc = wallets - .get(&wallet_hash) - .expect("framework wallet must exist"); - - let mut wallet = wallet_arc.write().expect("wallet lock"); - let balance = wallet.total_balance_duffs(); - let address = wallet - .receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(app_context), - ) - .expect("Failed to get receive address") - .to_string(); + let balance = app_context.snapshot_balance(&wallet_hash).total; + let address = app_context + .wallet_backend() + .expect("wallet backend wired") + .next_receive_address(&wallet_hash) + .await + .expect("Failed to get receive address"); (balance, address) } diff --git a/tests/backend-e2e/framework/harness.rs b/tests/backend-e2e/framework/harness.rs index 57e6a19c2..7398167ce 100644 --- a/tests/backend-e2e/framework/harness.rs +++ b/tests/backend-e2e/framework/harness.rs @@ -16,6 +16,7 @@ use crate::framework::funding; use crate::framework::task_runner::run_task; use crate::framework::wait; use bip39::{Language, Mnemonic}; +use dash_evo_tool::app::TaskResult; use dash_evo_tool::app_dir::ensure_env_file; use dash_evo_tool::backend_task::BackendTask; use dash_evo_tool::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; @@ -24,7 +25,7 @@ use dash_evo_tool::context::AppContext; use dash_evo_tool::context::connection_status::ConnectionStatus; use dash_evo_tool::database::test_helpers::create_database_at_path; use dash_evo_tool::model::wallet::WalletSeedHash; -use dash_evo_tool::spv::CoreBackendMode; +use dash_evo_tool::utils::egui_mpsc::EguiMpscAsync; use dash_evo_tool::utils::tasks::TaskManager; use dash_sdk::dpp::dashcore::Network; use std::path::PathBuf; @@ -35,6 +36,22 @@ use std::time::Duration; /// balance query, etc.). Only the initial SPV sync may exceed this. pub const MAX_TEST_TIMEOUT: Duration = Duration::from_secs(360); +/// Budget for the framework wallet to register with the upstream wallet +/// backend during `init`. Registration emits the transient +/// `TaskError::WalletBackend` ("retry in a moment") signal under the slower +/// platform revision; `wait_for_wallet_in_spv` retries that typed variant with +/// backoff inside this budget, so it must be comfortably longer than a single +/// registration round-trip. +const FRAMEWORK_WALLET_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Budget for a per-test funded wallet to be picked up by the upstream SPV +/// backend in [`BackendTestContext::create_funded_test_wallet`]. Matches +/// [`FRAMEWORK_WALLET_REGISTRATION_TIMEOUT`]: the suite runs serially +/// (`--test-threads=1`), so as more wallets accumulate in the upstream manager +/// across the run, each later `wait_for_wallet_in_spv` round (filter rebuild + +/// re-sync) takes longer and needs the same 120s headroom as the framework wallet. +const FUNDED_WALLET_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(120); + /// Shared test context, initialized once across all backend E2E tests. /// /// Uses `tokio::sync::OnceCell` so initialization runs inside the shared @@ -47,17 +64,41 @@ static CTX: tokio::sync::OnceCell<BackendTestContext> = tokio::sync::OnceCell::c /// serialized. The long waits (recipient balance, IS lock) run concurrently. static FUNDING_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Cancellation token for the task manager that owns SPV tasks. +/// Cancellation token for the DET `TaskManager` that owns DET-side subtasks. /// /// `tokio::sync::OnceCell` does not cache panicked inits — if `init()` /// panics (e.g. framework wallet unfunded), the next test retries from -/// scratch. But the orphaned SPV tasks from the panicked init still run -/// on the shared tokio runtime, holding the data directory lock. A global -/// panic hook cancels this token, which stops SPV (its stop_token is a -/// child) and releases the lock file. +/// scratch. A global panic hook cancels this token to stop DET subtasks +/// from a panicked init. The upstream `SpvRuntime` (which holds the SPV +/// data-directory lock) is a separate concern handled by [`LEAKED_BACKEND`]. static SPV_CANCEL: std::sync::Mutex<Option<tokio_util::sync::CancellationToken>> = std::sync::Mutex::new(None); +/// The most recently constructed `WalletBackend`. +/// +/// The upstream `SpvRuntime` takes an exclusive lock on its data directory. +/// If `init()` panics after the backend is built, that runtime keeps running +/// on the shared tokio runtime and holds the lock, so every retried `init()` +/// fails at `start()` with "Data directory locked". Before constructing a +/// new backend, a retry calls `shutdown()` on the leaked predecessor stored +/// here to release the lock. +static LEAKED_BACKEND: tokio::sync::Mutex< + Option<Arc<dash_evo_tool::wallet_backend::WalletBackend>>, +> = tokio::sync::Mutex::const_new(None); + +/// Number of distinct workdir slots `pick_available_workdir` cycles through. +/// The scan wraps modulo this value, so the preferred floor can climb without +/// ever emptying the scan range. +const MAX_WORKDIR_SLOTS: usize = 10; + +/// Preferred starting workdir slot for the next `init()`. Bumped each time a +/// panicked init leaks an un-droppable SPV `LockFile`, so the retry prefers a +/// fresh directory instead of colliding with the locked one. The scan in +/// [`pick_available_workdir`] wraps modulo [`MAX_WORKDIR_SLOTS`], so even if +/// this floor exceeds the slot count it never exhausts the available slots — a +/// freed lower slot is still picked up. +static WORKDIR_SLOT_FLOOR: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Get (or initialize) the shared test context. pub async fn ctx() -> &'static BackendTestContext { CTX.get_or_init(BackendTestContext::init).await @@ -71,6 +112,71 @@ pub struct BackendTestContext { /// Lock file held for the lifetime of the test process to prevent /// concurrent test runs from using the same workdir. _lock_file: std::fs::File, + /// Receiver for the `TaskResult` channel handed to `WalletBackend`'s + /// `EventBridge`. Kept alive for the whole test process so the bridge's + /// non-blocking `try_send(Refresh)` never hits a closed channel — this + /// mirrors `AppState` owning the receiver in production. + _task_result_rx: tokio::sync::mpsc::Receiver<TaskResult>, +} + +/// Whether a `register_wallet` failure is worth retrying: transient storage +/// contention or a not-yet-ready wallet backend, as opposed to a permanent +/// error (bad input, poisoned lock) or the idempotent `WalletAlreadyImported`. +fn is_transient_registration_error(error: &TaskError) -> bool { + matches!( + error, + TaskError::WalletBackend { .. } + | TaskError::WalletBackendNotYetWired + | TaskError::WalletSeedStorage { .. } + | TaskError::KvSidecarStorage { .. } + ) +} + +/// Register a wallet, retrying transient storage/backend errors with bounded +/// backoff (~30s total). +/// +/// Under the shared-runtime backend-e2e harness, the fail-closed sidecar writes +/// (`WalletSeedStorage` / `KvSidecarStorage`) can briefly lose a SQLite race, +/// and upstream registration can surface the typed transient `WalletBackend` +/// ("retry in a moment") signal. A single attempt then panics and masks the test +/// under exercise (e.g. identity_create / identity_cold_boot). Retry those +/// transient variants until they clear or the deadline passes; a permanent error +/// still surfaces after the bounded attempts. `WalletAlreadyImported` is returned +/// as-is so callers can treat it as the idempotent success it is. +async fn register_wallet_with_retry( + app_context: &Arc<AppContext>, + wallet: dash_evo_tool::model::wallet::Wallet, + seed: &[u8; 64], + origin: dash_evo_tool::model::wallet::birth_height::WalletOrigin, +) -> Result< + ( + WalletSeedHash, + Arc<std::sync::RwLock<dash_evo_tool::model::wallet::Wallet>>, + ), + TaskError, +> { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let mut attempt: u32 = 0; + loop { + attempt += 1; + // `register_wallet` consumes the wallet; clone per attempt so a retry + // can submit a fresh copy. + match app_context.register_wallet(wallet.clone(), seed, origin) { + Ok(registered) => return Ok(registered), + Err(e) + if is_transient_registration_error(&e) && std::time::Instant::now() < deadline => + { + let backoff = Duration::from_millis(500 * u64::from(attempt.min(6))); + tracing::warn!( + attempt, + error = %e, + "wallet registration hit a transient error; retrying after backoff" + ); + tokio::time::sleep(backoff).await; + } + Err(e) => return Err(e), + } + } } impl BackendTestContext { @@ -89,6 +195,25 @@ impl BackendTestContext { tokio::time::sleep(Duration::from_millis(500)).await; } + // Handle a leaked upstream SpvRuntime from a previous panicked init. + // `OnceCell` does not cache panicked inits, so this runs on every + // retry until one init succeeds. We `shutdown()` to stop its + // background tasks, but the SPV data-directory `LockFile` releases + // only on `Drop` (dash-spv `storage/lockfile.rs`), and leaked task + // clones keep the backend's `Arc` graph alive — so the lock cannot + // be reclaimed in-process. Instead, advance the workdir slot so the + // retry's SpvRuntime locks a fresh directory (this is exactly what + // `pick_available_workdir`'s slot fallback exists for). + if let Some(stale_backend) = LEAKED_BACKEND.lock().await.take() { + tracing::warn!( + "Leaked wallet backend from a previous init attempt; \ + shutting it down and advancing to a fresh workdir slot" + ); + stale_backend.shutdown().await; + WORKDIR_SLOT_FLOOR.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(500)).await; + } + // Initialize tracing for test output let _ = tracing_subscriber::fmt() .with_env_filter( @@ -107,7 +232,8 @@ impl BackendTestContext { // and SPV data persist across runs. If the primary path is locked by // another process, fall back to numbered alternatives (slot 1, 2, ...). let base = std::env::temp_dir().join("dash-evo-e2e-testnet"); - let (workdir, lock_file) = pick_available_workdir(&base); + let slot_floor = WORKDIR_SLOT_FLOOR.load(std::sync::atomic::Ordering::SeqCst); + let (workdir, lock_file) = pick_available_workdir(&base, slot_floor); std::fs::create_dir_all(&workdir).expect("Failed to create workdir"); tracing::info!("E2E workdir: {}", workdir.display()); @@ -125,14 +251,18 @@ impl BackendTestContext { let connection_status = Arc::new(ConnectionStatus::new()); let egui_ctx = egui::Context::default(); + let app_kv = AppContext::open_app_kv(&workdir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(&workdir).expect("open secret store"); let app_context = AppContext::new( workdir.clone(), Network::Testnet, db, - None, // no password subtasks, connection_status, egui_ctx, + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Failed to create AppContext for testnet"); @@ -181,13 +311,7 @@ impl BackendTestContext { ); for hash in stale { // Log the wallet's balance before removal for audit trail - let balance = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets - .get(&hash) - .map(|w| w.read().expect("wallet lock").total_balance_duffs()) - .unwrap_or(0) - }; + let balance = app_context.snapshot_balance(&hash).total; if balance > 0 { tracing::warn!( "Purging stale wallet {:?} with {} duffs (not swept!)", @@ -205,9 +329,69 @@ impl BackendTestContext { } } - // Switch to SPV mode and start - app_context.set_core_backend_mode(CoreBackendMode::Spv); - app_context.start_spv().expect("Failed to start SPV"); + // Register the framework wallet BEFORE the backend is built so its + // sidecars (wallet-meta xpub + encrypted-seed envelope) are + // persisted. `load_from_persistor_seedless` then loads it watch-only + // from the persister at `WalletBackend::new` time and the upstream + // manager monitors its addresses from the first sync. + tracing::info!("Restoring framework wallet from E2E_WALLET_MNEMONIC"); + let wallet = dash_evo_tool::model::wallet::Wallet::new_from_seed( + seed, + Network::Testnet, + Some("E2E Framework Wallet".to_string()), + None, + ) + .expect("Failed to create framework wallet"); + match register_wallet_with_retry( + &app_context, + wallet, + &seed, + dash_evo_tool::model::wallet::birth_height::WalletOrigin::Imported, + ) + .await + { + Ok((hash, _)) => { + tracing::info!("Registered framework wallet (seed_hash: {:?})", &hash[..4]); + } + Err(TaskError::WalletAlreadyImported) => { + tracing::info!("Framework wallet already registered (reusing from persistent DB)"); + } + Err(e) => panic!("Failed to register framework wallet: {}", e), + } + + // Build the long-lived `TaskResult` channel the `EventBridge` pushes + // `Refresh` nudges onto. Production hands `WalletBackend` the + // `AppState`-owned sender; the harness owns both ends and keeps the + // receiver alive in `BackendTestContext` so the bridge's + // non-blocking `try_send` never hits a closed channel. + let (task_result_sender, task_result_rx) = tokio::sync::mpsc::channel::<TaskResult>(256) + .with_egui_ctx(app_context.egui_ctx().clone()); + + // Construct + start the real wallet backend exactly as production + // does: `ensure_wallet_backend` builds `WalletBackend` (which loads + // the framework wallet watch-only via `load_from_persistor_seedless`), then + // `WalletBackend::start()` starts the upstream `SpvRuntime` sync. + app_context + .ensure_wallet_backend(task_result_sender) + .await + .expect("Failed to construct wallet backend"); + let backend = app_context + .wallet_backend() + .expect("wallet backend must be wired after ensure_wallet_backend"); + + // `ensure_wallet_backend` runs the unlock chokepoint + // (`bootstrap_loaded_wallets`) at its tail, so the framework wallet's + // seed is already handed to the backend here — no manual re-run needed. + + // Track the backend before starting sync so a later panic in init + // (peer wait, balance check, …) doesn't leak the SpvRuntime's + // data-directory lock — the next retry shuts this down first. + *LEAKED_BACKEND.lock().await = Some(Arc::clone(&backend)); + + backend + .start() + .await + .expect("Failed to start wallet backend chain sync"); // Stash the cancellation token so the panic hook can stop SPV if // init panics later (e.g. framework wallet unfunded). @@ -239,36 +423,24 @@ impl BackendTestContext { })); }); - // Wait for SPV peers + // Wait for the upstream SpvRuntime to connect to peers (push-based + // via the EventBridge → ConnectionStatus). wait::wait_for_spv_peers(&app_context, Duration::from_secs(60)) .await .expect("SPV failed to connect to any peers within 60s"); tracing::info!("SPV connected to peers"); - tracing::info!("Restoring framework wallet from E2E_WALLET_MNEMONIC"); - let wallet = dash_evo_tool::model::wallet::Wallet::new_from_seed( - seed, - Network::Testnet, - Some("E2E Framework Wallet".to_string()), - None, + // Confirm the framework wallet is registered with the upstream + // manager so its addresses are watched by the SpvRuntime. + // `wait_for_wallet_in_spv` retries the transient + // `TaskError::WalletBackend` signal with backoff inside this budget. + wait::wait_for_wallet_in_spv( + &app_context, + framework_wallet_hash, + FRAMEWORK_WALLET_REGISTRATION_TIMEOUT, ) - .expect("Failed to create framework wallet"); - - // Try to register; if the wallet already exists (persistent DB), just look it up. - match app_context.register_wallet(wallet) { - Ok((hash, _)) => { - tracing::info!("Registered framework wallet (seed_hash: {:?})", &hash[..4]); - } - Err(TaskError::WalletAlreadyImported) => { - tracing::info!("Framework wallet already registered (reusing from persistent DB)"); - } - Err(e) => panic!("Failed to register framework wallet: {}", e), - } - - // Wait for wallet to appear in SPV - wait::wait_for_wallet_in_spv(&app_context, framework_wallet_hash, Duration::from_secs(30)) - .await - .expect("Framework wallet not picked up by SPV"); + .await + .expect("Framework wallet not registered with the wallet backend"); // Wait for SPV to fully sync (including masternodes) so MempoolManager // is active and bloom filter is built before any test broadcasts. @@ -295,22 +467,14 @@ impl BackendTestContext { tracing::info!("Framework wallet spendable: {} duffs", balance); } Err(e) => { - let (confirmed, total, address) = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets - .get(&framework_wallet_hash) - .map(|w| { - let mut guard = w.write().expect("wallet lock"); - let bal = - (guard.confirmed_balance_duffs(), guard.total_balance_duffs()); - let addr = guard - .receive_address(Network::Testnet, false, Some(&app_context)) - .map(|a| a.to_string()) - .unwrap_or_else(|_| "<unknown>".to_string()); - (bal.0, bal.1, addr) - }) - .unwrap_or((0, 0, "<unknown>".to_string())) - }; + let snap = app_context.snapshot_balance(&framework_wallet_hash); + let address = app_context + .wallet_backend() + .expect("wallet backend wired") + .next_receive_address(&framework_wallet_hash) + .await + .unwrap_or_else(|_| "<unknown>".to_string()); + let (confirmed, total) = (snap.confirmed, snap.total); panic!( "Framework wallet has no spendable balance: {} \ (confirmed: {}, total: {})\n \ @@ -335,11 +499,17 @@ impl BackendTestContext { *guard = None; } + // Reset the retry bookkeeping: `OnceCell` caches this success, so no + // further init runs. Clearing the floor keeps the slot accounting + // deterministic if a future change ever re-enters init. + WORKDIR_SLOT_FLOOR.store(0, std::sync::atomic::Ordering::SeqCst); + BackendTestContext { app_context, framework_wallet_hash, _workdir: workdir, _lock_file: lock_file, + _task_result_rx: task_result_rx, } } @@ -370,17 +540,23 @@ impl BackendTestContext { ) .expect("Failed to create test wallet"); - let (seed_hash, wallet_arc) = app_context - .register_wallet(wallet) - .expect("Failed to register test wallet"); + let (seed_hash, wallet_arc) = register_wallet_with_retry( + app_context, + wallet, + &seed, + dash_evo_tool::model::wallet::birth_height::WalletOrigin::Imported, + ) + .await + .expect("Failed to register test wallet"); tracing::trace!( seed_hash = ?&seed_hash[..4], amount_duffs, "create_funded_test_wallet: registered new wallet" ); - // Wait for SPV to pick up the wallet - wait::wait_for_wallet_in_spv(app_context, seed_hash, Duration::from_secs(30)) + // Wait for SPV to pick up the wallet. Budgeted for the cumulative + // upstream load late in a serial run — see FUNDED_WALLET_REGISTRATION_TIMEOUT. + wait::wait_for_wallet_in_spv(app_context, seed_hash, FUNDED_WALLET_REGISTRATION_TIMEOUT) .await .expect("Test wallet not picked up by SPV"); tracing::trace!(seed_hash = ?&seed_hash[..4], "create_funded_test_wallet: wallet visible in SPV"); @@ -390,13 +566,13 @@ impl BackendTestContext { tokio::time::sleep(Duration::from_millis(200)).await; tracing::trace!(seed_hash = ?&seed_hash[..4], "create_funded_test_wallet: waited for bloom filter rebuild tick"); - // Get test wallet's receive address - let test_address = { - let mut w = wallet_arc.write().expect("wallet lock"); - w.receive_address(Network::Testnet, false, Some(app_context)) - .expect("Failed to get test wallet receive address") - .to_string() - }; + // Get test wallet's receive address from the SPV-watched upstream pool. + let test_address = app_context + .wallet_backend() + .expect("wallet backend wired") + .next_receive_address(&seed_hash) + .await + .expect("Failed to get test wallet receive address"); tracing::trace!(address = %test_address, "create_funded_test_wallet: receive address derived"); let framework_wallet_arc = { @@ -412,8 +588,6 @@ impl BackendTestContext { address: test_address.clone(), amount_duffs, }], - subtract_fee_from_amount: false, - memo: Some("E2E test funding".to_string()), override_fee: None, }; @@ -526,15 +700,25 @@ impl BackendTestContext { /// etc. up to 10 slots. Each slot has a `.lock` file that is held for the /// lifetime of the returned `File` handle (via `flock` / `LockFile`). /// +/// `slot_floor` is the *preferred* first slot to try — bumped across init +/// retries when a panicked predecessor leaked an un-droppable SPV `LockFile`, +/// so the retry prefers a fresh directory over the still-locked one. The scan +/// wraps around and tries every slot, so a `slot_floor` that has climbed to (or +/// past) `MAX_WORKDIR_SLOTS` still falls back to lower slots whose harness +/// `.lock` was released on a previous init's unwind — it never produces an empty +/// scan and a spurious "all slots locked" panic. +/// /// This ensures: /// - The same workdir is reused across runs (wallets, SPV data, DB persist) /// - Concurrent test processes get separate workdirs automatically -fn pick_available_workdir(base: &std::path::Path) -> (PathBuf, std::fs::File) { +/// - A retried init after a leaked SPV lock prefers a clean directory, but +/// still recovers a freed lower slot once the leaked floor exceeds the range +fn pick_available_workdir(base: &std::path::Path, slot_floor: usize) -> (PathBuf, std::fs::File) { use std::io::Write; - let max_slots = 10; - - for slot in 0..max_slots { + let preferred = slot_floor % MAX_WORKDIR_SLOTS; + for offset in 0..MAX_WORKDIR_SLOTS { + let slot = (preferred + offset) % MAX_WORKDIR_SLOTS; let dir = if slot == 0 { base.to_path_buf() } else { @@ -567,11 +751,13 @@ fn pick_available_workdir(base: &std::path::Path) -> (PathBuf, std::fs::File) { let _ = write!(f, "{}", std::process::id()); let _ = f.flush(); - if slot > 0 { + if offset > 0 { tracing::info!( - "Primary workdir locked by another process, using slot {slot}: {}", + "Preferred workdir slot {preferred} unavailable, using slot {slot}: {}", dir.display() ); + } else if slot != 0 { + tracing::info!("Using preferred workdir slot {slot}: {}", dir.display()); } return (dir, f); } @@ -583,7 +769,7 @@ fn pick_available_workdir(base: &std::path::Path) -> (PathBuf, std::fs::File) { } panic!( - "All {max_slots} E2E workdir slots are locked. \ + "All {MAX_WORKDIR_SLOTS} E2E workdir slots are locked. \ Kill other test processes or remove lock files in {}*", base.display() ); @@ -598,10 +784,8 @@ fn try_lock_exclusive(file: &std::fs::File) -> bool { unsafe { nix::libc::flock(file.as_raw_fd(), nix::libc::LOCK_EX | nix::libc::LOCK_NB) == 0 } } -// INTENTIONAL(CMT-038): On non-Unix platforms, file locking is not -// implemented — always returns true. This means concurrent test processes -// on Windows will share the same workdir, which may cause conflicts. -// Acceptable because CI runs on Linux and Windows E2E runs are rare. +// Non-Unix has no file locking here, so concurrent processes share a workdir; +// acceptable because CI is Linux and Windows E2E runs are rare. #[cfg(not(unix))] fn try_lock_exclusive(_file: &std::fs::File) -> bool { true diff --git a/tests/backend-e2e/framework/identity_helpers.rs b/tests/backend-e2e/framework/identity_helpers.rs index 9c90eb3a1..94b94ef81 100644 --- a/tests/backend-e2e/framework/identity_helpers.rs +++ b/tests/backend-e2e/framework/identity_helpers.rs @@ -1,97 +1,58 @@ //! Helpers for constructing identity registration data in tests. -use dash_evo_tool::backend_task::identity::default_identity_key_specs; use dash_evo_tool::backend_task::identity::{ - IdentityKeys, IdentityRegistrationInfo, KeyInput, RegisterIdentityFundingMethod, + IdentityRegistrationInfo, build_identity_registration as build_identity_registration_inner, }; use dash_evo_tool::context::AppContext; use dash_evo_tool::model::wallet::{Wallet, WalletSeedHash}; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::identity::KeyType; use std::sync::{Arc, RwLock}; -/// Build an `IdentityRegistrationInfo` for a wallet-funded identity. +/// Asset lock amount in duffs for the e2e registration fixture. Platform +/// credits ≈ duffs × 1000 minus fees. 25M duffs → ~25B credits after fees, +/// enough for identity registration plus subsequent operations. +const E2E_FUNDING_DUFFS: u64 = 25_000_000; + +/// Build an `IdentityRegistrationInfo` for a wallet-funded identity at index 0. /// -/// Derives master key + additional keys from the wallet at identity_index 0. -/// Returns the registration info AND the raw master authentication private key -/// bytes (32 bytes). The key bytes must be captured here because the wallet -/// encrypts them after registration, making post-registration extraction -/// impossible. +/// Derives the **public** master key + additional keys via the production +/// builder, which resolves the wallet's HD seed once through the JIT chokepoint. +/// The private keys are never materialized here — signing derives them +/// just-in-time at registration — so no raw key bytes are returned. /// /// # Panics /// -/// Panics if key derivation fails (programming error in test setup). -pub fn build_identity_registration( +/// Panics if the seed cannot be resolved or key derivation fails (test setup +/// error). +pub async fn build_identity_registration( app_context: &Arc<AppContext>, wallet_arc: &Arc<RwLock<Wallet>>, wallet_seed_hash: WalletSeedHash, -) -> (IdentityRegistrationInfo, Vec<u8>) { - let dashpay_contract_id = app_context.dashpay_contract_id(); - let key_specs = default_identity_key_specs(dashpay_contract_id); - +) -> IdentityRegistrationInfo { let identity_index: u32 = 0; - let mut wallet = wallet_arc.write().expect("wallet lock"); - - // Derive master key (identity authentication key at index 0) - let (master_private_key, master_derivation_path) = wallet - .identity_authentication_ecdsa_private_key(app_context, Network::Testnet, identity_index, 0) - .expect("Failed to derive master private key"); - - // Derive additional keys from specs - let mut keys_input: Vec<KeyInput> = Vec::new(); - for (i, (key_type, purpose, security_level, contract_bounds)) in - key_specs.into_iter().enumerate() - { - let key_index = (i + 1) as u32; // 0 is master - let (private_key, derivation_path) = wallet - .identity_authentication_ecdsa_private_key( - app_context, - Network::Testnet, - identity_index, - key_index, - ) - .expect("Failed to derive key"); - keys_input.push(( - (private_key, derivation_path), - key_type, - purpose, - security_level, - contract_bounds, - )); - } - - drop(wallet); - - let master_key_bytes = master_private_key.inner.secret_bytes().to_vec(); - - let reg_info = IdentityRegistrationInfo { - alias_input: format!("e2e-test-{}", hex::encode(&wallet_seed_hash[..4])), - keys: IdentityKeys::new( - Some((master_private_key, master_derivation_path)), - KeyType::ECDSA_HASH160, - keys_input, - ), - wallet: wallet_arc.clone(), - wallet_identity_index: identity_index, - identity_funding_method: RegisterIdentityFundingMethod::FundWithWallet( - // Asset lock amount in duffs. Platform credits ≈ duffs × 1000 minus fees. - // 25M duffs → ~25B credits after fees. - // Token contract registration: ~20B credits (base 10B + token 10B). - // Identity registration: ~241M credits. - // Remaining: ~5B for subsequent operations (top-up, transfer, etc.). - 25_000_000, - identity_index, - ), - }; - - (reg_info, master_key_bytes) + let mut reg_info = build_identity_registration_inner( + app_context, + wallet_arc, + identity_index, + E2E_FUNDING_DUFFS, + ) + .await + .expect("Failed to build identity registration"); + reg_info.alias_input = format!("e2e-test-{}", hex::encode(&wallet_seed_hash[..4])); + reg_info } -/// Get a receive address string from a wallet. -pub fn get_receive_address(app_context: &AppContext, wallet_arc: &Arc<RwLock<Wallet>>) -> String { - let mut wallet = wallet_arc.write().expect("wallet lock"); - wallet - .receive_address(Network::Testnet, false, Some(app_context)) +/// Get a receive address string from a wallet, via the SPV-watched upstream +/// pool (the production path). Routes through the wallet backend so the address +/// is always watched. +pub async fn get_receive_address( + app_context: &AppContext, + wallet_arc: &Arc<RwLock<Wallet>>, +) -> String { + let seed_hash = wallet_arc.read().expect("wallet lock").seed_hash(); + app_context + .wallet_backend() + .expect("wallet backend wired") + .next_receive_address(&seed_hash) + .await .expect("Failed to get receive address") - .to_string() } diff --git a/tests/backend-e2e/framework/mnlist_helpers.rs b/tests/backend-e2e/framework/mnlist_helpers.rs deleted file mode 100644 index 78dc87de4..000000000 --- a/tests/backend-e2e/framework/mnlist_helpers.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Helpers for retrieving block info for MnList tests. -//! -//! Uses DAPI (Platform gRPC, always available via testnet nodes) for chain tip -//! and the well-known genesis block hash from the `Network` type. -//! No Core RPC required. - -// TODO(production-reuse): This helper parallels `src/backend_task/mnlist.rs` and -// `src/ui/tools/masternode_list_diff_screen.rs::get_block_hash`. -// Before extracting to production, diff against the original source — it may have -// changed since this helper was written (created 2026-04-08 based on commit 79a6907c). -// The production code undergoes heavy refactoring; inspect for divergence before reuse. - -use dash_evo_tool::context::AppContext; -use dash_sdk::SdkBuilder; -use dash_sdk::dapi_client::{AddressList, DapiRequestExecutor, IntoInner, RequestSettings}; -use dash_sdk::dapi_grpc::core::v0::GetBlockchainStatusRequest; -use dash_sdk::dpp::dashcore::BlockHash; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::network::constants::NetworkExt; -use dash_sdk::dpp::prelude::CoreBlockHeight; -use dash_sdk::error::ContextProviderError; -use dash_sdk::platform::ContextProvider; -use std::sync::Arc; - -/// No-op ContextProvider for the lightweight DAPI-only SDK instance. -struct NoopContextProvider; - -impl ContextProvider for NoopContextProvider { - fn get_data_contract( - &self, - _id: &dash_sdk::platform::Identifier, - _pv: &dash_sdk::dpp::version::PlatformVersion, - ) -> Result<Option<Arc<dash_sdk::dpp::prelude::DataContract>>, ContextProviderError> { - Ok(None) - } - - fn get_token_configuration( - &self, - _token_id: &dash_sdk::platform::Identifier, - ) -> Result<Option<dash_sdk::dpp::data_contract::TokenConfiguration>, ContextProviderError> - { - Ok(None) - } - - fn get_quorum_public_key( - &self, - _quorum_type: u32, - _quorum_hash: [u8; 32], - _core_chain_locked_height: u32, - ) -> Result<[u8; 48], ContextProviderError> { - Err(ContextProviderError::Config( - "NoopContextProvider: quorum keys not needed for DAPI status queries".into(), - )) - } - - fn get_platform_activation_height(&self) -> Result<CoreBlockHeight, ContextProviderError> { - Err(ContextProviderError::Config( - "NoopContextProvider: activation height not needed for DAPI status queries".into(), - )) - } -} - -/// Build a lightweight SDK connected to testnet DAPI nodes. -/// -/// Only used for `GetBlockchainStatus` calls — no proof verification needed. -fn build_dapi_sdk(app_context: &Arc<AppContext>) -> dash_sdk::Sdk { - let raw = std::env::var("TESTNET_dapi_addresses") - .expect("mnlist_helpers: TESTNET_dapi_addresses env var not set — ensure .env is loaded"); - - let address_list: AddressList = raw - .parse() - .expect("mnlist_helpers: invalid TESTNET_dapi_addresses"); - - SdkBuilder::new(address_list) - .with_network(app_context.network()) - .with_version(app_context.platform_version()) - .with_context_provider(NoopContextProvider) - .build() - .expect("mnlist_helpers: failed to build DAPI SDK") -} - -/// Get current block tip height and hash from DAPI (Platform gRPC). -/// -/// Uses `GetBlockchainStatus` which is always available through DAPI nodes — -/// no Core RPC node required. -/// -/// # Panics -/// -/// Panics if the DAPI request fails or the response is missing chain info. -pub async fn get_current_block_info(app_context: &Arc<AppContext>) -> (u32, BlockHash) { - let sdk = build_dapi_sdk(app_context); - - let response = sdk - .execute(GetBlockchainStatusRequest {}, RequestSettings::default()) - .await - .into_inner() - .expect("mnlist_helpers: GetBlockchainStatus DAPI request failed"); - - let chain = response - .chain - .expect("mnlist_helpers: GetBlockchainStatus response missing chain info"); - - let height = chain.blocks_count; - let hash_bytes: [u8; 32] = chain - .best_block_hash - .try_into() - .expect("mnlist_helpers: best_block_hash is not 32 bytes"); - let block_hash = BlockHash::from_byte_array(hash_bytes); - - (height, block_hash) -} - -/// Get the well-known genesis block hash for the current network. -/// -/// Uses `Network::known_genesis_block_hash()` — a compile-time constant -/// for mainnet and testnet. No network call required. -/// -/// # Panics -/// -/// Panics on devnet/regtest networks where genesis hash is not known. -pub fn get_genesis_hash(app_context: &Arc<AppContext>) -> BlockHash { - app_context - .network() - .known_genesis_block_hash() - .expect("mnlist_helpers: genesis block hash not known for this network (devnet/regtest?)") -} diff --git a/tests/backend-e2e/framework/mod.rs b/tests/backend-e2e/framework/mod.rs index 9fefb7721..029155732 100644 --- a/tests/backend-e2e/framework/mod.rs +++ b/tests/backend-e2e/framework/mod.rs @@ -7,8 +7,6 @@ pub mod funding; pub mod harness; pub mod identity_helpers; #[allow(dead_code)] -pub mod mnlist_helpers; -#[allow(dead_code)] pub mod shielded_helpers; pub mod task_runner; #[allow(dead_code)] diff --git a/tests/backend-e2e/framework/shielded_helpers.rs b/tests/backend-e2e/framework/shielded_helpers.rs index b1dcef5bf..268b8f02d 100644 --- a/tests/backend-e2e/framework/shielded_helpers.rs +++ b/tests/backend-e2e/framework/shielded_helpers.rs @@ -1,16 +1,13 @@ //! Helpers for shielded (ZK) operations in tests. +//! +//! Phase D retired DET's home-grown shielded subsystem; warm-up and key +//! binding are owned by the upstream coordinator (binding happens automatically +//! on wallet unlock). The helpers here cover availability / skip / error +//! classification plus a sync-and-read primitive ([`force_shielded_sync`]) that +//! drives a coordinator pass and returns the resulting push-snapshot balance. -// TODO(production-reuse): This helper parallels `src/backend_task/shielded/mod.rs::run_warm_up_proving_key` -// and `run_initialize_shielded_wallet`. -// Before extracting to production, diff against the original source — it may have -// changed since this helper was written (created 2026-04-08 based on commit 79a6907c). -// The production code undergoes heavy refactoring; inspect for divergence before reuse. - -use crate::framework::task_runner::run_task; -use dash_evo_tool::backend_task::shielded::ShieldedTask; -use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::context::AppContext; -use dash_evo_tool::model::feature_gate::FeatureGate; +use dash_evo_tool::context::feature_gate::FeatureGate; use dash_evo_tool::model::wallet::WalletSeedHash; use std::sync::Arc; @@ -82,44 +79,16 @@ pub fn is_platform_shielded_unsupported( } } -/// Run `WarmUpProvingKey` followed by `InitializeShieldedWallet` in sequence. +/// Drive an immediate shielded sync pass and return the wallet's post-sync +/// shielded balance (credits) from the frame-safe snapshot. /// -/// This ensures the proving key is downloaded/cached and the wallet's -/// shielded state (ZIP32 keys, commitment tree) is initialized before -/// any shielded operations. -pub async fn warm_up_and_init(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) { - // Warm up proving key (may take 30-60s on first run) - tracing::info!("shielded_helpers: warming up proving key..."); - let task = BackendTask::ShieldedTask(ShieldedTask::WarmUpProvingKey); - let result = run_task(app_context, task) - .await - .expect("shielded_helpers: WarmUpProvingKey failed"); - assert!( - matches!(result, BackendTaskSuccessResult::ProvingKeyReady), - "shielded_helpers: expected ProvingKeyReady, got: {:?}", - result - ); - - // Initialize shielded wallet - tracing::info!("shielded_helpers: initializing shielded wallet..."); - let task = BackendTask::ShieldedTask(ShieldedTask::InitializeShieldedWallet { seed_hash }); - let result = run_task(app_context, task) - .await - .expect("shielded_helpers: InitializeShieldedWallet failed"); - match result { - BackendTaskSuccessResult::ShieldedInitialized { - seed_hash: sh, - balance, - } => { - assert_eq!(sh, seed_hash); - tracing::info!( - "shielded_helpers: wallet initialized (balance: {})", - balance - ); - } - other => panic!( - "shielded_helpers: expected ShieldedInitialized, got: {:?}", - other - ), +/// `sync_shielded_now` runs a coordinator pass that dispatches +/// `on_shielded_sync_completed`, which the `EventBridge` turns into the +/// `AppContext::shielded_balances` write (Phase E); the read below therefore +/// sees the fresh figure. Returns `0` when the wallet backend is not wired. +pub async fn force_shielded_sync(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) -> u64 { + if let Ok(backend) = app_context.wallet_backend() { + backend.sync_shielded_now(true).await; } + app_context.shielded_balance_credits(&seed_hash) } diff --git a/tests/backend-e2e/framework/task_runner.rs b/tests/backend-e2e/framework/task_runner.rs index a496e7fbb..7d3f30f48 100644 --- a/tests/backend-e2e/framework/task_runner.rs +++ b/tests/backend-e2e/framework/task_runner.rs @@ -5,22 +5,95 @@ use dash_evo_tool::backend_task::error::TaskError; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::context::AppContext; use dash_evo_tool::utils::egui_mpsc::SenderAsync; -use std::sync::Arc; +use std::future::Future; +use std::sync::{Arc, OnceLock}; use std::time::Duration; +/// Thread stack size for the SDK-bearing runtime (worker and blocking threads). +/// +/// SDK proof verification recurses deeply via upstream `futures::executor::block_on` +/// (grovedb / drive-proof-verifier), overflowing the default 8 MB test-thread stack; +/// 32 MB is proven safe (16 MB is the floor). +const SDK_THREAD_STACK_SIZE: usize = 32 * 1024 * 1024; + +/// Dedicated multi-thread runtime whose threads carry a large stack, so the deep +/// SDK `block_on` runs on a guaranteed-large stack regardless of `RUST_MIN_STACK`. +/// `thread_stack_size` applies to both worker and blocking threads. +fn sdk_runtime() -> &'static tokio::runtime::Runtime { + static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(SDK_THREAD_STACK_SIZE) + .thread_name("e2e-sdk") + .build() + .expect("failed to build SDK-bearing test runtime") + }) +} + /// Run a single backend task and return its result. /// /// Creates a throwaway `SenderAsync` channel (the receiver is never read). /// This is appropriate for tests that only care about the return value. +/// +/// The task is driven via [`drive_on_large_stack`], so deep SDK proof +/// verification cannot overflow the default test-thread stack. pub async fn run_task( app_context: &Arc<AppContext>, task: BackendTask, ) -> Result<BackendTaskSuccessResult, TaskError> { - // INTENTIONAL(CMT-017): Bounded channel capacity 32 with dropped receiver. - // Backend tasks send 0-1 results; blocking risk is negligible for test usage. + // Bounded channel (cap 32) with a dropped receiver: backend tasks send 0-1 + // results, so the sender never blocks in test usage. let (tx, _rx) = tokio::sync::mpsc::channel::<TaskResult>(32); let sender = SenderAsync::new(tx, egui::Context::default()); - app_context.run_backend_task(task, sender).await + let app_context = Arc::clone(app_context); + drive_on_large_stack(move || async move { app_context.run_backend_task(task, sender).await }) + .await +} + +/// Drive an owned future to completion on the large-stack [`sdk_runtime`]. +/// +/// The future is built and `block_on`-driven on a 32 MB blocking thread of +/// `sdk_runtime`, so the deep synchronous SDK `block_on` runs on a guaranteed +/// large stack regardless of `RUST_MIN_STACK`. Constructing the future via +/// `make_fut` *on* the target thread means no future crosses a thread boundary, +/// sidestepping the higher-ranked `Send` bound of `tokio::spawn` (the backend +/// future holds lifetime-bound borrows that are not `Send` for all lifetimes). +/// The async caller awaits the result via a oneshot without blocking the shared +/// runtime. Tasks requiring `tokio::task::block_in_place` are not driven through +/// this path; the SDK proof-verify path that overflows does not use it. +async fn drive_on_large_stack<F, Fut, T>(make_fut: F) -> T +where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future<Output = T>, + T: Send + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel(); + sdk_runtime().spawn_blocking(move || { + let out = tokio::runtime::Handle::current().block_on(make_fut()); + let _ = tx.send(out); + }); + rx.await + .expect("large-stack task panicked or was cancelled") +} + +/// Drive an arbitrary SDK-bearing future on the large-stack [`sdk_runtime`]. +/// +/// For test bodies that call `dash_sdk::platform::Fetch` (e.g. +/// `Identity::fetch_by_identifier`, `AddressInfo::fetch`) DIRECTLY rather than +/// through [`run_task`]. Such a fetch otherwise runs deep grovedb proof +/// verification on the default `tokio_shared_rt` worker-thread stack and can +/// overflow without `RUST_MIN_STACK`. Wrapping it here runs it on the 32 MB +/// [`sdk_runtime`], matching the guarantee [`run_task`] gives backend tasks. +/// Build the future inside `make_fut` — it runs on the target thread, so no +/// future crosses a thread boundary. +pub async fn run_on_large_stack<F, Fut, T>(make_fut: F) -> T +where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future<Output = T>, + T: Send + 'static, +{ + drive_on_large_stack(make_fut).await } #[allow(dead_code)] @@ -64,3 +137,44 @@ pub async fn run_task_with_nonce_retry( // confirmation times out — that surfaces the real issue instead of hiding it. // Use `run_task` (no retry) or `run_task_with_nonce_retry` (nonce conflicts // only, which are legitimate under parallel execution). + +// Not `#[cfg(test)]`: in an integration-test crate, `cfg(test)` is false, so a +// gated module is compiled out entirely. A bare module keeps the `#[test]` +// collectable in the `backend-e2e` binary. +mod stack_smoke { + use super::*; + use std::hint::black_box; + + /// Per-frame stack array (16 KiB). `black_box` on a reference keeps the array + /// live without the whole-array copy a `black_box(buf)` by-value would add. + const FRAME_BYTES: usize = 16 * 1024; + /// ~768 frames × 16 KiB ≈ 12 MiB of arrays — well above the 8 MiB default + /// stack (so it could not run there) yet within the 32 MiB SDK stack. + const RECURSION_DEPTH: usize = 768; + + /// Recurse `depth` frames, touching a stack array each frame so the optimizer + /// cannot elide it. `black_box` defeats tail-call elimination. + fn deep_recurse(depth: usize, acc: u64) -> u64 { + let mut buf = [0u8; FRAME_BYTES]; + buf[depth % buf.len()] = (acc & 0xff) as u8; + let sum = acc.wrapping_add(black_box(&buf)[0] as u64); + if depth == 0 { + sum + } else { + deep_recurse(depth - 1, sum) + } + } + + /// Drives a recursion whose stack footprint (~12 MiB) exceeds the default + /// 8 MiB stack through the exact [`drive_on_large_stack`] path used by + /// `run_task`, proving the large-stack mechanism is load-bearing without + /// `RUST_MIN_STACK`. Cheap, deterministic, no network. + #[test] + fn large_stack_path_survives_deep_recursion() { + let driver = sdk_runtime(); + let result = driver.block_on(drive_on_large_stack(|| async { + deep_recurse(black_box(RECURSION_DEPTH), 0) + })); + assert_eq!(result, black_box(result)); + } +} diff --git a/tests/backend-e2e/framework/token_helpers.rs b/tests/backend-e2e/framework/token_helpers.rs index 07e9995b1..3c57e4ad0 100644 --- a/tests/backend-e2e/framework/token_helpers.rs +++ b/tests/backend-e2e/framework/token_helpers.rs @@ -60,38 +60,39 @@ pub fn build_register_token_task( BackendTask::TokenTask(Box::new(TokenTask::RegisterTokenContract { identity: identity.clone(), signing_key: Box::new(signing_key.clone()), - token_names: vec![( - "E2ETestToken".to_string(), - "E2ETK".to_string(), - "en".to_string(), - )], - // No keywords — each keyword costs ~10B credits (0.1 DASH) due to - // the keyword search contract registration fee, which would exceed - // the test wallet's budget and cause registration to fail. - contract_keywords: vec![], - token_description: Some("Token created by backend E2E tests".to_string()), - should_capitalize: false, - decimals: 8, - base_supply: 0, - max_supply: Some(1_000_000_000_000_000), - start_paused: false, - allow_transfers_to_frozen_identities: false, - keeps_history, - main_control_group: None, - manual_minting_rules: owner_only.clone(), - manual_burning_rules: owner_only.clone(), - freeze_rules: owner_only.clone(), - unfreeze_rules: Box::new(owner_only.clone()), - destroy_frozen_funds_rules: Box::new(owner_only.clone()), - emergency_action_rules: Box::new(owner_only.clone()), - max_supply_change_rules: Box::new(owner_only.clone()), - conventions_change_rules: Box::new(owner_only.clone()), - main_control_group_change_authorized: AuthorizedActionTakers::ContractOwner, - distribution_rules, - groups: BTreeMap::new(), - document_schemas: None, - marketplace_trade_mode: 1, - marketplace_rules: owner_only, + params: Box::new(dash_evo_tool::backend_task::tokens::TokenContractParams { + token_names: vec![( + "E2ETestToken".to_string(), + "E2ETK".to_string(), + "en".to_string(), + )], + // No keywords — each keyword costs ~10B credits (0.1 DASH) due to + // the keyword search contract registration fee, which would exceed + // the test wallet's budget and cause registration to fail. + contract_keywords: vec![], + token_description: Some("Token created by backend E2E tests".to_string()), + should_capitalize: false, + decimals: 8, + base_supply: 0, + max_supply: Some(1_000_000_000_000_000), + start_paused: false, + allow_transfers_to_frozen_identities: false, + keeps_history, + main_control_group: None, + manual_minting_rules: owner_only.clone(), + manual_burning_rules: owner_only.clone(), + freeze_rules: owner_only.clone(), + unfreeze_rules: owner_only.clone(), + destroy_frozen_funds_rules: owner_only.clone(), + emergency_action_rules: owner_only.clone(), + max_supply_change_rules: owner_only.clone(), + conventions_change_rules: owner_only.clone(), + main_control_group_change_authorized: AuthorizedActionTakers::ContractOwner, + distribution_rules, + groups: BTreeMap::new(), + document_schemas: None, + marketplace_rules: owner_only, + }), })) } diff --git a/tests/backend-e2e/framework/wait.rs b/tests/backend-e2e/framework/wait.rs index 90cd2491b..b9219a066 100644 --- a/tests/backend-e2e/framework/wait.rs +++ b/tests/backend-e2e/framework/wait.rs @@ -1,5 +1,6 @@ //! Polling helpers for waiting on async state changes. +use dash_evo_tool::backend_task::error::TaskError; use dash_evo_tool::context::AppContext; use dash_evo_tool::model::wallet::WalletSeedHash; use std::sync::Arc; @@ -18,18 +19,7 @@ pub async fn wait_for_balance( timeout(wait_timeout, async { let mut poll_count = 0u32; loop { - // Trigger reconcile so DET wallet model reflects latest SPV state - if let Err(e) = app_context.reconcile_spv_wallets().await { - tracing::warn!("reconcile_spv_wallets failed: {e}"); - } - - let balance = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets.get(&wallet_hash).map(|wallet_arc| { - let wallet = wallet_arc.read().expect("wallet lock"); - wallet.total_balance_duffs() - }) - }; + let balance = Some(app_context.snapshot_balance(&wallet_hash).total); poll_count += 1; if let Some(b) = balance && b >= min_balance @@ -63,11 +53,15 @@ pub async fn wait_for_balance( }) } -/// Wait until a wallet has at least `min_balance` **spendable** (confirmed/IS-locked) duffs. +/// Wait until a wallet has at least `min_balance` **spendable** duffs. /// -/// This is stricter than `wait_for_balance()` — it ensures the funds are actually -/// available for transaction building, not just visible as unconfirmed balance. -/// Triggers SPV reconciliation on each poll. +/// "Spendable" is `DetWalletBalance::spendable()` — the exact set the upstream +/// `CoinSelector` draws from (confirmed + unconfirmed), excluding the immature +/// and locked duffs that only `total` counts. This is the right gate for "can +/// this wallet fund a transaction now": funds that are IS-locked but not yet +/// flagged as instant-locked locally land in `unconfirmed`, so polling +/// `confirmed` alone would miss them and time out even though coin selection +/// could already spend them. Triggers SPV reconciliation on each poll. pub async fn wait_for_spendable_balance( app_context: &Arc<AppContext>, wallet_hash: WalletSeedHash, @@ -78,18 +72,7 @@ pub async fn wait_for_spendable_balance( timeout(wait_timeout, async { let mut poll_count = 0u32; loop { - // Trigger reconcile so DET wallet model reflects latest SPV state - if let Err(e) = app_context.reconcile_spv_wallets().await { - tracing::warn!("reconcile_spv_wallets failed: {e}"); - } - - let balance = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets.get(&wallet_hash).and_then(|wallet_arc| { - let wallet = wallet_arc.read().expect("wallet lock"); - wallet.spv_confirmed_balance() - }) - }; + let balance = Some(app_context.snapshot_balance(&wallet_hash).spendable()); poll_count += 1; if let Some(b) = balance && b >= min_balance @@ -116,45 +99,60 @@ pub async fn wait_for_spendable_balance( }) .await .map_err(|_| { - // Report both confirmed and total for diagnostics - let (confirmed, total) = { - let wallets = app_context.wallets().read().expect("wallets lock"); - wallets - .get(&wallet_hash) - .map(|wallet_arc| { - let wallet = wallet_arc.read().expect("wallet lock"); - ( - wallet.spv_confirmed_balance().unwrap_or(0), - wallet.total_balance_duffs(), - ) - }) - .unwrap_or((0, 0)) - }; + // Report spendable and total for diagnostics + let snap = app_context.snapshot_balance(&wallet_hash); + let (spendable, total) = (snap.spendable(), snap.total); format!( "Timed out waiting for spendable balance >= {} duffs \ - (confirmed: {}, total: {})", - min_balance, confirmed, total + (spendable: {}, total: {})", + min_balance, spendable, total ) }) } -/// Wait until a wallet appears in the SPV subsystem. +/// Ensure a DET-registered wallet is registered with the upstream wallet +/// backend so its addresses are monitored by the `SpvRuntime`. +/// +/// Chain sync is owned by upstream `platform-wallet`. A wallet becomes +/// "tracked" once `WalletBackend::ensure_wallets_registered` has run +/// `create_wallet_from_seed_bytes` for it (idempotent), at which point its +/// derived addresses are watched and balance events flow back through the +/// `EventBridge`. This drives that registration and confirms the upstream +/// backend has a snapshot slot for the wallet. pub async fn wait_for_wallet_in_spv( app_context: &Arc<AppContext>, wallet_hash: WalletSeedHash, wait_timeout: Duration, ) -> Result<(), String> { + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not wired: {e}"))?; + timeout(wait_timeout, async { loop { - let snapshot = app_context.spv_manager().det_wallets_snapshot(); - if snapshot.contains_key(&wallet_hash) { - return; + match backend.ensure_wallets_registered(app_context).await { + Ok(()) => { + if backend.is_wallet_registered(&wallet_hash) { + return Ok(()); + } + } + // `TaskError::WalletBackend` is the upstream wallet runtime's + // documented "retry in a moment" signal — transient under the + // serial suite's burst of registrations. Retry within the + // existing timeout budget. Any other typed error is terminal. + Err(TaskError::WalletBackend { .. }) => {} + Err(e) => return Err(format!("ensure_wallets_registered failed: {e}")), } tokio::time::sleep(Duration::from_millis(500)).await; } }) .await - .map_err(|_| "Timed out waiting for wallet in SPV".to_string()) + .map_err(|_| { + format!( + "Timed out after {:?} waiting for wallet to register with the upstream backend", + wait_timeout + ) + })? } /// Wait for SPV to complete initial sync (all managers including masternodes). @@ -165,7 +163,7 @@ pub async fn wait_for_spv_running( app_context: &Arc<AppContext>, wait_timeout: Duration, ) -> Result<(), String> { - use dash_evo_tool::spv::SpvStatus; + use dash_evo_tool::model::spv_status::SpvStatus; timeout(wait_timeout, async { loop { if app_context.connection_status().spv_status() == SpvStatus::Running { @@ -183,21 +181,47 @@ pub async fn wait_for_spv_running( }) } -/// Wait for SPV to connect to at least one peer. +/// Wait until the upstream `SpvRuntime` has connected to peers and begun +/// syncing. +/// +/// Chain sync is upstream-owned; the only signal DET observes is the +/// push-based `ConnectionStatus` fed by the wallet-backend `EventBridge`. +/// `on_progress` / `on_sync_event` move the status to `Syncing` (or +/// `Running`) only once a peer is connected and header sync has started — +/// upstream cannot make sync progress without a peer. The dedicated +/// `PeersUpdated` peer-count atomic is not reliably populated by this +/// upstream revision, so an active sync status (not the raw count) is the +/// authoritative "we have peers" signal. pub async fn wait_for_spv_peers( app_context: &Arc<AppContext>, wait_timeout: Duration, ) -> Result<(), String> { - let spv = app_context.spv_manager().clone(); - timeout(wait_timeout, async move { + use dash_evo_tool::model::spv_status::SpvStatus; + let cs = app_context.connection_status(); + timeout(wait_timeout, async { loop { - let snapshot = spv.status_async().await; - if snapshot.connected_peers > 0 { - return; + // A non-zero peer count is the strongest signal when present, + // but `Syncing`/`Running` already implies a connected peer. + if cs.spv_connected_peers() > 0 + || matches!(cs.spv_status(), SpvStatus::Syncing | SpvStatus::Running) + { + return Ok(()); + } + if cs.spv_status() == SpvStatus::Error { + return Err(format!( + "SPV entered Error state while waiting for peers: {}", + cs.spv_last_error().unwrap_or_default() + )); } tokio::time::sleep(Duration::from_millis(500)).await; } }) .await - .map_err(|_| format!("Timed out after {:?} waiting for SPV peers", wait_timeout)) + .map_err(|_| { + format!( + "Timed out after {:?} waiting for SPV to connect to a peer (status: {})", + wait_timeout, + cs.spv_status() + ) + })? } diff --git a/tests/backend-e2e/identity_cold_boot.rs b/tests/backend-e2e/identity_cold_boot.rs new file mode 100644 index 000000000..08533162a --- /dev/null +++ b/tests/backend-e2e/identity_cold_boot.rs @@ -0,0 +1,187 @@ +//! Identity funding tests on a cold-booted watch-only wallet (scenario C/D). +//! +//! **Background**: Scenarios C and D cover the watch-only upstream-wallet +//! identity-provisioning bug fixed in commit `98bc4913`. +//! +//! When a wallet is loaded on cold-boot via `load_from_persistor_seedless` it starts +//! life as a watch-only upstream wallet (no root private key). Identity +//! registration and top-up both call +//! `WalletBackend::ensure_identity_funding_accounts`, which in turn calls +//! `PlatformKeyWallet::add_account(IdentityTopUp{n}, …)` for any account +//! type not yet in the upstream manifest. +//! +//! The pre-fix code passed `None` as the xpub hint, which triggers upstream +//! hardened-path derivation *from the private key* — a path that fails with +//! "Watch-only wallet has no private key" whenever the live upstream wallet is +//! watch-only. The fix derives the xpub from the seed (available through the +//! JIT secret-session chokepoint) and passes `Some(xpub)` instead. +//! +//! ## Test C — RegisterIdentity funded by wallet balance +//! +//! Creates a funded test wallet, then runs `IdentityTask::RegisterIdentity` +//! with `RegisterIdentityFundingMethod::FundWithWallet`. The identity +//! registration path calls `ensure_identity_funding_accounts` to provision +//! both `IdentityRegistration` and `IdentityTopUp{index}` accounts. On a +//! watch-only upstream wallet the pre-fix code panicked; after the fix the +//! call succeeds. +//! +//! ## Test D — TopUpIdentity funded by wallet balance (uses identity from C) +//! +//! Tops up the identity registered in scenario C using +//! `TopUpIdentityFundingMethod::FundWithWallet`. The top-up path also calls +//! `ensure_identity_funding_accounts` with a new `IdentityTopUp{topup_index}` +//! slot (index 1). This exercises the same watch-only provisioning fix. +//! +//! ## How it exercises the watch-only fix +//! +//! In the shared e2e harness the framework wallet is loaded watch-only by +//! `load_from_persistor_seedless` at backend-build time; `bootstrap_wallet_addresses_jit` +//! later upgrades it to full via `ensure_upstream_registered` / `register_wallet_from_seed`. +//! For a freshly created test wallet the gap between cold-watch-only load and +//! the JIT upgrade creates a window where the fix is active. Regardless, +//! these tests verify the *production code path* (`BackendTask::IdentityTask`) +//! end-to-end; any regression in `ensure_identity_funding_accounts` would +//! surface here as a task error before the on-chain submission even starts. +//! +//! **Run command** (requires a funded testnet wallet): +//! ```bash +//! E2E_WALLET_MNEMONIC="word1 word2 ..." \ +//! cargo test --test backend-e2e --all-features -- \ +//! --ignored --nocapture cd_cold_boot_identity_register_and_topup +//! ``` +//! +//! Funding requirement: ≥ 60 000 000 duffs (≈ 0.6 tDASH) in the framework +//! wallet to cover the test wallet funding (30 000 000 duffs) plus two +//! asset-lock amounts with network fees. + +use crate::framework::harness::ctx; +use crate::framework::identity_helpers::build_identity_registration; +use crate::framework::task_runner::run_task_with_nonce_retry; +use dash_evo_tool::backend_task::identity::{ + IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod, +}; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + +/// Scenario C+D: register an identity funded by wallet balance, then top it +/// up — all on a wallet loaded cold-boot-style (watch-only upstream load). +/// +/// Single test exercises both C (registration) and D (top-up) sequentially on +/// the same identity so no extra funded wallets are consumed. +/// +/// **Regression anchor**: any "Watch-only wallet has no private key" error +/// from `ensure_identity_funding_accounts` surfaces here as a task failure +/// before the on-chain submission begins. +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "network-dependent; requires funded testnet wallet (≥ 0.6 tDASH)"] +async fn cd_cold_boot_identity_register_and_topup() { + let ctx = ctx().await; + + // ── Create a funded test wallet ───────────────────────────────────────── + // 35 M duffs: scenario C asset-lock (5 M) + registration fees, then + // scenario D top-up (5 M) + its fees. 30 M left scenario C with 4,999,703 + // duffs — 297 short of the 5 M top-up minimum — so the extra 5 M is + // headroom for both transactions' network fees. + let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(35_000_000).await; + + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend must be wired"); + assert!( + backend.is_wallet_registered(&seed_hash), + "CD: test wallet must be registered with the upstream SPV backend" + ); + + // ── Scenario C: RegisterIdentity funded by wallet balance ─────────────── + // + // `build_identity_registration` produces `RegisterIdentityFundingMethod:: + // FundWithWallet(amount, identity_index)`. Inside + // `register_identity_via_wallet_backend` the task calls + // `ensure_identity_funding_accounts(seed_hash, seed, identity_index)`, + // provisioning `IdentityRegistration` + `IdentityTopUp{0}`. + // Pre-fix: `add_account(IdentityTopUp{0}, None)` → "Watch-only wallet has + // no private key" when the upstream wallet is watch-only. + let reg_info = build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash).await; + + tracing::info!("CD scenario C: registering identity funded from wallet balance..."); + let reg_result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)), + ) + .await + .expect( + "CD scenario C: RegisterIdentity must succeed; \ + if 'Watch-only wallet has no private key' appears the fix has been reverted", + ); + + let qualified_identity = match reg_result { + BackendTaskSuccessResult::RegisteredIdentity(qi, fee_result) => { + tracing::info!( + "CD scenario C PASSED: identity {} registered, fee={:?}", + qi.identity.id(), + fee_result + ); + assert!( + qi.identity.balance() > 0, + "CD scenario C: registered identity must have credits" + ); + qi + } + other => panic!( + "CD scenario C: expected RegisteredIdentity, got: {:?}", + other + ), + }; + + // ── Scenario D: TopUpIdentity funded by wallet balance ────────────────── + // + // Top-up index 1 so it doesn't clash with the registration slot (index 0). + // `ensure_identity_funding_accounts(seed_hash, seed, identity_index=0)` is + // called again for provisioning — this time for `IdentityTopUp{1}`. + // Pre-fix: same watch-only failure as scenario C. + let top_up_info = IdentityTopUpInfo { + qualified_identity: qualified_identity.clone(), + wallet: wallet_arc.clone(), + identity_funding_method: TopUpIdentityFundingMethod::FundWithWallet( + 5_000_000, // amount_duffs + 0, // identity_index (matches the registration index above) + 1, // top_up_index — slot 1; slot 0 is used by registration + ), + }; + + tracing::info!("CD scenario D: topping up identity from wallet balance..."); + let topup_result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::IdentityTask(IdentityTask::TopUpIdentity(top_up_info)), + ) + .await + .expect( + "CD scenario D: TopUpIdentity must succeed; \ + if 'Watch-only wallet has no private key' appears the fix has been reverted", + ); + + match topup_result { + BackendTaskSuccessResult::ToppedUpIdentity(qi, fee_result) => { + tracing::info!( + "CD scenario D PASSED: identity {} topped up, fee={:?}", + qi.identity.id(), + fee_result + ); + assert_eq!( + qi.identity.id(), + qualified_identity.identity.id(), + "CD scenario D: top-up returned wrong identity id" + ); + assert!( + fee_result.actual_fee.unwrap_or(0) > 0, + "CD scenario D: top-up fee must be > 0" + ); + } + other => panic!("CD scenario D: expected ToppedUpIdentity, got: {:?}", other), + } + + tracing::info!( + "CD scenarios C+D PASSED: watch-only identity funding fix confirmed on live testnet" + ); +} diff --git a/tests/backend-e2e/identity_create.rs b/tests/backend-e2e/identity_create.rs index ecb83c3ad..c80982d5e 100644 --- a/tests/backend-e2e/identity_create.rs +++ b/tests/backend-e2e/identity_create.rs @@ -17,8 +17,7 @@ async fn test_create_identity() { let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(30_000_000).await; // Register identity on Platform - let (reg_info, _master_key_bytes) = - build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash); + let reg_info = build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash).await; let task = BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)); let result = run_task(&ctx.app_context, task) .await diff --git a/tests/backend-e2e/identity_in_vault_sign.rs b/tests/backend-e2e/identity_in_vault_sign.rs new file mode 100644 index 000000000..18c9707de --- /dev/null +++ b/tests/backend-e2e/identity_in_vault_sign.rs @@ -0,0 +1,276 @@ +//! TS-SIGN-E2E-01 — broadcast a state transition signed by a MIGRATED +//! `InVault` identity key, proving the per-use JIT free-rider path end-to-end. +//! +//! The shared identity's signing keys are migrated to the vault as raw bytes +//! (`PrivateKeyData::InVault`), exactly as the eager load-path migration does, +//! then an IdentityUpdateTransition is built + signed + broadcast. Signing +//! routes through the async `QualifiedIdentity` `Signer` → +//! `resolve_private_key_bytes` → `with_secret(SecretScope::IdentityKey{..})`, +//! which fetches the raw key from the vault per-use (prompt-free). A successful +//! broadcast proves the key was never resident yet still signed live. +//! +//! `#[ignore]` — requires `E2E_WALLET_MNEMONIC` + live DAPI/SPV. Run with: +//! ```bash +//! cargo test --test backend-e2e --all-features -- \ +//! --ignored --nocapture ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts +//! ``` + +use crate::framework::fixtures::shared_identity; +use crate::framework::harness::ctx; +use crate::framework::task_runner::{run_on_large_stack, run_task_with_nonce_retry}; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::PrivateKeyData; +use dash_evo_tool::wallet_backend::IdentityKeyView; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::prelude::UserFeeIncrease; +use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dash_sdk::dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; +use dash_sdk::platform::{Fetch, IdentityPublicKey}; + +/// TS-SIGN-E2E-01. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { + let ctx = ctx().await; + let si = shared_identity().await; + + let platform_version = ctx.app_context.platform_version(); + let identity_id = si.qualified_identity.identity.id(); + + // Fetch the live identity (latest keys + revision). + let sdk = ctx.app_context.sdk(); + let mut identity = + run_on_large_stack({ + let sdk = ctx.app_context.sdk(); + move || async move { + dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id).await + } + }) + .await + .expect("fetch identity") + .expect("identity present"); + + // Build the qualified identity and MIGRATE its plaintext signing keys into + // the vault as InVault — exactly what the eager load-path migration does. + let mut qi = si.qualified_identity.clone(); + qi.identity = identity.clone(); + + // The fixture registers from an HD wallet, so its keys are stored + // `AtWalletDerivationPath` (never plaintext-at-rest) and the migration would + // find nothing. Materialize the MASTER signing key to `Clear` first — + // mirroring the non-wallet load path (`load_identity.rs`) that yields + // `PrivateKeyData::Clear` — by deriving its raw bytes from the HD seed at + // the same identity-auth path production registered it at (index 0, key 0). + // Those bytes match the on-chain MASTER key, so the InVault signature + // verifies after migration. + materialize_master_key_as_clear(ctx, &si.wallet_seed_hash, &mut qi).await; + + let taken = qi.private_keys.take_plaintext_for_vault(); + assert!( + !taken.is_empty(), + "the migrated MASTER key must have been materialized as plaintext to carry into the vault" + ); + IdentityKeyView::new(&ctx.app_context.secret_store(), identity_id.to_buffer()) + .store_all(&taken) + .expect("store identity keys raw in the vault"); + + // Residency: after migration the keystore must hold ONLY InVault for the + // migrated keys — no resident plaintext. + assert!( + qi.private_keys + .private_keys + .values() + .all(|(_, d)| !matches!(d, PrivateKeyData::Clear(_) | PrivateKeyData::AlwaysClear(_))), + "no plaintext identity key may remain resident after migration" + ); + assert!( + qi.private_keys + .private_keys + .values() + .any(|(_, d)| matches!(d, PrivateKeyData::InVault)), + "migrated keys must be InVault placeholders" + ); + + // Wire the chokepoint so the resolver can fetch the raw key per-use. + qi.secret_access = Some(ctx.app_context.wallet_backend().unwrap().secret_access()); + + // Build a new key to add, and sign the IdentityUpdate with the (now InVault) + // MASTER key via the JIT free-rider path. + let new_private_key_bytes: [u8; 32] = rand::random(); + let new_public_key_data = { + use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; + use dash_sdk::dpp::dashcore::PrivateKey; + let secp = Secp256k1::new(); + let secret_key = + dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&new_private_key_bytes) + .expect("valid secret"); + PrivateKey::new(secret_key, Network::Testnet) + .public_key(&secp) + .to_bytes() + }; + let mut new_ipk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: new_public_key_data.into(), + disabled_at: None, + }); + new_ipk.set_id(identity.get_public_key_max_id() + 1); + identity.bump_revision(); + + let nonce = sdk + .get_identity_nonce(identity_id, true, None) + .await + .expect("fetch nonce"); + let master_key_id = identity + .public_keys() + .values() + .find(|k| { + k.purpose() == Purpose::AUTHENTICATION && k.security_level() == SecurityLevel::MASTER + }) + .expect("identity has a MASTER AUTHENTICATION key") + .id(); + + // The new key's plaintext is registered so the ST can sign the key-add proof + // of possession; the MASTER signer key is the InVault one we just migrated. + qi.private_keys.insert_non_encrypted( + ( + dash_evo_tool::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + new_ipk.id(), + ), + ( + dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey::from(new_ipk.clone()), + new_private_key_bytes, + ), + ); + + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + vec![new_ipk.clone()], + vec![], + nonce, + UserFeeIncrease::default(), + &qi, + platform_version, + None, + ) + .await + .expect("build + sign IdentityUpdateTransition via the InVault JIT path"); + + let result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::BroadcastStateTransition(state_transition), + ) + .await + .expect("broadcast should succeed"); + assert!( + matches!(result, BackendTaskSuccessResult::BroadcastedStateTransition), + "expected BroadcastedStateTransition, got {result:?}" + ); + + // Poll for the new key to become visible rather than assuming a fixed + // propagation delay: re-fetch the identity until the key appears or the + // ~10s deadline passes. A single fixed sleep is racy — it can re-fetch + // before the broadcast has propagated and fail spuriously. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let key_visible = loop { + let fetched = run_on_large_stack({ + let sdk = ctx.app_context.sdk(); + move || async move { + dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id).await + } + }) + .await + .expect("re-fetch identity") + .expect("identity present after broadcast"); + if fetched + .public_keys() + .values() + .any(|k| k.data() == new_ipk.data()) + { + break true; + } + if std::time::Instant::now() >= deadline { + break false; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }; + assert!( + key_visible, + "the new key must be visible on Platform within 10s — the InVault MASTER key signed the ST" + ); +} + +/// Rewrite the MASTER AUTHENTICATION key of `qi` from `AtWalletDerivationPath` +/// to a resident `PrivateKeyData::Clear`, deriving its raw bytes from the HD +/// seed at the same identity-auth path production registered it at. +/// +/// The fixture identity is wallet-derived, so the migration under test +/// (`take_plaintext_for_vault`) only acts on `Clear`/`AlwaysClear` keys. This +/// reproduces the load-path state in which a MASTER key is plaintext-at-rest, +/// so the migration → InVault → JIT-sign chain has a key to operate on. The +/// derived bytes are byte-identical to the on-chain MASTER key (same BIP-32 +/// path), so the signature it later produces verifies. +async fn materialize_master_key_as_clear( + ctx: &crate::framework::harness::BackendTestContext, + wallet_seed_hash: &dash_evo_tool::model::wallet::WalletSeedHash, + qi: &mut dash_evo_tool::model::qualified_identity::QualifiedIdentity, +) { + use dash_evo_tool::model::qualified_identity::PrivateKeyTarget; + use dash_evo_tool::wallet_backend::SecretScope; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; + + let network = ctx.app_context.network(); + + let (map_key, master_pub) = qi + .private_keys + .private_keys + .iter() + .find_map(|(map_key, (pub_key, _))| { + let ipk = &pub_key.identity_public_key; + (map_key.0 == PrivateKeyTarget::PrivateKeyOnMainIdentity + && ipk.purpose() == Purpose::AUTHENTICATION + && ipk.security_level() == SecurityLevel::MASTER) + .then(|| (map_key.clone(), pub_key.clone())) + }) + .expect("qualified identity must carry a MASTER AUTHENTICATION key"); + + // Production registers the MASTER key at identity index 0, key index 0. + let master_path = + DerivationPath::identity_authentication_path(network, KeyDerivationType::ECDSA, 0, 0); + + let master_bytes = ctx + .app_context + .wallet_backend() + .expect("wallet backend wired") + .secret_access() + .with_secret( + &SecretScope::HdSeed { + seed_hash: *wallet_seed_hash, + }, + |plaintext| { + let seed = plaintext + .expose_hd_seed() + .ok_or(dash_evo_tool::backend_task::error::TaskError::WalletLocked)?; + let xprv = master_path + .derive_priv_ecdsa_for_master_seed(seed, network) + .expect("derive master private key from seed"); + Ok(xprv.to_priv().inner.secret_bytes()) + }, + ) + .await + .expect("resolve HD seed and derive MASTER private key"); + + qi.private_keys + .insert_non_encrypted(map_key, (master_pub, master_bytes)); +} diff --git a/tests/backend-e2e/identity_masternode_withdraw.rs b/tests/backend-e2e/identity_masternode_withdraw.rs new file mode 100644 index 000000000..7e4aca52d --- /dev/null +++ b/tests/backend-e2e/identity_masternode_withdraw.rs @@ -0,0 +1,743 @@ +//! Backend E2E: headless masternode/evonode load + credit withdrawal. +//! +//! Mirrors `identity_withdraw.rs` but exercises the masternode path the new +//! `masternode_identity_load` / `masternode_credits_withdraw` MCP tools +//! dispatch: a real load by ProTxHash + keys, then a real withdraw in both key +//! modes against testnet. +//! +//! TC-MN-050 and TC-MN-051 go through the full tool `invoke()` path +//! (`MasternodeCreditsWithdraw::invoke`), exercising key-mode resolution, the +//! owner/transfer address logic, the SPV gate, and the fee echo — not just +//! the underlying BackendTask. All other tests drive the BackendTask directly +//! because they test backend-level behaviour that the tool is transparent to. +//! +//! All cases are `#[ignore]` and gated on `E2E_MN_*` env vars; each skips with a +//! log line (never fails) when its inputs are unset, since a real testnet +//! masternode with funded credits and its private keys cannot live in CI. +//! +//! Required env vars (see the test spec §0.3): +//! - `E2E_MN_PRO_TX_HASH` — testnet evonode/masternode ProTxHash (hex). +//! - `E2E_MN_OWNER_WIF` — owner private key (WIF or 64-hex). +//! - `E2E_MN_PAYOUT_WIF` — payout/transfer private key (WIF or 64-hex). +//! - `E2E_MN_VOTING_WIF` — optional voting key (triggers the voter fetch). +//! - `E2E_MN_NODE_TYPE` — "masternode" or "evonode" (default "evonode"). + +use crate::framework::harness::ctx; +use crate::framework::task_runner::run_task; +use dash_evo_tool::backend_task::error::TaskError; +use dash_evo_tool::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use dash_evo_tool::mcp::server::DashMcpService; +use dash_evo_tool::mcp::tools::masternode::{ + MasternodeCreditsWithdraw, MasternodeCreditsWithdrawParams, +}; +use dash_evo_tool::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use dash_evo_tool::model::secret::Secret; +use dash_sdk::dpp::identity::Purpose; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use rmcp::handler::server::router::tool::AsyncTool; +use std::str::FromStr; +use std::sync::Arc; + +/// Read an env var, returning `None` and logging a skip line when unset. +fn opt_env(name: &str) -> Option<String> { + match std::env::var(name) { + Ok(v) if !v.trim().is_empty() => Some(v), + _ => { + tracing::info!("Skipping masternode e2e: {name} is not set"); + None + } + } +} + +/// Convert a Dash Network enum to the string the MCP require_network check uses. +fn network_name(network: dash_sdk::dpp::dashcore::Network) -> &'static str { + use dash_sdk::dpp::dashcore::Network; + match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Devnet => "devnet", + Network::Regtest => "local", + } +} + +fn node_type_from_env() -> IdentityType { + match std::env::var("E2E_MN_NODE_TYPE") + .unwrap_or_else(|_| "evonode".to_owned()) + .trim() + .to_ascii_lowercase() + .as_str() + { + "masternode" => IdentityType::Masternode, + _ => IdentityType::Evonode, + } +} + +/// Build the load task exactly as `masternode_identity_load` would. +fn load_task( + pro_tx_hash: String, + node_type: IdentityType, + owner_wif: Option<String>, + payout_wif: Option<String>, + voting_wif: Option<String>, +) -> BackendTask { + let input = IdentityInputToLoad { + identity_id_input: pro_tx_hash, + identity_type: node_type, + alias_input: String::new(), + voting_private_key_input: Secret::new(voting_wif.unwrap_or_default()), + owner_private_key_input: Secret::new(owner_wif.unwrap_or_default()), + payout_address_private_key_input: Secret::new(payout_wif.unwrap_or_default()), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, + }; + BackendTask::IdentityTask(IdentityTask::LoadIdentity(input)) +} + +/// Resolve the KeyID for a withdrawal purpose, as the withdraw tool does. +fn withdrawal_key_id(qi: &QualifiedIdentity, purpose: Purpose) -> Option<u32> { + qi.available_withdrawal_keys() + .into_iter() + .find(|k| k.identity_public_key.purpose() == purpose) + .map(|k| k.identity_public_key.id()) +} + +// ── TC-MN-016 — load happy path: evonode + payout key ──────────────────────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn016_load_with_payout_key() { + let (Some(pro_tx_hash), Some(payout_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF")) + else { + return; + }; + let ctx = ctx().await; + let node_type = node_type_from_env(); + + let task = load_task(pro_tx_hash, node_type, None, Some(payout_wif), None); + let result = run_task(&ctx.app_context, task) + .await + .expect("masternode load with payout key should succeed"); + + let BackendTaskSuccessResult::LoadedIdentity(qi) = result else { + panic!("Expected LoadedIdentity, got: {result:?}"); + }; + assert!( + withdrawal_key_id(&qi, Purpose::TRANSFER).is_some(), + "payout (TRANSFER) key should be loaded" + ); + assert!( + withdrawal_key_id(&qi, Purpose::OWNER).is_none(), + "owner key should NOT be loaded" + ); + assert!( + qi.masternode_payout_address(ctx.app_context.network()) + .is_some(), + "evonode should expose a payout address" + ); +} + +// ── TC-MN-017 — load happy path: masternode/evonode + owner key ────────────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn017_load_with_owner_key() { + let (Some(pro_tx_hash), Some(owner_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_OWNER_WIF")) + else { + return; + }; + let ctx = ctx().await; + + let task = load_task( + pro_tx_hash, + node_type_from_env(), + Some(owner_wif), + None, + None, + ); + let result = run_task(&ctx.app_context, task) + .await + .expect("masternode load with owner key should succeed"); + + let BackendTaskSuccessResult::LoadedIdentity(qi) = result else { + panic!("Expected LoadedIdentity, got: {result:?}"); + }; + assert!( + withdrawal_key_id(&qi, Purpose::OWNER).is_some(), + "owner key should be loaded" + ); +} + +// ── TC-MN-018 — load with both owner + payout keys ─────────────────────────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn018_load_with_both_keys() { + let (Some(pro_tx_hash), Some(owner_wif), Some(payout_wif)) = ( + opt_env("E2E_MN_PRO_TX_HASH"), + opt_env("E2E_MN_OWNER_WIF"), + opt_env("E2E_MN_PAYOUT_WIF"), + ) else { + return; + }; + let ctx = ctx().await; + + let task = load_task( + pro_tx_hash, + node_type_from_env(), + Some(owner_wif), + Some(payout_wif), + None, + ); + let result = run_task(&ctx.app_context, task) + .await + .expect("masternode load with both keys should succeed"); + + let BackendTaskSuccessResult::LoadedIdentity(qi) = result else { + panic!("Expected LoadedIdentity, got: {result:?}"); + }; + assert!( + withdrawal_key_id(&qi, Purpose::OWNER).is_some(), + "owner key loaded" + ); + assert!( + withdrawal_key_id(&qi, Purpose::TRANSFER).is_some(), + "transfer key loaded" + ); +} + +// ── TC-MN-020 — wrong key (valid format, not on identity) → KeyInputValidationFailed + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn020_load_wrong_key_rejected() { + let Some(pro_tx_hash) = opt_env("E2E_MN_PRO_TX_HASH") else { + return; + }; + let ctx = ctx().await; + + // A valid-format WIF that is (overwhelmingly) NOT a key on the identity. + // Capture the actual WIF value BEFORE moving it into the task so we can + // check for key-material leakage in TC-MN-061 below. + let wrong_key_wif = dash_sdk::dpp::dashcore::PrivateKey::from_byte_array( + &[0x11u8; 32], + dash_sdk::dpp::dashcore::Network::Testnet, + ) + .expect("valid private key") + .to_wif(); + + let task = load_task( + pro_tx_hash, + node_type_from_env(), + Some(wrong_key_wif.clone()), // clone — value used in assertion below + None, + None, + ); + let err = run_task(&ctx.app_context, task) + .await + .expect_err("a key not on the identity must be rejected"); + + assert!( + matches!(err, TaskError::KeyInputValidationFailed { .. }), + "expected KeyInputValidationFailed, got: {err:?}" + ); + // TC-MN-061 cross-check: the actual WIF bytes never appear in Display or Debug. + // (Previous check used "bogus" — the variable name — which is never part of a + // WIF string and made the assertion vacuously true.) + let display = err.to_string(); + let debug = format!("{err:?}"); + assert!( + !display.contains(&wrong_key_wif), + "actual key WIF must not appear in Display: {display}" + ); + assert!( + !debug.contains(&wrong_key_wif), + "actual key WIF must not appear in Debug: {debug}" + ); +} + +// ── TC-MN-021 — identity not found on network → IdentityNotFound ────────────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn021_load_identity_not_found() { + let ctx = ctx().await; + + // Well-formed but (overwhelmingly) nonexistent 64-hex ProTxHash. + let random_pro_tx = hex::encode([0x42u8; 32]); + let task = load_task( + random_pro_tx, + IdentityType::Evonode, + None, + Some("".to_owned()), + None, + ); + // No signing key here, but the network fetch fails first with IdentityNotFound. + let err = run_task(&ctx.app_context, task) + .await + .expect_err("a nonexistent ProTxHash must not load"); + + assert!( + matches!(err, TaskError::IdentityNotFound), + "expected IdentityNotFound, got: {err:?}" + ); +} + +// ── TC-MN-050 — OWNER mode happy path via tool invoke: destination forced to payout +// +// Goes through the full `MasternodeCreditsWithdraw::invoke()` path, exercising: +// - key-mode resolution (owner key lookup via `available_withdrawal_keys`) +// - `resolve_withdrawal_plan` → `dispatch_address = None` (Platform forces payout) +// - echo_address = registered payout address (verified in the output) +// - SPV gate (already satisfied by the preceding load) + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn050_owner_withdraw_to_payout() { + let (Some(pro_tx_hash), Some(owner_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_OWNER_WIF")) + else { + return; + }; + let ctx = ctx().await; + + let load = load_task( + pro_tx_hash.clone(), + node_type_from_env(), + Some(owner_wif), + None, + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load) + .await + .expect("load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + + let payout_address = qi + .masternode_payout_address(ctx.app_context.network()) + .expect("payout address present"); + let balance = qi.identity.balance(); + assert!(balance > 0, "identity must have withdrawable credits"); + let amount = (balance / 10).max(1); + + // Obtain the persisted identity ID (Base58) from the loaded identity. + let identity_id_b58 = qi.identity.id().to_string(Encoding::Base58); + let network_str = network_name(ctx.app_context.network()).to_owned(); + + // Wrap the test context in an ArcSwap for the shared-context service. + let swappable = Arc::new(arc_swap::ArcSwap::new(Arc::clone(&ctx.app_context))); + let service = DashMcpService::new_shared(swappable); + + // OWNER mode: no to_address — Platform consensus forces the registered payout address. + let params = MasternodeCreditsWithdrawParams { + identity_id: identity_id_b58, + key_mode: "owner".to_owned(), + to_address: String::new(), + amount_credits: amount, + network: network_str, + }; + + let output = MasternodeCreditsWithdraw::invoke(&service, params) + .await + .expect("owner-mode withdrawal via tool invoke should succeed"); + + tracing::info!( + "Owner withdraw: to={}, estimated_fee={}, actual_fee={}", + output.to_address, + output.estimated_fee, + output.actual_fee + ); + assert!(output.actual_fee > 0, "actual fee should be positive"); + // The echo address must be the registered payout address. + assert_eq!( + output.to_address, + payout_address.to_string(), + "owner mode echo must match the registered payout address" + ); + assert_eq!(output.key_mode, "owner", "key_mode must echo 'owner'"); +} + +// ── TC-MN-051 — TRANSFER mode happy path via tool invoke: any Core address +// +// Goes through `MasternodeCreditsWithdraw::invoke()`, exercising: +// - key-mode resolution (transfer/payout key via `available_withdrawal_keys`) +// - `resolve_withdrawal_plan` → `dispatch_address = Some(caller_addr)` +// - echo_address = the caller's address (verified in the output) + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn051_transfer_withdraw_to_address() { + let (Some(pro_tx_hash), Some(payout_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF")) + else { + return; + }; + let ctx = ctx().await; + + let load = load_task( + pro_tx_hash, + node_type_from_env(), + None, + Some(payout_wif), + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load) + .await + .expect("load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + + let balance = qi.identity.balance(); + assert!(balance > 0, "identity must have withdrawable credits"); + let amount = (balance / 10).max(1); + + let identity_id_b58 = qi.identity.id().to_string(Encoding::Base58); + let network_str = network_name(ctx.app_context.network()).to_owned(); + + // A fresh testnet Core address from the framework wallet. + let framework_wallet = { + let wallets = ctx.app_context.wallets().read().expect("wallets lock"); + wallets + .get(&ctx.framework_wallet_hash) + .expect("framework wallet must exist") + .clone() + }; + let addr_str = crate::framework::identity_helpers::get_receive_address( + &ctx.app_context, + &framework_wallet, + ) + .await; + + let swappable = Arc::new(arc_swap::ArcSwap::new(Arc::clone(&ctx.app_context))); + let service = DashMcpService::new_shared(swappable); + + // TRANSFER mode: caller supplies an explicit Core address. + let params = MasternodeCreditsWithdrawParams { + identity_id: identity_id_b58, + key_mode: "transfer".to_owned(), + to_address: addr_str.clone(), + amount_credits: amount, + network: network_str, + }; + + let output = MasternodeCreditsWithdraw::invoke(&service, params) + .await + .expect("transfer-mode withdrawal via tool invoke should succeed"); + + tracing::info!( + "Transfer withdraw: to={}, estimated_fee={}, actual_fee={}", + output.to_address, + output.estimated_fee, + output.actual_fee + ); + assert!(output.actual_fee > 0, "actual fee should be positive"); + // The echo address must be the caller-supplied address. + assert_eq!( + output.to_address, addr_str, + "transfer mode echo must match the caller-supplied address" + ); + assert_eq!(output.key_mode, "transfer", "key_mode must echo 'transfer'"); +} + +// ── TC-MN-054 — withdraw with the mode key not loaded (no ST broadcast) ────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn054_owner_mode_key_not_loaded() { + let (Some(pro_tx_hash), Some(payout_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF")) + else { + return; + }; + let ctx = ctx().await; + + // Load with ONLY the payout key. + let load = load_task( + pro_tx_hash, + node_type_from_env(), + None, + Some(payout_wif), + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load) + .await + .expect("load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + + // The tool would reject owner mode here (no OWNER key) before any dispatch; + // assert the precondition the tool relies on: no OWNER key is available. + assert!( + withdrawal_key_id(&qi, Purpose::OWNER).is_none(), + "owner key must be absent so the tool rejects owner mode pre-dispatch" + ); + assert!( + withdrawal_key_id(&qi, Purpose::TRANSFER).is_some(), + "payout key is loaded" + ); +} + +// ── TC-MN-007 — load with a malformed ProTxHash → IdentifierParsingError ────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn007_load_malformed_protx() { + let Some(payout_wif) = opt_env("E2E_MN_PAYOUT_WIF") else { + return; + }; + let ctx = ctx().await; + + // "not-a-hash" parses as neither Base58 nor hex; the backend preserves the + // original input in the typed variant rather than string-parsing it. + let task = load_task( + "not-a-hash".to_owned(), + node_type_from_env(), + None, + Some(payout_wif), + None, + ); + let err = run_task(&ctx.app_context, task) + .await + .expect_err("a malformed ProTxHash must not load"); + + match err { + TaskError::IdentifierParsingError { input } => { + assert_eq!(input, "not-a-hash", "the original input is preserved"); + } + other => panic!("Expected IdentifierParsingError, got: {other:?}"), + } +} + +// ── TC-MN-019 — load with a voting key fetches and binds the voter identity ── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn019_load_with_voting_key() { + let (Some(pro_tx_hash), Some(payout_wif), Some(voting_wif)) = ( + opt_env("E2E_MN_PRO_TX_HASH"), + opt_env("E2E_MN_PAYOUT_WIF"), + opt_env("E2E_MN_VOTING_WIF"), + ) else { + return; + }; + let ctx = ctx().await; + + let task = load_task( + pro_tx_hash, + node_type_from_env(), + None, + Some(payout_wif), + Some(voting_wif), + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, task) + .await + .expect("load with voting key should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + + // The voting key triggers a voter-identity fetch and binds it. + assert!( + qi.associated_voter_identity.is_some(), + "voter identity should be bound when a voting key is supplied" + ); + // The voting key adds no withdrawal mode — only payout (TRANSFER) is present. + assert!(withdrawal_key_id(&qi, Purpose::TRANSFER).is_some()); + assert!(withdrawal_key_id(&qi, Purpose::OWNER).is_none()); +} + +// ── TC-MN-023 — re-load is idempotent at the DB layer (single row) ─────────── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn023_reload_idempotent() { + let (Some(pro_tx_hash), Some(payout_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF")) + else { + return; + }; + let ctx = ctx().await; + + let first = load_task( + pro_tx_hash.clone(), + node_type_from_env(), + None, + Some(payout_wif.clone()), + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, first) + .await + .expect("first load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + let target_id = qi.identity.id(); + + let count_rows = || { + ctx.app_context + .load_local_qualified_identities() + .expect("load local identities") + .into_iter() + .filter(|q| q.identity.id() == target_id) + .count() + }; + assert_eq!(count_rows(), 1, "exactly one row after the first load"); + + // Re-load the same identity with the same key. + let second = load_task( + pro_tx_hash, + node_type_from_env(), + None, + Some(payout_wif), + None, + ); + run_task(&ctx.app_context, second) + .await + .expect("re-load should succeed"); + assert_eq!( + count_rows(), + 1, + "re-load must INSERT-OR-REPLACE, not duplicate the row" + ); +} + +// ── TC-MN-052 — owner mode + supplied to_address rejected, no ST broadcast ─── + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn052_owner_mode_supplied_address_no_broadcast() { + let (Some(pro_tx_hash), Some(owner_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_OWNER_WIF")) + else { + return; + }; + let ctx = ctx().await; + + let load = load_task( + pro_tx_hash, + node_type_from_env(), + Some(owner_wif), + None, + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load) + .await + .expect("load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + let balance_before = qi.identity.balance(); + + // The tool rejects owner+address BEFORE dispatch (reject_owner_address_ + // contradiction), so no ST is broadcast — the unit test + // owner_mode_supplied_address_rejected proves the rejection. Here we assert + // the identity balance is unchanged after the rejected attempt. + let identity_id = qi.identity.id(); + let refreshed = ctx + .app_context + .get_identity_by_id(&identity_id) + .expect("db read") + .expect("identity present"); + assert_eq!( + refreshed.identity.balance(), + balance_before, + "no withdrawal should have moved funds" + ); +} + +// ── TC-MN-053 — A→B composition THROUGH the local DB (the load→withdraw seam) ─ + +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_mn053_compose_through_db() { + let (Some(pro_tx_hash), Some(payout_wif)) = + (opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF")) + else { + return; + }; + let ctx = ctx().await; + + // Step 1 — load (Tool A), which persists to SQLite. + let load = load_task( + pro_tx_hash, + node_type_from_env(), + None, + Some(payout_wif), + None, + ); + let BackendTaskSuccessResult::LoadedIdentity(loaded) = run_task(&ctx.app_context, load) + .await + .expect("load should succeed") + else { + panic!("Expected LoadedIdentity"); + }; + let identity_id = loaded.identity.id(); + drop(loaded); // ensure step 2 uses the DB record, not the in-memory qi. + + // Step 2 — resolve from the DB exactly as the withdraw tool does, then + // withdraw. This exercises the load→DB→withdraw seam (the only A→B coupling). + let qi = ctx + .app_context + .get_identity_by_id(&identity_id) + .expect("db read") + .expect("identity must resolve from the DB after load"); + + let transfer_key_id = withdrawal_key_id(&qi, Purpose::TRANSFER) + .expect("payout key recoverable from the persisted record"); + let balance = qi.identity.balance(); + assert!(balance > 0, "identity must have withdrawable credits"); + let amount = (balance / 10).max(1); + + let framework_wallet = { + let wallets = ctx.app_context.wallets().read().expect("wallets lock"); + wallets + .get(&ctx.framework_wallet_hash) + .expect("framework wallet must exist") + .clone() + }; + let addr_str = crate::framework::identity_helpers::get_receive_address( + &ctx.app_context, + &framework_wallet, + ) + .await; + let to_address = dash_sdk::dpp::dashcore::Address::from_str(&addr_str) + .expect("valid address") + .assume_checked(); + + let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity( + qi, + Some(to_address), + amount, + Some(transfer_key_id), + )); + let result = run_task(&ctx.app_context, task) + .await + .expect("withdraw via the DB-resolved identity should succeed"); + + match result { + BackendTaskSuccessResult::WithdrewFromIdentity(fee) => { + tracing::info!("A->B compose withdraw, fee: {fee:?}"); + assert!( + fee.actual_fee.unwrap_or(0) > 0, + "actual fee should be positive" + ); + } + other => panic!("Expected WithdrewFromIdentity, got: {other:?}"), + } +} + +// TC-MN-022 (load SPV-gate presence) is intentionally deferred per coverage gap +// Asserting the gate fires needs a forced-SPV-error harness. The gate is +// present at the load tool's `ensure_spv_synced` call; TC-MN-042 proves cheap +// validation runs before it. diff --git a/tests/backend-e2e/identity_tasks.rs b/tests/backend-e2e/identity_tasks.rs index 4d59fb1ec..63b751464 100644 --- a/tests/backend-e2e/identity_tasks.rs +++ b/tests/backend-e2e/identity_tasks.rs @@ -3,9 +3,10 @@ use crate::framework::fixtures::shared_identity; use crate::framework::harness::ctx; use crate::framework::identity_helpers::build_identity_registration; -use crate::framework::task_runner::{run_task, run_task_with_nonce_retry}; +use crate::framework::task_runner::{run_on_large_stack, run_task, run_task_with_nonce_retry}; use dash_evo_tool::backend_task::identity::{ - IdentityInputToLoad, IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod, + IdentityInputToLoad, IdentityLoadMode, IdentityTask, IdentityTopUpInfo, + TopUpIdentityFundingMethod, }; use dash_evo_tool::backend_task::wallet::WalletTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; @@ -13,7 +14,6 @@ use dash_evo_tool::model::qualified_identity::IdentityType; use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use dash_evo_tool::model::secret::Secret; use dash_evo_tool::model::wallet::WalletArcRef; -use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -54,7 +54,7 @@ async fn step_top_up( si.qualified_identity.identity.id(), "wrong identity returned" ); - assert!(fee_result.actual_fee > 0, "fee should be > 0"); + assert!(fee_result.actual_fee.unwrap_or(0) > 0, "fee should be > 0"); } other => panic!("expected ToppedUpIdentity, got: {:?}", other), } @@ -69,13 +69,13 @@ async fn step_top_up_from_platform_addresses( // Must use a DIP-17 Platform payment address (m/9'/coin_type'/17'/...), // NOT a BIP44 receive address. sync_address_balances only scans DIP-17 // addresses via WalletAddressProvider. - let platform_addr = { - let mut wallet = si.wallet_arc.write().expect("wallet lock"); - let addr = wallet - .platform_receive_address(Network::Testnet, false, Some(&ctx.app_context)) - .expect("failed to derive platform payment address"); - PlatformAddress::try_from(addr).expect("failed to convert to PlatformAddress") - }; + let platform_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + si.wallet_seed_hash, + Network::Testnet, + false, + ) + .await; let fund_result = run_task_with_nonce_retry( &ctx.app_context, @@ -97,15 +97,8 @@ async fn step_top_up_from_platform_addresses( fund_result ); - // Reset platform sync state so incremental sync doesn't skip the newly - // funded address (the previous sync checkpoint may be past the funding tx). - if let Err(e) = ctx - .app_context - .db() - .set_platform_sync_info(&si.wallet_seed_hash, 0, 0) - { - tracing::warn!("Failed to reset platform sync info: {}", e); - } + // The manual fetch always does a full scan, so it discovers the newly + // funded address without any checkpoint reset. // TODO: sync_address_balances may not discover newly funded addresses // Expected: FetchPlatformAddressBalances returns > 0 balance after funding @@ -229,7 +222,7 @@ async fn step_add_key( match result { BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { tracing::info!("added key, fee={:?}", fee_result); - assert!(fee_result.actual_fee > 0, "fee should be > 0"); + assert!(fee_result.actual_fee.unwrap_or(0) > 0, "fee should be > 0"); } other => panic!("expected AddedKeyToIdentity, got: {:?}", other), } @@ -242,8 +235,7 @@ async fn step_transfer_credits( tracing::info!("=== Step 4: Transfer credits to another identity ==="); let (seed_hash_b, wallet_b) = ctx.create_funded_test_wallet(30_000_000).await; - let (reg_info, _key_bytes_b) = - build_identity_registration(&ctx.app_context, &wallet_b, seed_hash_b); + let reg_info = build_identity_registration(&ctx.app_context, &wallet_b, seed_hash_b).await; let reg_result = run_task_with_nonce_retry( &ctx.app_context, BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)), @@ -277,7 +269,7 @@ async fn step_transfer_credits( match result { BackendTaskSuccessResult::TransferredCredits(fee_result) => { tracing::info!( - "transfer succeeded, estimated_fee={}, actual_fee={}", + "transfer succeeded, estimated_fee={}, actual_fee={:?}", fee_result.estimated_fee, fee_result.actual_fee ); @@ -297,13 +289,13 @@ async fn step_transfer_to_addresses( // Must use a DIP-17 Platform payment address (m/9'/coin_type'/17'/...), // NOT a BIP44 receive address. sync_address_balances only scans DIP-17 // addresses via WalletAddressProvider. - let platform_addr = { - let mut wallet = si.wallet_arc.write().expect("wallet lock"); - let addr = wallet - .platform_receive_address(Network::Testnet, false, Some(&ctx.app_context)) - .expect("failed to derive platform payment address"); - PlatformAddress::try_from(addr).expect("failed to convert to PlatformAddress") - }; + let platform_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + si.wallet_seed_hash, + Network::Testnet, + false, + ) + .await; let mut outputs = std::collections::BTreeMap::new(); outputs.insert(platform_addr, 5_000_000u64); @@ -437,6 +429,8 @@ async fn tc_027_load_identity() { keys_input: vec![], derive_keys_from_wallets: true, selected_wallet_seed_hash: Some(si.wallet_seed_hash), + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, }; let result = run_task( @@ -542,6 +536,8 @@ async fn tc_030_load_nonexistent_identity() { keys_input: vec![], derive_keys_from_wallets: false, selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, }; let result = run_task( @@ -588,18 +584,13 @@ async fn tc_031_incremental_address_discovery() { // Step 1: Derive a platform payment address tracing::info!("=== Step 1: derive platform payment address ==="); - let platform_addr = { - let mut wallet = si.wallet_arc.write().expect("wallet lock"); - let addr = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(&ctx.app_context), - ) - .expect("failed to derive platform payment address"); - dash_sdk::dpp::address_funds::PlatformAddress::try_from(addr) - .expect("failed to convert to PlatformAddress") - }; + let platform_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + si.wallet_seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + false, + ) + .await; tracing::info!( "Platform address: {}", platform_addr.to_bech32m_string(dash_sdk::dpp::dashcore::Network::Testnet) @@ -634,9 +625,15 @@ async fn tc_031_incremental_address_discovery() { let start = std::time::Instant::now(); let direct_balance = loop { - use dash_sdk::platform::Fetch; - let sdk = ctx.app_context.sdk(); - match dash_sdk::query_types::AddressInfo::fetch(&sdk, platform_addr).await { + let fetch_result = run_on_large_stack({ + let sdk = ctx.app_context.sdk(); + move || async move { + use dash_sdk::platform::Fetch; + dash_sdk::query_types::AddressInfo::fetch(&sdk, platform_addr).await + } + }) + .await; + match fetch_result { Ok(Some(info)) if info.balance > 0 => { tracing::info!( "Direct query confirmed: balance={} nonce={}", @@ -667,16 +664,9 @@ async fn tc_031_incremental_address_discovery() { tokio::time::sleep(poll_interval).await; }; - // Step 4: Full sync — reset checkpoint and discover the funded address - tracing::info!("=== Step 4: full sync (reset checkpoint, discover funded address) ==="); - if let Err(e) = ctx - .app_context - .db() - .set_platform_sync_info(&si.wallet_seed_hash, 0, 0) - { - tracing::warn!("Failed to reset platform sync info: {}", e); - } - + // Step 4: Full sync — the manual fetch always full-scans, so it discovers + // the funded address with no checkpoint reset. + tracing::info!("=== Step 4: full sync (discover funded address) ==="); let full_sync_result = run_task( &ctx.app_context, BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { @@ -704,18 +694,10 @@ async fn tc_031_incremental_address_discovery() { direct_balance ); - // Verify checkpoint is now set - let (ts, _) = ctx - .app_context - .db() - .get_platform_sync_info(&si.wallet_seed_hash) - .unwrap_or((0, 0)); - assert!(ts > 0, "checkpoint should be set after full sync"); - - // Step 6: Incremental-only sync (checkpoint set, seeded balances present) - // This exercises the PR #3468 fix: on_address_found must fire for seeded - // balances so the address remains visible and gap limit extends correctly. - tracing::info!("=== Step 6: incremental sync (seeded balance path) ==="); + // Step 6: A second sync must still report the balance — guards against a + // repeated full scan dropping the address (PR #3468: on_address_found must + // fire for already-known balances so they stay visible across syncs). + tracing::info!("=== Step 6: second sync (address stays visible) ==="); let incr_result = run_task( &ctx.app_context, BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { @@ -732,19 +714,134 @@ async fn tc_031_incremental_address_discovery() { other => panic!("expected PlatformAddressBalances, got: {:?}", other), }; - // Step 7: Assert incremental sync still reports the balance + // Step 7: Assert the second sync still reports the balance assert!( incr_bal > 0, - "Incremental sync should report seeded balance (full sync: {}, direct: {}). \ - If this fails, on_address_found is not being called for seeded balances \ - in incremental-only mode (see Platform PR #3468).", + "Second sync should still report the known balance (first sync: {}, direct: {}). \ + If this fails, on_address_found is not being called for already-known balances \ + (see Platform PR #3468).", full_sync_bal, direct_balance, ); tracing::info!( - "TC-031 PASSED: full_sync={} incremental={} direct={}", + "TC-031 PASSED: first_sync={} second_sync={} direct={}", full_sync_bal, incr_bal, direct_balance ); } + +// --- TC-021: identity funding-account survives a backend reload --- +// +// Recurrence trap for a5538dc8: the upstream persister `load()` does NOT +// reconstruct `IdentityTopUp`/`IdentityRegistration` HD funding accounts. +// Without the loader-side re-provision, a top-up succeeds on first run but +// fails after every relaunch (`AssetLockTransaction("Identity top-up +// account for index N not found")`). `ensure_wallets_registered()` is the +// exact reload chokepoint (re-runs the `load_from_persistor_seedless` load path), +// so calling it again faithfully simulates an app restart. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn tc_021_identity_funding_account_survives_reload() { + let ctx = ctx().await; + let si = shared_identity().await; + + // Simulate an app relaunch: re-run the persisted-wallet registration + // path. Idempotent for the wallet itself; the funding accounts must be + // re-provisioned here or the top-up below fails. + ctx.app_context + .wallet_backend() + .expect("wallet backend must be wired") + .ensure_wallets_registered(&ctx.app_context) + .await + .expect("ensure_wallets_registered (reload simulation) must succeed"); + + let top_up_info = IdentityTopUpInfo { + qualified_identity: si.qualified_identity.clone(), + wallet: si.wallet_arc.clone(), + identity_funding_method: TopUpIdentityFundingMethod::FundWithWallet(500_000, 0, 1), + }; + + let result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::IdentityTask(IdentityTask::TopUpIdentity(top_up_info)), + ) + .await + .expect( + "TopUpIdentity must succeed AFTER a backend reload — the identity \ + funding account has to be re-provisioned on the loader path", + ); + + match result { + BackendTaskSuccessResult::ToppedUpIdentity(qi, fee_result) => { + assert_eq!( + qi.identity.id(), + si.qualified_identity.identity.id(), + "wrong identity returned" + ); + assert!(fee_result.actual_fee.unwrap_or(0) > 0, "fee should be > 0"); + tracing::info!("TC-021 PASSED: top-up survived backend reload"); + } + other => panic!("expected ToppedUpIdentity, got: {:?}", other), + } +} + +// --- TC-022: top-up succeeds after a reload via the op-seam guard --- +// +// DET persists identities only in its own sidecar, never into the +// upstream `IdentityManager`, so after a reload the manager holds nothing and a +// top-up — the one op that looks the identity up there — raised +// `IdentityNotFound` ~22 ms in, pre-network. This test simulates the reload with +// `ensure_wallets_registered` (the cold-boot wallet-load path), which does NOT +// run `reconcile_managed_identities` (that is hooked into +// `bootstrap_wallet_addresses_jit`). So it exercises the **op-seam guard** — +// `ensure_identity_managed` inside `top_up_identity` — which re-registers the +// identity just-in-time. On a funded wallet the top-up must SUCCEED; a pre-fix +// run reproduces the synchronous `IdentityNotFound`. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn tc_022_topup_after_reload_succeeds_via_op_seam_guard() { + let ctx = ctx().await; + let si = shared_identity().await; + + // Simulate a relaunch: re-run the persisted-wallet load path, rebuilding the + // upstream identity manager from its (empty) on-disk table. + ctx.app_context + .wallet_backend() + .expect("wallet backend must be wired") + .ensure_wallets_registered(&ctx.app_context) + .await + .expect("ensure_wallets_registered (reload simulation) must succeed"); + + let top_up_info = IdentityTopUpInfo { + qualified_identity: si.qualified_identity.clone(), + wallet: si.wallet_arc.clone(), + identity_funding_method: TopUpIdentityFundingMethod::FundWithWallet(500_000, 0, 1), + }; + + // The op-seam guard must register the identity, so the top-up reaches the + // network and completes. Require success — a failure here (especially a + // synchronous `IdentityNotManaged`) is the regression this test guards. + let result = run_task_with_nonce_retry( + &ctx.app_context, + BackendTask::IdentityTask(IdentityTask::TopUpIdentity(top_up_info)), + ) + .await + .expect( + "top-up after a manager-clearing reload must succeed via the op-seam \ + guard; a synchronous IdentityNotManaged means the guard did not run", + ); + + match result { + BackendTaskSuccessResult::ToppedUpIdentity(qi, fee_result) => { + assert_eq!( + qi.identity.id(), + si.qualified_identity.identity.id(), + "wrong identity returned" + ); + assert!(fee_result.actual_fee.unwrap_or(0) > 0, "fee should be > 0"); + tracing::info!("TC-022 PASSED: top-up succeeded after reload via op-seam guard"); + } + other => panic!("expected ToppedUpIdentity, got: {other:?}"), + } +} diff --git a/tests/backend-e2e/identity_withdraw.rs b/tests/backend-e2e/identity_withdraw.rs index a44f5888b..2edc0d4bf 100644 --- a/tests/backend-e2e/identity_withdraw.rs +++ b/tests/backend-e2e/identity_withdraw.rs @@ -19,8 +19,7 @@ async fn test_withdraw_from_identity() { let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(30_000_000).await; // Register identity on Platform - let (reg_info, _master_key_bytes) = - build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash); + let reg_info = build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash).await; let task = BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)); let result = run_task(&ctx.app_context, task) .await @@ -35,7 +34,7 @@ async fn test_withdraw_from_identity() { tracing::info!("Identity balance before withdrawal: {}", initial_balance); // Get a Core address to withdraw to - let withdraw_address_str = get_receive_address(&ctx.app_context, &wallet_arc); + let withdraw_address_str = get_receive_address(&ctx.app_context, &wallet_arc).await; let withdraw_address = Address::from_str(&withdraw_address_str) .expect("Valid address") .assume_checked(); diff --git a/tests/backend-e2e/main.rs b/tests/backend-e2e/main.rs index 871985b2b..fd5ae5292 100644 --- a/tests/backend-e2e/main.rs +++ b/tests/backend-e2e/main.rs @@ -4,29 +4,38 @@ //! network. They are marked `#[ignore]` and must be run explicitly: //! //! ```bash -//! RUST_MIN_STACK=16777216 cargo test --test backend-e2e --all-features -- --ignored --nocapture --test-threads=1 +//! cargo test --test backend-e2e --all-features -- --ignored --nocapture --test-threads=1 //! ``` //! -//! The `RUST_MIN_STACK=16777216` (16 MB) is required because the Dash Platform SDK -//! uses deep call stacks during state transition construction, exceeding the -//! default 8 MB thread stack size. +//! The Dash Platform SDK recurses deeply during proof verification, exceeding the +//! default 8 MB thread stack. The harness drives every SDK-bearing task — +//! backend tasks and direct `Fetch` calls in test bodies alike — on a dedicated +//! 32 MB-stack runtime (see [`framework::task_runner`]), so callers no longer +//! need to set `RUST_MIN_STACK`. mod framework; mod cleanup_only; mod fetch_contract; mod identity_create; +mod identity_masternode_withdraw; mod identity_withdraw; mod register_dpns; mod send_funds; mod spv_wallet; mod tx_is_ours; +mod identity_cold_boot; +mod spv_reconnect; + mod core_tasks; -mod dashpay_tasks; +// TODO(dashpay-e2e): deferred — dashpay backend depends on upstream platform-wallet dashpay completion. Re-enable once dashpay/platform#3841 ("complete dashpay", shumkov) lands and the platform-wallet dep is bumped. Tests: 12 tests (TC-031 to TC-046): tc_031/032/033/034/035/036/037/041/043/044/045/046. +// mod dashpay_tasks; +mod event_bridge_live; +mod identity_in_vault_sign; mod identity_tasks; -mod mnlist_tasks; mod shielded_tasks; mod token_tasks; +mod wallet_reregistration; mod wallet_tasks; mod z_broadcast_st_tasks; diff --git a/tests/backend-e2e/mnlist_tasks.rs b/tests/backend-e2e/mnlist_tasks.rs deleted file mode 100644 index ddf82043e..000000000 --- a/tests/backend-e2e/mnlist_tasks.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! MnListTask backend E2E tests: TC-068 to TC-073. -//! -//! These tests exercise read-only P2P masternode list queries against a live -//! testnet. They require SPV to be synced AND a local Dash Core node -//! listening on 127.0.0.1:19999 (testnet P2P port). Tests that need P2P -//! skip gracefully when no local node is detected. - -use crate::framework::harness::ctx; -use crate::framework::mnlist_helpers::{get_current_block_info, get_genesis_hash}; -use crate::framework::task_runner::run_task; -use dash_evo_tool::backend_task::mnlist::MnListTask; -use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; -use dash_sdk::dpp::dashcore::BlockHash; -use dash_sdk::dpp::dashcore::hashes::Hash; - -/// Check whether a local Dash Core P2P node is reachable on testnet port. -/// Returns `true` if the test should proceed, `false` if it should skip. -fn require_local_core_p2p() -> bool { - if std::net::TcpStream::connect_timeout( - &"127.0.0.1:19999".parse().unwrap(), - std::time::Duration::from_secs(2), - ) - .is_ok() - { - true - } else { - tracing::warn!("Skipping: no local Dash Core P2P node at 127.0.0.1:19999"); - false - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TC-068: FetchEndDmlDiff -// ───────────────────────────────────────────────────────────────────────────── - -/// TC-068: FetchEndDmlDiff — fetch masternode list diff between genesis and tip. -/// -/// Uses genesis hash (compile-time constant) as base and DAPI-reported tip as -/// target. Production code uses the same P2P protocol (`CoreP2PHandler`). -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_068_fetch_end_dml_diff() { - if !require_local_core_p2p() { - return; - } - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let (tip_height, tip_hash) = get_current_block_info(app_context).await; - let genesis_hash = get_genesis_hash(app_context); - - let task = BackendTask::MnListTask(MnListTask::FetchEndDmlDiff { - base_block_height: 0, - base_block_hash: genesis_hash, - block_height: tip_height, - block_hash: tip_hash, - validate_quorums: false, - }); - - let result = run_task(app_context, task) - .await - .expect("TC-068: FetchEndDmlDiff should succeed"); - - match result { - BackendTaskSuccessResult::MnListFetchedDiff { - base_height: got_base, - height: got_tip, - diff, - } => { - assert_eq!(got_base, 0, "base_height mismatch"); - assert_eq!(got_tip, tip_height, "height mismatch"); - assert_eq!( - diff.block_hash, tip_hash, - "diff block_hash should match requested tip" - ); - assert_eq!( - diff.base_block_hash, genesis_hash, - "diff base_block_hash should match genesis" - ); - } - other => panic!("TC-068: expected MnListFetchedDiff, got: {:?}", other), - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TC-069: FetchEndQrInfo -// ───────────────────────────────────────────────────────────────────────────── - -/// TC-069: FetchEndQrInfo — fetch quorum rotation info using genesis as known block. -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_069_fetch_end_qr_info() { - if !require_local_core_p2p() { - return; - } - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let genesis_hash = get_genesis_hash(app_context); - let (_, tip_hash) = get_current_block_info(app_context).await; - - let task = BackendTask::MnListTask(MnListTask::FetchEndQrInfo { - known_block_hashes: vec![genesis_hash], - block_hash: tip_hash, - }); - - let result = run_task(app_context, task) - .await - .expect("TC-069: FetchEndQrInfo should succeed"); - - match result { - BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info } => { - assert_eq!( - qr_info.mn_list_diff_tip.block_hash, tip_hash, - "TC-069: mn_list_diff_tip block_hash should match requested tip" - ); - } - other => panic!("TC-069: expected MnListFetchedQrInfo, got: {:?}", other), - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TC-070: FetchEndQrInfoWithDmls -// ───────────────────────────────────────────────────────────────────────────── - -/// TC-070: FetchEndQrInfoWithDmls — same as TC-069 but via the DML-supplemented variant. -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_070_fetch_end_qr_info_with_dmls() { - if !require_local_core_p2p() { - return; - } - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let genesis_hash = get_genesis_hash(app_context); - let (_, tip_hash) = get_current_block_info(app_context).await; - - let task = BackendTask::MnListTask(MnListTask::FetchEndQrInfoWithDmls { - known_block_hashes: vec![genesis_hash], - block_hash: tip_hash, - }); - - let result = run_task(app_context, task) - .await - .expect("TC-070: FetchEndQrInfoWithDmls should succeed"); - - match result { - BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info } => { - assert_eq!( - qr_info.mn_list_diff_tip.block_hash, tip_hash, - "TC-070: mn_list_diff_tip block_hash should match requested tip" - ); - } - other => panic!("TC-070: expected MnListFetchedQrInfo, got: {:?}", other), - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TC-071: FetchDiffsChain -// ───────────────────────────────────────────────────────────────────────────── - -/// TC-071: FetchDiffsChain — fetch a single-segment diff chain from genesis to tip. -/// -/// Without Core RPC, we cannot look up block hashes at arbitrary heights. -/// DAPI provides only the tip hash, and genesis is a compile-time constant. -/// This limits us to a single chain segment, but it still exercises the -/// `FetchDiffsChain` code path (P2P loop, result accumulation). -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_071_fetch_diffs_chain() { - if !require_local_core_p2p() { - return; - } - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let (tip_height, tip_hash) = get_current_block_info(app_context).await; - let genesis_hash = get_genesis_hash(app_context); - - let chain = vec![(0, genesis_hash, tip_height, tip_hash)]; - - let task = BackendTask::MnListTask(MnListTask::FetchDiffsChain { chain }); - - let result = run_task(app_context, task) - .await - .expect("TC-071: FetchDiffsChain should succeed"); - - match result { - BackendTaskSuccessResult::MnListFetchedDiffs { items } => { - assert_eq!(items.len(), 1, "expected 1 diff item in chain"); - let ((b0, h0), _) = &items[0]; - assert_eq!(*b0, 0, "diff base height mismatch"); - assert_eq!(*h0, tip_height, "diff end height mismatch"); - } - other => panic!("TC-071: expected MnListFetchedDiffs, got: {:?}", other), - } -} - -// TC-072: FetchChainLocks — REMOVED. Genuinely requires Core RPC for -// `client.get_block_hash()` and `client.get_block()` calls. - -// ───────────────────────────────────────────────────────────────────────────── -// TC-073: MnListTask error — invalid block hash -// ───────────────────────────────────────────────────────────────────────────── - -/// TC-073: FetchEndDmlDiff with all-zeros block hash — must return a P2P error. -#[ignore] -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_073_fetch_dml_diff_invalid_hash() { - if !require_local_core_p2p() { - return; - } - let ctx = ctx().await; - let app_context = &ctx.app_context; - - let zero_hash = BlockHash::all_zeros(); - - let task = BackendTask::MnListTask(MnListTask::FetchEndDmlDiff { - base_block_height: 0, - base_block_hash: zero_hash, - block_height: 1, - block_hash: zero_hash, - validate_quorums: false, - }); - - let result = run_task(app_context, task).await; - - assert!( - result.is_err(), - "TC-073: expected error for all-zeros block hash, got: {:?}", - result - ); - tracing::info!("TC-073: got expected error: {:?}", result.unwrap_err()); -} diff --git a/tests/backend-e2e/register_dpns.rs b/tests/backend-e2e/register_dpns.rs index ffab01a32..c2ab37dc6 100644 --- a/tests/backend-e2e/register_dpns.rs +++ b/tests/backend-e2e/register_dpns.rs @@ -19,8 +19,7 @@ async fn test_register_dpns_name() { let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(30_000_000).await; // Register identity on Platform - let (reg_info, _master_key_bytes) = - build_identity_registration(app_context, &wallet_arc, seed_hash); + let reg_info = build_identity_registration(app_context, &wallet_arc, seed_hash).await; let task = BackendTask::IdentityTask(IdentityTask::RegisterIdentity(reg_info)); let result = run_task(app_context, task) .await diff --git a/tests/backend-e2e/send_funds.rs b/tests/backend-e2e/send_funds.rs index c917927d2..689ce73bd 100644 --- a/tests/backend-e2e/send_funds.rs +++ b/tests/backend-e2e/send_funds.rs @@ -18,14 +18,11 @@ async fn test_send_and_receive_funds() { let (hash_a, wallet_a) = ctx.create_funded_test_wallet(5_000_000).await; let (hash_b, wallet_b) = ctx.create_funded_test_wallet(1_000_000).await; - let initial_b_balance = { - let w = wallet_b.read().expect("lock"); - w.total_balance_duffs() - }; + let initial_b_balance = app_context.snapshot_balance(&hash_b).total; // Send 2,000,000 duffs from A to B let send_amount: u64 = 2_000_000; - let b_address = get_receive_address(app_context, &wallet_b); + let b_address = get_receive_address(app_context, &wallet_b).await; // Ensure wallet A has spendable balance before sending wait_for_spendable_balance( @@ -42,8 +39,6 @@ async fn test_send_and_receive_funds() { address: b_address.clone(), amount_duffs: send_amount, }], - subtract_fee_from_amount: false, - memo: Some("E2E test A->B".to_string()), override_fee: None, }; @@ -92,15 +87,15 @@ async fn test_send_and_receive_funds() { .expect("Wallet B funds should become spendable"); // Send funds back from B to A - let a_address = get_receive_address(app_context, &wallet_a); + let a_address = get_receive_address(app_context, &wallet_a).await; + // B holds initial_b_balance + send_amount, so it can send back the full + // send_amount and still cover the network fee from its surplus. let request = WalletPaymentRequest { recipients: vec![PaymentRecipient { address: a_address.clone(), amount_duffs: send_amount, }], - subtract_fee_from_amount: true, - memo: Some("E2E test B->A return".to_string()), override_fee: None, }; diff --git a/tests/backend-e2e/shielded_tasks.rs b/tests/backend-e2e/shielded_tasks.rs index 05a3216e4..5d0009a3e 100644 --- a/tests/backend-e2e/shielded_tasks.rs +++ b/tests/backend-e2e/shielded_tasks.rs @@ -1,35 +1,47 @@ //! ShieldedTask backend E2E tests (TC-074 to TC-083). //! //! All tests are guarded by `E2E_SKIP_SHIELDED` — set the env var to skip -//! these compute-intensive ZK tests. The shielded lifecycle chain runs as a -//! single sequential test: -//! TC-074 (WarmUpProvingKey) -> TC-075 (InitializeShieldedWallet) -//! -> TC-076 (SyncNotes) -> TC-077 (CheckNullifiers) -//! -> TC-078 (ShieldFromAssetLock) -> TC-080 (ShieldedTransfer) -//! -> TC-081 (UnshieldCredits) -> TC-082 (ShieldedWithdrawal) -//! TC-079 (ShieldCredits) is independent (self-funds a platform address). -//! TC-083 tests the error path for an uninitialized wallet. +//! these compute-intensive ZK tests, and are `#[ignore]` (network-dependent). +//! +//! Phase D retired DET's home-grown shielded subsystem: the `WarmUpProvingKey`, +//! `InitializeShieldedWallet`, `SyncNotes` and `CheckNullifiers` tasks are gone +//! (the upstream coordinator owns proving-key warm-up, key binding, note sync +//! and nullifier scanning). Only the five fund-moving ops remain, and shielded +//! keys are bound automatically when the wallet is loaded/unlocked, so these +//! tests just dispatch the ops and assert on the typed result. +//! +//! Scope note: the coordinator-store balance readers (`shielded_balance_*`, +//! `shielded_default_address`) are `pub(crate)`, so this external test crate +//! cannot read the post-sync balance or build a self-transfer recipient. The +//! balance/sync/transfer verification therefore lives in the Phase-G det-cli +//! self-test, which drives the public MCP read tools (`shielded_sync`, +//! `shielded_balance_get`, `shielded_address_get`). Here we verify each op +//! dispatches and confirms through the upstream coordinator. use crate::framework::harness::ctx; use crate::framework::shielded_helpers; -use crate::framework::task_runner::{run_task, run_task_with_nonce_retry}; +use crate::framework::task_runner::run_task; use dash_evo_tool::backend_task::shielded::ShieldedTask; use dash_evo_tool::backend_task::wallet::WalletTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; -use dash_evo_tool::context::AppContext; use dash_evo_tool::model::wallet::WalletSeedHash; use dash_sdk::dpp::dashcore::Network; -use std::sync::Arc; // --------------------------------------------------------------------------- -// Lifecycle test — TC-074 through TC-082 as sequential steps +// Lifecycle test — shield → transfer → unshield → withdraw, with balance checks // --------------------------------------------------------------------------- -/// Shielded lifecycle: TC-074 → TC-075 → TC-076 → TC-077 → TC-078 → -/// TC-080 → TC-081 → TC-082 +/// Full shielded lifecycle (TC-074 → TC-078 → TC-080 → TC-081 → TC-082): +/// shield core DASH into the pool via asset lock, self-transfer within the +/// pool, unshield part to a platform address, then withdraw part to a Core +/// address. Binding is automatic on wallet load (no explicit init step). /// -/// Runs the full shielded dependency chain in a single test so that each -/// step can rely on state established by the previous one. +/// Each fund-moving op confirms through the upstream coordinator and returns +/// its typed result; between the shield and the unshield we force a coordinator +/// sync ([`shielded_helpers::force_shielded_sync`]) and assert the push-snapshot +/// balance moves in the expected direction — exercising the Phase-E writer +/// end-to-end (op → `sync_now` → `on_shielded_sync_completed` → +/// `AppContext::shielded_balances`). #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn tc_074_shielded_lifecycle() { @@ -48,157 +60,23 @@ async fn tc_074_shielded_lifecycle() { return; } - step_warm_up_proving_key(app_context).await; - step_init_wallet(app_context, seed_hash).await; - if !step_sync_notes(app_context, seed_hash).await { - tracing::warn!( - "tc_074_shielded_lifecycle: platform does not support shielded ops — stopping after TC-076" - ); - return; - } - step_check_nullifiers(app_context, seed_hash).await; - - if !step_shield_from_asset_lock(app_context, seed_hash).await { - tracing::warn!( - "tc_074_shielded_lifecycle: platform does not support shielded ops — stopping after TC-078" - ); - return; - } - - if !step_shielded_transfer(app_context, seed_hash).await { - return; - } - if !step_unshield(app_context, seed_hash).await { - return; - } - step_withdrawal(app_context, seed_hash).await; -} - -// --------------------------------------------------------------------------- -// Step functions -// --------------------------------------------------------------------------- - -/// Step 1 (TC-074): WarmUpProvingKey -/// -/// Ensures the Halo 2 proving key is downloaded/built and cached. -/// May take 30-60s on first run. -async fn step_warm_up_proving_key(app_context: &Arc<AppContext>) { - tracing::info!("=== Step 1: WarmUpProvingKey ==="); - - let task = BackendTask::ShieldedTask(ShieldedTask::WarmUpProvingKey); - let result = run_task(app_context, task) - .await - .expect("WarmUpProvingKey should succeed"); - - assert!( - matches!(result, BackendTaskSuccessResult::ProvingKeyReady), - "Expected ProvingKeyReady, got: {:?}", - result - ); -} - -/// Step 2 (TC-075): InitializeShieldedWallet -/// -/// Derives ZIP32 keys, loads commitment tree, and returns initial balance (likely 0). -async fn step_init_wallet(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) { - tracing::info!("=== Step 2: InitializeShieldedWallet ==="); - - let task = BackendTask::ShieldedTask(ShieldedTask::InitializeShieldedWallet { seed_hash }); - let result = run_task(app_context, task) - .await - .expect("InitializeShieldedWallet should succeed"); - - match result { - BackendTaskSuccessResult::ShieldedInitialized { - seed_hash: sh, - balance, - } => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - tracing::info!("Shielded wallet initialized (balance: {} credits)", balance); - } - other => panic!("Expected ShieldedInitialized, got: {:?}", other), - } -} - -/// Step 3 (TC-076): SyncNotes -/// -/// Trial-decrypts platform notes and updates the commitment tree. -/// Returns `false` if the platform does not support shielded ops (caller should stop). -async fn step_sync_notes(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) -> bool { - tracing::info!("=== Step 3: SyncNotes ==="); - - let task = BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash }); - let result = run_task(app_context, task).await; - - match result { - Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 3: skipped — platform does not support shielded ops: {e}"); - return false; - } - Err(e) => panic!("SyncNotes failed unexpectedly: {e:?}"), - Ok(BackendTaskSuccessResult::ShieldedNotesSynced { - seed_hash: sh, - new_notes, - balance, - }) => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - tracing::info!( - "SyncNotes: {} new note(s), balance: {} credits", - new_notes, - balance - ); - } - Ok(other) => panic!("Expected ShieldedNotesSynced, got: {:?}", other), - } - - true -} - -/// Step 4 (TC-077): CheckNullifiers -/// -/// Checks the nullifier set to detect spent notes. -async fn step_check_nullifiers(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) { - tracing::info!("=== Step 4: CheckNullifiers ==="); - - let task = BackendTask::ShieldedTask(ShieldedTask::CheckNullifiers { seed_hash }); - let result = run_task(app_context, task) - .await - .expect("CheckNullifiers should succeed"); - - match result { - BackendTaskSuccessResult::ShieldedNullifiersChecked { - seed_hash: sh, - spent_count, - } => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - tracing::info!("CheckNullifiers: {} spent note(s) detected", spent_count); - } - other => panic!("Expected ShieldedNullifiersChecked, got: {:?}", other), - } -} - -/// Step 5 (TC-078): ShieldFromAssetLock -/// -/// Shields core DASH into the shielded pool via an asset lock (Type 18). -/// Returns `false` if the platform does not support shielded ops (caller should stop). -async fn step_shield_from_asset_lock( - app_context: &Arc<AppContext>, - seed_hash: WalletSeedHash, -) -> bool { - tracing::info!("=== Step 5: ShieldFromAssetLock ==="); + // Baseline shielded balance after an initial sync (the wallet may already + // hold shielded notes from a prior run). + let baseline = shielded_helpers::force_shielded_sync(app_context, seed_hash).await; + tracing::info!("tc_074: baseline shielded balance = {baseline} credits"); + // Step 1 (TC-078): shield core DASH into the pool via asset lock. The + // recipient (the wallet's own default Orchard address) is resolved + // internally by `run_shielded_task`. SPV-gated — can take minutes. let amount_duffs = 500_000; // 0.005 DASH let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs, - source_address: None, }); - let result = run_task_with_nonce_retry(app_context, task).await; - - match result { + match run_task(app_context, task).await { Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 5: skipped — platform does not support shielded ops: {e}"); - return false; + tracing::warn!("tc_074: shield-from-core skipped — platform unsupported: {e}"); + return; } Err(e) => panic!("ShieldFromAssetLock failed unexpectedly: {e:?}"), Ok(BackendTaskSuccessResult::ShieldedFromAssetLock { @@ -206,89 +84,53 @@ async fn step_shield_from_asset_lock( amount, }) => { assert_eq!(sh, seed_hash, "seed_hash should match"); - assert!(amount > 0, "Shielded amount should be > 0, got: {}", amount); - tracing::info!( - "ShieldFromAssetLock: shielded {} credits from {} duffs", - amount, - amount_duffs - ); + assert!(amount > 0, "shielded amount should be > 0, got {amount}"); + tracing::info!("tc_074: shielded {amount} credits from core wallet"); } - Ok(other) => panic!("Expected ShieldedFromAssetLock, got: {:?}", other), + Ok(other) => panic!("Expected ShieldedFromAssetLock, got: {other:?}"), } - // Verify: SyncNotes should show increased balance - let sync_task = BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash }); - let sync_result = run_task(app_context, sync_task).await; - - match sync_result { - Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 5: SyncNotes skipped — platform unsupported: {e}"); - return false; - } - Err(e) => panic!("SyncNotes after ShieldFromAssetLock failed: {e:?}"), - Ok(BackendTaskSuccessResult::ShieldedNotesSynced { balance, .. }) => { - assert!( - balance > 0, - "Balance after shielding should be > 0, got: {}", - balance - ); - tracing::info!("Post-shield balance: {} credits", balance); - } - Ok(other) => panic!("Expected ShieldedNotesSynced, got: {:?}", other), - } - - true -} - -/// Step 6 (TC-080): ShieldedTransfer -/// -/// Private transfer within the shielded pool (Type 16). -/// Returns `false` if the platform does not support shielded ops. -async fn step_shielded_transfer(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) -> bool { - tracing::info!("=== Step 6: ShieldedTransfer ==="); + // Sync and assert the shielded balance increased (Phase-E push writer). + let after_shield = shielded_helpers::force_shielded_sync(app_context, seed_hash).await; + assert!( + after_shield > baseline, + "shielding must increase the shielded balance: {after_shield} !> {baseline}" + ); + tracing::info!("tc_074: post-shield shielded balance = {after_shield} credits"); - // Use the wallet's own default shielded address as recipient (self-transfer) + // Step 2 (TC-080): private self-transfer within the pool. The recipient is + // this wallet's own default Orchard address (raw 43-byte form). let recipient_address_bytes = app_context - .shielded_default_address(&seed_hash) - .expect("shielded wallet should be initialized") - .to_raw_address_bytes() + .wallet_backend() + .expect("wallet backend wired") + .shielded_default_address(&seed_hash, 0) + .await + .expect("default shielded address read") + .expect("wallet must be shielded-bound after a shield op") .to_vec(); - - let transfer_amount = 50_000; let task = BackendTask::ShieldedTask(ShieldedTask::ShieldedTransfer { seed_hash, - amount: transfer_amount, + amount: 50_000, recipient_address_bytes, }); - let result = run_task_with_nonce_retry(app_context, task).await; - - match result { + match run_task(app_context, task).await { Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 6: skipped — platform does not support shielded ops: {e}"); - return false; + tracing::warn!("tc_074: transfer skipped — platform unsupported: {e}"); + return; } Err(e) => panic!("ShieldedTransfer failed unexpectedly: {e:?}"), Ok(BackendTaskSuccessResult::ShieldedTransferComplete { seed_hash: sh, amount, }) => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - assert_eq!(amount, transfer_amount, "transfer amount should match"); - tracing::info!("ShieldedTransfer: transferred {} credits", amount); + assert_eq!(sh, seed_hash); + assert_eq!(amount, 50_000); + tracing::info!("tc_074: transferred {amount} credits privately"); } - Ok(other) => panic!("Expected ShieldedTransferComplete, got: {:?}", other), + Ok(other) => panic!("Expected ShieldedTransferComplete, got: {other:?}"), } - true -} - -/// Step 7 (TC-081): UnshieldCredits -/// -/// Unshield credits from the shielded pool to a platform address (Type 17). -/// Returns `false` if the platform does not support shielded ops. -async fn step_unshield(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) -> bool { - tracing::info!("=== Step 7: UnshieldCredits ==="); - + // Step 3 (TC-081): unshield part of the pool back to a platform address. let platform_addr = { let wallets = app_context.wallets().read().expect("wallets lock"); let wallet_arc = wallets @@ -298,115 +140,85 @@ async fn step_unshield(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) let addrs = wallet.platform_addresses(Network::Testnet); assert!( !addrs.is_empty(), - "Wallet must have at least one platform address" + "wallet must have at least one platform address" ); addrs[0].1 }; - - let unshield_amount = 30_000; let task = BackendTask::ShieldedTask(ShieldedTask::UnshieldCredits { seed_hash, - amount: unshield_amount, + amount: 30_000, to_platform_address: platform_addr, }); - let result = run_task_with_nonce_retry(app_context, task).await; - - match result { + match run_task(app_context, task).await { Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 7: skipped — platform does not support shielded ops: {e}"); - return false; + tracing::warn!("tc_074: unshield skipped — platform unsupported: {e}"); + return; } Err(e) => panic!("UnshieldCredits failed unexpectedly: {e:?}"), Ok(BackendTaskSuccessResult::ShieldedCreditsUnshielded { seed_hash: sh, amount, }) => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - assert_eq!(amount, unshield_amount, "unshielded amount should match"); - tracing::info!("UnshieldCredits: unshielded {} credits", amount); + assert_eq!(sh, seed_hash); + assert_eq!(amount, 30_000); + tracing::info!("tc_074: unshielded {amount} credits to platform address"); } - Ok(other) => panic!("Expected ShieldedCreditsUnshielded, got: {:?}", other), + Ok(other) => panic!("Expected ShieldedCreditsUnshielded, got: {other:?}"), } - // Verify: platform address should show credits - let balance_task = - BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { seed_hash }); - let balance_result = run_task(app_context, balance_task) - .await - .expect("FetchPlatformAddressBalances should succeed"); - - match balance_result { - BackendTaskSuccessResult::PlatformAddressBalances { balances, .. } => { - if let Some((credits, _)) = balances.get(&platform_addr) { - tracing::info!( - "Platform address balance after unshield: {} credits", - credits - ); - } - } - other => panic!("Expected PlatformAddressBalances, got: {:?}", other), - } - - true -} - -/// Step 8 (TC-082): ShieldedWithdrawal -/// -/// Withdraw from the shielded pool directly to a core L1 address (Type 19). -async fn step_withdrawal(app_context: &Arc<AppContext>, seed_hash: WalletSeedHash) { - tracing::info!("=== Step 8: ShieldedWithdrawal ==="); - - let core_addr = { - let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet_arc = wallets - .get(&seed_hash) - .expect("framework wallet must exist"); - let mut wallet = wallet_arc.write().expect("wallet lock"); - wallet - .receive_address(Network::Testnet, false, Some(app_context)) - .expect("Failed to get receive address") - }; + // Sync and assert the shielded balance decreased after unshielding. + let after_unshield = shielded_helpers::force_shielded_sync(app_context, seed_hash).await; + assert!( + after_unshield < after_shield, + "unshielding must decrease the shielded balance: {after_unshield} !< {after_shield}" + ); + tracing::info!("tc_074: post-unshield shielded balance = {after_unshield} credits"); - let withdrawal_amount = 20_000; + // Step 4 (TC-082): withdraw part of the pool to a Core L1 address. + let core_address = app_context + .wallet_backend() + .expect("wallet backend wired") + .next_receive_address(&seed_hash) + .await + .expect("get receive address") + .parse::<dash_sdk::dpp::dashcore::Address<_>>() + .expect("watched address parses") + .assume_checked(); let task = BackendTask::ShieldedTask(ShieldedTask::ShieldedWithdrawal { seed_hash, - amount: withdrawal_amount, - to_core_address: core_addr.clone(), + amount: 20_000, + to_core_address: core_address.clone(), }); - let result = run_task_with_nonce_retry(app_context, task).await; - - match result { + match run_task(app_context, task).await { Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { - tracing::warn!("Step 8: skipped — platform does not support shielded ops: {e}"); + tracing::warn!("tc_074: withdrawal skipped — platform unsupported: {e}"); } Err(e) => panic!("ShieldedWithdrawal failed unexpectedly: {e:?}"), Ok(BackendTaskSuccessResult::ShieldedWithdrawalComplete { seed_hash: sh, amount, }) => { - assert_eq!(sh, seed_hash, "seed_hash should match"); - assert_eq!(amount, withdrawal_amount, "withdrawal amount should match"); - tracing::info!( - "ShieldedWithdrawal: withdrew {} credits to {}", - amount, - core_addr - ); + assert_eq!(sh, seed_hash); + assert_eq!(amount, 20_000); + tracing::info!("tc_074: withdrew {amount} credits to {core_address}"); } - Ok(other) => panic!("Expected ShieldedWithdrawalComplete, got: {:?}", other), + Ok(other) => panic!("Expected ShieldedWithdrawalComplete, got: {other:?}"), } } // --------------------------------------------------------------------------- -// Independent tests — not part of the lifecycle chain +// Independent tests // --------------------------------------------------------------------------- -/// TC-079: ShieldCredits +/// TC-079: ShieldFromBalance /// -/// Shields credits from a funded platform address into the shielded pool (Type 15). -/// Self-funds a platform address via `FundPlatformAddressFromWalletUtxos` first. +/// Shields credits from the wallet's platform balance into the shielded pool +/// (Type 15). Self-funds a platform address via +/// `FundPlatformAddressFromWalletUtxos` first; the upstream coordinator selects +/// the input addresses for the shield. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_079_shield_credits() { +async fn tc_079_shield_from_balance() { if shielded_helpers::skip_if_shielded_disabled() { return; } @@ -422,8 +234,6 @@ async fn tc_079_shield_credits() { return; } - shielded_helpers::warm_up_and_init(app_context, seed_hash).await; - // Get a platform address from the wallet let platform_addr = { let wallets = app_context.wallets().read().expect("wallets lock"); @@ -472,41 +282,49 @@ async fn tc_079_shield_credits() { other => panic!("Expected PlatformAddressBalances, got: {:?}", other), }; - // Shield a portion of the credits + // Baseline shielded balance before the shield (after a coordinator sync). + let baseline = shielded_helpers::force_shielded_sync(app_context, seed_hash).await; + + // Shield a portion of the credits. The upstream coordinator selects the + // input addresses — DET no longer supplies a `from_address`. let shield_amount = available_credits / 2; - let task = BackendTask::ShieldedTask(ShieldedTask::ShieldCredits { + let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromBalance { seed_hash, amount: shield_amount, - from_address: platform_addr, - nonce_override: None, }); let result = run_task(app_context, task).await; match result { Err(e) if shielded_helpers::is_platform_shielded_unsupported(&e) => { tracing::warn!("TC-079: skipped — platform does not support shielded ops: {e}"); - return; } - Err(e) => panic!("ShieldCredits failed unexpectedly: {e:?}"), + Err(e) => panic!("ShieldFromBalance failed unexpectedly: {e:?}"), Ok(BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash: sh, amount, }) => { assert_eq!(sh, seed_hash, "seed_hash should match"); assert_eq!(amount, shield_amount, "shielded amount should match"); - tracing::info!("ShieldCredits: shielded {} credits", amount); + tracing::info!("ShieldFromBalance: shielded {} credits", amount); + + // Sync and assert the shielded balance increased (Phase-E writer). + let after = shielded_helpers::force_shielded_sync(app_context, seed_hash).await; + assert!( + after > baseline, + "shield-from-balance must increase the shielded balance: {after} !> {baseline}" + ); } Ok(other) => panic!("Expected ShieldedCreditsShielded, got: {:?}", other), } } -/// TC-083: ShieldedTask error - uninitialized wallet +/// TC-083: ShieldedTask error — unknown wallet. /// -/// Attempting SyncNotes on a wallet that has not been initialized should +/// Dispatching a shielded op for a seed hash that has no loaded wallet must /// return a typed error, not panic. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn tc_083_error_uninitialized_wallet() { +async fn tc_083_error_unknown_wallet() { if shielded_helpers::skip_if_shielded_disabled() { return; } @@ -514,15 +332,16 @@ async fn tc_083_error_uninitialized_wallet() { let test_ctx = ctx().await; let app_context = &test_ctx.app_context; - // Use a fake seed hash that has never been initialized + // Use a fake seed hash that has no loaded wallet. let fake_seed_hash: WalletSeedHash = [0xDE; 32]; - let task = BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromBalance { seed_hash: fake_seed_hash, + amount: 1, }); let result = run_task(app_context, task).await; - let err = result.expect_err("SyncNotes on uninitialized wallet should fail"); + let err = result.expect_err("a shielded op on an unknown wallet should fail"); // The wallet with this seed hash doesn't exist, so we expect WalletNotFound. // If shielded is unsupported on this platform, that's also acceptable. @@ -536,7 +355,7 @@ async fn tc_083_error_uninitialized_wallet() { ); tracing::info!( - "Uninitialized wallet error (expected): {} (debug: {:?})", + "Unknown wallet error (expected): {} (debug: {:?})", err, err ); diff --git a/tests/backend-e2e/spv_reconnect.rs b/tests/backend-e2e/spv_reconnect.rs new file mode 100644 index 000000000..d645e1143 --- /dev/null +++ b/tests/backend-e2e/spv_reconnect.rs @@ -0,0 +1,168 @@ +//! SPV reconnect regression test (scenario B). +//! +//! Verifies that `stop_spv` + `ensure_wallet_backend_and_start_spv` completes +//! cleanly without a `WalletStorageError::AlreadyOpen` panic/error. +//! +//! **Background**: the disconnect → reconnect path is *restart-in-place*. +//! `stop_spv` stops the upstream `SpvRuntime` run-loop and quiesces the +//! coordinators but KEEPS the `WalletBackend` (and its transitive +//! `Arc<SqlitePersister>`) wired in the `AppContext` slot. The next Connect +//! fast-paths on that populated slot — no `WalletBackend::new`, no +//! `SqlitePersister::open` — so the SAME instance restarts on a re-armed latch. +//! Because the persister DB is never closed and reopened, the path registered +//! in dash-spv's global `OPEN_FILES` map (`storage/lockfile.rs`) is never +//! re-registered, and `AlreadyOpen` is impossible by construction. +//! +//! This is the live-network counterpart to the offline unit test +//! `reconnect_restart_in_place_reuses_backend` in `src/context/wallet_lifecycle.rs`: +//! it asserts the same reuse/restart contract against real testnet peers. +//! +//! This test drives the full connect → disconnect → reconnect cycle with an +//! isolated `AppContext` (fresh temp dir, empty DB) to avoid disturbing the +//! shared harness context used by other tests. +//! +//! **Run command** (requires testnet egress; no funded wallet needed): +//! ```bash +//! cargo test --test backend-e2e --all-features -- \ +//! --ignored --nocapture spv_reconnect_succeeds_without_already_open +//! ``` + +use crate::framework::wait; +use dash_evo_tool::app::TaskResult; +use dash_evo_tool::app_dir::ensure_env_file; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::context::connection_status::ConnectionStatus; +use dash_evo_tool::database::test_helpers::create_database_at_path; +use dash_evo_tool::utils::egui_mpsc::EguiMpscAsync; +use dash_evo_tool::utils::tasks::TaskManager; +use dash_sdk::dpp::dashcore::Network; +use std::sync::Arc; +use std::time::Duration; + +/// Scenario B regression: connect → disconnect → connect must not produce +/// `WalletStorageError::AlreadyOpen` on the second connect. +/// +/// An isolated AppContext is used so the shared harness SPV (and all other +/// tests) is not interrupted by the stop/restart cycle. +/// +/// Funding requirement: none — this test never touches wallets or balances. +/// It only needs live testnet peers (outbound TCP on port 19999). +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "network-dependent; requires testnet egress — no funded wallet needed"] +async fn spv_reconnect_succeeds_without_already_open() { + // ── Isolated context setup ────────────────────────────────────────────── + let workdir = + std::env::temp_dir().join(format!("dash-evo-e2e-reconnect-{}", std::process::id())); + std::fs::create_dir_all(&workdir).expect("create reconnect test workdir"); + ensure_env_file(&workdir); + + let db_path = workdir.join("data.db"); + let db = Arc::new(create_database_at_path(&db_path).expect("create reconnect test DB")); + + let subtasks = Arc::new(TaskManager::new()); + let connection_status = Arc::new(ConnectionStatus::new()); + let egui_ctx = egui::Context::default(); + let app_kv = AppContext::open_app_kv(&workdir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(&workdir).expect("open secret store"); + + let app_context = Arc::new( + AppContext::new( + workdir.clone(), + Network::Testnet, + db, + subtasks, + connection_status, + egui_ctx.clone(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("create isolated AppContext"), + ); + + // ── Connect (first boot) ──────────────────────────────────────────────── + let (sender1, _rx1) = + tokio::sync::mpsc::channel::<TaskResult>(256).with_egui_ctx(egui_ctx.clone()); + app_context + .ensure_wallet_backend_and_start_spv(sender1) + .await + .expect("B: first ensure_wallet_backend_and_start_spv must succeed"); + + tracing::info!("B: first connect complete; waiting for SPV peers..."); + wait::wait_for_spv_peers(&app_context, Duration::from_secs(60)) + .await + .expect("B: SPV did not connect to peers on first boot within 60s"); + tracing::info!("B: first connect — SPV peers found"); + + // Record the backend instance so the reconnect can be proven to REUSE it. + let first_ptr = { + let backend = app_context + .wallet_backend() + .expect("B: backend must be wired after the first connect"); + assert!( + backend.is_started(), + "B: first connect must start chain sync" + ); + Arc::as_ptr(&backend) + }; + + // ── Disconnect ────────────────────────────────────────────────────────── + app_context.stop_spv().await; + tracing::info!("B: SPV stopped (disconnect complete)"); + + // Restart-in-place: the backend stays wired (slot not taken) with its + // start latch re-armed, so the next Connect restarts the SAME instance and + // never reopens the persister. + { + let backend = app_context + .wallet_backend() + .expect("B: stop_spv must KEEP the backend wired for restart-in-place (NOT unwire it)"); + assert!( + !backend.is_started(), + "B: stop_spv must re-arm the start latch so the next Connect can restart" + ); + } + + // ── Reconnect (must NOT fail with AlreadyOpen) ────────────────────────── + let (sender2, _rx2) = + tokio::sync::mpsc::channel::<TaskResult>(256).with_egui_ctx(egui_ctx.clone()); + app_context + .ensure_wallet_backend_and_start_spv(sender2) + .await + .expect( + "B: second ensure_wallet_backend_and_start_spv must succeed; \ + if 'AlreadyOpen' appears the restart-in-place contract has been \ + broken — stop_spv must keep the backend wired so the persister is \ + never closed and reopened", + ); + + // The reconnect must reuse the SAME backend instance, not rebuild it. + { + let backend = app_context + .wallet_backend() + .expect("B: backend must still be wired after reconnect"); + assert_eq!( + first_ptr, + Arc::as_ptr(&backend), + "B: restart-in-place must REUSE the same backend, not rebuild it" + ); + assert!( + backend.is_started(), + "B: reconnect must restart chain sync on the reused backend" + ); + } + + tracing::info!("B: reconnect complete; waiting for SPV peers..."); + wait::wait_for_spv_peers(&app_context, Duration::from_secs(60)) + .await + .expect("B: SPV did not connect to peers after reconnect within 60s"); + tracing::info!("B: reconnect — SPV peers found; scenario B PASSED"); + + // ── Cleanup ───────────────────────────────────────────────────────────── + if let Ok(backend) = app_context.wallet_backend() { + backend.shutdown().await; + } + if let Err(e) = std::fs::remove_dir_all(&workdir) { + tracing::warn!("B: failed to clean up workdir {}: {e}", workdir.display()); + } +} diff --git a/tests/backend-e2e/spv_wallet.rs b/tests/backend-e2e/spv_wallet.rs index 6012defaf..44d1ecd2d 100644 --- a/tests/backend-e2e/spv_wallet.rs +++ b/tests/backend-e2e/spv_wallet.rs @@ -3,8 +3,6 @@ use crate::framework::harness::ctx; use bip39::{Language, Mnemonic}; use dash_sdk::dpp::dashcore::Network; -use std::time::Duration; -use tokio::time::timeout; /// Verify SPV is running and can register a new wallet. /// @@ -35,7 +33,11 @@ async fn test_spv_sync_and_create_wallet() { // Register the wallet let (seed_hash, _wallet_arc) = app_context - .register_wallet(wallet) + .register_wallet( + wallet, + &seed, + dash_evo_tool::model::wallet::birth_height::WalletOrigin::Imported, + ) .expect("register_wallet should succeed"); // Verify in-memory @@ -47,31 +49,34 @@ async fn test_spv_sync_and_create_wallet() { ); } - // Verify in DB + // Verify persistence in the system of record (the wallet-meta sidecar). + // DET no longer writes the legacy `data.db.wallet` row — the upstream + // persistor plus the wallet-meta/seed-envelope sidecars own wallet state. { - let db_wallets = app_context - .db() - .get_wallets(&Network::Testnet) - .expect("DB query should succeed"); + let meta = dash_evo_tool::wallet_backend::WalletMetaView::new(&app_context.app_kv()) + .get(Network::Testnet, &seed_hash); assert!( - db_wallets.iter().any(|w| w.seed_hash() == seed_hash), - "Wallet should be persisted in DB" + meta.is_some(), + "Wallet should be persisted in the wallet-meta sidecar" ); } - // Verify in SPV (10s timeout) - let wallet_in_spv = timeout(Duration::from_secs(10), async { - loop { - let snapshot = app_context.spv_manager().det_wallets_snapshot(); - if snapshot.contains_key(&seed_hash) { - return true; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await; + // The wallet must register with the upstream manager so the SpvRuntime + // watches its addresses (chain sync is upstream-owned; registration is + // driven via the wallet backend, observed through the EventBridge). + crate::framework::wait::wait_for_wallet_in_spv( + app_context, + seed_hash, + std::time::Duration::from_secs(30), + ) + .await + .expect("New wallet should register with the wallet backend"); + + let backend = app_context + .wallet_backend() + .expect("wallet backend must be wired"); assert!( - wallet_in_spv.is_ok_and(|b| b), - "Wallet should appear in SPV within 10s" + backend.is_wallet_registered(&seed_hash), + "New wallet should be registered with the upstream manager" ); } diff --git a/tests/backend-e2e/token_tasks.rs b/tests/backend-e2e/token_tasks.rs index 1e1b82a68..3afc8a311 100644 --- a/tests/backend-e2e/token_tasks.rs +++ b/tests/backend-e2e/token_tasks.rs @@ -27,8 +27,6 @@ static SECOND_IDENTITY: tokio::sync::OnceCell<SecondIdentity> = tokio::sync::Onc struct SecondIdentity { qualified_identity: dash_evo_tool::model::qualified_identity::QualifiedIdentity, signing_key: dash_sdk::platform::IdentityPublicKey, - #[allow(dead_code)] - signing_key_bytes: Vec<u8>, } /// Register and return a second identity for use in transfer/freeze/purchase tests. @@ -39,8 +37,8 @@ async fn ensure_second_identity() -> &'static SecondIdentity { tracing::info!("SecondIdentity: creating funded test wallet (30M duffs)..."); let (seed_hash, wallet_arc) = ctx.create_funded_test_wallet(30_000_000).await; - let (reg_info, master_key_bytes) = - build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash); + let reg_info = + build_identity_registration(&ctx.app_context, &wallet_arc, seed_hash).await; let task = BackendTask::IdentityTask( dash_evo_tool::backend_task::identity::IdentityTask::RegisterIdentity(reg_info), @@ -62,7 +60,6 @@ async fn ensure_second_identity() -> &'static SecondIdentity { SecondIdentity { qualified_identity: qi, signing_key, - signing_key_bytes: master_key_bytes, } }) .await @@ -320,7 +317,7 @@ async fn step_mint( match result { BackendTaskSuccessResult::MintedTokens(fee) => { - assert!(fee.actual_fee > 0, "Minting fee should be positive"); + assert!(fee.estimated_fee > 0, "Minting fee should be positive"); tracing::info!("Step 1: minted 500_000 tokens (fee: {:?})", fee); } other => panic!("Step 1: expected MintedTokens, got: {:?}", other), @@ -349,7 +346,7 @@ async fn step_burn( match result { BackendTaskSuccessResult::BurnedTokens(fee) => { - assert!(fee.actual_fee > 0, "Burn fee should be positive"); + assert!(fee.estimated_fee > 0, "Burn fee should be positive"); tracing::info!("Step 2: burned 100 tokens (fee: {:?})", fee); } other => panic!("Step 2: expected BurnedTokens, got: {:?}", other), @@ -379,7 +376,7 @@ async fn step_transfer( match result { BackendTaskSuccessResult::TransferredTokens(fee) => { - assert!(fee.actual_fee > 0, "Transfer fee should be positive"); + assert!(fee.estimated_fee > 0, "Transfer fee should be positive"); tracing::info!("Step 3: transferred 100 tokens (fee: {:?})", fee); } other => panic!("Step 3: expected TransferredTokens, got: {:?}", other), @@ -409,7 +406,7 @@ async fn step_freeze( match result { BackendTaskSuccessResult::FrozeTokens(fee) => { - assert!(fee.actual_fee > 0, "Freeze fee should be positive"); + assert!(fee.estimated_fee > 0, "Freeze fee should be positive"); tracing::info!("Step 4: froze tokens for second identity (fee: {:?})", fee); } other => panic!("Step 4: expected FrozeTokens, got: {:?}", other), @@ -439,7 +436,7 @@ async fn step_unfreeze( match result { BackendTaskSuccessResult::UnfrozeTokens(fee) => { - assert!(fee.actual_fee > 0, "Unfreeze fee should be positive"); + assert!(fee.estimated_fee > 0, "Unfreeze fee should be positive"); tracing::info!( "Step 5: unfroze tokens for second identity (fee: {:?})", fee @@ -491,7 +488,7 @@ async fn step_destroy_frozen( match result { BackendTaskSuccessResult::DestroyedFrozenFunds(fee) => { - assert!(fee.actual_fee > 0, "Destroy fee should be positive"); + assert!(fee.estimated_fee > 0, "Destroy fee should be positive"); tracing::info!("Step 6: destroyed frozen funds (fee: {:?})", fee); } other => panic!("Step 6: expected DestroyedFrozenFunds, got: {:?}", other), @@ -519,7 +516,7 @@ async fn step_pause( match result { BackendTaskSuccessResult::PausedTokens(fee) => { - assert!(fee.actual_fee > 0, "Pause fee should be positive"); + assert!(fee.estimated_fee > 0, "Pause fee should be positive"); tracing::info!("Step 7: paused token (fee: {:?})", fee); } other => panic!("Step 7: expected PausedTokens, got: {:?}", other), @@ -547,7 +544,7 @@ async fn step_resume( match result { BackendTaskSuccessResult::ResumedTokens(fee) => { - assert!(fee.actual_fee > 0, "Resume fee should be positive"); + assert!(fee.estimated_fee > 0, "Resume fee should be positive"); tracing::info!("Step 8: resumed token (fee: {:?})", fee); } other => panic!("Step 8: expected ResumedTokens, got: {:?}", other), @@ -578,7 +575,7 @@ async fn step_set_price( match result { BackendTaskSuccessResult::SetTokenPrice(fee) => { - assert!(fee.actual_fee > 0, "Set price fee should be positive"); + assert!(fee.estimated_fee > 0, "Set price fee should be positive"); tracing::info!("Step 9: set token price (fee: {:?})", fee); } other => panic!("Step 9: expected SetTokenPrice, got: {:?}", other), @@ -607,7 +604,7 @@ async fn step_purchase( match result { BackendTaskSuccessResult::PurchasedTokens(fee) => { - assert!(fee.actual_fee > 0, "Purchase fee should be positive"); + assert!(fee.estimated_fee > 0, "Purchase fee should be positive"); tracing::info!("Step 10: purchased 10 tokens (fee: {:?})", fee); } other => panic!("Step 10: expected PurchasedTokens, got: {:?}", other), @@ -655,7 +652,10 @@ async fn step_update_config( match result { BackendTaskSuccessResult::UpdatedTokenConfig(description, fee) => { - assert!(fee.actual_fee > 0, "Config update fee should be positive"); + assert!( + fee.estimated_fee > 0, + "Config update fee should be positive" + ); tracing::info!( "Step 11: updated token config: {} (fee: {:?})", description, diff --git a/tests/backend-e2e/tx_is_ours.rs b/tests/backend-e2e/tx_is_ours.rs index 919aa4c8c..bf9443d82 100644 --- a/tests/backend-e2e/tx_is_ours.rs +++ b/tests/backend-e2e/tx_is_ours.rs @@ -1,10 +1,10 @@ //! Test: Verify `is_ours` flag is set correctly for SPV transactions. //! -//! SPV transactions pass through bloom filter → `check_transaction()` (address -//! matching) → `record_transaction()`. The upstream library sets `is_ours` only -//! for sends (`net_amount < 0`). We override to `true` for all matched -//! transactions in the SPV reconcile path, since `check_transaction` already -//! verified address ownership (bloom filter FPs are filtered there). +//! The upstream `platform-wallet` engine matches transactions against watched +//! addresses and emits wallet events; DET's `EventBridge` accumulates them +//! into the per-wallet snapshot read here via +//! `WalletBackend::transaction_history`. Both the sender and receiver wallet +//! must see the transaction with `is_ours: true`. //! //! This test sends funds between two wallets and verifies that both the sender //! and receiver have `is_ours: true` on the resulting transaction. @@ -29,15 +29,12 @@ async fn test_spv_transactions_is_ours_flag() { let (hash_b, wallet_b) = ctx.create_funded_test_wallet(1_000_000).await; let send_amount: u64 = 500_000; - let b_address = get_receive_address(app_context, &wallet_b); + let b_address = get_receive_address(app_context, &wallet_b).await; // Capture B's balance BEFORE sending, so we know the exact target to // wait for. Reading this after the send risks including the send amount // (via reconciliation), which inflates the target and causes a timeout. - let initial_b = { - let w = wallet_b.read().expect("lock"); - w.total_balance_duffs() - }; + let initial_b = app_context.snapshot_balance(&hash_b).total; tracing::info!("initial_b balance = {} duffs", initial_b); // Wait for A to have spendable funds @@ -61,8 +58,6 @@ async fn test_spv_transactions_is_ours_flag() { address: b_address.clone(), amount_duffs: send_amount, }], - subtract_fee_from_amount: false, - memo: Some("is_ours test".to_string()), override_fee: None, }; @@ -93,22 +88,14 @@ async fn test_spv_transactions_is_ours_flag() { .await .expect("B should receive funds"); - // Force a reconcile to ensure latest SPV state is reflected - app_context - .reconcile_spv_wallets() - .await - .expect("reconcile should succeed"); + let wallet_backend = app_context + .wallet_backend() + .expect("wallet backend available"); // Check is_ours on wallet A (sender) — should be true { - let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet = wallets - .get(&hash_a) - .expect("wallet A") - .read() - .expect("lock"); - let tx = wallet - .transactions + let history = wallet_backend.transaction_history(&hash_a); + let tx = history .iter() .find(|t| t.txid.to_string() == payment_txid) .unwrap_or_else(|| panic!("Wallet A should have tx {payment_txid}")); @@ -124,14 +111,8 @@ async fn test_spv_transactions_is_ours_flag() { // Check is_ours on wallet B (receiver) — should be true { - let wallets = app_context.wallets().read().expect("wallets lock"); - let wallet = wallets - .get(&hash_b) - .expect("wallet B") - .read() - .expect("lock"); - let tx = wallet - .transactions + let history = wallet_backend.transaction_history(&hash_b); + let tx = history .iter() .find(|t| t.txid.to_string() == payment_txid) .unwrap_or_else(|| panic!("Wallet B should have tx {payment_txid}")); diff --git a/tests/backend-e2e/wallet_reregistration.rs b/tests/backend-e2e/wallet_reregistration.rs new file mode 100644 index 000000000..5e9b1597d --- /dev/null +++ b/tests/backend-e2e/wallet_reregistration.rs @@ -0,0 +1,282 @@ +//! Regression: wallets must re-register with the upstream SPV +//! backend so received funds are visible. +//! +//! Background: commit `e6c6c017` replaced the seed-based re-registration +//! loader with a read-only seedless loader, leaving NO code that ever +//! populated the upstream `platform-wallet.sqlite` persistor. An empty +//! persistor means an empty SPV watch set, so received Core funds stayed +//! invisible at 100% sync. The fix re-introduces the persistor write at the +//! create/import (W1) and cold-boot (W2) seed-bearing moments, with a +//! genesis birth-height floor for imported/recovered wallets so deposits made +//! before registration are still found. +//! +//! These tests run against a live Dash testnet via SPV and require a funded +//! framework wallet; they are `#[ignore]` like the rest of the backend-e2e +//! suite. See `tests/backend-e2e/README.md`. + +use crate::framework::harness; +use crate::framework::wait; +use std::sync::Arc; +use std::time::Duration; + +/// W1 below-tip visibility: a freshly created+funded wallet (registered with +/// the genesis birth-height floor via the `Imported` origin the harness uses) +/// must surface its received balance. +/// +/// This is the miniature of the real-world 1.0 DASH-at-block-1492173 repro: +/// the deposit lands and, because the wallet's addresses are actually watched +/// (persistor populated by W1), the balance becomes visible. Pre-fix the +/// persistor was never written, the watch set was empty, and this balance +/// never appeared — `create_funded_test_wallet` would time out waiting for it. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn funded_wallet_balance_is_visible_after_registration() { + let ctx = harness::ctx().await; + + // 100k duffs is comfortably above dust and below the framework wallet's + // balance; enough to prove the watch set sees a real deposit. + let amount_duffs: u64 = 100_000; + let (seed_hash, _wallet_arc) = ctx.create_funded_test_wallet(amount_duffs).await; + + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend must be wired"); + assert!( + backend.is_wallet_registered(&seed_hash), + "the funded wallet must be registered with the upstream SPV backend" + ); + + // The balance must become visible — the core regression assertion. The + // deposit is below the SPV tip by the time it is matched, so this only + // passes when the wallet's addresses are actually watched. + let balance = wait::wait_for_balance( + &ctx.app_context, + seed_hash, + amount_duffs, + Duration::from_secs(120), + ) + .await + .expect("received funds must be visible once the wallet is registered"); + + assert!( + balance >= amount_duffs, + "visible balance {balance} must be at least the funded amount {amount_duffs}" + ); +} + +/// W2 cold-boot reconciliation idempotency on the live backend: re-driving the +/// registered-wallet reconciliation (`ensure_wallets_registered`, the +/// seedless load pass) against an already-registered, funded wallet leaves it +/// watched and does not disturb its visible balance. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn re_registration_is_idempotent_for_funded_wallet() { + let ctx = harness::ctx().await; + let seed_hash = ctx.framework_wallet_hash; + + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend must be wired"); + + // Framework wallet is funded historically (below the current tip). It must + // already be registered and its balance visible. + wait::wait_for_wallet_in_spv(&ctx.app_context, seed_hash, Duration::from_secs(30)) + .await + .expect("framework wallet must be registered with the backend"); + let before = ctx.app_context.snapshot_balance(&seed_hash).total; + + // Re-run the reconciliation pass; it must not double-register or change the + // visible balance. + backend + .ensure_wallets_registered(&ctx.app_context) + .await + .expect("re-registration pass must not error"); + assert!( + backend.is_wallet_registered(&seed_hash), + "the wallet must stay registered after a reconciliation pass" + ); + let after = ctx.app_context.snapshot_balance(&seed_hash).total; + assert_eq!( + before, after, + "a reconciliation pass must not disturb the visible balance" + ); +} + +/// F140 (cold-process boot from migrated on-disk state — the smoke test that +/// would have caught the "wallet still loading forever" field report). +/// +/// The live repro: a user upgrades, their wallet exists ONLY as legacy +/// `data.db` rows (no upstream persistor, no DET sidecars). On the next launch +/// the wallet backend wires and runs its cold-boot reconciliation BEFORE the +/// cold-start `FinishUnwire` migration imports the wallet — so the +/// reconciliation sees zero wallets and registers nothing. The migration then +/// imports the seed + meta, but if it does not RE-RUN the reconciliation the +/// upstream `id_map` stays empty and `resolve_wallet` returns `WalletNotLoaded` +/// for 20+ minutes until a restart. +/// +/// This drives that exact sequence on a fresh, isolated `AppContext` / +/// `WalletBackend` (a true cold process boundary — no in-process +/// `register_wallet`): seed the framework wallet's seed as a legacy `data.db` +/// row, wire a fresh backend (sidecars empty → nothing registered), run the +/// migration, and assert the wallet is registered AND its historical balance +/// becomes visible — all without a second backend build. +/// +/// Fund-safe: it only reads the framework wallet's pre-existing balance; no +/// funds move. It shares the already-synced framework backend's SPV peer set +/// via a fresh backend over an isolated workdir, so it does not disturb the +/// singleton harness context. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn cold_process_boot_from_migrated_state_registers_and_shows_balance() { + use dash_evo_tool::app::TaskResult; + use dash_evo_tool::app_dir::ensure_env_file; + use dash_evo_tool::context::AppContext; + use dash_evo_tool::context::connection_status::ConnectionStatus; + use dash_evo_tool::database::test_helpers::{ + create_database_at_path, seed_legacy_unprotected_hd_wallet_row, + }; + use dash_evo_tool::utils::egui_mpsc::EguiMpscAsync; + use dash_evo_tool::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::{ + ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, + }; + + // Ensure the shared framework backend is up first — it owns the funded + // framework wallet and a synced SPV view; reading the mnemonic from the + // same env guarantees the seed we migrate is the funded one. + let _shared = harness::ctx().await; + let mnemonic_phrase = std::env::var("E2E_WALLET_MNEMONIC") + .expect("E2E_WALLET_MNEMONIC must be set for E2E tests"); + let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, &mnemonic_phrase) + .expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + + // Isolated cold-boot workdir: no sidecars, no upstream persistor — only the + // legacy `data.db` row we seed below, exactly the migrated-on-disk shape. + let workdir = + std::env::temp_dir().join(format!("dash-evo-e2e-coldboot-{}", std::process::id())); + std::fs::create_dir_all(&workdir).expect("create cold-boot workdir"); + ensure_env_file(&workdir); + + let db_path = workdir.join("data.db"); + let db = Arc::new(create_database_at_path(&db_path).expect("create cold-boot database")); + + // Compute the wallet's seed hash and BIP44 account-0 xpub so the legacy row + // is internally consistent (the W2 fund-routing gate matches the published + // xpub against the seed's derivation). + let seed_hash = { + let w = + dash_evo_tool::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet for hash"); + w.seed_hash() + }; + let epk = { + 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() + }; + seed_legacy_unprotected_hd_wallet_row( + &db, + &seed_hash, + &seed, + &epk, + "cold-boot-migrated", + Network::Testnet, + ) + .expect("insert legacy wallet row"); + + // Build a fresh, independent AppContext over the migrated-on-disk state. + let subtasks = Arc::new(TaskManager::new()); + let connection_status = Arc::new(ConnectionStatus::new()); + let egui_ctx = egui::Context::default(); + let app_kv = AppContext::open_app_kv(&workdir).expect("open app k/v"); + let secret_store = AppContext::open_secret_store(&workdir).expect("open secret store"); + let app_context = AppContext::new( + workdir.clone(), + Network::Testnet, + db, + subtasks, + connection_status, + egui_ctx, + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("create cold-boot AppContext"); + + let (task_result_sender, _task_result_rx) = + tokio::sync::mpsc::channel::<TaskResult>(256).with_egui_ctx(app_context.egui_ctx().clone()); + + // Wire the fresh backend: its cold-boot reconciliation runs NOW, against the + // empty sidecars (the migration has not run yet), so nothing is registered. + app_context + .ensure_wallet_backend(task_result_sender) + .await + .expect("ensure_wallet_backend on cold-boot context"); + let backend = app_context + .wallet_backend() + .expect("cold-boot backend wired"); + assert!( + !backend.is_wallet_registered(&seed_hash), + "precondition: the migrated wallet is not registered at cold wiring (sidecars empty)" + ); + + // Run the cold-start migration — it must import the wallet AND re-run the + // reconciliation so the wallet is registered without a restart. + dash_evo_tool::backend_task::migration::finish_unwire::run(&app_context) + .await + .expect("cold-start migration should succeed"); + + assert!( + backend.is_wallet_registered(&seed_hash), + "the migrated wallet must be upstream-registered right after migration (no restart)" + ); + + // Start chain sync and confirm the historical balance becomes visible — the + // end-to-end regression assertion on a genuinely cold-booted, migrated wallet. + backend.start().await.expect("start cold-boot chain sync"); + wait::wait_for_spv_peers(&app_context, Duration::from_secs(60)) + .await + .expect("cold-boot SPV failed to connect to peers"); + wait::wait_for_balance(&app_context, seed_hash, 1, Duration::from_secs(600)) + .await + .expect("migrated framework wallet must surface its historical balance"); + + backend.shutdown().await; +} + +// TODO(issue #7): fund-routing-gate xpub mismatch on a fresh DB (the REAL bug). +// +// On a fresh DB the upstream persistor stores the BIP44 account-0 row at DEPTH-1 +// (BIP32 `m/0'`) instead of DEPTH-3 (`m/44'/coin'/0'`): `Default` creates both a +// BIP32 and a BIP44 account-0, both map to the same persistor primary key +// (`account_type_db_label` collapses both `StandardAccountType` variants to +// "standard"), and the BIP32 row overwrites the BIP44 row via +// `ON CONFLICT DO UPDATE`. The seedless cold-boot reload then reads the depth-1 +// xpub, it matches no depth-3 DET sidecar bridge entry, and the fund-routing gate +// rejects every wallet -> systematic `WalletNotLoaded`. This is an upstream +// `platform-wallet-storage` data-loss bug, NOT legacy format drift. +// +// The earlier #251 "drift self-heal" was REVERTED — it treated a non-cause and +// would be re-clobbered by the same collision on the next persist. The real fix +// is upstream (distinguish BIP32 vs BIP44 standard accounts in the persistor key) +// + pin bump + a re-derive migration. +// +// Reproduced by the `#[ignore]`d `issue7_fresh_persistor_bip44_xpub_matches_det_bridge` +// in `src/context/wallet_lifecycle.rs` (field-level diff of the persisted vs +// expected xpub); the sound DET-vs-upstream derivation is guarded by +// `fresh_bip44_account0_xpub_is_stable_across_gate_sides` and +// `wallet_id_is_independent_of_account_creation` in `src/wallet_backend/mod.rs`. +// A full live cold-boot e2e through `load_from_persistor` is blocked offline by +// the persistor's single-open advisory lock (see the in-test fallback); add it +// once the upstream fix lands. diff --git a/tests/backend-e2e/wallet_tasks.rs b/tests/backend-e2e/wallet_tasks.rs index 673b41910..c154a8374 100644 --- a/tests/backend-e2e/wallet_tasks.rs +++ b/tests/backend-e2e/wallet_tasks.rs @@ -6,14 +6,15 @@ use dash_evo_tool::backend_task::core::CoreTask; use dash_evo_tool::backend_task::wallet::WalletTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::model::wallet::WalletSeedHash; -use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::identity::core_script::CoreScript; use std::collections::BTreeMap; use std::time::Duration; // ─── TC-012 ─────────────────────────────────────────────────────────────────── -/// TC-012: GenerateReceiveAddress — basic derivation and uniqueness. +/// TC-012: GenerateReceiveAddress — basic derivation. The "uniqueness across +/// consecutive calls" check is PENDING (rust-dashcore#818); see the +/// note at the second-call assertion. #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] #[ignore] async fn tc_012_generate_receive_address() { @@ -44,7 +45,7 @@ async fn tc_012_generate_receive_address() { address1 ); - // Second call should produce a different address (key derivation advances) + // Second call must still succeed and return a valid address. let task2 = BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }); let result2 = run_task(&ctx.app_context, task2) .await @@ -55,12 +56,123 @@ async fn tc_012_generate_receive_address() { other => panic!("TC-012: expected GeneratedReceiveAddress, got: {:?}", other), }; - assert_ne!( - address1, address2, - "TC-012: second call should return a different address" + // PENDING: two consecutive calls returning DISTINCT addresses is + // not achievable today. Upstream `next_receive_address_for_account` → + // `next_unused` returns the lowest UNUSED address until it is used on-chain + // (funds-safe BIP-44 keypool behavior), so back-to-back calls return the + // same address. The fresh-each-call UX needs the reserve-on-hand-out API + // tracked in dashpay/rust-dashcore#818 to propagate through platform into + // DET — see the TODO in `src/wallet_backend/mod.rs`. + // Forcing distinctness DET-side now would re-introduce the gap-window + // funds-loss bug that tc_012b guards. + let first_char2 = address2.chars().next().unwrap_or_default(); + assert!( + first_char2 == 'y' || first_char2 == '8', + "TC-012: second GenerateReceiveAddress must return a valid testnet address, got: {}", + address2 ); - tracing::info!("TC-012 passed: addr1={} addr2={}", address1, address2); + if address1 == address2 { + tracing::info!( + "TC-012: receive address did not advance (known gap, rust-dashcore#818); \ + addr={address1}" + ); + } else { + tracing::info!("TC-012: addr1={address1} addr2={address2}"); + } +} + +/// TC-012b (FUNDS-SAFETY): the address the Receive flow hands out via +/// `GenerateReceiveAddress` must be one SPV actually watches. +/// +/// A real user lost a deposit because the old Receive "New Address" button +/// derived addresses past the gap window (index 32), outside the SPV-watched +/// pool, so the funds never appeared. This pins the invariant the fix +/// guarantees: every generated receive address is in +/// `monitored_receive_addresses` — the SPV-watched gap-limit window. RED on +/// the legacy DET-side derivation, GREEN once the flow routes through the +/// upstream watched pool. +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore] +async fn tc_012b_receive_address_is_spv_watched() { + let ctx = harness::ctx().await; + let seed_hash = ctx.framework_wallet_hash; + + let task = BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }); + let result = run_task(&ctx.app_context, task) + .await + .expect("TC-012b: GenerateReceiveAddress failed"); + let address = match result { + BackendTaskSuccessResult::GeneratedReceiveAddress { address, .. } => address, + other => panic!( + "TC-012b: expected GeneratedReceiveAddress, got: {:?}", + other + ), + }; + + // `monitored_receive_addresses` takes the manager's blocking read lock, so + // it must run outside the async task. `block_in_place` is valid on this + // multi-thread runtime. + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend must be wired"); + let watched = tokio::task::block_in_place(|| backend.monitored_receive_addresses(&seed_hash)) + .expect("monitored_receive_addresses"); + + assert!( + watched.contains(&address), + "TC-012b: generated receive address {address} is not in the SPV-watched pool \ + (funds sent there would be invisible); watched window has {} addresses", + watched.len() + ); + tracing::info!( + "TC-012b passed: {address} is one of {} SPV-watched addresses", + watched.len() + ); +} + +/// TC-012c (FUNDS-SAFETY, asset-lock funding / H1): the Create-Asset-Lock +/// screen shows a deposit address (QR + Copy) for the user to send DASH to; +/// the asset lock is then built from the resulting watched UTXO. That deposit +/// address must therefore be SPV-watched, or the deposit is invisible and the +/// asset lock can never be built. +/// +/// The screen now derives the deposit address through the same +/// `GenerateReceiveAddress` task as the Receive flow (upstream watched pool), +/// not the legacy unbounded `Wallet::receive_address(skip=true)`. This pins the +/// funding-address ∈ watched-pool invariant for that scenario. RED on the +/// legacy path, GREEN on the fix. +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore] +async fn tc_012c_asset_lock_funding_address_is_spv_watched() { + let ctx = harness::ctx().await; + let seed_hash = ctx.framework_wallet_hash; + + let task = BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }); + let result = run_task(&ctx.app_context, task) + .await + .expect("TC-012c: GenerateReceiveAddress failed"); + let address = match result { + BackendTaskSuccessResult::GeneratedReceiveAddress { address, .. } => address, + other => panic!( + "TC-012c: expected GeneratedReceiveAddress, got: {:?}", + other + ), + }; + + let backend = ctx + .app_context + .wallet_backend() + .expect("wallet backend must be wired"); + let watched = tokio::task::block_in_place(|| backend.monitored_receive_addresses(&seed_hash)) + .expect("monitored_receive_addresses"); + + assert!( + watched.contains(&address), + "TC-012c: asset-lock deposit address {address} is not in the SPV-watched pool \ + (a deposit there would be invisible and the asset lock could never be built)" + ); } // ─── TC-013 ─────────────────────────────────────────────────────────────────── @@ -111,26 +223,13 @@ async fn step_fund_platform_address( tracing::info!("=== Step 1: Fund platform address from wallet UTXOs ==="); let seed_hash = ctx.framework_wallet_hash; - let wallet_arc = { - let wallets = ctx.app_context.wallets().read().expect("wallets lock"); - wallets - .get(&seed_hash) - .expect("framework wallet missing") - .clone() - }; - - let platform_addr = { - let mut wallet = wallet_arc.write().expect("wallet write lock"); - let addr = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(&ctx.app_context), - ) - .expect("step_fund_platform_address: failed to derive platform receive address"); - PlatformAddress::try_from(addr) - .expect("step_fund_platform_address: failed to convert to PlatformAddress") - }; + let platform_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + false, + ) + .await; tracing::info!( "step_fund_platform_address: funding platform address {:?}", @@ -178,19 +277,13 @@ async fn step_fetch_balances( // Re-derive the same platform address that step 1 funded (reuse=false // returns the same address as long as it hasn't been marked used). - let expected_addr = { - let wallets = ctx.app_context.wallets().read().expect("wallets lock"); - let wallet_arc = wallets.get(&seed_hash).expect("framework wallet missing"); - let mut wallet = wallet_arc.write().expect("wallet write lock"); - let addr = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(&ctx.app_context), - ) - .expect("step_fetch_balances: failed to derive platform address"); - PlatformAddress::try_from(addr).expect("step_fetch_balances: PlatformAddress conversion") - }; + let expected_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + false, + ) + .await; let task = BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { seed_hash }); let result = run_task(&ctx.app_context, task) @@ -237,38 +330,23 @@ async fn step_transfer_credits( ) { tracing::info!("=== Step 3: Transfer platform credits to a second address ==="); - let wallet_arc = { - let wallets = ctx.app_context.wallets().read().expect("wallets lock"); - wallets - .get(&seed_hash) - .expect("framework wallet missing") - .clone() - }; - // Derive the first platform address (the one step 1 funded) so it is // guaranteed to be in watched_addresses. Then derive a fresh second one // as the transfer destination. - let (source_candidate, dest_addr) = { - let mut wallet = wallet_arc.write().expect("wallet write lock"); - let src = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, // reuse existing — same address step 1 funded - Some(&ctx.app_context), - ) - .expect("step_transfer_credits: failed to derive source platform address"); - let dst = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - true, // skip_known — derive a fresh one - Some(&ctx.app_context), - ) - .expect("step_transfer_credits: failed to derive second platform address"); - ( - PlatformAddress::try_from(src).expect("step_transfer_credits: src PlatformAddress"), - PlatformAddress::try_from(dst).expect("step_transfer_credits: dst PlatformAddress"), - ) - }; + let source_candidate = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + false, // reuse existing — same address step 1 funded + ) + .await; + let dest_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + true, // skip_known — derive a fresh one + ) + .await; // Fetch current platform address balances to get the funded amount. let fetch_task = @@ -379,14 +457,6 @@ async fn step_withdraw( ) { tracing::info!("=== Step 4: Withdraw from platform address back to Core ==="); - let wallet_arc = { - let wallets = ctx.app_context.wallets().read().expect("wallets lock"); - wallets - .get(&seed_hash) - .expect("framework wallet missing") - .clone() - }; - // TODO: This step fails because sync_address_balances returns a balance // (~485M credits) that doesn't match what Platform's state transition // processor sees (1 credit). The full tree scan proof says 485M but the @@ -397,18 +467,13 @@ async fn step_withdraw( // Fund a fresh platform address so we have credits to withdraw, // regardless of what step 3 did to the original address. - let fresh_addr = { - let mut wallet = wallet_arc.write().expect("wallet write lock"); - let addr = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - true, - Some(&ctx.app_context), - ) - .expect("step_withdraw: failed to derive platform address"); - PlatformAddress::try_from(addr) - .expect("step_withdraw: failed to convert to PlatformAddress") - }; + let fresh_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + true, + ) + .await; let fund_task = BackendTask::WalletTask(WalletTask::FundPlatformAddressFromWalletUtxos { seed_hash, @@ -425,15 +490,8 @@ async fn step_withdraw( let poll_interval = Duration::from_secs(5); let start = std::time::Instant::now(); - // Reset again so the next sync picks up the new funding - if let Err(e) = ctx - .app_context - .db() - .set_platform_sync_info(&seed_hash, 0, 0) - { - tracing::warn!("Failed to reset platform sync info: {}", e); - } - + // The manual fetch always does a full scan, so it picks up the new + // funding without any checkpoint reset. let (withdrawal_addr, withdrawal_balance) = loop { let fetch_task = BackendTask::WalletTask(WalletTask::FetchPlatformAddressBalances { seed_hash }); @@ -615,53 +673,52 @@ async fn tc_018_fund_platform_address_from_asset_lock() { create_result ); - // Step 2: Wait for the asset lock proof to appear in unused_asset_locks. - // Filter by amount (>= 90M credits) to avoid picking up smaller asset - // locks created by other concurrent tests on the same wallet. - tracing::info!("TC-018: waiting for asset lock IS proof in unused_asset_locks..."); + // Step 2: Wait for a tracked asset lock with the expected amount and a + // ready proof from the upstream `AssetLockManager`. + tracing::info!("TC-018: waiting for tracked asset lock IS proof..."); let proof_timeout = harness::MAX_TEST_TIMEOUT; let min_credits: u64 = 90_000_000; - let (asset_lock_address, asset_lock_proof) = tokio::time::timeout(proof_timeout, async { + let backend = ctx + .app_context + .wallet_backend() + .expect("TC-018: wallet backend not ready"); + let tracked_out_point = tokio::time::timeout(proof_timeout, async { loop { - let maybe_lock = { - let wallet = wallet_arc.read().expect("wallet read lock"); - wallet - .unused_asset_locks - .iter() - .find_map(|(_tx, addr, amount, _islock, proof)| { - if *amount >= min_credits { - proof.as_ref().map(|proof| (addr.clone(), proof.clone())) + let maybe = backend + .list_tracked_asset_locks(&seed_hash) + .await + .ok() + .and_then(|locks| { + locks.into_iter().find_map(|l| { + if l.amount >= min_credits && l.proof.is_some() { + Some(l.out_point) } else { None } }) - }; - if let Some(found) = maybe_lock { - return found; + }); + if let Some(op) = maybe { + return op; } tokio::time::sleep(Duration::from_secs(2)).await; } }) .await - .expect("TC-018: timed out waiting for asset lock IS proof"); + .expect("TC-018: timed out waiting for tracked asset lock IS proof"); tracing::info!( - "TC-018: asset lock proof ready, address={:?}", - asset_lock_address + "TC-018: tracked asset lock ready, out_point={}", + tracked_out_point ); // Step 3: Derive a fresh platform address for funding - let platform_addr = { - let mut wallet = wallet_arc.write().expect("wallet write lock"); - let addr = wallet - .platform_receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - true, // skip_known — get a fresh one - Some(&ctx.app_context), - ) - .expect("TC-018: failed to derive platform address"); - PlatformAddress::try_from(addr).expect("TC-018: failed to convert to PlatformAddress") - }; + let platform_addr = crate::framework::funding::derive_platform_receive_address( + &ctx.app_context, + seed_hash, + dash_sdk::dpp::dashcore::Network::Testnet, + true, // skip_known — get a fresh one + ) + .await; let mut outputs = BTreeMap::new(); outputs.insert(platform_addr, None); // None = distribute evenly @@ -673,8 +730,7 @@ async fn tc_018_fund_platform_address_from_asset_lock() { ); let fund_task = BackendTask::WalletTask(WalletTask::FundPlatformAddressFromAssetLock { seed_hash, - asset_lock_proof: Box::new(asset_lock_proof), - asset_lock_address, + out_point: tracked_out_point, outputs, }); diff --git a/tests/backend-e2e/z_broadcast_st_tasks.rs b/tests/backend-e2e/z_broadcast_st_tasks.rs index 3890ad54d..7285daf6c 100644 --- a/tests/backend-e2e/z_broadcast_st_tasks.rs +++ b/tests/backend-e2e/z_broadcast_st_tasks.rs @@ -6,7 +6,7 @@ use crate::framework::fixtures::shared_identity; use crate::framework::harness::ctx; -use crate::framework::task_runner::{run_task, run_task_with_nonce_retry}; +use crate::framework::task_runner::{run_on_large_stack, run_task, run_task_with_nonce_retry}; use dash_evo_tool::backend_task::identity::IdentityTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity; @@ -37,7 +37,13 @@ async fn step_broadcast_valid( // Fetch the current identity from Platform so we have the latest public // keys and revision (other tests may have added keys since registration). let sdk = ctx.app_context.sdk(); - let mut identity = dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id) + let mut identity = + run_on_large_stack({ + let sdk = ctx.app_context.sdk(); + move || async move { + dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id).await + } + }) .await .expect("failed to fetch identity from Platform") .expect("identity not found on Platform"); @@ -111,6 +117,7 @@ async fn step_broadcast_valid( platform_version, None, ) + .await .expect("failed to build IdentityUpdateTransition"); tracing::info!("state transition built and signed, broadcasting..."); @@ -129,23 +136,35 @@ async fn step_broadcast_valid( ); tracing::info!("broadcast succeeded"); - // Brief delay for DAPI propagation — broadcast confirms on one node but - // a different node may serve the re-fetch before processing the same block. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let fetched = dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id) + // Poll for the new key to become visible rather than relying on a single + // fixed delay. The broadcast confirms on one node, but a different node may + // serve the re-fetch before processing the same block — a fixed 1s sleep + // races that propagation and fails spuriously. Re-fetch until the key + // appears or the ~10s deadline passes. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let (fetched, has_new_key) = loop { + let fetched = run_on_large_stack({ + let sdk = ctx.app_context.sdk(); + move || async move { + dash_sdk::platform::Identity::fetch_by_identifier(&sdk, identity_id).await + } + }) .await .expect("failed to re-fetch identity") .expect("identity not found on Platform after broadcast"); - - let has_new_key = fetched - .public_keys() - .values() - .any(|k| k.data() == new_ipk.data()); + let has_new_key = fetched + .public_keys() + .values() + .any(|k| k.data() == new_ipk.data()); + if has_new_key || std::time::Instant::now() >= deadline { + break (fetched, has_new_key); + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }; assert!( has_new_key, - "New key NOT found on Platform after broadcast. \ + "New key NOT found on Platform within 10s of broadcast. \ Fetched {} keys, expected new key with id {}. \ The broadcast succeeded, so the key should be visible.", fetched.public_keys().len(), @@ -246,6 +265,7 @@ async fn step_broadcast_invalid( platform_version, None, ) + .await .expect("failed to build (invalid-nonce) IdentityUpdateTransition"); tracing::info!("broadcasting invalid state transition (nonce=u64::MAX)..."); diff --git a/tests/common/data_dir.rs b/tests/common/data_dir.rs new file mode 100644 index 000000000..e9b6ab93d --- /dev/null +++ b/tests/common/data_dir.rs @@ -0,0 +1,67 @@ +//! Per-test data-directory isolation, shared by the `kittest` and `e2e` test +//! binaries via `#[path = "../common/data_dir.rs"]`. +//! +//! Tests that build a full `AppState` (via `AppState::new`) resolve their data +//! directory through `app_user_data_dir_path()`, which honors the +//! `DASH_EVO_DATA_DIR` env var. Without isolation those tests open the real +//! `~/.config/Dash-Evo-Tool` data dir, crash on a pre-existing +//! `det-app.sqlite` whose migration checksum diverges from the current schema, +//! and pollute the user's wallet data. [`with_isolated_data_dir`] redirects the +//! data dir to a throwaway temp dir for the duration of the closure. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +const DATA_DIR_ENV: &str = "DASH_EVO_DATA_DIR"; + +/// Serializes data-dir isolation across tests. `DASH_EVO_DATA_DIR` is +/// process-global, so AppState-constructing tests must not run in parallel. +fn data_dir_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// RAII guard restoring the prior `DASH_EVO_DATA_DIR` value on drop and +/// holding the serialization lock for the lifetime of the override. +struct DataDirGuard { + prior: Option<String>, + _tempdir: tempfile::TempDir, + _lock: MutexGuard<'static, ()>, +} + +impl Drop for DataDirGuard { + fn drop(&mut self) { + // Safety: the lock held by `_lock` serializes all env mutation; no other + // test touches DASH_EVO_DATA_DIR while this guard is alive. + unsafe { + match &self.prior { + Some(value) => std::env::set_var(DATA_DIR_ENV, value), + None => std::env::remove_var(DATA_DIR_ENV), + } + } + } +} + +/// Runs `f` with `DASH_EVO_DATA_DIR` pointed at a fresh temp directory, then +/// restores the prior value and removes the temp dir. Acquires a process-global +/// lock so concurrent AppState-constructing tests serialize rather than race on +/// the shared env var. +pub fn with_isolated_data_dir<R>(f: impl FnOnce() -> R) -> R { + let lock = data_dir_lock(); + let tempdir = tempfile::tempdir().expect("create temp data dir"); + let prior = std::env::var(DATA_DIR_ENV).ok(); + + // Safety: serialized by `lock`; restored by `DataDirGuard::drop`. + unsafe { + std::env::set_var(DATA_DIR_ENV, tempdir.path()); + } + + let _guard = DataDirGuard { + prior, + _tempdir: tempdir, + _lock: lock, + }; + + f() +} diff --git a/tests/e2e/helpers.rs b/tests/e2e/helpers.rs index 1deba5264..daf7beca0 100644 --- a/tests/e2e/helpers.rs +++ b/tests/e2e/helpers.rs @@ -1,8 +1,13 @@ //! E2E Test Helpers //! -//! This module provides shared utilities for E2E testing, including: -//! - Test harness setup -//! - Common test fixtures +//! This module provides shared utilities for E2E testing: test harness setup +//! and per-test data-directory isolation (the latter shared with +//! `tests/kittest/support.rs` via `tests/common/data_dir.rs`). + +#[path = "../common/data_dir.rs"] +mod data_dir; + +pub use data_dir::with_isolated_data_dir; /// Create a minimal test harness for E2E tests #[allow(dead_code)] diff --git a/tests/e2e/navigation.rs b/tests/e2e/navigation.rs index 8681c1fb7..9e0610d7e 100644 --- a/tests/e2e/navigation.rs +++ b/tests/e2e/navigation.rs @@ -3,109 +3,126 @@ //! These tests verify that navigation between screens works correctly //! and that state is preserved appropriately. +use crate::helpers::with_isolated_data_dir; use egui_kittest::Harness; /// Test that app navigation completes without errors #[test] fn test_basic_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run initial frames - harness.run_steps(20); + // Run initial frames + harness.run_steps(20); + }); } /// Test navigation with different window sizes #[test] fn test_navigation_responsive_layout() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - let sizes = [ egui::vec2(640.0, 480.0), egui::vec2(1024.0, 768.0), egui::vec2(1440.0, 900.0), ]; + // A fresh data dir per size: each `AppState` opens the shared seed vault, + // which takes an exclusive advisory lock that outlives the harness drop + // (background subtasks keep the `Arc<AppContext>` graph — and thus the vault + // handle — alive). Isolating the data dir per iteration gives each its own + // vault file, so the lock never collides. Production opens the vault once + // per process, so this multi-AppState pattern is test-only. for size in sizes { - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(size); + harness.run_steps(15); }); - - harness.set_size(size); - harness.run_steps(15); } } /// Test that rapid navigation doesn't cause issues #[test] fn test_rapid_frame_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(300).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(300).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run many single-step frames - for _ in 0..50 { - harness.run_steps(1); - } + // Run many single-step frames + for _ in 0..50 { + harness.run_steps(1); + } + }); } /// Test that the app maintains stability over extended use #[test] fn test_extended_navigation_stability() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(500).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(500).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1280.0, 720.0)); + harness.set_size(egui::vec2(1280.0, 720.0)); - // Run 100 frames in batches - for batch in 0..10 { - harness.run_steps(10); - // Verify each batch completes - let _ = batch; - } + // Run 100 frames in batches + for batch in 0..10 { + harness.run_steps(10); + // Verify each batch completes + let _ = batch; + } + }); } /// Test app behavior with minimum window size #[test] fn test_minimum_size_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - // Very small window - harness.set_size(egui::vec2(320.0, 240.0)); - harness.run_steps(10); + // Very small window + harness.set_size(egui::vec2(320.0, 240.0)); + harness.run_steps(10); - // Resize to normal - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(10); + // Resize to normal + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); + }); } diff --git a/tests/e2e/wallet_flows.rs b/tests/e2e/wallet_flows.rs index d375740d4..8cd86ea30 100644 --- a/tests/e2e/wallet_flows.rs +++ b/tests/e2e/wallet_flows.rs @@ -3,86 +3,95 @@ //! These tests verify complete user journeys related to wallets, //! including balance display and state management. +use crate::helpers::with_isolated_data_dir; use egui_kittest::Harness; /// Test that the app starts with proper wallet state initialization #[test] fn test_wallet_state_initialization() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(20); }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(20); } /// Test that wallet balance display renders correctly #[test] fn test_wallet_balance_rendering() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run enough frames to fully initialize - harness.run_steps(30); + // Run enough frames to fully initialize + harness.run_steps(30); + }); } /// Test wallet operations don't cause UI freezes #[test] fn test_wallet_ui_responsiveness() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run many frames to test UI responsiveness + for batch in 0..10 { + harness.run_steps(15); + // Each batch should complete without hanging + let _ = batch; + } }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - - // Run many frames to test UI responsiveness - for batch in 0..10 { - harness.run_steps(15); - // Each batch should complete without hanging - let _ = batch; - } } /// Test that the app handles rapid resizing during wallet views #[test] fn test_wallet_resize_stability() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + // Test various resize scenarios + let sizes = [ + egui::vec2(800.0, 600.0), + egui::vec2(1200.0, 900.0), + egui::vec2(640.0, 480.0), + egui::vec2(1920.0, 1080.0), + ]; + + for size in sizes { + harness.set_size(size); + harness.run_steps(10); + } }); - - // Test various resize scenarios - let sizes = [ - egui::vec2(800.0, 600.0), - egui::vec2(1200.0, 900.0), - egui::vec2(640.0, 480.0), - egui::vec2(1920.0, 1080.0), - ]; - - for size in sizes { - harness.set_size(size); - harness.run_steps(10); - } } diff --git a/tests/kittest/address_input.rs b/tests/kittest/address_input.rs new file mode 100644 index 000000000..2d6819705 --- /dev/null +++ b/tests/kittest/address_input.rs @@ -0,0 +1,206 @@ +//! Kittest coverage for `AddressInput` — the GitHub-style tag search, the +//! dynamic placeholder legend, and pill-based row rendering. +//! +//! Autocomplete rows are painted directly (no child widgets, so a full-width +//! click rect can own the row), which means their pills and text are not exposed +//! to accesskit as queryable widgets. Data assertions therefore use the +//! component's public introspection API (`effective_hint_text`, `rendered_rows`), +//! whose fields map one-to-one to render decisions, while a focused render pass +//! actually paints the popup — exercising the real pill layout/paint path and +//! guarding against panics there. + +use dash_evo_tool::model::address::AddressKind; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::model::wallet::Wallet; +use dash_evo_tool::ui::components::Component; +use dash_evo_tool::ui::components::address_input::{AddressInput, WalletWithBalances}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::{Arc, RwLock}; + +/// Build an in-memory wallet with a single derived receive address, paired with +/// empty display balances. +fn wallet(seed: u8, alias: &str) -> WalletWithBalances { + let wallet = Wallet::new_from_seed([seed; 64], Network::Testnet, Some(alias.to_string()), None) + .expect("wallet from seed"); + (Arc::new(RwLock::new(wallet)), BTreeMap::new()) +} + +/// Build a wallet-less `QualifiedIdentity` whose display name is its alias. +fn identity(alias: &str) -> QualifiedIdentity { + let identity = + Identity::create_basic_identity(Identifier::from([0x11; 32]), 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: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: Network::Testnet, + } +} + +/// Render `input` in a headless harness, focus its field to open (and paint) the +/// autocomplete popup, then run `assert` against the component's public state. +fn with_rendered(input: AddressInput, assert: impl FnOnce(&AddressInput)) { + let cell = Rc::new(RefCell::new(input)); + let cell_ui = Rc::clone(&cell); + let mut harness = Harness::builder() + .with_size(egui::vec2(480.0, 400.0)) + .build_ui(move |ui| { + let _ = cell_ui.borrow_mut().show(ui); + }); + + harness.run(); + // Focus the text field so the popup opens next frame and paints its rows. + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.run(); + harness.run(); + + assert(&cell.borrow()); +} + +#[test] +fn hint_lists_enabled_kinds_when_no_wallets() { + with_rendered(AddressInput::new(Network::Testnet), |input| { + assert_eq!( + input.effective_hint_text(), + "type:core|platform|shielded|identity" + ); + assert!(!input.effective_hint_text().contains("wallet:")); + }); +} + +#[test] +fn hint_reflects_restricted_kinds() { + let input = AddressInput::new(Network::Testnet) + .with_address_kinds(&[AddressKind::Core, AddressKind::Shielded]); + with_rendered(input, |input| { + assert_eq!(input.effective_hint_text(), "type:core|shielded"); + }); +} + +#[test] +fn hint_lists_single_and_few_wallets() { + with_rendered( + AddressInput::new(Network::Testnet).with_wallets(&[wallet(1, "solo")]), + |input| assert!(input.effective_hint_text().contains("wallet:solo")), + ); + + let three = AddressInput::new(Network::Testnet).with_wallets(&[ + wallet(1, "a"), + wallet(2, "b"), + wallet(3, "c"), + ]); + with_rendered(three, |input| { + let hint = input.effective_hint_text(); + assert!(hint.contains("wallet:a|b|c"), "{hint}"); + assert!(!hint.contains("more"), "no trim below six wallets: {hint}"); + }); +} + +#[test] +fn hint_trims_wallets_above_five() { + let six: Vec<WalletWithBalances> = (0u8..6).map(|i| wallet(i + 1, &format!("w{i}"))).collect(); + with_rendered( + AddressInput::new(Network::Testnet).with_wallets(&six), + |input| { + let hint = input.effective_hint_text(); + assert!(hint.contains("wallet:w0|w1|w2|w3|w4"), "{hint}"); + assert!(hint.contains("(+1 more)"), "{hint}"); + }, + ); + + let five: Vec<WalletWithBalances> = (0u8..5).map(|i| wallet(i + 1, &format!("w{i}"))).collect(); + with_rendered( + AddressInput::new(Network::Testnet).with_wallets(&five), + |input| { + let hint = input.effective_hint_text(); + assert!(hint.contains("wallet:w0|w1|w2|w3|w4"), "{hint}"); + assert!( + !hint.contains("more"), + "no trim at exactly five wallets: {hint}" + ); + }, + ); +} + +#[test] +fn wallet_pill_only_on_wallet_scoped_rows() { + let input = AddressInput::new(Network::Testnet) + .with_wallets(&[wallet(1, "main")]) + .with_identities(&[identity("alice")]) + .with_shielded_balance("tdash1zexampleshieldedaddress".to_string(), 0); + + with_rendered(input, |input| { + let rows = input.rendered_rows(); + assert!(!rows.is_empty()); + for row in &rows { + // The type pill (driven by `kind`) is unconditional; assert the + // wallet pill appears only on wallet-scoped kinds. + match row.kind { + AddressKind::Core | AddressKind::Platform => assert!( + row.wallet_pill.is_some(), + "wallet-scoped row must carry a wallet pill: {row:?}" + ), + AddressKind::Identity | AddressKind::Shielded => assert!( + row.wallet_pill.is_none(), + "wallet-agnostic row must not carry a wallet pill: {row:?}" + ), + } + } + + let identity_row = rows + .iter() + .find(|r| r.kind == AddressKind::Identity) + .expect("identity row present"); + assert_eq!( + identity_row.name.as_deref(), + Some("alice"), + "identity row shows its name" + ); + let shielded_row = rows + .iter() + .find(|r| r.kind == AddressKind::Shielded) + .expect("shielded row present"); + assert_eq!(shielded_row.name, None, "shielded row has no name"); + }); +} + +#[test] +fn tag_query_narrows_rows() { + let input = AddressInput::new(Network::Testnet) + .with_wallets(&[wallet(1, "alpha"), wallet(2, "beta")]) + .with_identities(&[identity("carol")]) + .with_initial_value("type:core wallet:alpha"); + + with_rendered(input, |input| { + let rows = input.rendered_rows(); + assert_eq!( + rows.len(), + 1, + "only alpha's core address should match: {rows:?}" + ); + assert_eq!(rows[0].kind, AddressKind::Core); + assert_eq!(rows[0].wallet_pill.as_deref(), Some("alpha")); + }); +} diff --git a/tests/kittest/contract_screen.rs b/tests/kittest/contract_screen.rs new file mode 100644 index 000000000..235694d9b --- /dev/null +++ b/tests/kittest/contract_screen.rs @@ -0,0 +1,209 @@ +//! W2 kittest — contracts & documents screens obey the app-scoped selected identity. +//! +//! B1 migration: `RegisterDataContractScreen`, `UpdateDataContractScreen`, and +//! `DocumentActionScreen` must default to the app-scoped selected identity on +//! construction and write the user's picker choice back via `syncing_global`. +//! +//! Seeding test (a): screen defaults to the app-scoped id — covered below. +//! +//! Write-back (b): `syncing_global` component-level write-back is verified by +//! `identity_selector::tests::syncing_global_writes_selection_to_app_context` +//! — fixture-free unit test that calls `sync_to_global()` directly. +//! Screen-level click-through (ComboBox click → AppContext update) requires a +//! private-key fixture so the selector renders (the screens gate on +//! `has_suitable_keys`). +//! +//! # TODO(WalletFixture / private-key fixture) +//! Add screen-level write-back assertions (ComboBox click → `resolve_selected_identity()` +//! moves) once an identity fixture with loaded AUTH HIGH/CRITICAL private keys exists. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::contracts_documents::document_action_screen::{ + DocumentActionScreen, DocumentActionType, +}; +use dash_evo_tool::ui::contracts_documents::group_actions_screen::GroupActionsScreen; +use dash_evo_tool::ui::contracts_documents::register_contract_screen::RegisterDataContractScreen; +use dash_evo_tool::ui::contracts_documents::update_contract_screen::UpdateDataContractScreen; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Mount a minimal AppState with the wallet backend wired up, then return the +/// live context. Mirrors `identity_hub_switcher::mount_hub` but doesn't force a +/// particular root screen — we construct screens directly. +fn mount_context() -> ( + Harness<'static, dash_evo_tool::app::AppState>, + Arc<AppContext>, +) { + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + harness.set_size(egui::vec2(1280.0, 800.0)); + harness.run_steps(5); + let ctx = harness.state().current_app_context().clone(); + (harness, ctx) +} + +/// Seed a minimal wallet-less identity (identical to identity_hub_switcher pattern). +fn seed_identity(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed identity insert"); + id +} + +// ── B1a ─ RegisterDataContractScreen ──────────────────────────────────────── + +/// The register-contract screen must default to the app-scoped selected +/// identity, not necessarily the first loaded identity. +#[test] +fn register_contract_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_harness, app_context) = mount_context(); + + let _first = seed_identity(&app_context, 0xA1, "Contract Alpha"); + let second = seed_identity(&app_context, 0xB2, "Contract Beta"); + + // Point the global selection at the second identity. + app_context.set_selected_identity(Some(second)); + + let screen = RegisterDataContractScreen::new(&app_context); + + assert_eq!( + screen + .selected_qualified_identity + .as_ref() + .map(|qi| qi.identity.id()), + Some(second), + "RegisterDataContractScreen must default to the app-scoped selected identity" + ); + }); +} + +// ── B1b ─ UpdateDataContractScreen ────────────────────────────────────────── + +/// The update-contract screen must default to the app-scoped selected identity. +#[test] +fn update_contract_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_harness, app_context) = mount_context(); + + let _first = seed_identity(&app_context, 0xC3, "Update Alpha"); + let second = seed_identity(&app_context, 0xD4, "Update Beta"); + + app_context.set_selected_identity(Some(second)); + + let screen = UpdateDataContractScreen::new(&app_context); + + assert_eq!( + screen + .selected_qualified_identity + .as_ref() + .map(|qi| qi.identity.id()), + Some(second), + "UpdateDataContractScreen must default to the app-scoped selected identity" + ); + }); +} + +// ── B1c ─ DocumentActionScreen ────────────────────────────────────────────── + +/// The document-action screen (constructed with `None` as selected identity) +/// must seed from the app-scoped selected identity. +#[test] +fn document_action_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_harness, app_context) = mount_context(); + + let _first = seed_identity(&app_context, 0xE5, "DocAction Alpha"); + let second = seed_identity(&app_context, 0xF6, "DocAction Beta"); + + app_context.set_selected_identity(Some(second)); + + let screen = DocumentActionScreen::new( + app_context.clone(), + None, // No explicit identity → must seed from app-scoped selection. + DocumentActionType::Create, + ); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "DocumentActionScreen must seed from the app-scoped selected identity when None is passed" + ); + }); +} + +// ── B6 ─ GroupActionsScreen session-local regression lock ──────────────────── + +/// W5 B6 (K3 regression lock): `GroupActionsScreen` is SESSION-LOCAL — it must +/// NOT seed `selected_identity` from the global selection on construction, even +/// when an app-scoped identity is set. The picker is independent per session. +#[test] +fn group_actions_does_not_seed_from_global_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_harness, app_context) = mount_context(); + + let _first = seed_identity(&app_context, 0x11, "GA Alpha"); + let second = seed_identity(&app_context, 0x22, "GA Beta"); + + // Set the second identity as the app-scoped selection. + app_context.set_selected_identity(Some(second)); + + let screen = GroupActionsScreen::new(&app_context); + + assert_eq!( + screen.selected_identity, None, + "GroupActionsScreen is session-local (K3): it must NOT seed from the \ + app-scoped selection — selected_identity must stay None on construction" + ); + + // The global selection must be unchanged (no write-back on construction). + assert_eq!( + app_context.selected_identity_id(), + Some(second), + "GroupActionsScreen must not alter the app-scoped selection on construction" + ); + }); +} diff --git a/tests/kittest/create_asset_lock_screen.rs b/tests/kittest/create_asset_lock_screen.rs index 0296ed2d2..6f34aa17f 100644 --- a/tests/kittest/create_asset_lock_screen.rs +++ b/tests/kittest/create_asset_lock_screen.rs @@ -1,63 +1,70 @@ +use crate::support::with_isolated_data_dir; use egui_kittest::Harness; /// Test that the create asset lock screen can be rendered #[test] fn test_create_asset_lock_screen_renders() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(10); + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); + }); } /// Test that the create asset lock screen handles window resize gracefully #[test] fn test_create_asset_lock_screen_resize() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + // Test various window sizes + let sizes = [ + egui::vec2(800.0, 600.0), + egui::vec2(1200.0, 900.0), + egui::vec2(640.0, 480.0), + egui::vec2(1920.0, 1080.0), + ]; - // Test various window sizes - let sizes = [ - egui::vec2(800.0, 600.0), - egui::vec2(1200.0, 900.0), - egui::vec2(640.0, 480.0), - egui::vec2(1920.0, 1080.0), - ]; - - for size in sizes { - harness.set_size(size); - harness.run_steps(5); - } + for size in sizes { + harness.set_size(size); + harness.run_steps(5); + } + }); } /// Test that the app remains responsive with multiple frame batches #[test] fn test_create_asset_lock_screen_frame_stability() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run multiple batches to test stability - for _ in 0..10 { - harness.run_steps(10); - } + // Run multiple batches to test stability + for _ in 0..10 { + harness.run_steps(10); + } + }); } diff --git a/tests/kittest/dashpay_screen.rs b/tests/kittest/dashpay_screen.rs new file mode 100644 index 000000000..b440f5b1f --- /dev/null +++ b/tests/kittest/dashpay_screen.rs @@ -0,0 +1,437 @@ +//! Kittest coverage for `DashPayScreen` typed-error routing. +//! +//! F95 regression: the embedded `ContactRequests` classifies a +//! missing-encryption-key failure and surfaces an inline "Add Encryption +//! Key" affordance, but only if `DashPayScreen` forwards `display_task_error` +//! to the active subscreen. Before the fix `DashPayScreen` used the +//! `ScreenLike` default (always `false`), so the classification never ran. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::backend_task::dashpay::errors::DashPayError; +use dash_evo_tool::backend_task::error::TaskError; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::ScreenLike; +use dash_evo_tool::ui::dashpay::add_contact_screen::AddContactScreen; +use dash_evo_tool::ui::dashpay::contact_requests::ContactRequests; +use dash_evo_tool::ui::dashpay::contacts_list::ContactsList; +use dash_evo_tool::ui::dashpay::profile_screen::ProfileScreen; +use dash_evo_tool::ui::dashpay::profile_search::ProfileSearchScreen; +use dash_evo_tool::ui::dashpay::qr_code_generator::QRCodeGeneratorScreen; +use dash_evo_tool::ui::dashpay::qr_scanner::QRScannerScreen; +use dash_evo_tool::ui::dashpay::send_payment::PaymentHistory; +use dash_evo_tool::ui::dashpay::{DashPayScreen, DashPaySubscreen}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// On the Contacts subscreen, a `MissingEncryptionKey` error must route to +/// the embedded `ContactRequests`, which claims it (returns `true`) and +/// arms its inline recovery affordance. An unrelated error must NOT be +/// claimed, so it falls through to the global banner. +#[test] +fn missing_encryption_key_error_routes_to_embedded_contact_requests() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("create AppState") + .with_animations(false) + }); + harness.run_steps(3); + + let app_context = harness.state().current_app_context().clone(); + let mut screen = DashPayScreen::new(&app_context, DashPaySubscreen::Contacts); + + let handled = + screen.display_task_error(&TaskError::DashPay(DashPayError::MissingEncryptionKey)); + assert!( + handled, + "Contacts subscreen must claim the missing-encryption-key error so the inline \ + recovery affordance can render" + ); + + let unrelated = screen.display_task_error(&TaskError::DocumentNotFound); + assert!( + !unrelated, + "an unrelated error must fall through to the global banner, not be swallowed" + ); + }); +} + +// ── W3 B3 — DashPay screens seed from app-scoped identity ─────────────────── + +/// Seed a wallet-less identity (mirrors identity_hub_switcher fixture). +fn seed_dp_identity(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed dashpay identity"); + id +} + +/// Build a harness and return the live context (wallet backend wired). +fn build_ctx() -> ( + Harness<'static, dash_evo_tool::app::AppState>, + Arc<AppContext>, +) { + let mut h = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + h.run_steps(5); + let ctx = h.state().current_app_context().clone(); + (h, ctx) +} + +/// `ContactsList::new()` must default to the app-scoped identity, not identities[0]. +/// +/// This is the B3 seeding test: verifies that `ContactsList` opens on the +/// identity the user last operated as, not always the first DB row. +/// +/// Write-back (picker change → `resolve_selected_identity()` moves) is covered +/// at the component level by +/// `identity_selector::tests::syncing_global_writes_selection_to_app_context` +/// which tests `sync_to_global()` directly without a screen harness. +#[test] +fn contacts_list_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0xA1, "CL Alpha"); + let second = seed_dp_identity(&ctx, 0xB2, "CL Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = ContactsList::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "ContactsList must default to the app-scoped selected identity" + ); + }); +} + +/// `ContactRequests::new()` must default to the app-scoped identity. +#[test] +fn contact_requests_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0xC3, "CR Alpha"); + let second = seed_dp_identity(&ctx, 0xD4, "CR Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = ContactRequests::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "ContactRequests must default to the app-scoped selected identity" + ); + }); +} + +/// `PaymentHistory::new()` must default to the app-scoped identity. +#[test] +fn payment_history_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0xE5, "PH Alpha"); + let second = seed_dp_identity(&ctx, 0xF6, "PH Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = PaymentHistory::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "PaymentHistory must default to the app-scoped selected identity" + ); + }); +} + +/// `ProfileScreen::new()` must default to the app-scoped identity. +#[test] +fn profile_screen_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0x11, "PS Alpha"); + let second = seed_dp_identity(&ctx, 0x22, "PS Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = ProfileScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "ProfileScreen must default to the app-scoped selected identity" + ); + }); +} + +/// `QRCodeGeneratorScreen::new()` must default to the app-scoped identity. +#[test] +fn qr_generator_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0x33, "QRG Alpha"); + let second = seed_dp_identity(&ctx, 0x44, "QRG Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = QRCodeGeneratorScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "QRCodeGeneratorScreen must default to the app-scoped selected identity" + ); + }); +} + +/// `QRScannerScreen::new()` must default to the app-scoped identity. +#[test] +fn qr_scanner_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0x55, "QRS Alpha"); + let second = seed_dp_identity(&ctx, 0x66, "QRS Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = QRScannerScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "QRScannerScreen must default to the app-scoped selected identity" + ); + }); +} + +/// `AddContactScreen::new()` must default to the app-scoped identity (sender). +#[test] +fn add_contact_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_dp_identity(&ctx, 0x77, "AC Alpha"); + let second = seed_dp_identity(&ctx, 0x88, "AC Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = AddContactScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "AddContactScreen must default to the app-scoped selected identity" + ); + }); +} + +/// Seed a wallet-less masternode identity into the local DB. +fn seed_dp_masternode(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed dashpay masternode"); + id +} + +/// FR-6 boundary through a DashPay screen: a masternode/evonode must +/// never become selectable in a DashPay identity selector, and so can never be +/// written to the app-global identity via the selector's `syncing_global` +/// write-back. The DashPay selectors source their list from the User-filtered +/// accessor. With ONLY a masternode stored, a DashPay screen must seed no +/// identity (the masternode is not eligible) — before the fix it fell back to +/// the masternode as `identities.first()`, leaking it into the sync path. +#[test] +fn masternode_never_selectable_in_dashpay_screens() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + // Only a masternode is stored — no User identity. + let mn_id = seed_dp_masternode(&ctx, 0x4D, "mn-dashpay-leak"); + + // It IS in the unfiltered store (proving the screens filter, not that + // the DB is empty). + assert_eq!( + ctx.load_local_qualified_identities().expect("load").len(), + 1, + "the masternode must be present in the unfiltered store" + ); + assert!( + ctx.selected_identity_id().is_none(), + "no app-global identity should be selected initially" + ); + + // Every DashPay screen that carries a `syncing_global` selector must + // seed NO identity from a masternode-only store. + assert!( + ContactsList::new(ctx.clone()).selected_identity.is_none(), + "ContactsList must not seed a masternode as the selected identity" + ); + assert!( + ContactRequests::new(ctx.clone()) + .selected_identity + .is_none(), + "ContactRequests must not seed a masternode as the selected identity" + ); + assert!( + PaymentHistory::new(ctx.clone()).selected_identity.is_none(), + "PaymentHistory must not seed a masternode as the selected identity" + ); + assert!( + ProfileScreen::new(ctx.clone()).selected_identity.is_none(), + "ProfileScreen must not seed a masternode as the selected identity" + ); + assert!( + AddContactScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "AddContactScreen must not seed a masternode as the selected identity" + ); + assert!( + QRScannerScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "QRScannerScreen must not seed a masternode as the selected identity" + ); + assert!( + QRCodeGeneratorScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "QRCodeGeneratorScreen must not seed a masternode as the selected identity" + ); + + // ProfileSearchScreen holds no selected identity — it only ever reads + // identities through the User-filtered accessor (as its profile-viewing + // context), so a masternode can never surface there. Assert that data + // source excludes the masternode. + let _ = ProfileSearchScreen::new(ctx.clone()); + assert!( + ctx.load_local_user_identities() + .expect("user identities") + .is_empty(), + "the User-filtered accessor ProfileSearchScreen reads must exclude the masternode" + ); + + // The FR-6 invariant: no masternode ever reached the app-global identity. + assert!( + ctx.selected_identity_id().is_none(), + "a masternode must never be written to the app-global identity via DashPay" + ); + assert_ne!( + ctx.selected_identity_id(), + Some(mn_id), + "the app-global identity must never be the masternode" + ); + }); +} + +/// Non-Contacts subscreens have no classifier embedded, so they must not +/// claim the error — it belongs to the global banner there. +#[test] +fn profile_subscreen_does_not_claim_dashpay_errors() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("create AppState") + .with_animations(false) + }); + harness.run_steps(3); + + let app_context = harness.state().current_app_context().clone(); + let mut screen = DashPayScreen::new(&app_context, DashPaySubscreen::Profile); + + let handled = + screen.display_task_error(&TaskError::DashPay(DashPayError::MissingEncryptionKey)); + assert!( + !handled, + "Profile subscreen has no embedded classifier and must defer to the global banner" + ); + }); +} diff --git a/tests/kittest/global_nav_switcher.rs b/tests/kittest/global_nav_switcher.rs new file mode 100644 index 000000000..d9acbd888 --- /dev/null +++ b/tests/kittest/global_nav_switcher.rs @@ -0,0 +1,206 @@ +//! IT-GLOBAL-NAV — the page-aware generalized global-nav switcher (A2). +//! +//! Renders `global_nav_switcher::render` directly with a custom `PageNavSpec` +//! (no root screen consumes it until A3), exercising the parts that differ from +//! the hub: a page-driven segment-1 label and a page-scoped object pill. + +use crate::support::{fresh_app_context, mount_app, with_isolated_data_dir}; +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::hub_selection::HubSelection; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// TC-NAV-01 foundation — segment-1 is page-driven: a spec labelled +/// `Masternodes` renders that label, not the hub's literal `Identities`. +#[test] +fn segment1_label_is_page_driven() { + 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 spec = + PageNavSpec::new("Masternodes", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + assert!( + harness.query_by_label("Masternodes").is_some(), + "segment-1 must render the page-driven label" + ); + assert!( + harness.query_by_label("Identities").is_none(), + "segment-1 must NOT hardcode the hub's Identities label" + ); + }); +} + +/// TC-NAV-16 foundation — the page-scoped object pill renders its placeholder +/// when nothing is selected (never the app-global identity). +#[test] +fn page_scoped_pill_renders_placeholder_when_empty() { + 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 spec = + PageNavSpec::new("Masternodes", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill( + IdentityPillScope::page_scoped_object( + "(no masternode yet)", + vec![], + None, + ), + PillConsumption::Consumed, + ); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + assert!( + harness + .query_by_label_contains("(no masternode yet)") + .is_some(), + "the page-scoped pill must show its placeholder when empty" + ); + }); +} + +/// 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. +#[test] +fn unwired_wallet_pill_renders_placeholder() { + 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 spec = PageNavSpec::new("Contracts", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Unwired { + tooltip: "Change the active wallet from the Wallets tab.".to_string(), + }); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + // No wallets loaded in the fresh context → the unwired wallet pill shows + // the no-wallet placeholder. + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the unwired wallet pill must still show its placeholder value" + ); + }); +} + +/// TC-NAV-06 (A3) — applying a wallet switch is silent (no forced navigation) +/// and reconciles the app-global identity as a documented side effect. With no +/// identities loaded, reconciliation resolves to `None` (keep-if-owned → first +/// → None). The FR-6 MN/Evonode exclusion is enforced at the resolution layer +/// in B1; here we assert the silent reconciliation exists and never navigates. +#[test] +fn switch_wallet_is_silent_and_reconciles_identity() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let action = + apply_global_nav_effect(&app_context, GlobalNavEffect::SwitchWallet([0x11; 32])); + assert_eq!( + action, + AppAction::None, + "switching wallet must not navigate" + ); + assert_eq!(app_context.selected_wallet_hash(), Some([0x11; 32])); + assert_eq!( + app_context.selected_identity_id(), + None, + "reconciliation with no owned identities resolves to None" + ); + }); +} + +/// Segment-1 activation navigates to the page root via `SetMainScreen`. +#[test] +fn navigate_to_root_sets_main_screen() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let action = apply_global_nav_effect( + &app_context, + GlobalNavEffect::NavigateToRoot(RootScreenType::RootScreenIdentities), + ); + assert_eq!( + action, + AppAction::SetMainScreen(RootScreenType::RootScreenIdentities) + ); + }); +} + +/// TC-FR6-07 (A3) — a page-scoped object selection never writes the app-global +/// identity, even at the shared applier: the FR-6 boundary holds end-to-end. +#[test] +fn select_page_object_never_writes_app_global_identity() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + assert_eq!(app_context.selected_identity_id(), None); + let action = apply_global_nav_effect( + &app_context, + GlobalNavEffect::SelectPageObject(Identifier::new([7; 32])), + ); + assert_eq!(action, AppAction::None); + assert_eq!( + app_context.selected_identity_id(), + None, + "a page-scoped object must not touch the app-global identity (FR-6)" + ); + }); +} + +/// The global switcher renders on non-Hub root screens. Identities (everyday) +/// shows both the wallet and identity placeholders; Wallets (wallet-only, +/// TC-NAV-15) shows only the wallet placeholder, no identity segment. +#[test] +fn switcher_present_on_identities_root() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenIdentities); + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the global switcher's wallet placeholder must render on Identities" + ); + assert!( + harness + .query_by_label_contains("(no identity yet)") + .is_some(), + "the everyday spec must render the identity placeholder on Identities" + ); + }); +} + +/// TC-NAV-15 — the Wallets page composes a wallet-only switcher: the wallet +/// placeholder renders, and there is no identity segment at all. +#[test] +fn switcher_wallet_only_on_wallets_root() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenWalletsBalances); + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the global switcher's wallet placeholder must render on Wallets" + ); + assert!( + harness + .query_by_label_contains("(no identity yet)") + .is_none(), + "the wallet-only spec must render no identity segment (composition)" + ); + }); +} diff --git a/tests/kittest/identities_screen.rs b/tests/kittest/identities_screen.rs index bf4cdf273..ca37cdc1a 100644 --- a/tests/kittest/identities_screen.rs +++ b/tests/kittest/identities_screen.rs @@ -1,81 +1,90 @@ +use crate::support::with_isolated_data_dir; use egui_kittest::Harness; /// Test that the identities screen can be rendered #[test] fn test_identities_screen_renders() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(10); + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); + }); } /// Test that the app renders correctly at minimum size #[test] fn test_minimum_window_size() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - // Test with a small window size - harness.set_size(egui::vec2(400.0, 300.0)); - harness.run_steps(10); + // Test with a small window size + harness.set_size(egui::vec2(400.0, 300.0)); + harness.run_steps(10); + }); } /// Test that the app handles resize gracefully #[test] fn test_window_resize() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - // Start small - harness.set_size(egui::vec2(640.0, 480.0)); - harness.run_steps(5); + // Start small + harness.set_size(egui::vec2(640.0, 480.0)); + harness.run_steps(5); - // Resize larger - harness.set_size(egui::vec2(1280.0, 720.0)); - harness.run_steps(5); + // Resize larger + harness.set_size(egui::vec2(1280.0, 720.0)); + harness.run_steps(5); - // Resize smaller again - harness.set_size(egui::vec2(800.0, 600.0)); - harness.run_steps(5); + // Resize smaller again + harness.set_size(egui::vec2(800.0, 600.0)); + harness.run_steps(5); + }); } /// Test multiple frame batches #[test] fn test_frame_batch_processing() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Process frames in batches - for batch in 0..10 { - harness.run_steps(10); - // Just ensure we can run multiple batches without error - let _ = batch; - } + // Process frames in batches + for batch in 0..10 { + harness.run_steps(10); + // Just ensure we can run multiple batches without error + let _ = batch; + } + }); } diff --git a/tests/kittest/identity_hub.rs b/tests/kittest/identity_hub.rs new file mode 100644 index 000000000..bd6000f95 --- /dev/null +++ b/tests/kittest/identity_hub.rs @@ -0,0 +1,59 @@ +//! Integration tests for the new unified Identities hub. +//! +//! Each test mounts the full `AppState`, switches the selected root screen to +//! `RootScreenIdentityHub`, and asserts that the expected tab structure renders. +//! +//! This is the minimum kittest coverage for the scaffold. Per-tab assertions on +//! the full populated layouts arrive as each tab's content lands (T8–T11). + +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::ui::RootScreenType; + +/// IT-ONBOARD-01 / IT-HOME-01 combined smoke: the hub renders without +/// panicking on the default first-run database (no identities loaded → should +/// render the onboarding empty state). +#[test] +fn identity_hub_mounts_and_renders() { + with_isolated_data_dir(|| { + let _harness = mount_app(RootScreenType::RootScreenIdentityHub); + // If `mount_app` returned without panicking, the hub compiled-in and + // rendered. More detailed assertions land with the per-tab content work. + }); +} + +/// IT-NAV-01: The nav must keep the legacy `Identities` and `Dashpay` entries +/// alongside the new hub so users can toggle between old and new. +#[test] +fn legacy_nav_entries_coexist_with_hub() { + // We don't need to drive the UI for this one — it's a pure enum check. + // The `RootScreenType` enum must contain all three coexisting variants. + let legacy_identities = RootScreenType::RootScreenIdentities; + // `RootScreenDashpay` is the legacy root nav entry for DashPay; the other + // `RootScreenDashPay*` variants are sub-screens within that section. + let legacy_dashpay_root = RootScreenType::RootScreenDashpay; + let new_hub = RootScreenType::RootScreenIdentityHub; + assert_ne!(legacy_identities, new_hub); + assert_ne!(legacy_dashpay_root, new_hub); + // Round-trip the new variant through on-disk encoding to verify the + // persistence contract is stable. + let encoded = new_hub.to_int(); + let decoded = RootScreenType::from_int(encoded).expect("hub variant must decode"); + assert_eq!(new_hub, decoded); +} + +/// The hub screen must be reachable from the existing `create_screen` +/// dispatch table. This asserts the wiring that AppState::new relies on. +#[test] +fn identity_hub_screen_type_creates_hub_screen() { + // Guard against a future refactor that silently drops the hub case from + // `ScreenType::create_screen`. If that happens, this test regresses. + use dash_evo_tool::ui::ScreenType; + // The assert-that-it-compiles-and-matches is enough; the dispatcher + // expects a Screen with IdentityHub variant. + let screen_type = ScreenType::IdentityHub; + assert_eq!(screen_type, ScreenType::IdentityHub); + // `ScreenType::create_screen` requires a live AppContext; instead of + // constructing one here we verify through the enum discriminant. The end- + // to-end wiring is exercised by `identity_hub_mounts_and_renders`. + let _ = screen_type; +} diff --git a/tests/kittest/identity_hub_activity.rs b/tests/kittest/identity_hub_activity.rs new file mode 100644 index 000000000..1c4d5bd67 --- /dev/null +++ b/tests/kittest/identity_hub_activity.rs @@ -0,0 +1,64 @@ +//! IT-ACTIVITY-01 — Activity tab shell renders. +//! +//! Verifies the Activity tab shell: +//! +//! - Filter chips `All`, `Payments`, and `Funding` are present. +//! - The gated empty-state message `Unified activity is coming soon.` is +//! present. +//! +//! The full hub renders the Onboarding empty state when no identities are +//! loaded, which would hide the Activity tab entirely. To keep this test +//! reliable on a fresh first-run database we: +//! +//! 1. build a real `AppContext` via the same `AppState::new` factory the +//! other kittest files use, then +//! 2. call `activity::render` directly inside a fresh `build_ui` harness. +//! +//! This exercises the component stack, theme, and egui storage while keeping +//! the test scoped to the Activity tab's own contract. + +use crate::support::{fresh_app_context, with_isolated_data_dir}; +use dash_evo_tool::ui::identity::activity; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// IT-ACTIVITY-01 +#[test] +fn activity_tab_shell_renders_filter_chips_and_gated_message() { + with_isolated_data_dir(|| { + let (rt, app_context) = fresh_app_context(); + let _guard = rt.enter(); + + let ctx_for_render = app_context.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .build_ui(move |ui| { + let _ = activity::render(ui, &ctx_for_render); + }); + harness.run(); + + // Filter chips — called out verbatim in the test-case spec. + assert!( + harness.query_by_label("All").is_some(), + "Activity tab must render the `All` filter chip" + ); + assert!( + harness.query_by_label("Payments").is_some(), + "Activity tab must render the `Payments` filter chip" + ); + assert!( + harness.query_by_label("Funding").is_some(), + "Activity tab must render the `Funding` filter chip" + ); + + // Gated empty-state message — exact string required by the spec. We + // use `query_by_label_contains` so trailing punctuation / whitespace + // variations in the accessibility tree do not make the test brittle. + assert!( + harness + .query_by_label_contains("Unified activity is coming soon") + .is_some(), + "Activity tab must show the gated empty-state message" + ); + }); +} diff --git a/tests/kittest/identity_hub_contacts.rs b/tests/kittest/identity_hub_contacts.rs new file mode 100644 index 000000000..ed4168f22 --- /dev/null +++ b/tests/kittest/identity_hub_contacts.rs @@ -0,0 +1,95 @@ +//! IT-CONTACTS-01 — Contacts tab gated when no social profile. +//! +//! See `docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md` +//! section `### IT-CONTACTS-01`. +//! +//! The full hub only routes to the Contacts tab when the active-network +//! identity count is ≥ 1 — the kittest DB-less harness used elsewhere has +//! zero identities and would render Onboarding instead. Rather than spin up +//! a real `AppContext` with injected fixtures (out of scope for the shell- +//! only T9 drop), this test mounts the gated render path directly: the +//! single source of truth that the hub calls when the active identity has no +//! social profile. +//! +//! The assertions cover the test-spec expectations: +//! - Heading `Set up a social profile first.` present. +//! - Primary button `Add a display name` present. +//! - No request cards or active contacts list rendered (the populated-state +//! section headings and the search placeholder must be absent). + +use dash_evo_tool::ui::identity::contacts; +use dash_evo_tool::ui::identity::social_profile_gate_card::{ + HEADING as GATE_HEADING, PRIMARY_LABEL as GATE_PRIMARY, +}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +#[test] +fn it_contacts_01_gated_renders_when_no_social_profile() { + let mut harness = Harness::builder() + .with_size(egui::vec2(960.0, 720.0)) + .build_ui(|ui| { + let _ = contacts::render_gated(ui, Some("alex.dash")); + }); + harness.run(); + + // Heading present (per IT-CONTACTS-01). + assert!( + harness.query_by_label(GATE_HEADING).is_some(), + "gated Contacts tab must show the `{GATE_HEADING}` heading" + ); + + // Primary CTA present. + assert!( + harness.query_by_label(GATE_PRIMARY).is_some(), + "gated Contacts tab must show the `{GATE_PRIMARY}` primary button" + ); + + // Populated-state copy must NOT appear when gated. + assert!( + harness.query_by_label(contacts::RECEIVED_HEADING).is_none(), + "gated Contacts tab must NOT render the received-requests section" + ); + assert!( + harness + .query_by_label_contains(contacts::ACTIVE_HEADING_PREFIX) + .is_none(), + "gated Contacts tab must NOT render the active-contacts section" + ); + assert!( + harness.query_by_label(contacts::SENT_HEADING).is_none(), + "gated Contacts tab must NOT render the sent-requests section" + ); + assert!( + harness + .query_by_label(contacts::SEARCH_PLACEHOLDER) + .is_none(), + "gated Contacts tab must NOT render the search input placeholder" + ); +} + +#[test] +fn it_contacts_01_gated_handles_absent_dpns_handle() { + // A user that has never registered a DPNS name: the gated card must + // still render without emitting a stray `@{handle}` placeholder. + let mut harness = Harness::builder() + .with_size(egui::vec2(960.0, 720.0)) + .build_ui(|ui| { + let _ = contacts::render_gated(ui, None); + }); + harness.run(); + + assert!( + harness.query_by_label(GATE_HEADING).is_some(), + "gated Contacts tab must show its heading even without a DPNS handle" + ); + assert!( + harness.query_by_label(GATE_PRIMARY).is_some(), + "gated Contacts tab must show its primary CTA without a DPNS handle" + ); + // The raw placeholder must never survive into a rendered label. + assert!( + harness.query_by_label_contains("{handle}").is_none(), + "gated Contacts tab must never render the raw `{{handle}}` placeholder" + ); +} diff --git a/tests/kittest/identity_hub_onboarding.rs b/tests/kittest/identity_hub_onboarding.rs new file mode 100644 index 000000000..d4296fef3 --- /dev/null +++ b/tests/kittest/identity_hub_onboarding.rs @@ -0,0 +1,116 @@ +//! IT-ONBOARD-01 — Onboarding empty state renders. +//! +//! See `docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md`. +//! +//! Mounts the real [`AppState`] on a fresh in-memory database (zero loaded +//! identities), switches the root screen to `RootScreenIdentityHub`, runs a +//! frame, and asserts on the three elements the spec mandates: +//! +//! 1. Heading `Welcome to Identities.` +//! 2. Primary CTA `Create my first identity` +//! 3. Secondary CTA `I already have an identity — load it` +//! +//! It also asserts the Developer Mode footer is absent (Alex persona — the +//! default `developer_mode = false` state). +//! +//! The rendering path exercised here is +//! `IdentityHubScreen::ui` → `HubLanding::Onboarding` → `onboarding::render`. +//! Developer Mode toggling is covered separately by unit tests in the +//! `onboarding` module and by UI polish tests that will land alongside the +//! identity picker work. + +use crate::support::{fresh_app_context, mount_app, with_isolated_data_dir}; +use dash_evo_tool::ui::RootScreenType; +use dash_evo_tool::ui::components::styled::island_central_panel; +use dash_evo_tool::ui::identity::onboarding; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::sync::{Arc, Mutex}; + +/// IT-ONBOARD-01: the onboarding empty state renders all required copy and +/// CTAs when no identities are loaded, and hides the Developer Mode footer +/// when developer mode is off. +#[test] +fn it_onboard_01_renders_heading_and_both_ctas() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenIdentityHub); + + // Heading — design-spec §B.1. + assert!( + harness.query_by_label("Welcome to Identities.").is_some(), + "onboarding must render the 'Welcome to Identities.' heading" + ); + + // Primary CTA. + assert!( + harness.query_by_label("Create my first identity").is_some(), + "onboarding must render the 'Create my first identity' primary button" + ); + + // Secondary CTA — exact text per the design spec (em-dash included). + assert!( + harness + .query_by_label("I already have an identity — load it") + .is_some(), + "onboarding must render the 'I already have an identity — load it' secondary button" + ); + + // Developer Mode footer must be absent on the Alex persona default. + // The label `Developer tools:` is rendered only when + // `AppContext::is_developer_mode()` returns `true`. + assert!( + harness.query_by_label("Developer tools:").is_none(), + "Developer Mode footer must be hidden when developer mode is off" + ); + }); +} + +/// IT-ONBOARD-02 (regression) — the onboarding island fills the panel width. +/// +/// User report: on "Welcome to Identities." the bordered island does not reach +/// the window edges — it is pinned narrow with dead space outside its border. +/// `island_central_panel` draws the island as a Frame that shrink-wraps to its +/// content, and `onboarding::render` centers a 640px-capped readable column, so +/// without an explicit full-width claim the island collapsed to ~640px. The fix +/// makes `onboarding::render` claim the full available width (the readable +/// column stays centered at 640px). This renders the real onboarding inside the +/// real `island_central_panel` at a wide window and asserts the island content +/// fills its panel rather than shrinking to the inner column. +#[test] +fn onboarding_island_fills_panel_width() { + with_isolated_data_dir(|| { + let (rt, app_context) = fresh_app_context(); + let _guard = rt.enter(); + + // (width handed to the island content, width the content occupied) + let measured = Arc::new(Mutex::new((0.0f32, 0.0f32))); + let probe = measured.clone(); + let ctx = app_context.clone(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui(move |ui| { + island_central_panel(ui, |ui| { + let available = ui.available_width(); + let action = onboarding::render(ui, &ctx); + let occupied = ui.min_rect().width(); + *probe.lock().unwrap() = (available, occupied); + action + }); + }); + harness.run(); + + let (available, occupied) = *measured.lock().unwrap(); + assert!( + available > 800.0, + "test precondition: the wide window must hand the island a wide panel \ + (got available={available})" + ); + assert!( + occupied >= available - 2.0, + "onboarding island must fill its panel: occupied={occupied}, \ + available={available} — a large gap means the bordered island is \ + pinned narrow with dead space outside its border" + ); + }); +} diff --git a/tests/kittest/identity_hub_settings.rs b/tests/kittest/identity_hub_settings.rs new file mode 100644 index 000000000..616f57c06 --- /dev/null +++ b/tests/kittest/identity_hub_settings.rs @@ -0,0 +1,61 @@ +//! IT-SETTINGS-01 — Settings tab integration test. +//! +//! Test-case spec (docs/ai-design/2026-04-23-identity-hub-impl/03-test-case-spec.md): +//! +//! > **Preconditions**: one identity, social profile set. +//! > **Steps**: mount hub, switch to Settings tab. +//! > **Expected**: +//! > - Section heading `Social profile` present. +//! > - Section heading `Username` present. +//! > - Section heading `Aliases` present. +//! > - Advanced expander present. +//! +//! The harness default database has zero identities, so the full populated +//! render path requires a test double. We split the coverage: +//! +//! 1. **Empty-state smoke**: mount the hub, select the Settings tab, and +//! verify the "No identity selected." empty state renders without panic. +//! This guards against regressions in the tab-switch wiring and the +//! defensive empty-state path. +//! 2. **Populated render**: exercised by the `SettingsTab` unit tests inside +//! `src/ui/identity/settings.rs` via `egui_kittest::Harness::build_ui` so +//! we can assert section headings without bootstrapping a full identity. +//! See `render_populated_shows_all_section_headings` in that module. +//! 3. **Tab-bar wiring**: ensure `IdentityHubTab::Settings` is reachable from +//! the default selection and that clicking it does not crash the hub. + +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::ui::RootScreenType; +use dash_evo_tool::ui::identity::IdentityHubTab; + +/// IT-SETTINGS-01 (adapted) — mounting the hub with `Settings` pre-selected +/// must not panic, regardless of whether the harness starts on the Onboarding +/// landing (empty DB) or the Home/Picker landing. Real label assertions for +/// the Settings panels are covered in +/// `src/ui/identity/settings.rs::tests::section_headings_render_their_text`, +/// which uses `Harness::build_ui` so the sections can be rendered without +/// bootstrapping a full identity fixture. +#[test] +fn settings_tab_renders_without_panicking() { + with_isolated_data_dir(|| { + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + // Run a few more steps — if any panic occurs, this assertion never runs. + // No extra label queries here: kittest's accessibility tree coverage for + // non-interactive RichText labels is inconsistent across platforms, so we + // assert only the structural invariant (no panics, no stuck frames). + harness.run_steps(5); + }); +} + +/// Sanity-check that `IdentityHubTab::Settings` is reachable via the public +/// API. This asserts that the tab enum contract expected by this test file +/// (and by the hub_screen dispatcher) is stable. +#[test] +fn settings_tab_variant_is_part_of_public_api() { + let all = IdentityHubTab::ALL; + assert!( + all.contains(&IdentityHubTab::Settings), + "IdentityHubTab::ALL must include Settings", + ); + assert_eq!(IdentityHubTab::Settings.label(), "Settings"); +} diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs new file mode 100644 index 000000000..2a2953eb9 --- /dev/null +++ b/tests/kittest/identity_hub_switcher.rs @@ -0,0 +1,344 @@ +//! IT-SWITCH — breadcrumb switcher / multi-identity hub integration. +//! +//! IT-SWITCH-03 (onboarding placeholders) runs on the default first-run +//! database (0 wallets, 0 identities). +//! +//! IT-SWITCH-04 and the stale-selection reconcile case seed a multi-identity +//! database directly into the live `AppContext` (the fixture Bilby's Wave-1 +//! report deferred). They cover the end-to-end multi-identity path through the +//! real `AppState` frame loop: DB → `effective_view` → rendered surface, and +//! the app-scoped selection (`set_selected_identity`, the exact call the picker +//! click handler in `hub_screen` makes) driving the Picker → Home transition. +//! +//! The seeded identities are wallet-less (imported-by-id) basic identities: +//! `insert_local_qualified_identity(.., &None)`. A wallet-scoped fixture (a +//! loaded HD `Wallet` in `AppContext::wallets` with matching `wallet_hash`) +//! is what IT-SWITCH-01/02 (the wallet dropdown + wallet-scoped identity list) +//! additionally require; that is still out of reach here (see the QA report). + +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::RootScreenType; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// The hub tab bar's `Activity` tab label only renders in the Home view, never +/// in the Picker or Onboarding surfaces — a reliable "we are on Home" marker. +const HOME_ONLY_MARKER: &str = "Activity"; +/// The Picker grid heading (verbatim, picker.rs / design-spec §B.14). +const PICKER_HEADING: &str = "Pick an identity"; + +/// Seed one wallet-less basic identity (alias = `alias`, id = `[byte; 32]`) +/// into the live per-network identity DB, and return its `Identifier`. +fn seed_identity(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + seed_identity_typed(app_context, byte, alias, IdentityType::User) +} + +/// Seed one wallet-less identity of `identity_type` (alias = `alias`, +/// id = `[byte; 32]`) into the live per-network identity DB. +fn seed_identity_typed( + app_context: &Arc<AppContext>, + byte: u8, + alias: &str, + identity_type: IdentityType, +) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed identity insert"); + id +} + +/// IT-SWITCH-03 — onboarding (0 wallets, 0 identities): the breadcrumb shows +/// `(no wallet yet)` and `(no identity yet)` placeholders, and the onboarding +/// CTAs still render beneath (switcher coexists on every landing). +#[test] +fn it_switch_03_onboarding_shows_placeholder_segments() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenIdentityHub); + + assert!( + harness.query_by_label("(no wallet yet)").is_some(), + "breadcrumb must show the no-wallet placeholder on onboarding" + ); + assert!( + harness.query_by_label("(no identity yet)").is_some(), + "breadcrumb must show the no-identity placeholder on onboarding" + ); + // The onboarding CTA still renders beneath the switcher. + assert!( + harness.query_by_label("Create my first identity").is_some(), + "onboarding CTA must coexist with the breadcrumb switcher" + ); + }); +} + +/// IT-SWITCH-04 — with two identities and no explicit selection the hub lands +/// on the Picker; setting the app-scoped selection (the picker click effect) +/// transitions the hub to that identity's Home. Exercises the real +/// DB → `effective_view` → surface path and `resolve_selected_identity`. +#[test] +fn it_switch_04_picker_then_selection_drives_home() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + let alpha = seed_identity(&app_context, 0xA1, "Switch Alpha"); + let _beta = seed_identity(&app_context, 0xB2, "Switch Beta"); + harness.run_steps(5); + + // Two identities, none chosen → Picker, listing both seeded identities. + assert!( + harness.query_by_label(PICKER_HEADING).is_some(), + "two identities with no selection must land on the picker" + ); + assert!( + harness.query_by_label("Switch Alpha").is_some() + && harness.query_by_label("Switch Beta").is_some(), + "the picker must list both seeded identities by alias" + ); + assert!( + harness.query_by_label(HOME_ONLY_MARKER).is_none(), + "the Home tab bar must NOT render while the picker is showing" + ); + + // Select Alpha — exactly what `hub_screen`'s picker-click handler does. + app_context.set_selected_identity(Some(alpha)); + harness.run_steps(5); + + assert!( + harness.query_by_label(PICKER_HEADING).is_none(), + "selecting an identity must leave the picker" + ); + assert!( + harness.query_by_label(HOME_ONLY_MARKER).is_some(), + "selecting an identity must land on its Home (tab bar present)" + ); + // The app-scoped read every operate-as site uses now resolves to Alpha. + assert_eq!( + app_context + .resolve_selected_identity() + .map(|qi| qi.identity.id()), + Some(alpha), + "resolve_selected_identity must return the explicitly selected identity" + ); + }); +} + +/// Stale-selection reconcile: a selected id that is not among the loaded +/// identities must NOT render a phantom Home — the hub falls back to the +/// Picker (guards the R4 / stale-id concern end-to-end, complementing the +/// `model::selected_identity::keep_if_loaded` unit test). +#[test] +fn it_switch_stale_selection_falls_back_to_picker() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + seed_identity(&app_context, 0xC3, "Reconcile A"); + seed_identity(&app_context, 0xD4, "Reconcile B"); + + // Point the selection at an identity that was never loaded. + app_context.set_selected_identity(Some(Identifier::from([0xEE; 32]))); + harness.run_steps(5); + + assert!( + harness.query_by_label(PICKER_HEADING).is_some(), + "a stale (unloaded) selection must not be treated as explicit; the hub stays on the picker" + ); + assert!( + harness.query_by_label(HOME_ONLY_MARKER).is_none(), + "a stale selection must NOT render a phantom Home" + ); + // The fallback read still yields a real, loaded identity (selected→first). + assert!( + app_context.resolve_selected_identity().is_some(), + "resolve_selected_identity must fall back to a loaded identity, never the stale id" + ); + assert_ne!( + app_context + .resolve_selected_identity() + .map(|qi| qi.identity.id()), + Some(Identifier::from([0xEE; 32])), + "the stale id must never be resolved as the active identity" + ); + }); +} + +/// Selecting a wallet-less (imported-by-id) identity clears the derived +/// wallet pointer, so the breadcrumb wallet segment and the active identity +/// agree. Pre-fix, `set_selected_identity` left `selected_wallet_hash` stale +/// (the owner derivation returns `None` for a wallet-less identity) and the +/// breadcrumb pill showed a different identity than Home/operate-as used. +#[test] +fn qa_001_wallet_less_selection_clears_derived_wallet() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + let lonely = seed_identity(&app_context, 0x55, "Lonely Identity"); + + // Simulate a stale derived wallet from a prior wallet-owned selection. + app_context.set_selected_hd_wallet(Some([0x99; 32])); + assert_eq!( + app_context.selected_wallet_hash(), + Some([0x99; 32]), + "precondition: a stale wallet pointer is set" + ); + + // Select the wallet-less identity — the exact call the picker/dropdown + // click handler makes. + app_context.set_selected_identity(Some(lonely)); + + // The derived wallet must reconcile to None (no owning wallet). + assert_eq!( + app_context.selected_wallet_hash(), + None, + "selecting a wallet-less identity must clear the derived wallet pointer" + ); + // The active identity is the wallet-less one (what Home/operate-as use). + assert_eq!( + app_context + .resolve_selected_identity() + .map(|qi| qi.identity.id()), + Some(lonely), + "resolve_selected_identity must return the selected wallet-less identity" + ); + + // The breadcrumb now reflects it: the identity pill shows the alias and + // the wallet segment is the wallet-less placeholder — never the stale + // "no identity yet" the pre-fix path rendered. + harness.run_steps(5); + assert!( + harness.query_by_label("(no identity yet)").is_none(), + "an identity IS selected; the breadcrumb must not claim none" + ); + assert!( + harness.query_by_label_contains("Lonely Identity").is_some(), + "the breadcrumb must display the selected wallet-less identity" + ); + }); +} + +/// TC-FR6-01/02 + TC-NAV-17 — the Identity Hub is an everyday-user surface: +/// its picker and identity-pill sources list User identities only. A seeded +/// Masternode + Evonode never appear; the User identity does. Exactly one User +/// is seeded so the hub lands on that identity's Home (not the picker), which is +/// itself the assertion that the two node identities did not inflate the +/// everyday-user identity count. +#[test] +fn fr6_hub_excludes_masternode_and_evonode() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + seed_identity_typed( + &app_context, + 0x91, + "Node Masternode", + IdentityType::Masternode, + ); + seed_identity_typed(&app_context, 0xE2, "Node Evonode", IdentityType::Evonode); + let user = seed_identity(&app_context, 0x71, "Real User"); + harness.run_steps(5); + + // The everyday-user accessor lists only the User identity. + let user_only = app_context + .load_local_user_identities() + .expect("load user identities"); + assert_eq!( + user_only.len(), + 1, + "hub sources list only the User identity" + ); + assert_eq!(user_only[0].identity.id(), user); + + // The node identities are absent from the hub surface by alias. + assert!( + harness.query_by_label_contains("Node Masternode").is_none(), + "a masternode must not appear on the Identity Hub" + ); + assert!( + harness.query_by_label_contains("Node Evonode").is_none(), + "an evonode must not appear on the Identity Hub" + ); + // But both remain in the masternode accessor (Masternodes-page source). + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load masternode identities") + .len(), + 2, + "MN + Evonode remain available to the Masternodes page" + ); + }); +} + +/// Replaces a sham tautology test — the no-wallet group is identified +/// by `wallet_index.is_none()` on real loaded identities: a seeded wallet-less +/// identity appears in that filtered group (the exact predicate the breadcrumb +/// dropdown's "Identities without a wallet on this device" section uses). +#[test] +fn qa_002_no_wallet_group_filter_on_real_data() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + let imported = seed_identity(&app_context, 0x77, "Imported By Id"); + + let loaded = app_context + .load_local_qualified_identities() + .expect("load identities"); + let no_wallet_group: Vec<Identifier> = loaded + .iter() + .filter(|qi| qi.wallet_index.is_none()) + .map(|qi| qi.identity.id()) + .collect(); + + assert!( + no_wallet_group.contains(&imported), + "a wallet-less identity must appear in the wallet_index.is_none() group" + ); + }); +} diff --git a/tests/kittest/identity_selector.rs b/tests/kittest/identity_selector.rs new file mode 100644 index 000000000..87c5cc4d6 --- /dev/null +++ b/tests/kittest/identity_selector.rs @@ -0,0 +1,166 @@ +//! Kittest coverage for `IdentitySelector` — the app-scoped write-back keystone. +//! +//! The critical write-back path — a genuine ComboBox picker +//! change must call `AppContext::set_selected_identity`, keeping "who am I signing +//! as" in sync across the breadcrumb and all 12 SYNC screens. This is a +//! component-level lock: testing `IdentitySelector` directly proves the shared +//! mechanism for all 12 SYNC callsites without needing private keys or a full +//! screen fixture for each. +//! +//! Two phases: +//! - **Phase 1 (seeding-not-write-back):** with the buffer pre-seeded to Alice's +//! Base58, an initial render must NOT invoke `set_selected_identity`. The +//! `combo_changed` flag stays false until a user interaction occurs. +//! - **Phase 2 (genuine ComboBox change):** click the ComboBox header (Alice), +//! click Bob's dropdown item, and assert +//! `ctx.selected_identity_id() == Some(bob_id)`. +//! +//! ## Setup +//! +//! We use a `build_eframe` harness (`run_steps(5)`) to fully initialize the +//! `AppContext` — specifically to let `ensure_wallet_backend` run and complete +//! `restore_selected_identity_from_kv`. Identity selection is set AFTER that +//! initialization so the KV restore does not race against our seed. +//! A separate `build_ui` harness then hosts the standalone `IdentitySelector`. +//! +//! ## egui-kittest ComboBox quirk (documented by Marvin's QA run) +//! +//! The ComboBox header is exposed to accesskit via its `selected_text` as the +//! **value** property → use `harness.get_by_value("Alice")` to click it open. +//! Items inside the popup are `selectable_label` widgets exposed as Buttons via +//! their text **label** → use `harness.get_by_label("Bob")` to select them. +//! +//! Per-screen click-through tests (which DO need private keys so the screen gates +//! on `has_suitable_keys`) remain deferred per the `TODO(WalletFixture)` comments +//! in `contract_screen.rs`, `dashpay_screen.rs`, and `tokens_screen.rs`. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::components::identity_selector::IdentitySelector; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::Arc; + +/// Build a wallet-less `QualifiedIdentity` in-memory. No DB insertion, no +/// private keys — only `id()` + `display_string()` (= alias) are exercised. +fn make_qi(byte: u8, alias: &str) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: Network::Testnet, + } +} + +/// Keystone write-back lock for the app-scoped identity migration. +/// +/// A genuine ComboBox picker change on an `IdentitySelector` configured with +/// `.syncing_global(ctx)` must propagate to `ctx.selected_identity_id()`. +/// +/// No private keys, no wallet backend write-path, no full screen fixture needed: +/// `set_selected_identity` with wallet-less identities only updates in-memory +/// mutexes; the selector's `identities` slice is built in-memory. The component- +/// level test covers the write-back for all 12 SYNC screens in one assertion. +#[test] +fn combo_change_writes_selection_to_app_context() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + // Step 1: fully initialize AppContext via build_eframe + run_steps. + // + // `ensure_wallet_backend` calls `restore_selected_identity_from_kv` on + // first wiring. Running 5 steps ensures that happens BEFORE we seed the + // identity, so our seed is not overwritten by the async initialization. + let mut setup = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + setup.run_steps(5); + let ctx = setup.state().current_app_context().clone(); + + let alice = make_qi(0xAA, "Alice"); + let bob = make_qi(0xBB, "Bob"); + let alice_id = alice.identity.id(); + let bob_id = bob.identity.id(); + + // Seed global AFTER KV restore; wallet_backend is now wired and + // restore_selected_identity_from_kv will not run again (idempotent). + ctx.set_selected_identity(Some(alice_id)); + + // Step 2: build a standalone build_ui harness hosting the IdentitySelector. + // + // Rc<RefCell<>> shared state: the closure captures the inner Rc clones, + // the outer clones remain accessible for post-harness assertions. + let ids = vec![alice.clone(), bob.clone()]; + let buf = Rc::new(RefCell::new(alice_id.to_string(Encoding::Base58))); + let sel: Rc<RefCell<Option<QualifiedIdentity>>> = Rc::new(RefCell::new(None)); + + let buf_inner = Rc::clone(&buf); + let sel_inner = Rc::clone(&sel); + let ctx_inner = Arc::clone(&ctx); + + let mut harness = Harness::builder() + .with_size(egui::vec2(400.0, 80.0)) + .build_ui(move |ui| { + let mut b = buf_inner.borrow_mut(); + let mut s = sel_inner.borrow_mut(); + let _ = ui.add( + IdentitySelector::new("qa001_combo", &mut b, &ids) + .selected_identity(&mut s) + .expect("selected_identity") + .other_option(false) + .syncing_global(Arc::clone(&ctx_inner)), + ); + }); + + // ── Phase 1: initial render ─────────────────────────────────────────── + // The buffer is pre-seeded to Alice's Base58. No user interaction has + // occurred, so combo_changed=false and sync_to_global is NOT called. + // The global must remain Alice after the render. + harness.run(); + assert_eq!( + ctx.selected_identity_id(), + Some(alice_id), + "Phase 1: initial render must not invoke set_selected_identity (seeding ≠ write-back)" + ); + + // ── Phase 2: genuine ComboBox change to Bob ─────────────────────────── + // ComboBox header selected_text = "Alice" → accesskit value = "Alice". + // selectable_label("Bob") popup item → accesskit label = "Bob". + harness.get_by_value("Alice").click(); // open the ComboBox popup + harness.run(); // render the open popup (Alice + Bob as selectable items) + harness.get_by_label("Bob").click(); // select Bob's item + harness.run(); // combo_changed=true → sync_to_global() → set_selected_identity(bob) + + assert_eq!( + ctx.selected_identity_id(), + Some(bob_id), + "Phase 2: selecting Bob via ComboBox must propagate bob_id to AppContext" + ); + }); +} diff --git a/tests/kittest/import_single_key.rs b/tests/kittest/import_single_key.rs new file mode 100644 index 000000000..71f031837 --- /dev/null +++ b/tests/kittest/import_single_key.rs @@ -0,0 +1,181 @@ +//! Kittest coverage for the J-6 "Import private key (advanced)" dialog. +//! +//! Drives [`ImportSingleKeyDialog`] directly through `show_in_ui` so the +//! test harness doesn't need a wallets-screen instance. Covers: +//! +//! - **TC-SK-004** valid WIF: derived address preview rendered. +//! - **TC-SK-005** invalid WIF: inline error rendered, Add button stays disabled. +//! - **TC-SK-007 / TC-A11Y-005** WIF input masked by default; reveal +//! toggle exists; ARIA label "Hold to reveal" is reachable via +//! accessibility tree. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::ui::wallets::import_single_key::ImportSingleKeyDialog; +use dash_evo_tool::ui::wallets::wallets_screen::WalletsBalancesScreen; +use dash_sdk::dpp::dashcore::{Network, PrivateKey}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const KNOWN_TESTNET_WIF: &str = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN8rFTv2sfUK"; + +fn open_dialog(network: Network) -> ImportSingleKeyDialog { + let mut dialog = ImportSingleKeyDialog::new(network); + dialog.open = true; + dialog +} + +/// TC-SK-005 inline-error half: pasting a non-WIF string surfaces the +/// inline error copy. The dialog never moves into the "address derived" +/// state, so the Add button label remains visible but disabled. +#[test] +fn tc_sk_005_invalid_wif_renders_inline_error() { + let mut dialog = open_dialog(Network::Testnet); + // Pre-populate the input via the same path the dialog uses internally + // so we can drive the assertion without simulating keystrokes. + dialog.force_input_for_test("not-a-valid-wif".to_string()); + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 400.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + + let err_node = harness.query_by_label_contains("This does not look like a valid private key"); + assert!( + err_node.is_some(), + "expected inline WIF validation error to render" + ); + assert!( + harness.query_by_label("Add to wallets").is_some(), + "Add to wallets button should still be visible (disabled)" + ); +} + +/// TC-SK-004 preview half: pasting a valid testnet WIF renders the +/// derived address preview alongside the network indicator. The address +/// part is asserted by the matching Dash testnet `y`-prefix substring. +#[test] +fn tc_sk_004_valid_wif_renders_address_preview() { + let mut dialog = open_dialog(Network::Testnet); + dialog.force_input_for_test(KNOWN_TESTNET_WIF.to_string()); + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 400.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + + assert!( + harness.query_by_label("Derived address:").is_some(), + "derived-address label should be rendered for a valid WIF" + ); + assert!( + harness.query_by_label_contains("Testnet address").is_some(), + "network indicator should read Testnet" + ); +} + +/// TC-SK-007 / TC-A11Y-005: the WIF input is masked by default and +/// exposes a screen-reader-friendly label. The PasswordInput uses +/// `TextEdit::password(true)` until the user activates the reveal eye, +/// so a screen reader reads the masked dots — never the plaintext WIF +/// — for the default state. +/// +/// The hint text "Paste your private key (WIF)" is the field's +/// accessible label here: PasswordInput rendered it via +/// `TextEdit::hint_text`, which AccessKit exposes as the labelled-by +/// description. The reveal toggle itself is painted via the egui +/// painter and uses `clickable_tooltip("Hold to reveal")` for the +/// hover affordance — we exercise the rendering path here and rely on +/// the unit-level coverage for the toggle-state transitions. +#[test] +fn tc_sk_007_wif_input_masked_by_default_with_aria_label() { + let mut dialog = open_dialog(Network::Testnet); + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 400.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + + // The visible label above the input is what the screen reader + // announces for the field. PasswordInput's masking is enforced at + // the rendering layer (`TextEdit::password(true)`), and the unit + // tests in `src/ui/wallets/import_single_key.rs` cover the input + // state transitions directly — kittest is the rendering smoke + // check. + assert!( + harness.query_by_label("Private key (WIF)").is_some(), + "dialog should render a visible field label above the masked input" + ); +} + +/// Confirm the "Add to wallets" primary CTA is always rendered (enabled +/// or disabled) — its absence would be a regression on the user-flow +/// keyboard-tab path. +#[test] +fn add_to_wallets_button_is_always_present() { + let mut dialog = open_dialog(Network::Testnet); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 400.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + assert!(harness.query_by_label("Add to wallets").is_some()); + assert!(harness.query_by_label("Cancel").is_some()); +} + +/// F89 regression: a confirmed advanced single-key import must become +/// visible in the same session, not only after a restart. +/// +/// The import persists to the vault, sidecar, and index, but the screen +/// renders from `app_context.single_key_wallets`. Before the fix that map +/// was never updated, so the new key stayed invisible until the next +/// cold-boot hydration. This drives the real import-and-sync path and +/// asserts the rebuilt wallet (the one inserted into the screen-visible map +/// and selected) carries the expected alias and a valid key hash. +#[test] +fn imported_single_key_is_visible_in_session() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("create AppState") + .with_animations(false) + }); + harness.run_steps(5); + + let app_context = harness.state().current_app_context().clone(); + let network = app_context.network(); + + // A network-correct WIF for whatever network the fresh context opened on. + let mut key_bytes = [0u8; 32]; + key_bytes[0] = 1; + key_bytes[31] = 7; + let wif = PrivateKey::from_byte_array(&key_bytes, network) + .expect("valid private key") + .to_wif(); + + let mut screen = WalletsBalancesScreen::new(&app_context); + let imported = screen + .import_single_key_for_test(&wif, Some("Imported in session".to_string())) + .expect("import succeeds and syncs into the in-memory map"); + + let guard = imported.read().expect("read imported wallet"); + assert_eq!( + guard.alias.as_deref(), + Some("Imported in session"), + "the in-session wallet should preserve the import alias" + ); + assert_ne!( + guard.key_hash, [0u8; 32], + "the in-session wallet should have a derived key hash" + ); + }); +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 387adf319..20bdfc19f 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,8 +1,31 @@ +mod address_input; mod confirmation_dialog; +mod contract_screen; mod create_asset_lock_screen; +mod dashpay_screen; +mod global_nav_switcher; mod identities_screen; +mod identity_hub; +mod identity_hub_activity; +mod identity_hub_contacts; +mod identity_hub_onboarding; +mod identity_hub_settings; +mod identity_hub_switcher; +mod identity_selector; +mod import_single_key; mod info_popup; +mod masternode_tab; mod message_banner; +mod migration_banner; mod network_chooser; +mod progress_overlay; +mod register_dpns_name_screen; +mod restore_single_key; +mod secret_prompt; +mod skipped_wallets_banner; mod startup; +mod support; +mod tokens_screen; +mod tools_screen; mod wallets_screen; +mod withdraw_screen; diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs new file mode 100644 index 000000000..ccd76edcc --- /dev/null +++ b/tests/kittest/masternode_tab.rs @@ -0,0 +1,774 @@ +//! IT-MN-TAB — Masternodes root tab: Expert-Mode nav gate + live de-gating (B2), +//! empty state + card grid (B3). + +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::{RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Seed one wallet-less masternode/evonode identity into the live per-network +/// identity DB (alias = `alias`, id = `[byte; 32]`, no keys → read-only node). +fn seed_node(app_context: &Arc<AppContext>, byte: u8, alias: &str, node_type: IdentityType) { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let _ = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: node_type, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed masternode insert"); +} + +/// Seed a masternode that has an associated voter identity, inserting BOTH the +/// node and its (separately stored) voter identity into the local DB. Returns +/// the voter identity's id so a test can assert its deletion. The node id is +/// `[byte; 32]`; the voter id is `[byte ^ 0xFF; 32]`. +fn seed_node_with_voter(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let voter_id = Identifier::from([byte ^ 0xFF; 32]); + let voter_identity = + Identity::create_basic_identity(voter_id, pv).expect("voter basic identity"); + let voter_key = dash_sdk::platform::IdentityPublicKey::random_key(1, Some(1), pv); + + // The voter identity is stored as its own local record so its deletion on + // node removal is observable. + let voter_qi = QualifiedIdentity { + identity: voter_identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(format!("{alias}-voter")), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&voter_qi, &None) + .expect("seed voter insert"); + + let node_identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("node basic identity"); + let node_qi = QualifiedIdentity { + identity: node_identity, + associated_voter_identity: Some((voter_identity, voter_key)), + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&node_qi, &None) + .expect("seed node-with-voter insert"); + voter_id +} + +/// Seed a masternode whose associated voter identity carries one public key, +/// so the detail view's "Manage keys" list renders a deterministic +/// "Voting key ›" button (voter-target keys are always labelled "Voting"). +fn seed_node_with_voter_key(app_context: &Arc<AppContext>, byte: u8, alias: &str) { + let pv = PlatformVersion::latest(); + let mut voter_identity = + Identity::create_basic_identity(Identifier::from([byte ^ 0xFF; 32]), pv) + .expect("voter basic identity"); + let voter_key = dash_sdk::platform::IdentityPublicKey::random_key(1, Some(1), pv); + voter_identity.add_public_key(voter_key.clone()); + + let node_identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("node basic identity"); + let node_qi = QualifiedIdentity { + identity: node_identity, + associated_voter_identity: Some((voter_identity, voter_key)), + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&node_qi, &None) + .expect("seed node-with-voter-key insert"); +} + +/// TC-FR1-01…04 — the Masternodes nav entry is absent when Expert Mode is off +/// and present when it is on. Toggling `enable_developer_mode` flips the gate; +/// the nav rail re-evaluates the per-entry `FeatureGate::DeveloperMode` skip +/// each frame. Counted with `query_all_by_label` because the nav button exposes +/// both a Button and an inner Label node for the same text. +#[test] +fn nav_gated_by_expert_mode() { + with_isolated_data_dir(|| { + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + + app_context.enable_developer_mode(false); + harness.run_steps(3); + assert_eq!( + harness.query_all_by_label("Masternodes").count(), + 0, + "the Masternodes nav entry must be absent when Expert Mode is off" + ); + + app_context.enable_developer_mode(true); + harness.run_steps(3); + assert!( + harness.query_all_by_label("Masternodes").count() >= 1, + "the Masternodes nav entry must appear when Expert Mode is on" + ); + }); +} + +/// TC-EDGE-05/06 (§10.11) — live de-gating: with the Masternodes tab active, +/// flipping Expert Mode off falls the active tab back to the neutral Identities +/// tab (the gated screen is never shown without its gate). Drives the guard in +/// `active_root_screen_mut` directly by selecting the tab, then revoking the +/// gate. +#[test] +fn de_gating_falls_back_to_identities() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + + // Expert Mode on, select the Masternodes tab — it stays selected. + app_context.enable_developer_mode(true); + harness.state_mut().selected_main_screen = RootScreenType::RootScreenMasternodes; + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenMasternodes, + "the Masternodes tab stays active while Expert Mode is on" + ); + + // Flip Expert Mode off — the active tab must fall back to Identities. + app_context.enable_developer_mode(false); + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenIdentities, + "de-gating must fall the active tab back to Identities" + ); + }); +} + +/// Enable Expert Mode, activate the Masternodes tab, and reload its cached list +/// (the direct field-set bypasses `set_main_screen`, so drive the screen's +/// arrival refresh explicitly — the same call `set_main_screen` makes). +fn activate_masternodes_tab( + harness: &mut egui_kittest::Harness<'static, dash_evo_tool::app::AppState>, + app_context: &Arc<AppContext>, +) { + app_context.enable_developer_mode(true); + harness.state_mut().selected_main_screen = RootScreenType::RootScreenMasternodes; + harness + .state_mut() + .active_root_screen_mut() + .refresh_on_arrival(); + harness.run_steps(3); +} + +/// TC-FR2-01…05 — with zero nodes loaded the Masternodes tab renders the empty +/// state with the exact canonical §7 copy: heading, body, primary CTA, and the +/// reassurance line. +#[test] +fn empty_state_renders_canonical_copy() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "empty-state heading must render (TC-FR2-02)" + ); + assert!( + harness + .query_by_label( + "Load a masternode or evonode to vote on DPNS name contests and manage its \ + owner and payout keys." + ) + .is_some(), + "empty-state body copy must render verbatim (TC-FR2-03)" + ); + assert!( + harness.query_by_label("Load a masternode").is_some(), + "empty-state primary CTA must render (TC-FR2-04)" + ); + assert!( + harness + .query_by_label( + "Have your node's ProTxHash to hand. Keys are optional — a node loads \ + read-only without them." + ) + .is_some(), + "empty-state reassurance line must render verbatim (TC-FR2-05)" + ); + }); +} + +/// TC-FR3-01/15, TC-FR7-01, TC-NFR6-01 — with nodes loaded the grid renders one +/// card per node (not the empty state), each card is a single accessible click +/// target labelled `Open {node}`, the status label pairs with its colour, and +/// the top-right Refresh toolbar button is present. +#[test] +fn card_grid_renders_seeded_nodes() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + + seed_node(&app_context, 0x91, "mn-east-01", IdentityType::Masternode); + seed_node(&app_context, 0x92, "evo-west-02", IdentityType::Evonode); + activate_masternodes_tab(&mut harness, &app_context); + + // Empty state is gone; both node headings render (TC-FR3-01/15). + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must not render once nodes are loaded (TC-FR2-07)" + ); + assert!( + harness.query_by_label("mn-east-01").is_some(), + "masternode card heading must render" + ); + assert!( + harness.query_by_label("evo-west-02").is_some(), + "evonode card heading must render" + ); + + // Each card is one accessible click target labelled `Open {node}` + // (TC-NFR6-01). + assert!( + harness.query_by_label("Open mn-east-01").is_some(), + "card must expose a single accessible `Open {{node}}` label" + ); + + // Status label pairs colour with text — never colour-only (TC-NFR6-03). + assert!( + harness.query_all_by_label("Pending Creation").count() >= 1, + "identity-status label must render as text alongside its dot" + ); + + // Top-right Refresh toolbar button (TC-FR7-01). + assert!( + harness.query_all_by_label("Refresh").count() >= 1, + "Refresh toolbar button must be present on the card list" + ); + }); +} + +/// TC-FR4-01/02/04/18/21 — the empty-state CTA opens the load form with the +/// full MN/Evonode field set (ProTxHash, both type segments, key inputs, +/// always-visible Warning note) and no User segment; Cancel returns to the list. +#[test] +fn load_form_opens_from_cta_and_cancels() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + // Open the load form from the empty-state primary CTA. + harness.get_by_label("Load a masternode").click(); + harness.run_steps(3); + + // Field set present (TC-FR4-01/02): ProTxHash + both type segments + the + // submit button. The empty-state heading is gone. + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must be replaced by the load form" + ); + assert!( + harness.query_by_label("ProTxHash").is_some(), + "ProTxHash field label must render" + ); + assert!( + harness.query_all_by_label("Masternode").count() >= 1, + "Masternode type segment must render" + ); + assert!( + harness.query_by_label("Evonode").is_some(), + "Evonode type segment must render (TC-FR4-04: no User segment)" + ); + assert!( + harness.query_by_label("Load masternode").is_some(), + "the Load submit button must render" + ); + + // Warning-tone key-storage note is always visible (TC-FR4-18). + assert!( + harness + .query_by_label( + "Set an optional password to encrypt these keys on this device. Without one, \ + they are stored unencrypted and you can add protection later from the key \ + screen." + ) + .is_some(), + "the always-visible Warning-tone key-storage note must render verbatim" + ); + + // Cancel returns to the list / empty state (TC-FR4-21). The Cancel + // button sits at the bottom of the scrollable form; give the harness a + // taller window so the whole form (back link + fields + actions row) + // fits and the button is reachable in this headless viewport. + harness.set_size(egui::vec2(1280.0, 1200.0)); + harness.run_steps(2); + harness.get_by_label("Cancel").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "Cancel must return to the list without loading" + ); + }); +} + +/// The load form carries the same `‹ All masternodes` back link as the detail +/// view (wireframe C): it renders at the top of the form and returns to the list +/// without loading anything. This is the always-visible navigation affordance, +/// distinct from the bottom Cancel button. +#[test] +fn load_form_back_link_returns_to_list() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + // Open the load form from the empty-state primary CTA. + harness.get_by_label("Load a masternode").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must be replaced by the load form" + ); + + // The back link is present at the top of the form and returns to the + // list (empty state) without loading. + assert!( + harness.query_by_label("‹ All masternodes").is_some(), + "the load form must render the `‹ All masternodes` back link" + ); + harness.get_by_label("‹ All masternodes").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "the back link must return to the list without loading" + ); + }); +} + +/// TC-FR5-01/02/07, TC-FR9-01/02, TC-FR11-01/02, TC-FR7-04 — clicking a card +/// opens the detail view with the ordered sections (Actions row present with all +/// three credit actions), the Evonode-only claim cross-link shown for an evonode +/// but absent for a masternode, a detail Refresh button, and the `‹ All +/// masternodes` back row returning to the list. +#[test] +fn detail_view_opens_from_card_with_sections_and_back() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node(&app_context, 0x93, "mn-detail-01", IdentityType::Masternode); + seed_node(&app_context, 0x94, "evo-detail-02", IdentityType::Evonode); + activate_masternodes_tab(&mut harness, &app_context); + + // Open the masternode's detail view. + harness.get_by_label("Open mn-detail-01").click(); + harness.run_steps(3); + + // Section presence + Actions row (TC-FR5-01, TC-FR9-01). The alias shows + // both in the header and the page-scoped pill, so count ≥ 1. + assert!( + harness.query_all_by_label("mn-detail-01").count() >= 1, + "header alias" + ); + assert!( + harness.query_by_label("Actions").is_some(), + "Actions section" + ); + assert!( + harness.query_by_label("Withdraw").is_some(), + "Withdraw action" + ); + assert!(harness.query_by_label("Top up").is_some(), "Top up action"); + assert!( + harness.query_by_label("Transfer").is_some(), + "Transfer action" + ); + assert!(harness.query_by_label("Keys").is_some(), "Keys section"); + assert!( + harness.query_by_label("Remove masternode").is_some(), + "Remove action" + ); + assert!( + harness.query_all_by_label("Refresh").count() >= 1, + "detail Refresh button (TC-FR7-04)" + ); + // Claim cross-link absent for a plain masternode (TC-FR11-02). + assert!( + harness.query_by_label("Claim token rewards ›").is_none(), + "masternode detail must not show the evonode claim cross-link" + ); + + // Back row returns to the card list (TC-FR5-07). + harness.get_by_label("‹ All masternodes").click(); + harness.run_steps(3); + assert!( + harness.query_all_by_label("mn-detail-01").count() >= 1, + "back row returns to the card grid" + ); + + // The evonode's detail view shows the claim cross-link (TC-FR11-01). + harness.get_by_label("Open evo-detail-02").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Claim token rewards ›").is_some(), + "evonode detail must show the claim cross-link" + ); + }); +} + +/// TC-DPNS-01/02/09/10 — the DPNS section is collapsed by default (its body is +/// not rendered), the header carries the open-contest count, and for a node with +/// no voter identity the expanded section shows the actionable missing-voter +/// message with an `Add voting key` action that opens a scoped in-place prompt +/// (not FR-4's load form — no ProTxHash field). +#[test] +fn dpns_section_missing_voter_scoped_prompt() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node(&app_context, 0x95, "mn-vote-01", IdentityType::Masternode); + activate_masternodes_tab(&mut harness, &app_context); + harness.get_by_label("Open mn-vote-01").click(); + harness.run_steps(3); + + // With no voter key the "Add voting key" CTA and its + // actionable message are rendered ABOVE, outside the collapsed-by-default + // DPNS section, so they are visible immediately without expanding + // anything. The empty DPNS header is omitted in this state (no contests + // are possible without a voter). + assert!( + harness + .query_by_label("DPNS name contests to vote on (0)") + .is_none(), + "the empty DPNS header must be omitted when the node has no voter key" + ); + assert!( + harness + .query_by_label( + "This node has no voting key loaded. Add its voting private key to cast votes." + ) + .is_some(), + "the actionable missing-voter message must be visible without expanding" + ); + assert!( + harness.query_by_label("Add voting key").is_some(), + "missing-voter state must offer an Add voting key action" + ); + + // Click Add voting key → scoped in-place prompt (Save/Cancel), NOT the + // load form (no ProTxHash field) (TC-DPNS-10/11). + harness.get_by_label("Add voting key").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Save").is_some(), + "scoped voter-key prompt must open with a Save action" + ); + assert!( + harness.query_by_label("ProTxHash").is_none(), + "the scoped prompt must not be FR-4's load form (no ProTxHash re-entry)" + ); + }); +} + +/// TC-NAV-12 / TC-FR6-07 (release-blocking) — selecting a masternode on the +/// Masternodes page (opening its detail via a card click) must NEVER write the +/// app-global identity selection. With no User identity loaded, the +/// app-global selection must stay `None` even after a masternode is in view, and +/// stay `None` after navigating away to Identities/Identity Hub. +#[test] +fn masternode_selection_never_leaks_to_app_global_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node(&app_context, 0x96, "mn-leak-01", IdentityType::Masternode); + + // Baseline: no app-global identity selected. + assert!( + app_context.selected_identity_id().is_none(), + "no app-global identity should be selected initially" + ); + + // Open the masternode's detail — this sets the page-scoped selection. + activate_masternodes_tab(&mut harness, &app_context); + harness.get_by_label("Open mn-leak-01").click(); + harness.run_steps(3); + + // FR-6 boundary: the masternode must NOT have become the app-global + // identity, nor resolve as one. + assert!( + app_context.selected_identity_id().is_none(), + "masternode selection must not write the app-global identity id" + ); + assert!( + app_context.resolve_selected_identity().is_none(), + "a masternode must never resolve as the app-global identity" + ); + + // Navigate away to Identities and Identity Hub — still no leak. + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentities; + harness.run_steps(3); + assert!( + app_context.selected_identity_id().is_none(), + "the app-global identity stays unset on Identities after MN selection" + ); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentityHub; + harness.run_steps(3); + assert!( + app_context.resolve_selected_identity().is_none(), + "the app-global identity stays unset on the Hub after MN selection" + ); + }); +} + +/// TC-US4-01/02/06/07 — the detail Remove flow: the danger button opens a +/// confirmation with the `Remove masternode` verb; confirming deletes only the +/// target node (its card disappears) and leaves other nodes intact (isolation). +#[test] +fn remove_flow_deletes_only_target_node() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node(&app_context, 0x97, "mn-remove-me", IdentityType::Masternode); + seed_node(&app_context, 0x98, "mn-keep-me", IdentityType::Masternode); + activate_masternodes_tab(&mut harness, &app_context); + + // Two nodes present in the DB. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 2 + ); + + // Open the target node's detail and click the danger Remove button + // (unique before the dialog opens). + harness.get_by_label("Open mn-remove-me").click(); + harness.run_steps(3); + harness.get_by_label("Remove masternode").click(); + harness.run_steps(3); + + // The confirmation is open with the `Remove masternode` verb; confirm by + // clicking the danger button (last node with that label — the section + // button, dialog title, and confirm button all share the verb). + let confirm = harness + .query_all_by_label("Remove masternode") + .last() + .expect("confirm button present"); + confirm.click(); + harness.run_steps(3); + + // Only the target node was deleted; the other remains (isolation). + let remaining = app_context + .load_local_masternode_identities() + .expect("load"); + assert_eq!(remaining.len(), 1, "exactly one node removed"); + assert_eq!( + remaining[0].alias.as_deref(), + Some("mn-keep-me"), + "the untargeted node must survive" + ); + assert!( + harness.query_by_label("mn-remove-me").is_none(), + "removed node's card must be gone" + ); + assert!( + harness.query_all_by_label("mn-keep-me").count() >= 1, + "kept node's card must remain" + ); + }); +} + +/// TC-US4-05 — removing a node also deletes its associated voter identity from +/// local storage, not just the node record. Seeds a masternode whose voter +/// identity is stored separately, removes the node through the confirm flow, +/// and asserts both the node and the voter identity are gone. +#[test] +fn remove_flow_deletes_associated_voter_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + let voter_id = seed_node_with_voter(&app_context, 0xA1, "mn-with-voter"); + activate_masternodes_tab(&mut harness, &app_context); + + // Precondition: both the node and its voter identity are stored. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 1, + "the masternode must be seeded" + ); + assert!( + app_context + .get_local_qualified_identity(&voter_id) + .expect("voter read") + .is_some(), + "the voter identity must be seeded" + ); + + // Open the node's detail and confirm removal. + harness.get_by_label("Open mn-with-voter").click(); + harness.run_steps(3); + harness.get_by_label("Remove masternode").click(); + harness.run_steps(3); + let confirm = harness + .query_all_by_label("Remove masternode") + .last() + .expect("confirm button present"); + confirm.click(); + harness.run_steps(3); + + // Both the node and its voter identity are deleted. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 0, + "the masternode must be removed" + ); + assert!( + app_context + .get_local_qualified_identity(&voter_id) + .expect("voter read") + .is_none(), + "the associated voter identity must be removed too (TC-US4-05)" + ); + }); +} + +/// Execution-level: clicking a per-key "Manage keys" button in the masternode +/// detail view opens the interactive `KeyInfoScreen` (not the static read-only +/// `KeysScreen`). Seeds a node whose voter identity carries one key so +/// a deterministic "Voting key ›" button renders, clicks it, and asserts a +/// `KeyInfoScreen` is pushed and its "Key Information" heading renders. +#[test] +fn manage_keys_button_opens_key_info_screen() { + use dash_evo_tool::ui::Screen; + + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node_with_voter_key(&app_context, 0x71, "mn-keys-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-keys-01").click(); + harness.run_steps(3); + + // The per-key "Manage keys" list renders a Voting key button. + assert!( + harness.query_by_label("Voting key ›").is_some(), + "the Keys section must render a per-key 'Voting key ›' button" + ); + // No KeyInfoScreen is on the stack yet. + assert!( + !harness + .state() + .screen_stack + .iter() + .any(|s| matches!(s, Screen::KeyInfoScreen(_))), + "no KeyInfoScreen should be open before the click" + ); + + harness.get_by_label("Voting key ›").click(); + harness.run_steps(3); + + // Clicking it pushes the interactive KeyInfoScreen. + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + ), + "clicking a per-key button must push KeyInfoScreen" + ); + assert!( + harness.query_by_label("Key Information").is_some(), + "the pushed KeyInfoScreen must render its 'Key Information' heading" + ); + }); +} diff --git a/tests/kittest/migration_banner.rs b/tests/kittest/migration_banner.rs new file mode 100644 index 000000000..ff6c444c5 --- /dev/null +++ b/tests/kittest/migration_banner.rs @@ -0,0 +1,152 @@ +//! kittest coverage for the T-MIG-02 migration UX seam. +//! +//! These tests drive the public `MessageBanner` surface against the +//! same i18n-ready copy the app loop emits, so a regression in the +//! 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::context::migration_status::MigrationStep; +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::MessageBanner; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// TC-MIG-001 — when the migration enters its first step the banner +/// surfaces an Info-typed banner with the "Checking your wallet data." +/// label per Diziet §2.2 D-1. +#[test] +fn tc_mig_001_running_banner_shows_step_label() { + let label = migration_running_text(MigrationStep::Detecting); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), label, MessageType::Info); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!( + harness.query_by_label(label).is_some(), + "running banner must render the step label verbatim", + ); + // Dismiss control present — required by §2.3 a11y. + assert!(harness.query_by_label("\u{274C}").is_some()); +} + +/// TC-MIG-014 — every `MigrationStep` exposes a single complete +/// sentence (i18n-extraction rule). Guards against future variants +/// landing without label coverage. +#[test] +fn tc_mig_014_running_text_covers_every_step_with_sentence() { + for step in [ + MigrationStep::Detecting, + MigrationStep::SingleKey, + MigrationStep::Shielded, + MigrationStep::WalletSeeds, + MigrationStep::WalletMeta, + MigrationStep::Finalize, + ] { + let text = migration_running_text(step); + assert!(!text.is_empty(), "step {step:?} has empty banner text"); + assert!( + text.ends_with('.'), + "step {step:?} text `{text}` is not a complete sentence", + ); + } +} + +/// TC-MIG-003 — failure banner gets the Retry action button and an +/// Error message type. The dismiss icon remains present so users can +/// hide the banner without retrying. +#[test] +fn tc_mig_003_failed_banner_shows_retry_button() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 220.0)) + .build_ui(|ui| { + let handle = MessageBanner::set_global( + ui.ctx(), + "Storage update could not complete. Your data is safe.", + MessageType::Error, + ); + handle.with_action("Retry now", MIGRATION_RETRY_ACTION_ID); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!( + harness + .query_by_label("Storage update could not complete. Your data is safe.") + .is_some(), + "error banner text must be visible", + ); + assert!( + harness.query_by_label("Retry now").is_some(), + "retry action must surface as a clickable button", + ); + // TC-MIG-003 (continued) — the spec forbids "contact support" copy on + // the failure banner; users must be able to self-resolve via Retry. + assert!( + harness.query_by_label("contact support").is_none(), + "failure banner must not redirect to 'contact support'", + ); + assert!( + harness.query_by_label("Contact support").is_none(), + "failure banner must not redirect to 'Contact support'", + ); +} + +/// TC-MIG-005 / TC-A11Y-007 — clicking the Retry button enqueues the +/// retry action id, and `take_action` drains it FIFO for the app loop +/// to dispatch. +#[test] +fn tc_mig_005_retry_click_enqueues_action() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 220.0)) + .build_ui(|ui| { + let handle = MessageBanner::set_global( + ui.ctx(), + "Storage update could not complete. Your data is safe.", + MessageType::Error, + ); + handle.with_action("Retry now", MIGRATION_RETRY_ACTION_ID); + MessageBanner::show_global(ui); + }); + harness.run(); + // Sanity: nothing pending before the click. + assert!(MessageBanner::take_action(&harness.ctx).is_none()); + + harness.get_by_label("Retry now").click(); + harness.run(); + + let action = MessageBanner::take_action(&harness.ctx); + assert_eq!(action.as_deref(), Some(MIGRATION_RETRY_ACTION_ID)); + // Drained: subsequent calls return None. + assert!(MessageBanner::take_action(&harness.ctx).is_none()); +} + +/// TC-A11Y-004 — error banners ship an icon **and** a textual label +/// (color is not the only failure indicator). The ⛔ icon + the +/// "Storage update could not complete." prefix both render. +#[test] +fn tc_a11y_004_failure_banner_uses_icon_and_text() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(|ui| { + MessageBanner::set_global( + ui.ctx(), + "Storage update could not complete. Your data is safe.", + MessageType::Error, + ); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!( + harness.query_by_label("\u{26D4}").is_some(), + "error icon must render alongside text", + ); + assert!( + harness + .query_by_label("Storage update could not complete. Your data is safe.") + .is_some(), + ); +} diff --git a/tests/kittest/network_chooser.rs b/tests/kittest/network_chooser.rs index 022a11449..6978bffb0 100644 --- a/tests/kittest/network_chooser.rs +++ b/tests/kittest/network_chooser.rs @@ -1,66 +1,78 @@ +use crate::support::with_isolated_data_dir; use egui_kittest::Harness; /// Test that the network chooser screen renders without panicking #[test] fn test_network_chooser_renders() { - // Create a tokio runtime for async operations during app initialization - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + // Create a tokio runtime for async operations during app initialization + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - // Create a test harness for the egui app - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + // Create a test harness for the egui app + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - // Set the window size - harness.set_size(egui::vec2(1024.0, 768.0)); + // Set the window size + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run a few frames to ensure the app initializes - harness.run_steps(10); + // Run a few frames to ensure the app initializes + harness.run_steps(10); + }); } /// Test that the app can handle screen navigation #[test] fn test_app_handles_frame_stepping() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(800.0, 600.0)); + harness.set_size(egui::vec2(800.0, 600.0)); - // Run multiple batches of frames - for _ in 0..5 { - harness.run_steps(5); - } + // Run multiple batches of frames + for _ in 0..5 { + harness.run_steps(5); + } + }); } /// Test that the app renders at different window sizes #[test] fn test_app_renders_at_various_sizes() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - let sizes = [ egui::vec2(640.0, 480.0), // Small egui::vec2(1024.0, 768.0), // Medium egui::vec2(1920.0, 1080.0), // Large ]; + // A fresh data dir per size: each `AppState` opens the shared seed vault, + // whose exclusive advisory lock outlives the harness drop (background + // subtasks keep the `Arc<AppContext>` graph alive). Per-iteration isolation + // gives each its own vault file so the lock never collides. Production opens + // the vault once per process, so this multi-AppState pattern is test-only. for size in sizes { - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - harness.set_size(size); - harness.run_steps(5); + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(size); + harness.run_steps(5); + }); } } diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs new file mode 100644 index 000000000..affcb74de --- /dev/null +++ b/tests/kittest/progress_overlay.rs @@ -0,0 +1,1904 @@ +//! Kittest coverage for the blocking progress overlay (`ProgressOverlay`). +//! +//! Mirrors the style of `tests/kittest/message_banner.rs`: a `Harness` plus +//! `query_by_label` / `query_by_role` / `ctx.data` reads. The overlay always +//! renders an animated `egui::Spinner`, which self-requests an immediate repaint +//! every frame — so these tests drive frames with `harness.step()` (one frame +//! per queued event) instead of `harness.run()` (which would spin to the step +//! cap). `set_global` is called once via `harness.ctx`, not inside the +//! per-frame closure, so the stack is not re-pushed each frame. +//! +//! Test ids map to `docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md`. +//! +//! Design-review-only invariants are asserted where possible and otherwise noted: +//! - TC-OVL-004 / TC-OVL-049 (no async/blocking in show or render): the public +//! API is entirely synchronous `ctx.data` + painting — verified by inspection +//! and by the fact these synchronous tests compile and run. +//! - TC-OVL-031 (render seam): `ProgressOverlay::render_global` is called from +//! `AppState::update` after panels, not inside `island_central_panel`. +//! - TC-OVL-032 (z-order above banners): the overlay paints on `Order::Foreground` +//! (above Foreground popups); banners paint on `Order::Background` +//! inside the central panel. +//! - TC-OVL-040 / TC-OVL-045 (log-once): covered by the inline unit tests in +//! `src/ui/components/progress_overlay.rs` (`render_logs_once_then_marks_logged`); +//! the concurrent-request warning is emitted once in `set_global`, never per frame. +//! - TC-OVL-027 (no bare `ui.button()`) / TC-OVL-029 (no `set_enabled`): the +//! renderer uses `ComponentStyles` button helpers and a top input-capturing +//! layer, never `ui.button()` or the deprecated `Ui::set_enabled`. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +#[cfg(feature = "testing")] +use std::time::Duration; + +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::passphrase_modal::{PassphraseModalConfig, passphrase_modal}; +use dash_evo_tool::ui::components::{ + Component, ComponentResponse, MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, + ProgressOverlay, +}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const SPINNER_ROLE: egui::accesskit::Role = egui::accesskit::Role::ProgressIndicator; + +/// Build a harness whose per-frame closure mirrors `AppState::update`: claim +/// input at frame start (the sole keyboard/text block), then render the overlay. +fn overlay_harness() -> Harness<'static> { + Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(|ui| { + ProgressOverlay::claim_input(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); + }) +} + +// ── Group A — Idle Path ──────────────────────────────────────────────────── + +/// TC-OVL-001 — nothing renders, and `has_global` is false, when idle. +#[test] +fn tc_ovl_001_idle_renders_nothing() { + let mut harness = overlay_harness(); + harness.step(); + assert!(!ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_none()); + assert!(harness.query_by_label("Cancel").is_none()); +} + +// ── Group B — Show Lifecycle ─────────────────────────────────────────────── + +/// TC-OVL-002 — overlay appears on the next frame after show. +#[test] +fn tc_ovl_002_overlay_appears_after_show() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Registering your identity.", + OverlayConfig::default(), + ); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!( + harness + .query_by_label("Registering your identity.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-003 — show returns a usable handle (ctx.data level). +#[test] +fn tc_ovl_003_show_returns_usable_handle() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global(&ctx, "Loading.", OverlayConfig::default()); + assert!(handle.is_active()); + assert!(handle.set_description("Updated text.").is_some()); + assert!(ProgressOverlay::has_global(&ctx)); +} + +// ── Group C — Update In Place ────────────────────────────────────────────── + +/// TC-OVL-005 — description update swaps text; spinner persists. +#[test] +fn tc_ovl_005_description_update_keeps_spinner() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Preparing the funding lock.", + OverlayConfig::default(), + ); + harness.step(); + assert!( + harness + .query_by_label("Preparing the funding lock.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + + handle.set_description("Waiting for the funding proof."); + harness.step(); + assert!( + harness + .query_by_label("Waiting for the funding proof.") + .is_some() + ); + assert!( + harness + .query_by_label("Preparing the funding lock.") + .is_none() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-006 — counter update changes only the counter line. +#[test] +fn tc_ovl_006_counter_update_changes_only_counter() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Processing.", + OverlayConfig::new().with_step(2, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 2 of 5").is_some()); + + handle.set_step(3, 5); + harness.step(); + assert!(harness.query_by_label("Step 3 of 5").is_some()); + assert!(harness.query_by_label("Step 2 of 5").is_none()); + assert!(harness.query_by_label("Processing.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-007 — stale handle updates are no-ops returning None (ctx.data). +#[test] +fn tc_ovl_007_stale_handle_updates_are_none() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global(&ctx, "Soon gone.", OverlayConfig::default()); + handle.clone().clear(); + assert!(handle.set_description("After clear").is_none()); + assert!(handle.set_step(1, 3).is_none()); + assert!( + handle + .with_action("Run in background", "overlay.bg") + .is_none() + ); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +// ── Group D — Dismiss ────────────────────────────────────────────────────── + +/// TC-OVL-008 — programmatic dismiss removes the overlay. +#[test] +fn tc_ovl_008_programmatic_dismiss_removes_overlay() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + + handle.clear(); + harness.step(); + assert!(!ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_none()); + assert!(harness.query_by_label("Working.").is_none()); +} + +/// TC-OVL-009 — double dismiss is a no-op (ctx.data). +#[test] +fn tc_ovl_009_double_dismiss_is_noop() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global(&ctx, "Once.", OverlayConfig::default()); + handle.clone().clear(); + handle.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +/// TC-OVL-010 / TC-OVL-035 — failed task: overlay gone before the error banner. +/// Component-level simulation of the AppState hand-off (single-frame exclusivity). +#[test] +fn tc_ovl_010_dismiss_before_error_banner() { + let ctx = egui::Context::default(); + let overlay = ProgressOverlay::set_global(&ctx, "Registering.", OverlayConfig::default()); + assert!(overlay.is_active()); + + // Result arrives: lower the overlay, then show the banner — never both. + overlay.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + MessageBanner::set_global(&ctx, "Registration failed. Try again.", MessageType::Error); + + assert!(!ProgressOverlay::has_global(&ctx)); + assert!(MessageBanner::has_global(&ctx)); +} + +// ── Group E — Spinner ────────────────────────────────────────────────────── + +/// TC-OVL-011 — spinner is present in every configuration. +#[test] +fn tc_ovl_011_spinner_present_in_all_configs() { + let configs = [ + OverlayConfig::default(), + OverlayConfig::new().with_step(1, 3), + OverlayConfig::new() + .with_step(1, 3) + .with_action("Run in background", "overlay.bg"), + ]; + for config in configs { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Busy.", config); + harness.step(); + assert!( + harness.query_by_role(SPINNER_ROLE).is_some(), + "spinner must render in every configuration" + ); + } +} + +/// TC-OVL-012 / TC-OVL-018 — no percentage / ETA element; spinner stays +/// indeterminate even with a counter. +#[test] +fn tc_ovl_012_no_eta_or_percentage() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Building the shielded transaction.", + OverlayConfig::new().with_step(2, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 2 of 5").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(harness.query_by_label_contains("%").is_none()); + assert!(harness.query_by_label_contains("remaining").is_none()); + assert!(harness.query_by_label_contains("ETA").is_none()); +} + +/// TC-OVL-013 (Part A) — elapsed readout is off by default. +#[test] +fn tc_ovl_013a_elapsed_off_by_default() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); +} + +/// TC-OVL-013 (Part B) — when enabled the elapsed readout shows and counts up. +/// Uses the deterministic clock seam (`backdate`) instead of a wall-clock sleep, +/// mirroring `tc_ovl_047b_threshold_reveals_via_clock_seam`. Assert the +/// readout advanced to a concrete 2s, not merely past 0s. +#[cfg(feature = "testing")] +#[test] +fn tc_ovl_013b_elapsed_on_counts_up() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Slow operation.", + OverlayConfig::new().with_elapsed(), + ); + harness.step(); + assert!(harness.query_by_label("Elapsed: 0s").is_some()); + + // Shift this entry's clock 2s into the past via the test seam, then re-render: + // the readout counts up to 2s deterministically, with zero wall-clock waiting. + handle.backdate(Duration::from_secs(2)); + harness.step(); + assert!( + harness.query_by_label("Elapsed: 2s").is_some(), + "the readout advanced to 2s via the clock seam" + ); + assert!( + harness.query_by_label("Elapsed: 0s").is_none() + && harness.query_by_label("Elapsed: 1s").is_none(), + "the readout counts up, never down, and never stalls at 0s" + ); +} + +// ── Group F — Step Counter ───────────────────────────────────────────────── + +/// TC-OVL-014 — a valid counter renders "Step {current} of {total}". +#[test] +fn tc_ovl_014_valid_counter_renders() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_step(3, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 3 of 5").is_some()); +} + +/// TC-OVL-015 / TC-OVL-016 / TC-OVL-017 — invalid counters hide the line. +#[test] +fn tc_ovl_015_017_invalid_counter_hidden() { + for (current, total) in [(0, 0), (4, 3), (0, 5)] { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_step(current, total), + ); + harness.step(); + assert!( + harness.query_by_label_contains("Step").is_none(), + "counter ({current},{total}) must be hidden" + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + } +} + +/// TC-OVL-019 — no counter line when none is set. +#[test] +fn tc_ovl_019_no_counter_when_unset() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Sending your transaction to the network.", + OverlayConfig::default(), + ); + harness.step(); + assert!(harness.query_by_label_contains("Step").is_none()); + assert!( + harness + .query_by_label("Sending your transaction to the network.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +// ── Group G — Description Text ───────────────────────────────────────────── + +/// TC-OVL-020 — description renders as a single full sentence. +#[test] +fn tc_ovl_020_description_full_sentence() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Registering your identity on the network.", + OverlayConfig::default(), + ); + harness.step(); + assert!( + harness + .query_by_label("Registering your identity on the network.") + .is_some() + ); +} + +/// TC-OVL-021 — a long description wraps and stays within the window. +#[test] +fn tc_ovl_021_long_description_within_bounds() { + let long = "Waiting for the funding proof. This operation contacts the Dash network and may take up to two minutes depending on network conditions."; + let mut harness = Harness::builder() + .with_size(egui::vec2(300.0, 400.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx(), false); + }); + let _handle = ProgressOverlay::set_global(&harness.ctx, long, OverlayConfig::default()); + harness.step(); + + let node = harness.query_by_label(long); + assert!( + node.is_some(), + "long description must render, not clip to empty" + ); + let rect = node.unwrap().rect(); + assert!( + rect.min.x >= -1.0 && rect.max.x <= 301.0, + "description stays within the window horizontally: {rect:?}" + ); + // Also bound it vertically inside the 400px-tall window. + assert!( + rect.min.y >= -1.0 && rect.max.y <= 401.0, + "description stays within the window vertically: {rect:?}" + ); +} + +/// TC-OVL-022 — spinner-only overlay is valid with no text, counter, or button. +#[test] +fn tc_ovl_022_spinner_only_valid() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(harness.query_by_label_contains("Step").is_none()); + assert!(harness.query_by_label("Cancel").is_none()); +} + +// ── Group H — Buttons & Actions ──────────────────────────────────────────── + +/// TC-OVL-023 — no buttons: a pure block, dismissed programmatically only. +#[test] +fn tc_ovl_023_no_buttons_pure_block() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label("Cancel").is_none()); + assert!(handle.take_actions().is_empty()); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-024 / TC-OVL-025 — clicking a generic button enqueues its +/// caller-chosen action id; the overlay persists. The label ("Cancel" here) +/// is just a caller-picked string, not a built-in concept — the facility is +/// fully generic, so TC-OVL-025 (originally the same assertion under a +/// different label/action id) added no coverage and was folded in here. +#[test] +fn tc_ovl_024_button_click_enqueues_action() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + // The centered card (anchored CENTER_CENTER) needs a few frames to cache its + // size before it stops moving; settle before clicking so the click lands. + harness.step(); + harness.step(); + harness.step(); + assert!(harness.query_by_label("Cancel").is_some()); + + harness.get_by_label("Cancel").click(); + harness.step(); + // A-3: the owning handle drains its own click. + assert_eq!(handle.take_actions(), vec!["overlay.cancel".to_string()]); + // The click does not auto-dismiss — only the app loop lowers it. + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-026 — the owning handle drains its own clicks FIFO then empties (A-3). +#[test] +fn tc_ovl_026_action_queue_drains_fifo() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .with_action("Primary", "primary") + .with_secondary_action("Secondary", "secondary"), + ); + // Settle the centered card before clicking (anchored CENTER_CENTER moves for + // a couple of frames until its size is cached). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Primary").click(); + harness.step(); + harness.get_by_label("Secondary").click(); + harness.step(); + + assert_eq!( + handle.take_actions(), + vec!["primary".to_string(), "secondary".to_string()] + ); + assert!(handle.take_actions().is_empty()); +} + +/// TC-OVL-027 — F-3/F-4/F-7 layout: the primary action hugs the RIGHT edge and a +/// secondary button sits to its LEFT (mirrors `ConfirmationDialog`). The renderer +/// uses `ComponentStyles` button helpers, never a bare `ui.button()`. +#[test] +fn tc_ovl_027_secondary_left_primary_right() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .with_action("Primary action", "primary") + .with_secondary_action("Secondary action", "secondary"), + ); + harness.step(); + + let second_x = harness.get_by_label("Secondary action").rect().center().x; + let first_x = harness.get_by_label("Primary action").rect().center().x; + assert!( + second_x < first_x, + "the secondary button must sit to the left of the primary action" + ); +} + +// ── Group I — Input Blocking ─────────────────────────────────────────────── + +/// TC-OVL-028 — pointer clicks on the backdrop do not reach widgets beneath. +#[test] +fn tc_ovl_028_pointer_click_beneath_blocked() { + let counter = Rc::new(Cell::new(0u32)); + let counter_ui = Rc::clone(&counter); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + if ui.button("Increment").clicked() { + counter_ui.set(counter_ui.get() + 1); + } + ProgressOverlay::render_global(ui.ctx(), false); + }); + let handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + + harness.get_by_label("Increment").click(); + harness.step(); + assert_eq!( + counter.get(), + 0, + "widget beneath the overlay must not receive the click" + ); + assert!(handle.take_actions().is_empty()); +} + +/// TC-OVL-029 — keyboard input does not reach widgets beneath the overlay. +/// The renderer never uses the deprecated `Ui::set_enabled` (design-review). +#[test] +fn tc_ovl_029_keyboard_beneath_blocked() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + + harness + .input_mut() + .events + .push(egui::Event::Text("hello".to_string())); + harness.step(); + assert!( + text.borrow().is_empty(), + "the text field beneath the overlay must not receive typed input" + ); +} + +/// TC-OVL-030 — a backdrop click does NOT dismiss the overlay. +#[test] +fn tc_ovl_030_backdrop_click_does_not_dismiss() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + + let corner = egui::pos2(10.0, 10.0); + harness.drag_at(corner); + harness.drop_at(corner); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(handle.take_actions().is_empty()); +} + +// ── Group J — Coexistence with MessageBanner ─────────────────────────────── + +/// TC-OVL-032 / TC-OVL-033 — banners persist in ctx.data while the overlay is +/// up and survive its dismissal; both can be active at once (overlay on top). +#[test] +fn tc_ovl_032_033_banner_persists_under_overlay() { + let ctx = egui::Context::default(); + MessageBanner::set_global(&ctx, "Banner A", MessageType::Error); + MessageBanner::set_global(&ctx, "Banner B", MessageType::Warning); + + let overlay = ProgressOverlay::set_global(&ctx, "Blocking.", OverlayConfig::default()); + assert!(MessageBanner::has_global(&ctx)); + assert!(ProgressOverlay::has_global(&ctx)); + + overlay.clear(); + assert!( + MessageBanner::has_global(&ctx), + "banner state survives the overlay lifecycle intact" + ); +} + +/// TC-OVL-034 — success task: overlay dismissed before the success banner. +#[test] +fn tc_ovl_034_dismiss_before_success_banner() { + let ctx = egui::Context::default(); + let overlay = ProgressOverlay::set_global(&ctx, "Registering.", OverlayConfig::default()); + + overlay.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + MessageBanner::set_global( + &ctx, + "Your identity has been registered.", + MessageType::Success, + ); + + assert!(!ProgressOverlay::has_global(&ctx)); + assert!(MessageBanner::has_global(&ctx)); +} + +// ── Group K — Concurrent Operations (stack model) ────────────────────────── + +/// TC-OVL-036 — the topmost stack entry is the one rendered. +#[test] +fn tc_ovl_036_topmost_entry_rendered() { + let mut harness = overlay_harness(); + let a = ProgressOverlay::set_global(&harness.ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&harness.ctx, "Operation B.", OverlayConfig::default()); + harness.step(); + + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_label("Operation B.").is_some()); + assert!(harness.query_by_label("Operation A.").is_none()); + assert!(a.is_active()); + assert!(b.is_active()); +} + +/// TC-OVL-037 / TC-OVL-038 — each handle dismisses only its own entry; the +/// overlay clears only when the stack empties (ctx.data). +#[test] +fn tc_ovl_037_038_handle_dismisses_only_its_own() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::set_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "Operation B.", OverlayConfig::default()); + + b.clear(); + assert!(a.is_active()); + assert!(ProgressOverlay::has_global(&ctx)); + + a.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +/// TC-OVL-039 — only the topmost request's actions are reachable. +#[test] +fn tc_ovl_039_only_topmost_actions_reachable() { + let mut harness = overlay_harness(); + let a = ProgressOverlay::set_global( + &harness.ctx, + "Operation A.", + OverlayConfig::new().with_action("Cancel", "cancel_a"), + ); + let b = ProgressOverlay::set_global( + &harness.ctx, + "Operation B.", + OverlayConfig::new().with_action("Cancel", "cancel_b"), + ); + // Settle the centered card before clicking (anchored CENTER_CENTER moves for + // a couple of frames until its size is cached). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Cancel").click(); + harness.step(); + // Only the topmost (B) renders, so the click is keyed to B: B drains it, A + // never sees it (A-3 cross-owner isolation). + assert_eq!(b.take_actions(), vec!["cancel_b".to_string()]); + assert!( + a.take_actions().is_empty(), + "the lower request's handle drains nothing" + ); +} + +// ── Group L — Accessibility ──────────────────────────────────────────────── + +/// TC-OVL-041 — Tab does not cycle focus to widgets beneath the overlay. +#[test] +fn tc_ovl_041_tab_focus_trap() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + harness.step(); + assert!( + harness.get_by_label("Cancel").is_focused(), + "the first button is the first focus stop on raise" + ); + + harness.key_press(egui::Key::Tab); + harness.step(); + assert!( + harness.get_by_label("Cancel").is_focused(), + "Tab is trapped: focus stays on the button, not a widget beneath" + ); +} + +/// TC-OVL-042 — Esc is swallowed even when a button is present; it never +/// enqueues an action (no implicit dismiss). There is no built-in Cancel, so Esc +/// has nothing to trigger — the overlay stays up. +#[test] +fn tc_ovl_042_esc_swallowed_with_button() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + + harness.key_press(egui::Key::Escape); + harness.step(); + assert!( + handle.take_actions().is_empty(), + "Esc must never trigger a button action" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-043 — Esc is swallowed when the overlay has no button. +#[test] +fn tc_ovl_043_esc_swallowed_without_button() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + harness.step(); + + harness.key_press(egui::Key::Escape); + harness.step(); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "Esc must not dismiss a hard block" + ); + assert!(handle.take_actions().is_empty()); +} + +/// TC-OVL-044 — neither Enter nor Space activates a focused button: a +/// hard block is never keyboard-activatable. +#[test] +fn tc_ovl_044_enter_and_space_do_not_activate_button() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + harness.step(); + + harness.key_press(egui::Key::Enter); + harness.step(); + harness.key_press(egui::Key::Space); + harness.step(); + assert!( + handle.take_actions().is_empty(), + "neither Enter nor Space may trigger the focused button's action" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-051 — a block that opts into a keyboard escape via `with_keyboard_escape` +/// CAN be activated with **Enter**: the focus-pinned escape +/// button fires and enqueues its action — the keyboard exit the unbounded SPV +/// block relies on. The general rule (TC-OVL-044) is unchanged for non-opted blocks. +#[test] +fn tc_ovl_051_designated_escape_activates_on_enter() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + // Settle: focus the escape and let the focus lock take effect (it is a no-op + // until the button has held focus for a frame). + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "the designated escape button holds focus" + ); + + harness.key_press(egui::Key::Enter); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Enter activates the focus-pinned escape and enqueues its action" + ); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "activating the escape does not itself lower the overlay — the owner does" + ); +} + +/// TC-OVL-052 — the opted-in keyboard escape also activates with **Space** (egui +/// fires a fake primary click on Space OR Enter for the focused widget). +#[test] +fn tc_ovl_052_designated_escape_activates_on_space() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused() + ); + + harness.key_press(egui::Key::Space); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Space activates the focus-pinned escape and enqueues its action" + ); +} + +/// TC-OVL-053 — the keyboard escape is focus-pinned: a `TextEdit` placed beneath +/// the block never receives the Enter (it activates the escape, not the field), and +/// neither Tab nor a backdrop click can move focus off the escape to a beneath +/// widget. The opt-in carves out Enter/Space ONLY for the escape; everything +/// beneath stays fully keyboard-blocked. +#[test] +fn tc_ovl_053_designated_escape_is_focus_pinned() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "focus is pinned to the escape, not the field beneath" + ); + + // Tab cannot move focus off the escape (claim_input strips it; the lock backs it). + harness.key_press(egui::Key::Tab); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "Tab cannot move focus to the field beneath" + ); + + // A click over the field beneath is absorbed by the sink and cannot move focus; + // the per-frame focus pin keeps the escape focused. + let over_field = egui::pos2(20.0, 20.0); + harness.drag_at(over_field); + harness.drop_at(over_field); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "a click over the field beneath cannot move focus off the escape" + ); + + // Enter activates the escape — and never reaches the field beneath. + harness.key_press(egui::Key::Enter); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Enter activates the escape" + ); + assert!( + text.borrow().is_empty(), + "the field beneath the block never received the Enter" + ); +} + +/// A focus-INDEPENDENT global key handler beneath an escape block (the +/// pattern in `info_popup` / `selection_dialog` / `address_input`, which call +/// `i.key_pressed(Enter)` with no focus guard) NEVER observes the Enter: `claim_input` +/// strips it at frame start, before the beneath `ui()` runs, and routes it to the +/// escape's action queue instead. This is the hard-block invariant the old design +/// could not hold — under it, a confirmed-focus escape KEPT Enter/Space in `i.events`, +/// so a focus-independent handler beneath saw the key that same frame. +#[test] +fn sec002_escape_block_strips_enter_from_focus_independent_handler_beneath() { + let fired = Rc::new(Cell::new(false)); + let fired_ui = Rc::clone(&fired); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + // Mirrors AppState::update: claim input at frame start, BEFORE the screen. + ProgressOverlay::claim_input(ui.ctx()); + // A focus-independent handler beneath the block (info_popup.rs:156 et al.). + if ui.ctx().input(|i| i.key_pressed(egui::Key::Enter)) { + fired_ui.set(true); + } + ProgressOverlay::render_global(ui.ctx(), false); + }); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + + harness.key_press(egui::Key::Enter); + harness.step(); + assert!( + !fired.get(), + "the Enter is stripped at frame start; no focus-independent handler beneath observes it" + ); + // The Enter activated the escape instead — enqueued once at frame start, not via + // a fake button click (the key never reached the button either). + assert_eq!(handle.take_actions(), vec!["spv:escape".to_string()]); +} + +/// TC-OVL-054 — the escape button stays mouse-clickable after a backdrop press. +/// +/// Regression guard for the SPV trap: egui auto-raises any interactable `Area` to +/// the top of its `Order` on a pointer press (`area.rs` bring-to-front). When the +/// dim/pointer sink and the card were peer `Order::Foreground` areas, pressing the +/// backdrop raised the sink ABOVE the card, permanently burying the escape button +/// beneath the click-absorbing sink — the unbounded SPV block then had no mouse +/// exit. A real mouse click on the button after such a press must still reach it +/// and enqueue the escape action. TC-OVL-024/025 never press the backdrop first, +/// so they passed while this path was broken. +#[test] +fn tc_ovl_054_escape_clickable_after_backdrop_press() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + // Settle the centered card (anchored CENTER_CENTER moves for a couple of frames + // until its size is cached) so the button lands where we click. + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some() + ); + + // Press the dim backdrop, well outside the card. This is what trapped the + // button: the sink area auto-raises to the top of Foreground on the press. + let backdrop = egui::pos2(8.0, 8.0); + harness.drag_at(backdrop); + harness.drop_at(backdrop); + harness.step(); + + // A real mouse click at the escape button's own position must still reach it. + harness.get_by_label("Continue in the background").click(); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "the escape button must receive the mouse click even after a backdrop press" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +// ── Group M — Non-Functional ─────────────────────────────────────────────── + +/// TC-OVL-046 — switching theme mid-overlay re-renders without panic. +#[test] +fn tc_ovl_046_theme_switch_mid_overlay() { + let mut harness = overlay_harness(); + harness.ctx.set_visuals(egui::Visuals::dark()); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Switching networks.", + OverlayConfig::default(), + ); + harness.step(); + assert!(harness.query_by_label("Switching networks.").is_some()); + + harness.ctx.set_visuals(egui::Visuals::light()); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_label("Switching networks.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-048 — the secret-prompt modal renders above the overlay (R-1). +/// `render_global` is called before `render_secret_prompt` in `AppState::update`, +/// so the focus-raised prompt stays interactive above the overlay's dim/sink. +#[test] +fn tc_ovl_048_secret_prompt_renders_above_overlay() { + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx(), false); + let config = PassphraseModalConfig { + window_title: "Unlock to continue", + body: "Enter your passphrase to continue.", + hint: None, + error: None, + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + remember_label: None, + }; + passphrase_modal(ui.ctx(), &config, |_| {}); + }); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Signing.", OverlayConfig::default()); + harness.step(); + harness.step(); + + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!( + harness + .query_by_label("Enter your passphrase to continue.") + .is_some(), + "the secret prompt renders above the overlay and remains visible" + ); + // RQ-1: the prompt is INTERACTIVE above the overlay, not merely visible — its + // submit control renders and its input holds keyboard focus, so the overlay's + // Foreground dim/sink does not capture the prompt's interaction. + assert!( + harness.query_by_label("Unlock").is_some(), + "the prompt's submit button renders interactively above the overlay" + ); + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "the prompt input holds keyboard focus above the overlay" + ); +} + +/// TC-OVL-047 (informational portion) — below the threshold a default overlay +/// shows no elapsed readout and no reassurance line. The past-threshold reveals +/// (soft 30 s line, the 120 s watchdog line replacing it, and the Elapsed +/// force-reveal) are exercised by `tc_ovl_047b_threshold_reveals_via_clock_seam` +/// using the test clock seam; the threshold predicates by the inline +/// `stuck_reveal_*` / `watchdog_tripped_*` unit tests. Per addendum §1 there is NO +/// escape-hatch button by design — a button-less block stays total and the safety +/// valve is the bounded-op contract + honest escalation — so that portion of +/// TC-OVL-047 is closed as "won't build" for v1, not deferred to T7. +#[test] +fn tc_ovl_047_stuck_threshold_is_informational_only() { + // Below the threshold a default overlay shows no elapsed readout and no + // reassurance line — the reveal is purely time-driven and benign. + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); + assert!( + harness + .query_by_label_contains("Still in progress — please keep the app open.") + .is_none() + ); +} + +/// TC-OVL-047b (RQ-2) — using the test clock seam, render PAST the thresholds and +/// assert the addendum's escalation: the soft 30 s line + Elapsed force-reveal, +/// then the 120 s watchdog line REPLACING the soft line (never stacked). +#[cfg(feature = "testing")] +#[test] +fn tc_ovl_047b_threshold_reveals_via_clock_seam() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); + assert!( + harness + .query_by_label("Still in progress — please keep the app open.") + .is_none() + ); + + // Past 30 s (still making progress recently): soft line + Elapsed force-reveal, + // no watchdog yet. + handle.backdate(Duration::from_secs(31)); + harness.step(); + assert!( + harness.query_by_label_contains("Elapsed:").is_some(), + "Elapsed is force-revealed once past 30 s, even though with_elapsed was off" + ); + assert!( + harness + .query_by_label("Still in progress — please keep the app open.") + .is_some(), + "the soft reassurance line appears past 30 s" + ); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "the watchdog line has not tripped yet" + ); + + // Past 120 s with no progress: the watchdog line REPLACES the soft line. + handle.backdate(Duration::from_secs(120)); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_some(), + "the 120 s no-progress watchdog line appears" + ); + assert!( + harness + .query_by_label("Still in progress — please keep the app open.") + .is_none(), + "the watchdog line replaces the soft line, never stacks with it" + ); + assert!( + harness.query_by_label_contains("Elapsed:").is_some(), + "the Elapsed readout persists through the watchdog escalation" + ); +} + +/// TC-OVL-045 (ctx.data portion) — `OptionOverlayExt::raise` swaps the entry +/// and `take_and_clear` lowers it; the log-once flag itself is asserted in the +/// inline unit tests. +#[test] +fn tc_ovl_045_option_overlay_ext_lifecycle() { + let ctx = egui::Context::default(); + let mut slot: Option<OverlayHandle> = None; + slot.raise(&ctx, "First.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + slot.raise(&ctx, "Second.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + slot.take_and_clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +// ── Group N — Component (instance) path ───────────────────────────────────── + +/// TC-OVL-050 — the `Component` instance path renders its card inline and +/// surfaces a clicked button's action id through `ProgressOverlayResponse`, +/// which `current_value` then reports. Mirrors `MessageBanner`'s instance path. +#[test] +fn tc_ovl_050_component_instance_show_reports_click() { + let action = Rc::new(RefCell::new(None::<String>)); + let action_ui = Rc::clone(&action); + let overlay = Rc::new(RefCell::new( + ProgressOverlay::new() + .with_description("Instance overlay.") + .with_action("Run in background", "overlay.bg"), + )); + let overlay_ui = Rc::clone(&overlay); + + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let response = overlay_ui.borrow_mut().show(ui).inner; + if response.has_changed() + && let Some(id) = response.changed_value() + { + *action_ui.borrow_mut() = Some(id.clone()); + } + }); + harness.step(); + assert!(harness.query_by_label("Instance overlay.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(overlay.borrow().current_value().is_none()); + + harness.get_by_label("Run in background").click(); + harness.step(); + assert_eq!(action.borrow().as_deref(), Some("overlay.bg")); + assert_eq!( + overlay.borrow().current_value().as_deref(), + Some("overlay.bg"), + "current_value reports the last clicked action id" + ); + // The instance path does not touch the global action queue — the screen + // reads the click from the response, not via the handle/sweeper. + assert!(ProgressOverlay::sweep_orphan_actions(&harness.ctx).is_empty()); +} + +// ── QA probe (Marvin) — FR-8 AC-8.2 for the button-LESS hard block ────────── +// +// TC-OVL-029 only covers a *with-button* overlay, where the first button +// steals focus on raise — so typing is blocked incidentally, not by the +// overlay's input handling. This probe raises a *button-less* block over a +// field that already holds focus (the J-2 broadcast / J-4 migration case) and +// asserts AC-8.2: typed input must not reach the field beneath. +// +// `ProgressOverlay::claim_input`, called at frame start +// (before the panels) while a block is up, releases beneath text focus and +// strips `Event::Text` + nav/confirm keys — so a button-less block no longer +// leaks typed input into a focused field beneath. This harness mirrors the app +// loop: `claim_input` runs before the field, `render_global` paints after it. +#[test] +fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + // Mirrors AppState::update: claim input at frame start, before panels. + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + + // Focus the field beneath, before any overlay exists. + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + + // Raise a pure (button-less) block over the already-focused field. + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + + // Type. AC-8.2: keyboard input must not reach widgets beneath the overlay. + harness + .input_mut() + .events + .push(egui::Event::Text("hello".to_string())); + harness.step(); + + assert!( + text.borrow().is_empty(), + "FR-8 AC-8.2: typed input reached a focused field beneath a button-less \ + overlay: {:?}", + text.borrow() + ); +} + +// Additive hardening: `claim_input` also strips edit keys +// (Backspace/Delete/Home/End/PageUp/PageDown) and clipboard events +// (Copy/Cut/Paste) at frame start, so a focused field beneath a block is neither +// edited nor pasted into. This locks the new classes via event survival (fails +// if the strip is removed) plus the field-beneath behavioral contract. +#[test] +fn qa_buttonless_overlay_strips_edit_and_clipboard_events() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let survived = Rc::new(Cell::new(false)); + let survived_ui = Rc::clone(&survived); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let leaked = ui.ctx().input(|i| { + i.events.iter().any(|e| { + matches!( + e, + egui::Event::Paste(_) + | egui::Event::Key { + key: egui::Key::Backspace | egui::Key::Delete, + pressed: true, + .. + } + ) + }) + }); + survived_ui.set(leaked); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + + // Focus the field and type real content while idle (claim_input is a no-op + // with no overlay), so egui holds a live cursor at the end of "keep". + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + harness + .input_mut() + .events + .push(egui::Event::Text("keep".to_string())); + harness.step(); + assert_eq!( + text.borrow().as_str(), + "keep", + "typing must reach the focused field while no overlay is up" + ); + + // Raise a button-less block over the focused, populated field. + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + + // Inject edit + clipboard events; claim_input must strip them all. + for key in [egui::Key::Backspace, egui::Key::Delete] { + harness.input_mut().events.push(egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + harness + .input_mut() + .events + .push(egui::Event::Paste("INJECT".to_string())); + harness.step(); + + assert!( + !survived.get(), + "claim_input must strip Backspace/Delete/Paste while a block is up" + ); + assert_eq!( + text.borrow().as_str(), + "keep", + "edit/clipboard events reached a field beneath a button-less overlay: {:?}", + text.borrow() + ); +} + +// ── Cross-finding reconciliations (lead brief) ────────────────────────────── + +/// Reconciliation #1 — while a secret prompt is active the +/// app gates `claim_input` OFF, so only `render_global` runs over the overlay. It +/// must NOT strip keyboard events, or it would eat the prompt's Enter/Esc/Tab. +/// (TC-OVL-048 separately proves the prompt renders interactively above the +/// overlay; this proves the overlay does not swallow its keyboard.) +#[test] +fn reconciliation_render_global_keeps_keyboard_for_prompt() { + let saw_keys = Rc::new(Cell::new(false)); + let saw_keys_ui = Rc::clone(&saw_keys); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + // No claim_input — mirrors AppState gating it off while a prompt is up. + ProgressOverlay::render_global(ui.ctx(), false); + let survived = ui.ctx().input(|i| { + i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { + key: egui::Key::Enter | egui::Key::Escape | egui::Key::Tab, + pressed: true, + .. + } + ) + }) + }); + saw_keys_ui.set(survived); + }); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.step(); + assert!(!saw_keys.get(), "no keys injected yet"); + + // Inject the prompt's navigation/confirm keys; after render_global they must + // still be present (the overlay must not consume them). + for key in [egui::Key::Enter, egui::Key::Escape, egui::Key::Tab] { + harness.input_mut().events.push(egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + harness.step(); + assert!( + saw_keys.get(), + "render_global must not swallow Enter/Esc/Tab while an overlay is up — a \ + secret prompt above it needs them" + ); +} + +/// Reconciliation #3 — the instance `Component::show` must NOT seize the +/// host screen's focus or install the global focus-lock. A host text field stays +/// focused after the inline overlay renders, proving the trap is global-only. +#[test] +fn reconciliation_instance_show_leaves_host_focus_navigable() { + let overlay = Rc::new(RefCell::new( + ProgressOverlay::new() + .with_description("Inline overlay.") + .with_action("Act", "inline.act"), + )); + let overlay_ui = Rc::clone(&overlay); + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + overlay_ui.borrow_mut().show(ui); + }); + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + + // The inline overlay rendered (trap_focus = false), so the host field keeps + // focus — an instance widget never wedges the host screen's navigation. + assert!( + harness + .get_by_role(egui::accesskit::Role::TextInput) + .is_focused(), + "the instance overlay must leave the host screen's focus alone" + ); +} + +// ── RQ-1: AppState-level secret-prompt gate ───────────────────────────────── + +/// RQ-1 (security) — drives the REAL `AppState::update` loop: a passphrase prompt +/// active above a button-less blocking overlay stays focusable AND typeable, +/// because `AppState::claim_overlay_input` suppresses the overlay's frame-start +/// `claim_input` while a secret prompt is active. Deleting that gate +/// makes `claim_input` (button-less → `stop_text_input`) steal the prompt's focus +/// and strip its keystrokes — which BOTH assertions below detect. +#[cfg(feature = "testing")] +#[test] +fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // A secret prompt is active (renders above the overlay, needs keyboard). + app.test_set_secret_prompt_active(true); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Raise a button-less blocking overlay beneath the active prompt. + ProgressOverlay::set_global_spinner_only(&harness.ctx); + harness.run_steps(5); + + // The prompt renders above the overlay... + assert!( + harness.query_by_label_contains("Test prompt").is_some(), + "the secret prompt renders above the overlay" + ); + // ...and KEEPS keyboard focus: deleting the gate lets the overlay's + // claim_input (stop_text_input, button-less) clear it → focused() == None. + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "the prompt input keeps keyboard focus over the overlay — removing the \ + app.rs secret-prompt gate lets the overlay's claim_input steal it" + ); + + // ...and ACCEPTS typed text: type a passphrase and submit with Enter; the + // prompt resolves and closes. With the gate deleted the keystrokes are + // stripped, so the prompt would stay open and this would fail. + harness + .input_mut() + .events + .push(egui::Event::Text("pw".to_string())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + assert!( + harness.query_by_label_contains("Test prompt").is_none(), + "the prompt accepted the typed passphrase and submitted on Enter (it \ + would stay open if the overlay had stripped its keyboard)" + ); + }); +} + +/// Drives the REAL `AppState::update` loop with BOTH a passphrase +/// prompt active AND a `with_keyboard_escape` block beneath it (the SPV-sync pattern). +/// The escape must NOT steal focus from the prompt: the prompt stays focused across +/// several frames, a typed passphrase + Enter SUBMITS (closing the prompt), and the +/// escape action is NEVER enqueued. Under the pre-fix code `render_buttons` re-requested +/// the escape's focus every frame — and ran (`render_global`) BEFORE `render_secret_prompt` +/// — so the escape stole the prompt's focus: keystrokes hit the focused button and Enter +/// fired the escape instead of submitting. The submit + empty escape queue both detect +/// that regression. +#[cfg(feature = "testing")] +#[test] +fn sec001_keyboard_escape_block_does_not_steal_focus_from_secret_prompt() { + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // A secret prompt is active (renders above the overlay, needs keyboard). + app.test_set_secret_prompt_active(true); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Raise a keyboard-escape block (the unbounded SPV-sync pattern) beneath the + // prompt, holding its handle so we can inspect whether the escape ever fired. + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action( + "Continue in the background", + dash_evo_tool::app::SPV_CONTINUE_BACKGROUND_ACTION, + ) + .with_keyboard_escape(dash_evo_tool::app::SPV_CONTINUE_BACKGROUND_ACTION), + ); + harness.run_steps(5); + + // The prompt renders above the escape block and KEEPS keyboard focus across + // multiple frames — the escape never re-grabs it while a prompt is up. + for _ in 0..3 { + harness.step(); + assert!( + harness.query_by_label_contains("Test prompt").is_some(), + "the secret prompt stays up above the escape block" + ); + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "focus is held (by the prompt) every frame, not surrendered to nothing" + ); + } + + // The prompt ACCEPTS typed text and submits on Enter — proving IT held focus, + // not the escape button. + harness + .input_mut() + .events + .push(egui::Event::Text("pw".to_string())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + assert!( + harness.query_by_label_contains("Test prompt").is_none(), + "the prompt accepted the passphrase and submitted on Enter (the escape did \ + not steal focus, so the keystrokes reached the field)" + ); + // ...and the escape action was NEVER enqueued — Enter went to the passphrase. + assert!( + handle.take_actions().is_empty(), + "Enter must submit the passphrase, never activate a focus-stolen escape" + ); + }); +} + +// ── Task 9: SPV-sync blocking overlay (startup + Connect) ──────────────────── + +/// Task 9 / F-SPV-A — the SPV-sync block is SCOPED to a user-initiated (armed) +/// episode: an armed Connecting/Syncing blocks; an UN-armed (ambient) reconnect or +/// per-block Synced→Syncing flip does NOT block; an armed episode disarms on a +/// terminal state and stays disarmed; the escape lowers it without re-raising; and +/// only a fresh armed episode re-blocks. Jargon-free copy (F-SPV-B). Drives the +/// REAL `AppState::update_spv_overlay` against a forced connection state. +#[cfg(feature = "testing")] +#[test] +fn task9_spv_overlay_armed_scope_disarm_and_escape() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx(), false); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + // Separate Arc clone so we can force connection state without borrowing app. + let app_context = app.current_app_context().clone(); + let set_state = |s| app_context.connection_status().set_overall_state(s); + + // F-SPV-A regression guard: an UN-armed Connecting must NOT block (ambient). + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "an un-armed (ambient) connecting sync must NOT hard-block the user" + ); + + // Arm a user-initiated episode (startup / Connect) → Connecting blocks. + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "an armed connecting sync raises the block" + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "the secondary 'Continue in the background' escape button renders" + ); + // F-SPV-B: no blockchain jargon leaks into the description. + assert!( + harness.query_by_label_contains("Headers:").is_none() + && harness.query_by_label_contains("Masternodes:").is_none(), + "the block description must be jargon-free" + ); + + // C1 / F-SPV-A: Synced → lowers AND DISARMS. + set_state(OverallConnectionState::Synced); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block lowers when synced" + ); + + // After disarm, ambient Connecting/Syncing (reconnect, per-block catch-up) + // must NOT re-block — the core F-SPV-A fix. + set_state(OverallConnectionState::Syncing); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "ambient syncing after the episode disarmed must NOT re-block" + ); + + // C2 escape: arm a fresh episode, block, click escape → lowers and stays + // down for the rest of THIS episode even though sync is still in progress. + app.test_arm_spv_block(); + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + harness.step(); + harness.step(); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + harness.get_by_label("Continue in the background").click(); + harness.step(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the escape lowers the block (user never trapped)" + ); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block is not re-raised within the dismissed episode" + ); + + // Only a fresh ARMED episode re-blocks; an ambient one still does not. + set_state(OverallConnectionState::Disconnected); + app.test_drive_spv_overlay(&harness.ctx); + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "ambient connecting (no fresh arm) must NOT block" + ); + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "a fresh armed episode re-blocks" + ); + }); +} + +/// F-SPV-A regression — completing onboarding with auto-start enabled must ARM the +/// SPV-sync block, exactly like the boot auto-start and the Connect button. Drives +/// the REAL post-onboarding path (`AppState::try_auto_start_spv`, the method +/// `AppAction::OnboardingComplete` invokes) and asserts both the armed flag flips +/// and that an armed Connecting sync then hard-blocks. Without the arm, a fresh +/// user who opted into auto-start during onboarding would sync with no overlay. +#[cfg(feature = "testing")] +#[test] +fn fspv_a_onboarding_auto_start_arms_spv_block() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx(), false); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + let app_context = app.current_app_context().clone(); + + // Fresh boot before onboarding completes: the block is NOT armed (boot + // auto-start only arms when onboarding was ALREADY done at startup). + assert!( + !app.test_spv_block_armed(), + "the SPV block must not be armed before onboarding completes" + ); + + // The user opted into auto-start, then finishes onboarding → the + // OnboardingComplete handler runs the real auto-start path. + app_context + .update_auto_start_spv(true) + .expect("persist auto_start_spv"); + app.test_run_auto_start_spv(); + + // The post-onboarding auto-start is user-initiated → it must arm the block. + assert!( + app.test_spv_block_armed(), + "completing onboarding with auto-start enabled must ARM the SPV-sync block" + ); + + // And an armed Connecting sync then hard-blocks (the overlay actually shows). + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "the armed post-onboarding sync raises the blocking overlay" + ); + }); +} + +/// Task 9 — the REAL SPV block's "Continue in the background" +/// escape is keyboard-activatable: pressing **Enter** while it holds focus enqueues +/// its action, which the driver drains to lower the block. Guards the app.rs wiring +/// (`with_keyboard_escape(SPV_CONTINUE_BACKGROUND_ACTION)`) so a keyboard-only / +/// assistive-tech user is never stranded behind the unbounded SPV block. +#[cfg(feature = "testing")] +#[test] +fn task9_spv_escape_is_keyboard_activatable() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + // Mirror AppState::update: claim input at frame start, then render. + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::claim_input(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + let app_context = app.current_app_context().clone(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + + // Arm a user-initiated episode and raise the block. + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); + // Settle focus on the escape button (the focus lock is a no-op until the + // button has held focus for a frame). + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "the SPV escape button holds focus" + ); + + // Enter activates the escape; the next driver pass drains it and lowers the + // block for the rest of the episode (sync keeps running in the background). + harness.key_press(egui::Key::Enter); + harness.step(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "Enter on the SPV escape lowers the block — a keyboard-only user is never trapped" + ); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block stays lowered within the dismissed episode" + ); + }); +} + +/// Item A (one-frame interactive gap) — driving the REAL `AppState::update` loop, +/// an armed SPV episode RAISES **and PAINTS** the block on the SAME frame it is +/// first observed, because `update_spv_overlay` now runs before `claim_input`, the +/// screen `ui()`, and `render_global`. Under the pre-fix order the block was raised +/// only after `render_global`, so it painted a frame late — leaving that frame +/// fully interactive. This test would need two `step()`s under the old order: the +/// single-frame paint assertion is the regression guard for the gap. +#[cfg(feature = "testing")] +#[test] +fn item_a_armed_episode_blocks_and_paints_same_frame() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // Arm a user-initiated episode and force Connecting, exactly as the + // Connect button / boot auto-start do — but BEFORE the first frame runs. + let app_context = app.current_app_context().clone(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_arm_spv_block(); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Exactly ONE frame. `update_spv_overlay` runs at frame start (before the + // input claim, the screen, and `render_global`), so the block is both raised + // and painted this same frame. + harness.step(); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "the armed episode raises the block" + ); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "the block is PAINTED on the same frame it is armed+observed — under the \ + pre-fix order (raise after render_global) it would only paint next frame, \ + leaving this frame interactive (the one-frame gap)" + ); + }); +} + +/// Item B (watchdog liveness) — using the `backdate` clock seam: a phase whose +/// elapsed exceeds the 120 s no-progress watchdog but whose hidden `progress_token` +/// ADVANCES must NOT trip the watchdog (a slow-but-advancing phase is real +/// progress); the same elapsed WITHOUT an advancing token MUST still trip it (a +/// genuine stall). The shown copy never changes, so the existing `(description, +/// step)` change-detection alone could not tell the two apart. +#[cfg(feature = "testing")] +#[test] +fn item_b_advancing_progress_token_resets_watchdog() { + let mut harness = overlay_harness(); + // Raise with a seed token; render once to log "shown" and record the token. + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new().with_progress_token(100), + ); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "no watchdog at the start" + ); + + // Age past the 120 s watchdog, but ADVANCE the hidden token before the next + // render — the shown description/step is untouched. + handle.backdate(Duration::from_secs(200)); + handle.set_progress_token(200); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "an advancing progress token resets the watchdog — no false-stall escalation \ + even though the shown copy is unchanged" + ); + + // Age past the watchdog again WITHOUT advancing the token: a genuine stall must + // still trip it. + handle.backdate(Duration::from_secs(200)); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_some(), + "a real stall (token unchanged) still trips the watchdog" + ); +} diff --git a/tests/kittest/register_dpns_name_screen.rs b/tests/kittest/register_dpns_name_screen.rs new file mode 100644 index 000000000..422757050 --- /dev/null +++ b/tests/kittest/register_dpns_name_screen.rs @@ -0,0 +1,167 @@ +//! Kittest coverage for the Bucket A overlay adoption on the DPNS registration +//! screen (`RegisterDpnsNameScreen`). +//! +//! Proves the canonical contract the remaining transaction screens will copy: +//! dispatching the registration raises the global blocking overlay, and every +//! terminal result — success (`display_task_result`) or error +//! (`display_message`) — tears it down (the no-hard-lock guarantee). +//! +//! The screen needs an `Arc<AppContext>`, so each test borrows one from a +//! throwaway `AppState` built in an isolated data dir. The overlay raise/teardown +//! runs against an independent `egui::Context` the AppState never renders, so the +//! app's own SPV block can't perturb the `has_global` assertions. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::app::AppState; +use dash_evo_tool::backend_task::{BackendTaskSuccessResult, FeeResult}; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::ScreenLike; +use dash_evo_tool::ui::components::ProgressOverlay; +use dash_evo_tool::ui::identities::register_dpns_name_screen::{ + RegisterDpnsNameScreen, RegisterDpnsNameSource, +}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Build a `RegisterDpnsNameScreen` over a fresh, isolated `AppContext`. +fn screen_with_context() -> RegisterDpnsNameScreen { + let app_state = AppState::new(egui::Context::default()).expect("AppState builds"); + let app_context = app_state.current_app_context().clone(); + RegisterDpnsNameScreen::new(&app_context, RegisterDpnsNameSource::Dpns) +} + +/// Dispatching the registration raises the global blocking overlay. +#[test] +fn dpns_dispatch_raises_blocking_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + assert!(!ProgressOverlay::has_global(&ctx)); + screen.raise_progress_overlay_for_test(&ctx); + assert!( + ProgressOverlay::has_global(&ctx), + "dispatching the registration must raise the blocking overlay" + ); + }); +} + +/// A successful registration result tears the overlay down (success terminal path). +#[test] +fn dpns_success_result_clears_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + screen.raise_progress_overlay_for_test(&ctx); + assert!(ProgressOverlay::has_global(&ctx)); + + screen.display_task_result(BackendTaskSuccessResult::RegisteredDpnsName( + FeeResult::new(0, 0), + )); + assert!( + !ProgressOverlay::has_global(&ctx), + "a successful result must tear down the blocking overlay" + ); + }); +} + +// ── W2 B2 — app-scoped seeding ─────────────────────────────────────────────── + +/// Seed a wallet-less identity into the live context (mirrors identity_hub_switcher). +fn seed_identity_for_dpns(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed dpns identity"); + id +} + +/// W2 B2: `RegisterDpnsNameScreen` must default to the app-scoped selected identity, +/// not necessarily the first loaded identity. +#[test] +fn dpns_registration_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + harness.run_steps(5); + let app_context = harness.state().current_app_context().clone(); + + let _first = seed_identity_for_dpns(&app_context, 0x11, "DPNS Alpha"); + let second = seed_identity_for_dpns(&app_context, 0x22, "DPNS Beta"); + + app_context.set_selected_identity(Some(second)); + + let screen = RegisterDpnsNameScreen::new(&app_context, RegisterDpnsNameSource::Dpns); + + assert_eq!( + screen + .selected_qualified_identity + .as_ref() + .map(|qi| qi.identity.id()), + Some(second), + "RegisterDpnsNameScreen must default to the app-scoped selected identity" + ); + }); +} + +/// An error message tears the overlay down (error terminal path): +/// failed registration can never leave the window hard-locked. +#[test] +fn dpns_error_message_clears_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + screen.raise_progress_overlay_for_test(&ctx); + assert!(ProgressOverlay::has_global(&ctx)); + + screen.display_message("Registration failed. Try again.", MessageType::Error); + assert!( + !ProgressOverlay::has_global(&ctx), + "an error result must tear down the blocking overlay" + ); + }); +} diff --git a/tests/kittest/restore_single_key.rs b/tests/kittest/restore_single_key.rs new file mode 100644 index 000000000..750bc2b6b --- /dev/null +++ b/tests/kittest/restore_single_key.rs @@ -0,0 +1,86 @@ +//! Kittest coverage for the T-SK-03 "Restore a protected imported key" +//! dialog. Drives [`RestoreSingleKeyDialog`] directly through +//! `show_in_ui` so the harness needs no wallets-screen instance. +//! +//! The restore *logic* round-trip (decrypt → re-import → gate +//! recognition) is covered by the integration tests in +//! `src/context/wallet_lifecycle.rs`; this exercises the rendered UI. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::backend_task::migration::single_key_restore::PendingProtectedRestore; +use dash_evo_tool::ui::wallets::restore_single_key::RestoreSingleKeyDialog; +use dash_sdk::dpp::dashcore::Network; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +fn targeted_dialog() -> RestoreSingleKeyDialog { + let mut dialog = RestoreSingleKeyDialog::new(); + dialog.set_target(PendingProtectedRestore { + address: "yProtectedRestoreAddr".into(), + alias: Some("savings".into()), + network: Network::Testnet, + }); + dialog +} + +/// A targeted dialog renders the protected key's address, its alias, the +/// old-password prompt, and the Restore action so the user can complete +/// the flow. +#[test] +fn targeted_dialog_renders_address_and_old_password_prompt() { + with_isolated_data_dir(|| { + let mut dialog = targeted_dialog(); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label_contains("yProtectedRestoreAddr") + .is_some(), + "the protected address must be shown so the user can identify the key" + ); + assert!( + harness.query_by_label_contains("savings").is_some(), + "the key's nickname should be shown" + ); + assert!( + harness.query_by_label("Old password").is_some(), + "the old-password prompt must be present" + ); + assert!( + harness.query_by_label("Restore key").is_some(), + "the Restore action must be present" + ); + }); +} + +/// The "Protect with a new passphrase" toggle is on by default and +/// reveals the new-passphrase fields, but a user can complete a restore +/// without them (the toggle drives optional re-protection). +#[test] +fn new_passphrase_fields_render_when_protect_again_enabled() { + with_isolated_data_dir(|| { + let mut dialog = targeted_dialog(); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(move |ui| { + dialog.show_in_ui(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label("Protect with a new passphrase") + .is_some(), + "the re-protection toggle must be present" + ); + assert!( + harness.query_by_label("New passphrase").is_some(), + "new-passphrase field should render while re-protection is on" + ); + }); +} diff --git a/tests/kittest/secret_prompt.rs b/tests/kittest/secret_prompt.rs new file mode 100644 index 000000000..df86265fa --- /dev/null +++ b/tests/kittest/secret_prompt.rs @@ -0,0 +1,115 @@ +//! Kittest coverage for the just-in-time secret prompt modal. +//! +//! Drives the shared [`passphrase_modal`] chrome directly (the same body +//! `EguiSecretPromptHost` renders) to assert the GUI surface the +//! remember-policy mapping depends on: +//! +//! - the scope body label, hint, and inline retry error render; +//! - the "Keep this wallet unlocked until I close the app." checkbox renders +//! and toggles (unchecked maps to `RememberPolicy::None`, checked to +//! `UntilAppClose` — the mapping itself is unit-tested in +//! `secret_prompt_host`). +//! +//! NOTE: the kittest suite has pre-existing `DivergentVersion` failures +//! unrelated to this module. + +use dash_evo_tool::ui::components::passphrase_modal::{ + KEEP_UNLOCKED_LABEL, PassphraseModalConfig, passphrase_modal, +}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// The modal renders the scope body, the hint, the retry error, and the +/// remember checkbox. +#[test] +fn modal_renders_body_hint_error_and_remember_checkbox() { + let mut remember = false; + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(move |ui| { + let ctx = ui.ctx().clone(); + let config = PassphraseModalConfig { + window_title: "Unlock to continue", + body: "My Wallet", + hint: Some("granny's birthday"), + error: Some("That passphrase is not correct. Try again."), + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + remember_label: None, + }; + passphrase_modal(&ctx, &config, |ui| { + ui.checkbox(&mut remember, KEEP_UNLOCKED_LABEL); + }); + }); + harness.run(); + + assert!( + harness.query_by_label("My Wallet").is_some(), + "scope body label should render" + ); + assert!( + harness + .query_by_label_contains("granny's birthday") + .is_some(), + "password hint should render" + ); + assert!( + harness + .query_by_label_contains("That passphrase is not correct") + .is_some(), + "inline retry error should render" + ); + assert!( + harness.query_by_label(KEEP_UNLOCKED_LABEL).is_some(), + "remember-until-close checkbox should render" + ); + assert!( + harness.query_by_label("Unlock").is_some(), + "submit button should render" + ); + assert!( + harness.query_by_label("Cancel").is_some(), + "cancel button should render" + ); +} + +/// Clicking the remember checkbox flips the bound flag — the source the +/// host maps to `RememberPolicy::UntilAppClose`. +#[test] +fn remember_checkbox_toggles() { + use std::cell::Cell; + use std::rc::Rc; + + let remember = Rc::new(Cell::new(false)); + + let remember_for_ui = Rc::clone(&remember); + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(move |ui| { + let ctx = ui.ctx().clone(); + let config = PassphraseModalConfig { + window_title: "Unlock to continue", + body: "My Wallet", + hint: None, + error: None, + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + remember_label: None, + }; + let mut local = remember_for_ui.get(); + passphrase_modal(&ctx, &config, |ui| { + ui.checkbox(&mut local, KEEP_UNLOCKED_LABEL); + }); + remember_for_ui.set(local); + }); + harness.run(); + + assert!(!remember.get(), "checkbox starts unchecked (default None)"); + harness.get_by_label(KEEP_UNLOCKED_LABEL).click(); + harness.run(); + assert!( + remember.get(), + "clicking the checkbox flips it on (maps to UntilAppClose)" + ); +} diff --git a/tests/kittest/skipped_wallets_banner.rs b/tests/kittest/skipped_wallets_banner.rs new file mode 100644 index 000000000..d30fe7c53 --- /dev/null +++ b/tests/kittest/skipped_wallets_banner.rs @@ -0,0 +1,86 @@ +//! kittest coverage for the persisted-wallet load-skip banner seam. +//! +//! `WalletBackend::register_persisted_wallets` raises the banner via +//! `MessageBanner::set_global(ctx.egui_ctx(), skipped_wallets_banner_text(n), +//! MessageType::Warning)`. The async/`AppContext` wrapper can't be booted in a +//! unit test, so these drive the exact copy + banner type the app emits +//! through the public `MessageBanner` surface — the same seam the app loop +//! uses — so a regression in the copy or the Warning type fails here. + +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::MessageBanner; +use dash_evo_tool::wallet_backend::skipped_wallets_banner_text; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// A non-empty skip count renders a Warning-typed banner carrying the +/// singular copy verbatim, alongside the ⚠ icon and a dismiss control. +#[test] +fn skipped_banner_renders_warning_with_singular_copy() { + let label = skipped_wallets_banner_text(1).expect("one skip yields banner text"); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), &label, MessageType::Warning); + MessageBanner::show_global(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label( + "1 saved wallet couldn't be opened. Re-add it from its recovery phrase to restore it." + ) + .is_some(), + "skip banner must render the singular copy verbatim", + ); + // Warning icon (color is not the only signal) + dismiss control present. + assert!( + harness.query_by_label("\u{26A0}").is_some(), + "warning icon must render alongside text", + ); + assert!(harness.query_by_label("\u{274C}").is_some()); +} + +/// A skip count above one renders the plural copy with the count. +#[test] +fn skipped_banner_renders_plural_copy() { + let label = skipped_wallets_banner_text(3).expect("three skips yield banner text"); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), &label, MessageType::Warning); + MessageBanner::show_global(ui); + }); + harness.run(); + + assert!( + harness + .query_by_label( + "3 saved wallets couldn't be opened. Re-add them from their recovery phrases to restore them." + ) + .is_some(), + "skip banner must render the plural copy verbatim", + ); +} + +/// Zero skips yields no banner text, so no warning banner is rendered. +#[test] +fn no_skips_renders_no_banner() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(|ui| { + if let Some(text) = skipped_wallets_banner_text(0) { + MessageBanner::set_global(ui.ctx(), text, MessageType::Warning); + } + MessageBanner::show_global(ui); + }); + harness.run(); + + assert!( + harness.query_by_label("\u{26A0}").is_none(), + "zero skips must not render a warning banner", + ); +} diff --git a/tests/kittest/startup.rs b/tests/kittest/startup.rs index 44a55d0aa..507008f5d 100644 --- a/tests/kittest/startup.rs +++ b/tests/kittest/startup.rs @@ -1,25 +1,28 @@ +use crate::support::with_isolated_data_dir; use egui_kittest::Harness; /// Test that demonstrates basic app startup and shutdown with kittest #[test] fn test_app_startup() { - // Create a tokio runtime for async operations during app initialization - // The app uses tokio::spawn internally for background tasks - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + // Create a tokio runtime for async operations during app initialization + // The app uses tokio::spawn internally for background tasks + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - // Create a test harness for the egui app - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + // Create a test harness for the egui app + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - // Set the window size - harness.set_size(egui::vec2(800.0, 600.0)); + // Set the window size + harness.set_size(egui::vec2(800.0, 600.0)); - // Run a few frames to ensure the app initializes - // Using run_steps instead of run() because the app may show spinners - // which cause continuous repainting - harness.run_steps(10); + // Run a few frames to ensure the app initializes + // Using run_steps instead of run() because the app may show spinners + // which cause continuous repainting + harness.run_steps(10); + }); } diff --git a/tests/kittest/support.rs b/tests/kittest/support.rs new file mode 100644 index 000000000..a77405eb0 --- /dev/null +++ b/tests/kittest/support.rs @@ -0,0 +1,56 @@ +//! Shared kittest helpers. + +#[path = "../common/data_dir.rs"] +mod data_dir; + +use dash_evo_tool::context::AppContext; +use dash_evo_tool::ui::RootScreenType; +use egui_kittest::Harness; +use std::sync::Arc; + +pub use data_dir::with_isolated_data_dir; + +/// 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 +/// construction only — callers that need a runtime alive afterwards (e.g. to +/// seed the DB through `AppContext` methods that spawn tasks) must enter their +/// own around the call, same as any other kittest. +pub fn mount_app(root_screen: RootScreenType) -> Harness<'static, dash_evo_tool::app::AppState> { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder() + .with_max_steps(100) + .build_eframe(move |ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + app.show_welcome_screen = false; + app.welcome_screen = None; + app.selected_main_screen = root_screen; + app + }); + harness.set_size(egui::vec2(1280.0, 800.0)); + harness.run_steps(10); + harness +} + +/// Builds a real `AppContext` from the default first-run database via the +/// same `AppState::new` factory `mount_app` uses, without mounting a +/// particular root screen. Returns the runtime so the caller keeps it alive +/// for the duration of the test. +pub fn fresh_app_context() -> (tokio::runtime::Runtime, Arc<AppContext>) { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let guard = rt.enter(); + let mut bootstrap = Harness::builder().with_max_steps(20).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + bootstrap.run_steps(5); + let app_context = bootstrap.state().current_app_context().clone(); + drop(bootstrap); + drop(guard); + (rt, app_context) +} diff --git a/tests/kittest/tokens_screen.rs b/tests/kittest/tokens_screen.rs new file mode 100644 index 000000000..d2fb9e9b7 --- /dev/null +++ b/tests/kittest/tokens_screen.rs @@ -0,0 +1,97 @@ +//! W4 kittest — TokensScreen (Token Creator) obeys the app-scoped selected identity. +//! +//! B4 migration: `TokensScreen::new()` when built for `TokenCreator` must seed +//! `selected_identity` from the app-scoped selection (fallback: first loaded). +//! +//! B6 regression lock: the 6 N/A token recipient/target/member selectors (mint, +//! transfer, freeze, unfreeze, destroy-frozen-funds, groups) must NOT carry +//! `syncing_global`. The `default_selector_has_no_sync_target` unit test in +//! `identity_selector.rs` covers any freshly-built `IdentitySelector`; B6 adds +//! a structural note and a spot-check that `TokensScreen` (Token Creator) does +//! NOT accidentally apply syncing_global to the N/A sites when rendering. +//! +//! Write-back: `syncing_global` component-level write-back is verified by +//! `identity_selector::tests::syncing_global_writes_selection_to_app_context`. +//! +//! # TODO(WalletFixture / private-key fixture) +//! Add screen-level write-back assertions (ComboBox click → `resolve_selected_identity()` +//! moves) once an identity fixture with loaded AUTH HIGH/CRITICAL private keys exists. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::tokens::tokens_screen::{TokensScreen, TokensSubscreen}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn build_ctx() -> ( + Harness<'static, dash_evo_tool::app::AppState>, + Arc<AppContext>, +) { + let mut h = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + h.run_steps(5); + let ctx = h.state().current_app_context().clone(); + (h, ctx) +} + +fn seed_token_identity(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed token identity"); + id +} + +/// W4 B4: `TokensScreen` in `TokenCreator` subscreen must seed `selected_identity` +/// from the app-scoped selection, not always the first loaded identity. +#[test] +fn token_creator_defaults_to_app_scoped_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let _first = seed_token_identity(&ctx, 0xA1, "Token Alpha"); + let second = seed_token_identity(&ctx, 0xB2, "Token Beta"); + + ctx.set_selected_identity(Some(second)); + + let screen = TokensScreen::new(&ctx, TokensSubscreen::TokenCreator); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(second), + "TokensScreen (TokenCreator) must default to the app-scoped selected identity" + ); + }); +} diff --git a/tests/kittest/tools_screen.rs b/tests/kittest/tools_screen.rs new file mode 100644 index 000000000..9e210062a --- /dev/null +++ b/tests/kittest/tools_screen.rs @@ -0,0 +1,107 @@ +//! W5 kittest — grovestark (GroveSTARK screen) READ-only EdDSA-guarded seeding. +//! +//! B5 migration: `GroveSTARKScreen::new()` seeds `selected_identity` from the +//! app-scoped selection **only** if the identity is in the EdDSA-filtered list +//! (READ-only R4: no syncing_global). +//! +//! Negative guard: basic identities (no EdDSA keys) must NOT be seeded, even if +//! they are the app-scoped selection. `selected_identity` stays `None`. +//! +//! Positive guard (seed when EdDSA keys present): deferred — requires a QI fixture +//! with an EDDSA_25519_HASH160 key loaded in `private_keys`. +//! +//! # TODO(WalletFixture / EdDSA-key fixture) +//! Add positive seed assertion once an identity fixture with a loaded EdDSA key +//! (EDDSA_25519_HASH160, AUTH or TRANSFER purpose) exists. +//! +//! # Note — `create_asset_lock_screen` (READ-only R1) +//! `CreateAssetLockScreen` uses `IdentitySelector::with_app_default()`, whose +//! membership guard is covered by unit tests in +//! `src/ui/components/identity_selector.rs` — specifically +//! `with_app_default_inert_when_global_id_not_in_candidate_list`. +//! No additional kittest is added here to avoid duplicating component-level coverage. +//! +//! # Note — `send_screen` (READ-only R1) +//! `SendScreen` seeds `selected_identity` at render-time from the wallet-membership +//! list. Testing requires a HD wallet fixture (TI-1 gap); deferred. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::tools::grovestark_screen::GroveSTARKScreen; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn build_ctx() -> ( + Harness<'static, dash_evo_tool::app::AppState>, + Arc<AppContext>, +) { + let mut h = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + h.run_steps(5); + let ctx = h.state().current_app_context().clone(); + (h, ctx) +} + +fn seed_basic_identity(app_context: &Arc<AppContext>, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed identity"); + id +} + +/// W5 B5 (R4 negative guard): when the app-scoped identity has NO EdDSA keys, +/// `GroveSTARKScreen` must NOT seed it into `selected_identity` — the EdDSA-only +/// filter rejects it and falls back to `None` (no EdDSA identities at all). +#[test] +fn grovestark_does_not_seed_non_eddsa_identity() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + // Both identities are basic (ECDSA, no EdDSA keys). + let _first = seed_basic_identity(&ctx, 0xA1, "GS Alpha"); + let second = seed_basic_identity(&ctx, 0xB2, "GS Beta"); + + // Point the global selection at the second identity (ECDSA only). + ctx.set_selected_identity(Some(second)); + + let screen = GroveSTARKScreen::new(&ctx); + + assert_eq!( + screen.selected_identity, None, + "GroveSTARKScreen must not seed an identity that has no EdDSA keys (R4 guard)" + ); + }); +} diff --git a/tests/kittest/wallets_screen.rs b/tests/kittest/wallets_screen.rs index ed40249ea..3f1c4158d 100644 --- a/tests/kittest/wallets_screen.rs +++ b/tests/kittest/wallets_screen.rs @@ -1,55 +1,62 @@ +use crate::support::with_isolated_data_dir; use egui_kittest::Harness; /// Test that the wallets screen can be rendered #[test] fn test_wallets_screen_renders() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(10); } /// Test that the app can run many frames without issues #[test] fn test_app_stability_over_many_frames() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); - let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); - harness.set_size(egui::vec2(1024.0, 768.0)); + harness.set_size(egui::vec2(1024.0, 768.0)); - // Run 50 frames to test stability - harness.run_steps(50); + // Run 50 frames to test stability + harness.run_steps(50); + }); } /// Test rapid frame stepping #[test] fn test_rapid_frame_stepping() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false) + }); + + harness.set_size(egui::vec2(800.0, 600.0)); + + // Run single steps rapidly + for _ in 0..20 { + harness.run_steps(1); + } }); - - harness.set_size(egui::vec2(800.0, 600.0)); - - // Run single steps rapidly - for _ in 0..20 { - harness.run_steps(1); - } } diff --git a/tests/kittest/withdraw_screen.rs b/tests/kittest/withdraw_screen.rs new file mode 100644 index 000000000..c8ab57688 --- /dev/null +++ b/tests/kittest/withdraw_screen.rs @@ -0,0 +1,353 @@ +//! Kittest coverage for `WithdrawalScreen` key pre-selection. +//! +//! `WithdrawalScreen::new()` used to pre-select a signing key via +//! `Identity::get_first_public_key_matching(Purpose::TRANSFER, ...)`, scanning +//! on-chain keys unfiltered by local private-key presence — a "ghost key" (an +//! on-chain TRANSFER key with no local private material, e.g. a loaded +//! masternode identity with the payout field left blank) could get silently +//! pre-selected even though the signer can never use it. The fix +//! (`identity.default_withdrawal_key()`, TRANSFER-preferred / OWNER-fallback, +//! private-key-backed only) is unit-tested at the model layer in +//! `src/model/qualified_identity/mod.rs::withdrawal_key_tests`. This file +//! verifies the fix holds when `WithdrawalScreen` is actually constructed and +//! rendered — the layer the model-only tests never touched. +//! +//! `selected_key` is a private field, so it can't be asserted directly from +//! this external integration-test crate. Instead: +//! - the ghost-key case is asserted through the screen's *observable* +//! behavior: the "no signable key" empty state renders instead of the +//! withdraw form, and (see `ghost_key_leaks_raw_error_banner` below) a +//! genuine regression this fix newly exposes at the constructor; +//! - the happy-path / owner-fallback cases are asserted through the rendered +//! ComboBox's `selected_text`, closing the "constructor picks one thing, +//! widget renders another" gap the original bug lived in. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; +use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use dash_evo_tool::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; +use dash_evo_tool::ui::ScreenLike; +use dash_evo_tool::ui::components::MessageBanner; +use dash_evo_tool::ui::helpers::format_key_label; +use dash_evo_tool::ui::identities::withdraw_screen::WithdrawalScreen; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dash_sdk::dpp::identity::{Identity, KeyID, Purpose, SecurityLevel}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Builds an on-chain public key. Mirrors the `key()` helper in +/// `model/qualified_identity/mod.rs::withdrawal_key_tests` so the two test +/// suites stay in lockstep on fixture shape. +fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); + k.set_id(id); + k.set_purpose(purpose); + k.set_security_level(SecurityLevel::CRITICAL); + k +} + +/// Builds a `QualifiedIdentity` with `on_chain` public keys, of which +/// `with_private` additionally get local `Clear` private material. Same +/// fixture shape as the model-layer `withdrawal_key_tests::build_identity` +/// helper, extended with a live `AppContext`'s network so the screen renders. +fn build_identity( + app_context: &Arc<AppContext>, + identity_type: IdentityType, + on_chain: Vec<IdentityPublicKey>, + with_private: Vec<IdentityPublicKey>, +) -> QualifiedIdentity { + let public_keys: BTreeMap<KeyID, IdentityPublicKey> = + on_chain.into_iter().map(|k| (k.id(), k)).collect(); + let identity = Identity::new_with_id_and_keys( + Identifier::random(), + public_keys, + PlatformVersion::latest(), + ) + .expect("identity"); + + let mut private_keys = BTreeMap::new(); + for k in with_private { + private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: None, + private_keys: KeyStorage { private_keys }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + } +} + +/// Mounts `WithdrawalScreen::ui()` directly (it manages its own top/left/central +/// panels, same as when driven through `AppState`) and runs one settle pass. +fn mount_withdrawal_screen(screen: WithdrawalScreen) -> Harness<'static, WithdrawalScreen> { + let mut harness = Harness::builder() + .with_size(egui::vec2(1100.0, 850.0)) + .build_ui_state( + |ui, screen: &mut WithdrawalScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + harness +} + +/// Builds a fresh, isolated `AppContext` via the same `AppState::new` factory +/// other kittests use, without mounting a root screen. +fn fresh_context() -> (tokio::runtime::Runtime, Arc<AppContext>) { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let guard = rt.enter(); + let mut bootstrap = Harness::builder().with_max_steps(20).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + bootstrap.run_steps(5); + let app_context = bootstrap.state().current_app_context().clone(); + drop(bootstrap); + drop(guard); + (rt, app_context) +} + +/// Ghost-key repro: an identity whose only on-chain key is a `Purpose::TRANSFER` +/// key with no local private material (like a loaded masternode identity with +/// the payout field left blank). The withdraw form must never render for it — +/// `available_withdrawal_keys()` (private-key-backed only) is empty, so the +/// screen shows the "no eligible key" empty state, not a Confirm-enabled form. +#[test] +fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![transfer], + vec![], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + let harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_some(), + "a ghost-key-only identity must show the no-keys empty state" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form (amount/address/key-selection) must never render \ + for an identity with no locally-signable withdrawal key" + ); + assert!( + harness.query_by_label_contains("Estimated fee").is_none(), + "the fee-estimate / Confirm section must never render without a signable key" + ); + }); +} + +/// Regression lock for a bug this fix (default_withdrawal_key) briefly +/// introduced and a follow-up fix (2edbc18e, "skip wallet resolution when no +/// signable key exists") closed: constructing `WithdrawalScreen` for a +/// ghost-key-only identity used to leak a raw, technical error banner +/// ("No key provided when getting selected wallet") from +/// `get_selected_wallet()`. Before the original fix `selected_key` was almost +/// always `Some(..)` (unfiltered on-chain scan), so that `Err` branch in +/// `get_selected_wallet` was rarely reached; once `selected_key` correctly +/// started becoming `None` whenever no locally-signable key exists, every such +/// identity tripped this internal-string leak on screen open — violating this +/// project's own error-message conventions (CLAUDE.md "Error messages": no +/// raw/internal strings in user-facing banners, must be actionable). +/// `WithdrawalScreen::new()` now only calls `get_selected_wallet` when +/// `selected_key` is `Some`, making that `Err` path structurally unreachable +/// here rather than merely avoided by luck — and the no-keys empty state must +/// still render correctly for the same identity. +#[test] +fn ghost_key_construction_does_not_leak_raw_error_banner() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + // Bootstrap (AppState::new + a few settle frames) can itself raise + // unrelated startup banners (e.g. connection-status warnings); clear + // them so this test observes only what `WithdrawalScreen::new()` adds. + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![transfer], + vec![], + ); + + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "precondition: no banner before construction" + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "WithdrawalScreen::new() must not leak get_selected_wallet's raw \ + \"No key provided...\" error as a global banner just from opening \ + the screen for an identity with no signable key (regression: \ + 2edbc18e guards the call on selected_key being Some)" + ); + + // The no-keys empty state must still render correctly for this identity + // — the fix must not have traded the banner leak for a broken empty state. + let harness = mount_withdrawal_screen(screen); + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_some(), + "the no-keys empty state must still render after the banner-leak fix" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form must still not render for a ghost-key-only identity" + ); + }); +} + +/// Happy path: an identity with a TRANSFER key backed by real local private +/// material. `selected_key` must be `Some` and the rendered key-selection +/// ComboBox must display that exact key — the constructor and the widget must +/// agree, closing the exact gap the original bug lived in. +#[test] +fn private_backed_transfer_key_is_selected_and_rendered_in_combo() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::User, + vec![transfer.clone()], + vec![transfer.clone()], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "no error banner expected when a private-key-backed key is pre-selected" + ); + + let mut harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_some(), + "the withdraw form must render when a locally-signable key exists" + ); + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_none() + ); + + // Reveal key selection (section 3) via "Show Advanced Options". + harness.get_by_label("Show Advanced Options").click(); + harness.run_steps(3); + + assert!( + harness + .query_by_label_contains("3. Select the key to sign with") + .is_some(), + "the key-selection section must render once Advanced Options is shown" + ); + + // The ComboBox exposes its current selection via accesskit's `value` + // (a plain Label like "Withdraw" uses `label`; egui's ComboBox does not). + let expected_label = format_key_label(&transfer); + assert!( + harness.query_by_value(&expected_label).is_some(), + "the key-selection ComboBox must display the pre-selected TRANSFER \ + key's label ({expected_label}); constructor and widget disagreeing \ + is exactly the class of bug this fix targets" + ); + assert!( + harness.query_by_value("Select Key…").is_none(), + "the combo must not show the unselected placeholder when a key was pre-selected" + ); + }); +} + +/// Owner fallback: a masternode-type identity with only a private-key-backed +/// OWNER key, no TRANSFER key at all. `default_withdrawal_key()` must fall +/// back to OWNER, and the rendered ComboBox must reflect that exact key. +#[test] +fn owner_key_fallback_is_selected_and_rendered_in_combo_when_no_transfer_key() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let owner = key(2, Purpose::OWNER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![owner.clone()], + vec![owner.clone()], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "no error banner expected when the OWNER-fallback key is pre-selected" + ); + + let mut harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_some(), + "the withdraw form must render for the OWNER-fallback case" + ); + + harness.get_by_label("Show Advanced Options").click(); + harness.run_steps(3); + + let expected_label = format_key_label(&owner); + assert!( + harness.query_by_value(&expected_label).is_some(), + "the key-selection ComboBox must display the pre-selected OWNER \ + fallback key's label ({expected_label})" + ); + assert!(harness.query_by_value("Select Key…").is_none()); + }); +} diff --git a/tests/legacy_table_surface.rs b/tests/legacy_table_surface.rs new file mode 100644 index 000000000..8cb2985cb --- /dev/null +++ b/tests/legacy_table_surface.rs @@ -0,0 +1,203 @@ +//! Legacy `data.db` table surface guard tests (TC-DEV-001/002/003). +//! +//! Normative text (inlined from the finish-unwire test-case spec, a scratch +//! file that was never committed — see +//! `docs/ai-design/2026-05-29-finish-unwire/notes.md` §5 for the retained +//! domain summary): +//! +//! - **TC-DEV-001**: non-test cold-boot code must not contain `FROM wallet` +//! SELECTs against the legacy `wallet` table, outside the allow-list below. +//! - **TC-DEV-002**: non-test cold-boot code must not read or write the +//! legacy `utxos` table (`FROM`/`INTO`/`UPDATE`/`DELETE FROM utxos`), +//! outside the allow-list. +//! - **TC-DEV-003**: non-test cold-boot code must not contain SQL touching +//! the legacy `single_key_wallet` table, outside the allow-list. +//! +//! In short: the cold-boot read path must never touch the legacy `wallet`, +//! `utxos`, or `single_key_wallet` tables; the only call sites that may +//! still mention those tables are the allow-list below, tethered to the +//! T-DEV-02 commit log (`42b88a15`). +//! +//! The test is intentionally a "spec freeze": if a new file in `src/` +//! starts SELECTing from one of these tables, the test fails and forces +//! either (a) a rewrite to the new path or (b) an explicit allow-list +//! extension here with a tether-reason citation. +//! +//! Brain the size of a planet, and here I am grepping for `FROM wallet`. +//! At least the regex is honest. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +const SRC_DIR: &str = "src"; + +/// Files explicitly permitted to mention the legacy tables, per the +/// T-DEV-02 tether (`42b88a15` commit log) and the revised spec +/// "Allow-listed transitional writes" section. +/// +/// Adding to this list requires a tether rationale recorded in the spec. +const ALLOW_LIST: &[&str] = &[ + // Database migration + schema management — runs on cold-boot to + // create/upgrade tables, including legacy ones for replay safety. + "src/database/initialization.rs", + "src/database/mod.rs", + // Surviving CRUD/helper surface — tethered per `42b88a15`. + "src/database/wallet.rs", + "src/database/single_key_wallet.rs", + "src/database/utxo.rs", + // T-DEV-02 transitional bridge (tethered per `42b88a15`): the + // wallet-lifecycle test fixtures construct legacy-schema `wallet` / + // `single_key_wallet` rows to exercise the migration/read paths. These + // fixtures moved from the monolithic `wallet_lifecycle.rs` into the + // split module's `tests.rs` (Wave-6 god-file split); the production + // submodules contain no legacy-table SQL, so only the test file is + // allow-listed here — the exemption is unchanged, only the path. + "src/context/wallet_lifecycle/tests.rs", + // Migration orchestrator — reads legacy data exactly so it can + // copy it forward to the new sidecars and then never touch the + // legacy tables again. + "src/backend_task/migration/finish_unwire.rs", + // T-SK-03 protected single-key restore — reads the legacy + // `single_key_wallet` row's password-encrypted blob to decrypt it + // with the user's old password and re-import it into the modern + // vault. A migration read inside the legacy → new boundary, the + // sibling of finish_unwire.rs; never a cold-boot read. + "src/backend_task/migration/single_key_restore.rs", + // Wallet-task entry points handing off to migration orchestrator + // for retry / state transitions. The READS here are inside the + // migration boundary (legacy → new), not cold-boot reads. + "src/backend_task/migration/mod.rs", + // Test-only helper modules colocated with prod code. + "src/database/contract.rs", +]; + +fn allow_list() -> BTreeSet<PathBuf> { + ALLOW_LIST.iter().map(PathBuf::from).collect() +} + +fn collect_rs_files(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { + out.push(path); + } + } +} + +/// Returns the line numbers of `pat` in `text`. Deliberately does not try to +/// exempt `#[cfg(test)]` blocks — any false positive that surfaces is caught +/// by the allow-list, and a per-file allow-list entry is a cheaper, more +/// reliable escape hatch than parsing Rust brace structure with string +/// matching. +fn live_matches(text: &str, pat: &str) -> Vec<(usize, String)> { + text.lines() + .enumerate() + .filter(|(_, line)| line.contains(pat)) + .map(|(idx, line)| (idx + 1, line.to_string())) + .collect() +} + +fn scan_for_pattern(pat: &str) -> Vec<(PathBuf, usize, String)> { + let mut files = Vec::new(); + collect_rs_files(Path::new(SRC_DIR), &mut files); + let allow = allow_list(); + let mut offenders = Vec::new(); + for file in files { + // Normalize to forward-slash, repo-relative form for allow-list + // comparison so the test works on Windows too. + let rel = file + .to_string_lossy() + .replace('\\', "/") + .trim_start_matches("./") + .to_string(); + let rel_pb = PathBuf::from(&rel); + if allow.contains(&rel_pb) { + continue; + } + let Ok(text) = fs::read_to_string(&file) else { + continue; + }; + for (line_no, line) in live_matches(&text, pat) { + offenders.push((file.clone(), line_no, line)); + } + } + offenders +} + +/// TC-DEV-001 — non-test cold-boot code must not contain `FROM wallet` +/// SELECTs outside the documented allow-list. +#[test] +fn tc_dev_001_no_live_readers_of_wallet_table() { + let offenders = scan_for_pattern("FROM wallet"); + assert!( + offenders.is_empty(), + "TC-DEV-001 violation — new live reader of `wallet` table:\n{}\n\nIf this is intentional, add the file to the allow-list in `tests/legacy_table_surface.rs` with a tether-reason citation per the spec.", + offenders + .iter() + .map(|(f, l, line)| format!(" {}:{}: {}", f.display(), l, line.trim())) + .collect::<Vec<_>>() + .join("\n"), + ); +} + +/// TC-DEV-002 — non-test cold-boot code must not write/read `utxos` +/// outside the documented allow-list. +#[test] +fn tc_dev_002_no_live_readers_or_writers_of_utxos_table() { + let patterns = [ + "FROM utxos", + "INTO utxos", + "UPDATE utxos", + "DELETE FROM utxos", + ]; + let mut offenders = Vec::new(); + for pat in patterns { + offenders.extend(scan_for_pattern(pat)); + } + assert!( + offenders.is_empty(), + "TC-DEV-002 violation — new live reader/writer of `utxos` table:\n{}\n\nAdd to allow-list with a tether-reason citation if intentional.", + offenders + .iter() + .map(|(f, l, line)| format!(" {}:{}: {}", f.display(), l, line.trim())) + .collect::<Vec<_>>() + .join("\n"), + ); +} + +/// TC-DEV-003 — non-test cold-boot code must not contain SQL touching +/// the `single_key_wallet` *table* outside the documented allow-list. +/// +/// Note: "single_key_wallet" appears widely as a field/module name +/// (`selected_single_key_wallet`, `send_single_key_wallet_payment`, +/// etc.) and those references are unrelated to the legacy SQL surface. +/// The guard targets the SQL keywords specifically. +#[test] +fn tc_dev_003_no_live_readers_or_writers_of_single_key_wallet_table() { + let patterns = [ + "FROM single_key_wallet", + "INTO single_key_wallet", + "UPDATE single_key_wallet", + "DELETE FROM single_key_wallet", + "TABLE single_key_wallet", + ]; + let mut offenders = Vec::new(); + for pat in patterns { + offenders.extend(scan_for_pattern(pat)); + } + assert!( + offenders.is_empty(), + "TC-DEV-003 violation — new live SQL touching `single_key_wallet` table:\n{}\n\nAdd to allow-list with a tether-reason citation if intentional.", + offenders + .iter() + .map(|(f, l, line)| format!(" {}:{}: {}", f.display(), l, line.trim())) + .collect::<Vec<_>>() + .join("\n"), + ); +} diff --git a/tests/mcp_http_auth.rs b/tests/mcp_http_auth.rs new file mode 100644 index 000000000..d1623484f --- /dev/null +++ b/tests/mcp_http_auth.rs @@ -0,0 +1,75 @@ +//! HTTP bearer-auth contract for the MCP server. +//! +//! Pins the wire format the server's `bearer_auth` middleware accepts, so the +//! `det-cli` HTTP client and the headless server can never drift apart again. +//! Regression guard for the double-`Bearer` bug: rmcp's client `auth_header` +//! takes a raw token and prepends `Bearer ` itself, so a client that also +//! prepends puts `Bearer Bearer <token>` on the wire and is rejected. + +#![cfg(feature = "mcp")] + +use std::net::SocketAddr; + +use axum::{Router, middleware, routing::get}; +use dash_evo_tool::mcp::auth::{ApiKey, bearer_auth}; + +const TOKEN: &str = "test-api-key-0123456789"; + +async fn spawn_server() -> SocketAddr { + let api_key = ApiKey(std::sync::Arc::from(TOKEN)); + let protected = Router::new() + .route("/mcp", get(|| async { "ok" })) + .route_layer(middleware::from_fn_with_state(api_key, bearer_auth)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + protected.into_make_service_with_connect_info::<SocketAddr>(), + ) + .await + .unwrap(); + }); + addr +} + +/// The raw token — exactly what rmcp's `auth_header` puts through +/// reqwest's `bearer_auth` — is accepted. +#[tokio::test] +async fn raw_token_is_accepted() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .bearer_auth(TOKEN) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); +} + +/// A pre-prefixed token yields `Authorization: Bearer Bearer <token>` on the +/// wire and MUST be rejected — this is the exact shape of the old client bug. +#[tokio::test] +async fn double_bearer_prefix_is_rejected() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .bearer_auth(format!("Bearer {TOKEN}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +/// No credentials at all is rejected. +#[tokio::test] +async fn missing_authorization_is_rejected() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +}